@mulanjs/mulanjs 1.0.1-dev.20260227091247 → 1.0.1-dev.20260227135307

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.
@@ -1,39 +1,7 @@
1
+
1
2
  import { SFCDescriptor } from './sfc-parser';
2
3
  import { ScriptCompileResult } from './script-compiler';
3
-
4
- // --- AST Definitions ---
5
-
6
- type NodeType = 'Element' | 'Text' | 'Interpolation';
7
-
8
- type Node = ElementNode | TextNode | InterpolationNode;
9
-
10
- interface BaseNode {
11
- type: NodeType;
12
- }
13
-
14
- interface ElementNode extends BaseNode {
15
- type: 'Element';
16
- tag: string;
17
- props: Record<string, string>;
18
- children: Node[];
19
- directives: {
20
- vFor?: { item: string; list: string };
21
- vIf?: string;
22
- };
23
- }
24
-
25
- interface TextNode extends BaseNode {
26
- type: 'Text';
27
- content: string;
28
- }
29
-
30
- interface InterpolationNode extends BaseNode {
31
- type: 'Interpolation';
32
- content: string; // The expression inside {{ }}
33
- }
34
-
35
- // --- Compile Function ---
36
-
4
+ import { parse, Node, ElementNode, TextNode, InterpolationNode } from './ast-parser';
37
5
  import { SourceMapGenerator } from 'source-map';
38
6
 
