pulse-js-framework 1.2.0 → 1.4.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/cli/format.js ADDED
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Pulse CLI - Format Command
3
+ * Formats .pulse files consistently
4
+ */
5
+
6
+ import { readFileSync, writeFileSync } from 'fs';
7
+ import { findPulseFiles, parseArgs, relativePath } from './utils/file-utils.js';
8
+
9
+ /**
10
+ * Default format options
11
+ */
12
+ export const FormatOptions = {
13
+ indentSize: 2,
14
+ maxLineLength: 100,
15
+ sortImports: true,
16
+ insertFinalNewline: true
17
+ };
18
+
19
+ /**
20
+ * Pulse code formatter (AST-based)
21
+ */
22
+ export class PulseFormatter {
23
+ constructor(ast, options = {}) {
24
+ this.ast = ast;
25
+ this.options = { ...FormatOptions, ...options };
26
+ this.indent = 0;
27
+ this.output = [];
28
+ }
29
+
30
+ /**
31
+ * Format the AST to string
32
+ */
33
+ format() {
34
+ // Imports (sorted if option enabled)
35
+ this.formatImports();
36
+
37
+ // Page declaration
38
+ if (this.ast.page) {
39
+ this.emit(`@page ${this.ast.page.name}`);
40
+ this.emit('');
41
+ }
42
+
43
+ // Route declaration
44
+ if (this.ast.route) {
45
+ this.emit(`@route "${this.ast.route.path}"`);
46
+ this.emit('');
47
+ }
48
+
49
+ // State block
50
+ if (this.ast.state) {
51
+ this.formatStateBlock();
52
+ }
53
+
54
+ // View block
55
+ if (this.ast.view) {
56
+ this.formatViewBlock();
57
+ }
58
+
59
+ // Actions block
60
+ if (this.ast.actions) {
61
+ this.formatActionsBlock();
62
+ }
63
+
64
+ // Style block
65
+ if (this.ast.style) {
66
+ this.formatStyleBlock();
67
+ }
68
+
69
+ let result = this.output.join('\n');
70
+
71
+ // Ensure final newline
72
+ if (this.options.insertFinalNewline && !result.endsWith('\n')) {
73
+ result += '\n';
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Format import statements
81
+ */
82
+ formatImports() {
83
+ let imports = [...(this.ast.imports || [])];
84
+
85
+ if (imports.length === 0) return;
86
+
87
+ if (this.options.sortImports) {
88
+ imports.sort((a, b) => a.source.localeCompare(b.source));
89
+ }
90
+
91
+ for (const imp of imports) {
92
+ this.emit(this.formatImportStatement(imp));
93
+ }
94
+
95
+ this.emit('');
96
+ }
97
+
98
+ /**
99
+ * Format a single import statement
100
+ */
101
+ formatImportStatement(imp) {
102
+ const specifiers = imp.specifiers || [];
103
+
104
+ if (specifiers.length === 0) {
105
+ return `import '${imp.source}'`;
106
+ }
107
+
108
+ // Default import
109
+ const defaultSpec = specifiers.find(s => s.type === 'default');
110
+ // Named imports
111
+ const namedSpecs = specifiers.filter(s => s.type === 'named');
112
+ // Namespace import
113
+ const namespaceSpec = specifiers.find(s => s.type === 'namespace');
114
+
115
+ let parts = ['import'];
116
+
117
+ if (defaultSpec) {
118
+ parts.push(defaultSpec.local);
119
+ }
120
+
121
+ if (namespaceSpec) {
122
+ if (defaultSpec) parts.push(',');
123
+ parts.push(`* as ${namespaceSpec.local}`);
124
+ }
125
+
126
+ if (namedSpecs.length > 0) {
127
+ if (defaultSpec || namespaceSpec) parts.push(',');
128
+
129
+ const namedParts = namedSpecs.map(s => {
130
+ if (s.imported && s.imported !== s.local) {
131
+ return `${s.imported} as ${s.local}`;
132
+ }
133
+ return s.local;
134
+ });
135
+
136
+ parts.push(`{ ${namedParts.join(', ')} }`);
137
+ }
138
+
139
+ parts.push('from');
140
+ parts.push(`'${imp.source}'`);
141
+
142
+ return parts.join(' ');
143
+ }
144
+
145
+ /**
146
+ * Format state block
147
+ */
148
+ formatStateBlock() {
149
+ this.emit('state {');
150
+ this.indent++;
151
+
152
+ const properties = this.ast.state.properties || [];
153
+ for (const prop of properties) {
154
+ const value = this.formatValue(prop.value);
155
+ this.emit(`${prop.name}: ${value}`);
156
+ }
157
+
158
+ this.indent--;
159
+ this.emit('}');
160
+ this.emit('');
161
+ }
162
+
163
+ /**
164
+ * Format a value (literal, object, array)
165
+ */
166
+ formatValue(node) {
167
+ if (node === null || node === undefined) {
168
+ return 'null';
169
+ }
170
+
171
+ if (typeof node === 'string') {
172
+ return `"${this.escapeString(node)}"`;
173
+ }
174
+
175
+ if (typeof node === 'number' || typeof node === 'boolean') {
176
+ return String(node);
177
+ }
178
+
179
+ if (node.type === 'Literal') {
180
+ if (typeof node.value === 'string') {
181
+ return `"${this.escapeString(node.value)}"`;
182
+ }
183
+ return String(node.value);
184
+ }
185
+
186
+ if (node.type === 'Identifier') {
187
+ return node.name;
188
+ }
189
+
190
+ if (node.type === 'ArrayLiteral' || Array.isArray(node.elements)) {
191
+ const elements = node.elements || [];
192
+ if (elements.length === 0) return '[]';
193
+
194
+ const formatted = elements.map(el => this.formatValue(el));
195
+ const inline = `[${formatted.join(', ')}]`;
196
+
197
+ if (inline.length <= 60) {
198
+ return inline;
199
+ }
200
+
201
+ // Multi-line array
202
+ return `[\n${formatted.map(f => this.getIndent(1) + f).join(',\n')}\n${this.getIndent()}]`;
203
+ }
204
+
205
+ if (node.type === 'ObjectLiteral' || node.properties) {
206
+ const properties = node.properties || [];
207
+ if (properties.length === 0) return '{}';
208
+
209
+ const formatted = properties.map(p => `${p.name}: ${this.formatValue(p.value)}`);
210
+ const inline = `{ ${formatted.join(', ')} }`;
211
+
212
+ if (inline.length <= 60) {
213
+ return inline;
214
+ }
215
+
216
+ // Multi-line object
217
+ return `{\n${formatted.map(f => this.getIndent(1) + f).join(',\n')}\n${this.getIndent()}}`;
218
+ }
219
+
220
+ // Default - return as-is
221
+ return String(node.value || node);
222
+ }
223
+
224
+ /**
225
+ * Format view block
226
+ */
227
+ formatViewBlock() {
228
+ this.emit('view {');
229
+ this.indent++;
230
+
231
+ const children = this.ast.view.children || [];
232
+ for (const child of children) {
233
+ this.formatViewNode(child);
234
+ }
235
+
236
+ this.indent--;
237
+ this.emit('}');
238
+ this.emit('');
239
+ }
240
+
241
+ /**
242
+ * Format a single view node
243
+ */
244
+ formatViewNode(node) {
245
+ if (!node) return;
246
+
247
+ switch (node.type) {
248
+ case 'Element':
249
+ this.formatElement(node);
250
+ break;
251
+
252
+ case 'TextNode':
253
+ this.formatTextNode(node);
254
+ break;
255
+
256
+ case 'IfDirective':
257
+ this.formatIfDirective(node);
258
+ break;
259
+
260
+ case 'EachDirective':
261
+ this.formatEachDirective(node);
262
+ break;
263
+
264
+ case 'SlotElement':
265
+ this.formatSlot(node);
266
+ break;
267
+
268
+ default:
269
+ // Unknown node type - skip
270
+ break;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Format an expression to string
276
+ */
277
+ formatExpression(expr) {
278
+ if (!expr) return '';
279
+ if (typeof expr === 'string') return expr;
280
+
281
+ // Handle AST expression nodes
282
+ switch (expr.type) {
283
+ case 'Identifier':
284
+ return expr.name;
285
+ case 'Literal':
286
+ return typeof expr.value === 'string' ? `"${expr.value}"` : String(expr.value);
287
+ case 'MemberExpression':
288
+ return `${this.formatExpression(expr.object)}.${this.formatExpression(expr.property)}`;
289
+ case 'CallExpression':
290
+ const callee = this.formatExpression(expr.callee);
291
+ const args = (expr.arguments || []).map(a => this.formatExpression(a)).join(', ');
292
+ return `${callee}(${args})`;
293
+ case 'BinaryExpression':
294
+ return `${this.formatExpression(expr.left)} ${expr.operator} ${this.formatExpression(expr.right)}`;
295
+ case 'UpdateExpression':
296
+ return expr.prefix
297
+ ? `${expr.operator}${this.formatExpression(expr.argument)}`
298
+ : `${this.formatExpression(expr.argument)}${expr.operator}`;
299
+ case 'UnaryExpression':
300
+ return `${expr.operator}${this.formatExpression(expr.argument)}`;
301
+ default:
302
+ // Fallback to raw if available
303
+ if (expr.raw) return expr.raw;
304
+ return String(expr);
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Format an element node
310
+ */
311
+ formatElement(node) {
312
+ let line = node.selector || node.tag || 'div';
313
+
314
+ // Add inline directives
315
+ for (const dir of node.directives || []) {
316
+ const handler = this.formatExpression(dir.handler || dir.expression || '');
317
+ line += ` @${dir.event || dir.name}(${handler})`;
318
+ }
319
+
320
+ // Get text content
321
+ const textContent = this.getTextContent(node);
322
+ const children = node.children || [];
323
+ const hasChildren = children.length > 0;
324
+
325
+ if (textContent && !hasChildren) {
326
+ // Inline text content
327
+ line += ` "${this.escapeString(textContent)}"`;
328
+ this.emit(line);
329
+ } else if (hasChildren) {
330
+ // Block with children
331
+ this.emit(`${line} {`);
332
+ this.indent++;
333
+
334
+ // Add text content as first child if present
335
+ if (textContent) {
336
+ this.emit(`"${this.escapeString(textContent)}"`);
337
+ }
338
+
339
+ for (const child of children) {
340
+ this.formatViewNode(child);
341
+ }
342
+
343
+ this.indent--;
344
+ this.emit('}');
345
+ } else {
346
+ // Empty element
347
+ this.emit(line);
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Format a part of an interpolated string
353
+ */
354
+ formatPart(p) {
355
+ if (typeof p === 'string') return p;
356
+ // Handle Interpolation AST node (type: 'Interpolation', expression: string)
357
+ if (p.type === 'Interpolation') {
358
+ const expr = typeof p.expression === 'string' ? p.expression : this.formatExpression(p.expression);
359
+ return `{${expr}}`;
360
+ }
361
+ // Handle plain object with expr property
362
+ if (p.expr) return `{${p.expr}}`;
363
+ if (p.expression) return `{${p.expression}}`;
364
+ return String(p);
365
+ }
366
+
367
+ /**
368
+ * Get text content from element
369
+ */
370
+ getTextContent(node) {
371
+ const textContent = node.textContent || [];
372
+
373
+ if (textContent.length === 0) return '';
374
+
375
+ return textContent.map(t => {
376
+ if (typeof t === 'string') return t;
377
+ // Handle TextNode with parts (interpolated strings)
378
+ if (t.type === 'TextNode' && t.parts) {
379
+ return t.parts.map(p => this.formatPart(p)).join('');
380
+ }
381
+ if (t.type === 'Interpolation') {
382
+ const expr = typeof t.expression === 'string' ? t.expression : this.formatExpression(t.expression);
383
+ return `{${expr}}`;
384
+ }
385
+ if (t.type === 'TextNode') return t.value || '';
386
+ return String(t.value || t);
387
+ }).join('');
388
+ }
389
+
390
+ /**
391
+ * Format a text node
392
+ */
393
+ formatTextNode(node) {
394
+ const value = node.value || '';
395
+ this.emit(`"${this.escapeString(value)}"`);
396
+ }
397
+
398
+ /**
399
+ * Format if directive
400
+ */
401
+ formatIfDirective(node) {
402
+ const condition = node.condition || 'true';
403
+ this.emit(`@if (${condition}) {`);
404
+ this.indent++;
405
+
406
+ const consequent = node.consequent?.children || node.consequent || [];
407
+ for (const child of consequent) {
408
+ this.formatViewNode(child);
409
+ }
410
+
411
+ this.indent--;
412
+
413
+ if (node.alternate) {
414
+ this.emit('} @else {');
415
+ this.indent++;
416
+
417
+ const alternate = node.alternate?.children || node.alternate || [];
418
+ for (const child of alternate) {
419
+ this.formatViewNode(child);
420
+ }
421
+
422
+ this.indent--;
423
+ }
424
+
425
+ this.emit('}');
426
+ }
427
+
428
+ /**
429
+ * Format each directive
430
+ */
431
+ formatEachDirective(node) {
432
+ const item = node.item || 'item';
433
+ const iterable = node.iterable || 'items';
434
+ this.emit(`@each (${item} in ${iterable}) {`);
435
+ this.indent++;
436
+
437
+ const body = node.body?.children || node.body || [];
438
+ for (const child of body) {
439
+ this.formatViewNode(child);
440
+ }
441
+
442
+ this.indent--;
443
+ this.emit('}');
444
+ }
445
+
446
+ /**
447
+ * Format slot element
448
+ */
449
+ formatSlot(node) {
450
+ const name = node.name;
451
+ const hasFallback = node.fallback && node.fallback.length > 0;
452
+
453
+ if (name === 'default' && !hasFallback) {
454
+ this.emit('slot');
455
+ } else if (!hasFallback) {
456
+ this.emit(`slot "${name}"`);
457
+ } else {
458
+ this.emit(`slot "${name}" {`);
459
+ this.indent++;
460
+
461
+ for (const child of node.fallback) {
462
+ this.formatViewNode(child);
463
+ }
464
+
465
+ this.indent--;
466
+ this.emit('}');
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Format actions block
472
+ */
473
+ formatActionsBlock() {
474
+ this.emit('actions {');
475
+ this.indent++;
476
+
477
+ const functions = this.ast.actions.functions || [];
478
+ for (let i = 0; i < functions.length; i++) {
479
+ const fn = functions[i];
480
+ this.formatFunction(fn);
481
+
482
+ // Add blank line between functions
483
+ if (i < functions.length - 1) {
484
+ this.emit('');
485
+ }
486
+ }
487
+
488
+ this.indent--;
489
+ this.emit('}');
490
+ this.emit('');
491
+ }
492
+
493
+ /**
494
+ * Format a function declaration
495
+ */
496
+ formatFunction(fn) {
497
+ const async = fn.async ? 'async ' : '';
498
+ const params = (fn.params || []).join(', ');
499
+ const body = fn.body || fn.rawBody || '';
500
+
501
+ // Simple one-liner
502
+ if (!body.includes('\n') && body.length < 50) {
503
+ this.emit(`${async}${fn.name}(${params}) { ${body.trim()} }`);
504
+ } else {
505
+ this.emit(`${async}${fn.name}(${params}) {`);
506
+ this.indent++;
507
+
508
+ // Format body with proper indentation
509
+ const lines = body.split('\n').filter(l => l.trim());
510
+ for (const line of lines) {
511
+ this.emit(line.trim());
512
+ }
513
+
514
+ this.indent--;
515
+ this.emit('}');
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Format style block
521
+ */
522
+ formatStyleBlock() {
523
+ this.emit('style {');
524
+ this.indent++;
525
+
526
+ const rules = this.ast.style.rules || [];
527
+ for (let i = 0; i < rules.length; i++) {
528
+ this.formatStyleRule(rules[i]);
529
+
530
+ // Add blank line between top-level rules
531
+ if (i < rules.length - 1) {
532
+ this.emit('');
533
+ }
534
+ }
535
+
536
+ this.indent--;
537
+ this.emit('}');
538
+ }
539
+
540
+ /**
541
+ * Format a CSS rule
542
+ */
543
+ formatStyleRule(rule) {
544
+ const selector = rule.selector || '';
545
+ const properties = rule.properties || [];
546
+ const nestedRules = rule.rules || [];
547
+
548
+ if (properties.length === 0 && nestedRules.length === 0) {
549
+ this.emit(`${selector} {}`);
550
+ return;
551
+ }
552
+
553
+ this.emit(`${selector} {`);
554
+ this.indent++;
555
+
556
+ // Properties
557
+ for (const prop of properties) {
558
+ this.emit(`${prop.name}: ${prop.value}`);
559
+ }
560
+
561
+ // Nested rules
562
+ for (const nested of nestedRules) {
563
+ if (properties.length > 0) {
564
+ this.emit('');
565
+ }
566
+ this.formatStyleRule(nested);
567
+ }
568
+
569
+ this.indent--;
570
+ this.emit('}');
571
+ }
572
+
573
+ /**
574
+ * Emit a line with current indentation
575
+ */
576
+ emit(text) {
577
+ if (text === '') {
578
+ this.output.push('');
579
+ } else {
580
+ this.output.push(this.getIndent() + text);
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Get indentation string
586
+ */
587
+ getIndent(extra = 0) {
588
+ return ' '.repeat((this.indent + extra) * this.options.indentSize);
589
+ }
590
+
591
+ /**
592
+ * Escape string for output
593
+ */
594
+ escapeString(str) {
595
+ return str
596
+ .replace(/\\/g, '\\\\')
597
+ .replace(/"/g, '\\"')
598
+ .replace(/\n/g, '\\n')
599
+ .replace(/\r/g, '\\r')
600
+ .replace(/\t/g, '\\t');
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Format a single file
606
+ */
607
+ export async function formatFile(filePath, options = {}) {
608
+ const { parse } = await import('../compiler/index.js');
609
+
610
+ const source = readFileSync(filePath, 'utf-8');
611
+
612
+ // Parse the file
613
+ let ast;
614
+ try {
615
+ ast = parse(source);
616
+ } catch (e) {
617
+ return {
618
+ file: filePath,
619
+ error: e.message,
620
+ formatted: null
621
+ };
622
+ }
623
+
624
+ // Format
625
+ const formatter = new PulseFormatter(ast, options);
626
+ const formatted = formatter.format();
627
+
628
+ return {
629
+ file: filePath,
630
+ original: source,
631
+ formatted,
632
+ changed: source !== formatted
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Main format command handler
638
+ */
639
+ export async function runFormat(args) {
640
+ const { options, patterns } = parseArgs(args);
641
+ const check = options.check || false;
642
+ const write = !check; // Default to write unless --check is specified
643
+
644
+ // Find files to format
645
+ const files = findPulseFiles(patterns);
646
+
647
+ if (files.length === 0) {
648
+ console.log('No .pulse files found to format.');
649
+ return;
650
+ }
651
+
652
+ console.log(`${check ? 'Checking' : 'Formatting'} ${files.length} file(s)...\n`);
653
+
654
+ let changedCount = 0;
655
+ let errorCount = 0;
656
+
657
+ for (const file of files) {
658
+ const result = await formatFile(file, options);
659
+ const relPath = relativePath(file);
660
+
661
+ if (result.error) {
662
+ console.log(` ${relPath} - ERROR: ${result.error}`);
663
+ errorCount++;
664
+ continue;
665
+ }
666
+
667
+ if (result.changed) {
668
+ changedCount++;
669
+
670
+ if (check) {
671
+ console.log(` ${relPath} - needs formatting`);
672
+ } else if (write) {
673
+ writeFileSync(file, result.formatted);
674
+ console.log(` ${relPath} - formatted`);
675
+ }
676
+ } else {
677
+ if (!check) {
678
+ console.log(` ${relPath} - unchanged`);
679
+ }
680
+ }
681
+ }
682
+
683
+ // Summary
684
+ console.log('\n' + '─'.repeat(60));
685
+
686
+ if (errorCount > 0) {
687
+ console.log(`✗ ${errorCount} file(s) had errors`);
688
+ }
689
+
690
+ if (check) {
691
+ if (changedCount > 0) {
692
+ console.log(`✗ ${changedCount} file(s) need formatting`);
693
+ process.exit(1);
694
+ } else {
695
+ console.log(`✓ All ${files.length} file(s) are properly formatted`);
696
+ }
697
+ } else {
698
+ if (changedCount > 0) {
699
+ console.log(`✓ ${changedCount} file(s) formatted`);
700
+ } else {
701
+ console.log(`✓ All ${files.length} file(s) were already formatted`);
702
+ }
703
+ }
704
+ }