39
7
  export interface TemplateCompileResult {
@@ -43,14 +11,14 @@ export interface TemplateCompileResult {
43
11
  }
44
12
 
45
13
  export function compileTemplate(descriptor: SFCDescriptor, scriptResult: ScriptCompileResult, scopedId?: string): TemplateCompileResult {
46
- console.log(`[MulanJS Compiler v1.0.1-dev.2] Compiling template for: ${descriptor.filename || 'anonymous'}`);
14
+ console.log(`[MulanJS Template Compiler v1.0.1-dev.2] Compiling template for: ${descriptor.filename || 'anonymous'}`);
47
15
  const template = descriptor.template;
48
16
  if (!template) return { code: 'function render() { return ""; }', errors: [] };
49
17
 
50
18
  let html = template.content;
51
19
  const errors: string[] = [];
52
20
 
53
- // 1. Parsing Phase (HTML -> AST)
21
+ // 1. Parsing Phase (HTML -> AST) - Unified Parser
54
22
  const ast = parse(html, errors);
55
23
 
56
24
  // 2. Transform Phase (Scopes, Bindings)
@@ -74,44 +42,28 @@ export function compileTemplate(descriptor: SFCDescriptor, scriptResult: ScriptC
74
42
  }`;
75
43
 
76
44
  // 4. Source Map Generation
77
- // We map the entire render function to the starting line of the template in the source file
78
45
  let map = undefined;
79
46
  if (descriptor.filename) {
80
47
  const generator = new SourceMapGenerator({
81
- file: descriptor.filename + '.template.js', // Virtual file name
48
+ file: descriptor.filename + '.template.js',
82
49
  });
83
50
 
84
- // Find start line of template in original source
85
- // descriptor.template.start is the index of content start
86
51
  const sourceBefore = descriptor.source.substring(0, template.start);
87
52
  const startLine = sourceBefore.split(/\r?\n/).length;
88
53
 
89
- // Simple mapping: One-to-one mapping isn't easy without AST location tracking
90
- // But we can map the 'return' statement to the template start
91
- // The render function has about 12 lines of preamble.
92
- // Let's just mapping the whole block to the start line for now.
93
- // This ensures the file shows up in devtools.
94
54
  generator.addMapping({
95
55
  generated: { line: 1, column: 0 },
96
- source: descriptor.filename, // This should be the .mujs file path
56
+ source: descriptor.filename,
97
57
  original: { line: startLine, column: 0 }
98
58
  });
99
59
 
100
- // Also map the return line (approx line 12)
101
60
  generator.addMapping({
102
61
  generated: { line: 12, column: 0 },
103
62
  source: descriptor.filename,
104
63
  original: { line: startLine, column: 0 }
105
64
  });
106
65
 
107
- // We must include the "source content" so DevTools can display it!
108
- // IMPORTANT: We want the FULL .mujs content here, so it matches what Webpack sees?
109
- // Actually, if we use the same filename as script-compiler, they might conflict or merge.
110
- // Script compiler uses 'webpack:///...'
111
- // Let's use the same convention.
112
- // But we just use the filename here, compiler.ts will handle the final path adjustment if needed.
113
66
  generator.setSourceContent(descriptor.filename, descriptor.source);
114
-
115
67
  map = generator.toString();
116
68
  }
117
69
 
@@ -122,182 +74,6 @@ export function compileTemplate(descriptor: SFCDescriptor, scriptResult: ScriptC
122
74
  };
123
75
  }
124
76
 
125
- // --- Parser (Recursive Descent) ---
126
-
127
- function parse(template: string, errors: string[]): ElementNode {
128
- // Root Wrapper
129
- const root: ElementNode = {
130
- type: 'Element',
131
- tag: 'fragment', // Virtual root
132
- props: {},
133
- children: [],
134
- directives: {}
135
- };
136
-
137
- const stack: ElementNode[] = [root];
138
- let cursor = 0;
139
-
140
- while (cursor < template.length) {
141
- const char = template[cursor];
142
-
143
- if (template.startsWith('<!--', cursor)) {
144
- // Comment <!-- ... -->
145
- const end = template.indexOf('-->', cursor);
146
- if (end === -1) break;
147
- // Just skip comments or keep them?
148
- // Let's keep them as raw strings to preserve output structure if needed,
149
- // but usually valid HTML comments shouldn't affect structure.
150
- // For now, let's just append them to the previous text node or create a text node.
151
- // Actually, simply ignoring them is safer for logic, but might remove user comments.
152
- // Better: treat as Text so they are emitted as-is.
153
- const content = template.slice(cursor, end + 3);
154
- stack[stack.length - 1].children.push({ type: 'Text', content });
155
- cursor = end + 3;
156
- } else if (char === '<') {
157
- // Tag
158
- if (template[cursor + 1] === '/') {
159
- // Closing Tag </tag>
160
- const end = template.indexOf('>', cursor);
161
- if (end === -1) break;
162
- stack.pop();
163
- cursor = end + 1;
164
- } else {
165
- // Opening Tag <tag ...>
166
- const end = template.indexOf('>', cursor);
167
- if (end === -1) break;
168
-
169
- let tagContent = template.slice(cursor + 1, end);
170
- const isSelfClosing = tagContent.endsWith('/') || ['img', 'br', 'input', 'hr'].includes(tagContent.split(' ')[0]);
171
-
172
- if (tagContent.endsWith('/')) {
173
- tagContent = tagContent.slice(0, -1).trim();
174
- }
175
-
176
- const { tag, props, directives } = parseTag(tagContent);
177
-
178
- const element: ElementNode = { type: 'Element', tag, props, children: [], directives };
179
- stack[stack.length - 1].children.push(element);
180
-
181
- if (!isSelfClosing) {
182
- stack.push(element);
183
- }
184
-
185
- cursor = end + 1;
186
- }
187
- } else if (char === '{' && template[cursor + 1] === '{') {
188
- // Interpolation {{ }}
189
- const end = template.indexOf('}}', cursor);
190
- if (end === -1) break; // formatting error
191
-
192
- const content = template.slice(cursor + 2, end).trim();
193
- stack[stack.length - 1].children.push({ type: 'Interpolation', content });
194
- cursor = end + 2;
195
- } else {
196
- // Text
197
- let nextTag = template.indexOf('<', cursor);
198
- let nextInterp = template.indexOf('{{', cursor);
199
-
200
- let end = template.length;
201
- if (nextTag !== -1 && nextTag < end) end = nextTag;
202
- if (nextInterp !== -1 && nextInterp < end) end = nextInterp;
203
-
204
- const content = template.slice(cursor, end);
205
- if (content) {
206
- stack[stack.length - 1].children.push({ type: 'Text', content });
207
- }
208
- cursor = end;
209
- }
210
- }
211
-
212
- return root;
213
- }
214
-
215
- function parseTag(content: string) {
216
- // console.log('DEBUG: parseTag content:', content);
217
- const parts = content.split(' ');
218
- const tag = parts[0];
219
- const props: Record<string, string> = {};
220
- const directives: ElementNode['directives'] = {};
221
-
222
- // Resume attribute parsing after tag
223
- const attrStr = content.slice(tag.length).trim();
224
- // console.log('DEBUG: attrStr:', attrStr);
225
-
226
- let i = 0;
227
- while (i < attrStr.length) {
228
- // Skip spaces
229
- if (/\s/.test(attrStr[i])) {
230
- i++;
231
- continue;
232
- }
233
-
234
- // Find key
235
- const keyStart = i;
236
- while (i < attrStr.length && !/\s|=/.test(attrStr[i])) {
237
- i++;
238
- }
239
- const key = attrStr.slice(keyStart, i);
240
-
241
- // Check for value
242
- let value = 'true'; // Default for boolean attributes
243
-
244
- // Skip potential spaces before '=' (e.g. class = "foo")
245
- let peek = i;
246
- while (peek < attrStr.length && /\s/.test(attrStr[peek])) peek++;
247
-
248
- if (peek < attrStr.length && attrStr[peek] === '=') {
249
- i = peek + 1; // Move past '='
250
-
251
- // Skip spaces after '='
252
- while (i < attrStr.length && /\s/.test(attrStr[i])) i++;
253
-
254
- if (i < attrStr.length && (attrStr[i] === '"' || attrStr[i] === "'")) {
255
- const quote = attrStr[i];
256
- i++; // skip quote
257
- const valStart = i;
258
- while (i < attrStr.length && attrStr[i] !== quote) {
259
- if (attrStr[i] === '\\' && attrStr[i + 1] === quote) {
260
- i += 2;
261
- } else {
262
- i++;
263
- }
264
- }
265
- value = attrStr.slice(valStart, i);
266
- i++; // skip closing quote
267
- } else {
268
- // unquoted value
269
- const valStart = i;
270
- while (i < attrStr.length && !/\s/.test(attrStr[i])) {
271
- i++;
272
- }
273
- value = attrStr.slice(valStart, i);
274
- }
275
- } else {
276
- // Boolean attribute, no value
277
- // i remains at end of key
278
- }
279
-
280
- // Store
281
- if (key === 'v-if' || key === 'mu-if') {
282
- directives.vIf = value;
283
- } else if (key === 'v-for' || key === 'mu-for') {
284
- const parts = value.split(' in ');
285
- if (parts.length < 2) {
286
- console.warn(`[MulanJS Compiler] Warning: Invalid loop expression "${value}". Expected "item in list".`);
287
- directives.vFor = { item: '_item', list: '[]' }; // Fallback with safe identifier
288
- } else {
289
- const item = parts[0];
290
- const list = parts.slice(1).join(' in '); // Join rest in case list has 'in'
291
- directives.vFor = { item: item.trim(), list: list.trim() };
292
- }
293
- } else if (key) {
294
- props[key] = value;
295
- }
296
- }
297
-
298
- return { tag, props, directives };
299
- }
300
-
301
77
  // --- Transformer ---
302
78
 
303
79
  const JS_KEYWORDS = new Set([
@@ -317,29 +93,24 @@ const GLOBALS = new Set([
317
93
  function transform(node: Node, scriptResult: ScriptCompileResult, scopedId?: string, localScope: string[] = [], filename?: string) {
318
94
  if (node.type === 'Element') {
319
95
  const element = node as ElementNode;
320
- // Scoped ID
321
96
  if (scopedId && element.tag !== 'fragment') {
322
97
  element.props[scopedId] = '';
323
98
  }
324
99
 
325
- // IRON FORTRESS: Detect mu-raw/v-raw for XSS bypass
326
100
  const isRaw = 'mu-raw' in element.props || 'v-raw' in element.props;
327
101
  if (isRaw) {
328
102
  delete element.props['mu-raw'];
329
103
  delete element.props['v-raw'];
330
104
  }
331
105
 
332
- // 0. Update Local Scope for Children (v-for)
333
106
  const childScope = [...localScope];
334
107
  if (element.directives.vFor) {
335
108
  childScope.push(element.directives.vFor.item);
336
109
  }
337
110
 
338
- // Bindings (Attributes)
339
- const propertyBindings: string[] = []; // Stores generated side-effect code (props and events)
111
+ const propertyBindings: string[] = [];
340
112
 
341
113
  for (const key in element.props) {
342
- // 0. Property Binding: .columns="${state.cols}"
343
114
  if (key.startsWith('.')) {
344
115
  if (!element.props['data-mu-id']) {
345
116
  const id = 'mu_' + Math.random().toString(36).substr(2, 9);
@@ -359,7 +130,6 @@ function transform(node: Node, scriptResult: ScriptCompileResult, scopedId?: str
359
130
  propertyBindings.push(`this._b('${id}', '${propName}', ${expr})`);
360
131
  delete element.props[key];
361
132
  }
362
- // 1. Event Handlers: @click="count++", v-on:click="toggle", or onclick="increment()"
363
133
  else if (key.startsWith('@') || key.startsWith('v-on:') || (key.startsWith('on') && key.length > 2)) {
364
134
  if (!element.props['data-mu-id']) {
365
135
  const id = 'mu_' + Math.random().toString(36).substr(2, 9);
@@ -370,13 +140,11 @@ function transform(node: Node, scriptResult: ScriptCompileResult, scopedId?: str
370
140
  let eventName = '';
371
141
  if (key.startsWith('@')) eventName = key.slice(1);
372
142
  else if (key.startsWith('v-on:')) eventName = key.slice(5);
373
- else eventName = key.slice(2); // standard 'on' prefix (onclick -> click)
143
+ else eventName = key.slice(2);
374
144
 
375
145
  const rawHandler = element.props[key];
376
146
 
377
147
  let bound = processBindings(rawHandler, scriptResult.bindings, localScope);
378
- // Wrap in anonymous function if it looks like a statement or expression with side effects
379
- // Simple heuristic: if it has parentheses and isn't a simple function reference
380
148
  const finalHandler = (bound.includes('(') || bound.includes('=') || bound.includes('++'))
381
149
  ? `($event) => { ${bound} }`
382
150
  : bound;
@@ -384,30 +152,22 @@ function transform(node: Node, scriptResult: ScriptCompileResult, scopedId?: str
384
152
  propertyBindings.push(`this._e('${id}', '${eventName}', ${finalHandler})`);
385
153
  delete element.props[key];
386
154
  }
387
- // 2. Standard Attributes Interpolation: class="{{ active }}"
388
155
  else {
389
156
  let rawValue = element.props[key];
390
- // Check for {{ }} -> convert to ${ }
391
157
  if (rawValue.includes('{{')) {
392
- // Capture content and wrap in ${}, trimming whitespace
393
158
  rawValue = rawValue.replace(/\{\{\s*(.*?)\s*\}\}/g, '${$1}');
394
159
  }
395
160
 
396
- // Check for ${ } -> process internal bindings
397
161
  if (rawValue.includes('${')) {
398
- // It's a template literal now
399
162
  element.props[key] = rawValue.replace(/\$\{(.*?)\}/g, (_, expr) => {
400
163
  const bound = processBindings(expr, scriptResult.bindings, localScope);
401
164
  return '${_h(' + bound + ')}';
402
165
  });
403
166
  }
404
167
  }
405
- // ... (rest of attributes)
406
168
  }
407
169
 
408
- // Store side-effects on the node for the Generator to use
409
170
  if (propertyBindings.length > 0) {
410
- // We'll attach it to a temporary property on the AST node
411
171
  (element as any)._propertySideEffects = propertyBindings;
412
172
  }
413
173
 
@@ -422,14 +182,12 @@ function transform(node: Node, scriptResult: ScriptCompileResult, scopedId?: str
422
182
  interpolation.content = processBindings(interpolation.content, scriptResult.bindings, localScope);
423
183
  } else if (node.type === 'Text') {
424
184
  const text = node as TextNode;
425
- // Support native template literals: ${ expr }
426
185
  if (text.content.includes('${')) {
427
186
  const componentName = filename ? filename.split(/[/\\]/).pop()?.split('.')[0].replace(/\W/g, '') || 'App' : 'App';
428
187
  const bindPrefix = `window['${componentName}'].`;
429
188
 
430
189
  text.content = text.content.replace(/\$\{(.*?)\}/g, (_, expr) => {
431
190
  let bound = processBindings(expr, scriptResult.bindings, localScope);
432
- // If processBindings added 'this.', replace it with global access for maximum safety in string templates
433
191
  bound = bound.replace(/this\./g, bindPrefix);
434
192
  return '${_h(' + bound + ')}';
435
193
  });
@@ -438,36 +196,23 @@ function transform(node: Node, scriptResult: ScriptCompileResult, scopedId?: str
438
196
  }
439
197
 
440
198
  function processBindings(exp: string, bindings: string[] | undefined, localScope: string[]) {
441
- // Strategy:
442
- // 1. Identifiers in 'bindings' (Setup API) -> prefix this.
443
- // 2. Identifiers in 'localScope' (v-for) -> keep as is.
444
- // 3. If 'bindings' is empty (Options API), prefix everything NOT in localScope/Keywords/Globals.
445
-
446
199
  const isOptionsAPI = !bindings || bindings.length === 0;
447
200
  const bindingSet = new Set(bindings || []);
448
201
 
449
- // Regex for identifiers
450
202
  return exp.replace(/\b([a-zA-Z_$][\w$]*)\b/g, (match, id, offset, str) => {
451
- // 1. Skip if property access (dot before) (e.g. user.name -> user is checked, name is skipped)
452
203
  if (offset > 0 && str[offset - 1] === '.') return match;
453
204
 
454
- // 2. Skip object key (colon after) (e.g. { name: val } -> name skipped)
455
- // Simple check: next char is ':'
456
205
  let i = offset + match.length;
457
206
  while (i < str.length && /\s/.test(str[i])) i++;
458
- if (str[i] === ':' && str[i + 1] !== '=') return match; // : but not := (not that valid in JS contexts usually but safe)
207
+ if (str[i] === ':' && str[i + 1] !== '=') return match;
459
208
 
460
- // 3. Skip Keywords / Globals / Local Vars
461
209
  if (JS_KEYWORDS.has(id)) return match;
462
210
  if (GLOBALS.has(id)) return match;
463
211
  if (localScope.includes(id)) return match;
464
212
 
465
- // 4. Decision
466
213
  if (isOptionsAPI) {
467
- // Options API: Prefix everything unknown
468
214
  return `this.${id}`;
469
215
  } else {
470
- // Setup API: Only prefix explicit bindings
471
216
  if (bindingSet.has(id)) {
472
217
  return `this.${id}`;
473
218
  }
@@ -482,66 +227,50 @@ function processBindings(exp: string, bindings: string[] | undefined, localScope
482
227
  function generate(node: Node, bindings: string[], localScope: string[] = []): string {
483
228
  if (node.type === 'Text') {
484
229
  const text = node as TextNode;
485
- // Escape backticks
486
230
  return `\`${text.content.replace(/`/g, '\\`')}\``;
487
231
  }
488
232
 
489
233
  if (node.type === 'Interpolation') {
490
234
  const interp = node as InterpolationNode;
491
235
  const isRaw = (node as any).raw === true;
492
- return `String(_h(${interp.content}, ${isRaw}))`; // Safe cast with Heimdall Shield
236
+ return `String(_h(${interp.content}, ${isRaw}))`;
493
237
  }
494
238
 
495
239
  if (node.type === 'Element') {
496
240
  const element = node as ElementNode;
497
241
 
498
- // Update Local Scope for Children (v-for)
499
242
  const childScope = [...localScope];
500
243
  if (element.directives.vFor) {
501
244
  childScope.push(element.directives.vFor.item);
502
245
  }
503
246
 
504
247
  if (element.tag === 'fragment') {
505
- return element.children.map(c => generate(c, bindings, childScope)).join(' + '); // Root fragment join
248
+ return element.children.map(c => generate(c, bindings, childScope)).join(' + ');
506
249
  }
507
250
 
508
- // Directives
509
251
  if (element.directives.vIf) {
510
252
  const condition = element.directives.vIf;
511
- // Generate ternary: cond ? render() : ''
512
253
  const children = element.children.map(c => generate(c, bindings, childScope)).join(' + ') || '""';
513
254
  const open = `<${element.tag}${genProps(element.props)}>`;
514
255
  const close = `</${element.tag}>`;
515
- // Apply bindings to v-if condition
516
- // FIX: Wrap condition in _s() to unwrap signals (handling 'isOpen' vs 'isOpen.value')
517
256
  return `(_s(${processBindings(condition, bindings, localScope)}) ? \`${open}\` + (${children}) + \`${close}\` : "")`;
518
257
  }
519
258
 
520
259
  if (element.directives.vFor) {
521
260
  const { item, list } = element.directives.vFor;
522
- // list.map(item => render).join('')
523
-
524
- // Apply bindings to the list expression
525
- // FIX: Wrap list in _s() to ensure we map over the Array, not the Signal Object
526
261
  const boundList = `_s(${processBindings(list, bindings, localScope)})`;
527
-
528
262
  const children = element.children.map(c => generate(c, bindings, childScope)).join(' + ') || '""';
529
263
  const open = `<${element.tag}${genProps(element.props)}>`;
530
264
  const close = `</${element.tag}>`;
531
-
532
- // Add safety check: ensure boundList is an Array before mapping
533
265
  return `(Array.isArray(${boundList}) ? ${boundList}.map(${item} => \`${open}\` + (${children}) + \`${close}\`).join('') : "")`;
534
266
  }
535
267
 
536
- // Standard Element
537
268
  const children = element.children.map(c => generate(c, bindings, childScope)).join(' + ') || '""';
538
269
  const open = `<${element.tag}${genProps(element.props)}>`;
539
270
  const close = `</${element.tag}>`;
540
271
 
541
272
  let code = `\`${open}\` + (${children}) + \`${close}\``;
542
273
 
543
- // Inject Property Binding Side Effects using Comma Operator
544
- // (this._b(..), this._b(..), `html`)
545
274
  if ((element as any)._propertySideEffects) {
546
275
  const effects = (element as any)._propertySideEffects.join(', ');
547
276
  code = `(${effects}, ${code})`;
@@ -558,7 +287,6 @@ function genProps(props: Record<string, string>) {
558
287
  for (const key in props) {
559
288
  str += ` ${key}`;
560
289
  if (props[key] !== '') {
561
- // Escape double quotes in value
562
290
  const escaped = props[key].replace(/"/g, '&quot;');
563
291
  str += `="${escaped}"`;
564
292
  }