pulse-js-framework 1.7.32 → 1.7.37

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/index.js CHANGED
File without changes
package/cli/release.js CHANGED
@@ -532,6 +532,92 @@ function verifyTagOnRemote(version) {
532
532
  }
533
533
  }
534
534
 
535
+ /**
536
+ * Check if gh CLI is available
537
+ */
538
+ function isGhCliAvailable() {
539
+ try {
540
+ execSync('gh --version', { cwd: root, stdio: 'ignore' });
541
+ return true;
542
+ } catch {
543
+ return false;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Create GitHub release using gh CLI
549
+ */
550
+ function createGitHubRelease(version, title, changes) {
551
+ if (!isGhCliAvailable()) {
552
+ log.warn(' gh CLI not available, skipping GitHub release creation');
553
+ log.info(' Install gh CLI: https://cli.github.com/');
554
+ return { success: false, reason: 'gh-not-installed' };
555
+ }
556
+
557
+ // Build release notes
558
+ let notes = '';
559
+
560
+ if (title) {
561
+ notes += `## ${title}\n\n`;
562
+ }
563
+
564
+ if (changes.added?.length > 0) {
565
+ notes += `### ✨ Added\n\n`;
566
+ for (const item of changes.added) {
567
+ notes += `- ${item}\n`;
568
+ }
569
+ notes += '\n';
570
+ }
571
+
572
+ if (changes.changed?.length > 0) {
573
+ notes += `### 🔄 Changed\n\n`;
574
+ for (const item of changes.changed) {
575
+ notes += `- ${item}\n`;
576
+ }
577
+ notes += '\n';
578
+ }
579
+
580
+ if (changes.fixed?.length > 0) {
581
+ notes += `### 🐛 Fixed\n\n`;
582
+ for (const item of changes.fixed) {
583
+ notes += `- ${item}\n`;
584
+ }
585
+ notes += '\n';
586
+ }
587
+
588
+ if (changes.removed?.length > 0) {
589
+ notes += `### 🗑️ Removed\n\n`;
590
+ for (const item of changes.removed) {
591
+ notes += `- ${item}\n`;
592
+ }
593
+ notes += '\n';
594
+ }
595
+
596
+ // Write notes to temp file
597
+ const tempFile = join(tmpdir(), `pulse-release-notes-${Date.now()}.md`);
598
+ writeFileSync(tempFile, notes, 'utf-8');
599
+
600
+ try {
601
+ const releaseTitle = title ? `v${version} - ${title}` : `v${version}`;
602
+ const command = `gh release create v${version} --title "${releaseTitle}" --notes-file "${tempFile}"`;
603
+
604
+ log.info(` Creating GitHub release v${version}...`);
605
+ execSync(command, { cwd: root, stdio: 'inherit' });
606
+
607
+ log.info(` GitHub release created successfully`);
608
+ return { success: true };
609
+ } catch (error) {
610
+ log.error(` Failed to create GitHub release: ${error.message}`);
611
+ return { success: false, error };
612
+ } finally {
613
+ try {
614
+ unlinkSync(tempFile);
615
+ } catch {
616
+ // Ignore cleanup errors
617
+ }
618
+ }
619
+ }
620
+
535
621
  /**
536
622
  * Execute a git command with error handling
537
623
  */
@@ -619,24 +705,53 @@ function gitCommitTagPush(newVersion, title, changes, dryRun = false) {
619
705
  return { success: false, stage: 'push', error: result.error };
620
706
  }
621
707
 
622
- // git push --tags
623
- result = execGitCommand('git push --tags', 'git push --tags');
624
- if (!result.success) {
625
- log.error('');
626
- log.error('Tag push failed. The tag exists locally but not on remote.');
627
- log.error('You can manually push tags with:');
628
- log.error(' git push --tags');
629
- return { success: false, stage: 'push-tags', error: result.error };
630
- }
631
-
632
- // Verify tag was pushed to remote
633
- if (!verifyTagOnRemote(newVersion)) {
634
- log.warn('');
635
- log.warn('Tag push appeared to succeed but tag not found on remote.');
636
- log.warn('You may need to manually verify or push with:');
637
- log.warn(' git push --tags');
708
+ // git push --tags (use gh CLI if available for better error handling)
709
+ if (isGhCliAvailable()) {
710
+ // Use git push directly as gh doesn't have a push command
711
+ result = execGitCommand('git push --tags', 'git push --tags');
712
+ if (!result.success) {
713
+ log.error('');
714
+ log.error('Tag push failed. The tag exists locally but not on remote.');
715
+ log.error('You can manually push tags with:');
716
+ log.error(' git push --tags');
717
+ return { success: false, stage: 'push-tags', error: result.error };
718
+ }
719
+
720
+ // Verify tag was pushed using gh
721
+ try {
722
+ execSync(`gh release view v${newVersion}`, { cwd: root, stdio: 'ignore' });
723
+ log.info(` Verified: tag v${newVersion} exists on remote (via gh)`);
724
+ } catch {
725
+ // Tag pushed but no release yet, which is fine
726
+ if (verifyTagOnRemote(newVersion)) {
727
+ log.info(` Verified: tag v${newVersion} exists on remote`);
728
+ } else {
729
+ log.warn('');
730
+ log.warn('Tag push appeared to succeed but tag not found on remote.');
731
+ log.warn('You may need to manually verify or push with:');
732
+ log.warn(' git push --tags');
733
+ }
734
+ }
638
735
  } else {
639
- log.info(` Verified: tag v${newVersion} exists on remote`);
736
+ // Fallback to standard git push --tags
737
+ result = execGitCommand('git push --tags', 'git push --tags');
738
+ if (!result.success) {
739
+ log.error('');
740
+ log.error('Tag push failed. The tag exists locally but not on remote.');
741
+ log.error('You can manually push tags with:');
742
+ log.error(' git push --tags');
743
+ return { success: false, stage: 'push-tags', error: result.error };
744
+ }
745
+
746
+ // Verify tag was pushed to remote
747
+ if (!verifyTagOnRemote(newVersion)) {
748
+ log.warn('');
749
+ log.warn('Tag push appeared to succeed but tag not found on remote.');
750
+ log.warn('You may need to manually verify or push with:');
751
+ log.warn(' git push --tags');
752
+ } else {
753
+ log.info(` Verified: tag v${newVersion} exists on remote`);
754
+ }
640
755
  }
641
756
 
642
757
  return { success: true };
@@ -714,6 +829,7 @@ Types:
714
829
  Options:
715
830
  --dry-run Show what would be done without making changes
716
831
  --no-push Create commit and tag but don't push
832
+ --no-gh-release Skip creating GitHub release (only create tag)
717
833
  --title <text> Release title (e.g., "Performance Improvements")
718
834
  --skip-prompt Use empty changelog (for automated releases)
719
835
  --skip-docs-test Skip documentation validation before release
@@ -733,6 +849,7 @@ Examples:
733
849
  pulse release patch --title "Security" --fixed "XSS vulnerability,SQL injection" -y
734
850
  pulse release patch --title "New API" --added "Feature A,Feature B" --fixed "Bug X" -y
735
851
  pulse release patch --discord-webhook "https://discord.com/api/webhooks/..."
852
+ pulse release patch --no-gh-release # Create tag but skip GitHub release
736
853
  `);
737
854
  }
738
855
 
@@ -750,6 +867,7 @@ export async function runRelease(args) {
750
867
  // Parse options
751
868
  const dryRun = args.includes('--dry-run');
752
869
  const noPush = args.includes('--no-push');
870
+ const noGhRelease = args.includes('--no-gh-release');
753
871
  const skipPrompt = args.includes('--skip-prompt');
754
872
  const fromCommits = args.includes('--from-commits') || args.includes('--fc');
755
873
  const autoConfirm = args.includes('--yes') || args.includes('-y');
@@ -984,8 +1102,32 @@ export async function runRelease(args) {
984
1102
  log.info(`Release v${newVersion} complete!`);
985
1103
  log.info('');
986
1104
 
1105
+ // Create GitHub release using gh CLI
1106
+ if (!dryRun && !noPush && !noGhRelease && hasChanges) {
1107
+ log.info('Creating GitHub release...');
1108
+ const ghResult = createGitHubRelease(newVersion, title, changes);
1109
+
1110
+ if (!ghResult.success) {
1111
+ if (ghResult.reason === 'gh-not-installed') {
1112
+ log.info('Manual step:');
1113
+ log.info(` Create GitHub release: https://github.com/vincenthirtz/pulse-js-framework/releases/new?tag=v${newVersion}`);
1114
+ } else {
1115
+ log.warn('GitHub release creation failed, but release was successful.');
1116
+ log.info('Manual step:');
1117
+ log.info(` Create GitHub release: https://github.com/vincenthirtz/pulse-js-framework/releases/new?tag=v${newVersion}`);
1118
+ }
1119
+ }
1120
+ } else if (dryRun) {
1121
+ log.info(' [dry-run] Would create GitHub release using gh CLI');
1122
+ } else if (noGhRelease) {
1123
+ log.info('Skipping GitHub release creation (--no-gh-release)');
1124
+ log.info('Manual step:');
1125
+ log.info(` Create GitHub release: https://github.com/vincenthirtz/pulse-js-framework/releases/new?tag=v${newVersion}`);
1126
+ }
1127
+
987
1128
  // Send Discord notification if webhook URL provided
988
1129
  if (discordWebhook && !dryRun) {
1130
+ log.info('');
989
1131
  log.info('Sending Discord notification...');
990
1132
  try {
991
1133
  await sendDiscordNotification(discordWebhook, newVersion, title, changes);
@@ -996,9 +1138,4 @@ export async function runRelease(args) {
996
1138
  } else if (discordWebhook && dryRun) {
997
1139
  log.info(' [dry-run] Would send Discord notification to webhook');
998
1140
  }
999
-
1000
- if (!dryRun && !noPush) {
1001
- log.info('Next step:');
1002
- log.info(` Create GitHub release: https://github.com/vincenthirtz/pulse-js-framework/releases/new?tag=v${newVersion}`);
1003
- }
1004
1141
  }
@@ -100,7 +100,9 @@ export class Parser {
100
100
  * Peek at token at offset
101
101
  */
102
102
  peek(offset = 1) {
103
- return this.tokens[this.pos + offset];
103
+ const index = this.pos + offset;
104
+ if (index < 0 || index >= this.tokens.length) return undefined;
105
+ return this.tokens[index];
104
106
  }
105
107
 
106
108
  /**
@@ -1118,15 +1120,31 @@ export class Parser {
1118
1120
  }
1119
1121
 
1120
1122
  /**
1121
- * Parse assignment expression (a = b)
1123
+ * Parse assignment expression (a = b, a += b, a -= b, etc.)
1122
1124
  */
1123
1125
  parseAssignmentExpression() {
1124
1126
  const left = this.parseConditionalExpression();
1125
1127
 
1126
- if (this.is(TokenType.EQ)) {
1127
- this.advance();
1128
+ // Check for assignment operators
1129
+ const assignmentOps = [
1130
+ TokenType.EQ, // =
1131
+ TokenType.PLUS_ASSIGN, // +=
1132
+ TokenType.MINUS_ASSIGN, // -=
1133
+ TokenType.STAR_ASSIGN, // *=
1134
+ TokenType.SLASH_ASSIGN, // /=
1135
+ TokenType.AND_ASSIGN, // &&=
1136
+ TokenType.OR_ASSIGN, // ||=
1137
+ TokenType.NULLISH_ASSIGN // ??=
1138
+ ];
1139
+
1140
+ if (this.isAny(...assignmentOps)) {
1141
+ const operator = this.advance().value;
1128
1142
  const right = this.parseAssignmentExpression();
1129
- return new ASTNode(NodeType.AssignmentExpression, { left, right });
1143
+ return new ASTNode(NodeType.AssignmentExpression, {
1144
+ left,
1145
+ right,
1146
+ operator
1147
+ });
1130
1148
  }
1131
1149
 
1132
1150
  return left;
@@ -1545,15 +1563,83 @@ export class Parser {
1545
1563
  */
1546
1564
  parseStyleBlock() {
1547
1565
  this.expect(TokenType.STYLE);
1548
- this.expect(TokenType.LBRACE);
1566
+ const startBrace = this.expect(TokenType.LBRACE);
1549
1567
 
1550
- const rules = [];
1551
- while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1552
- rules.push(this.parseStyleRule());
1568
+ // Extract raw CSS content for preprocessor support
1569
+ // Instead of parsing token by token, collect all tokens until matching }
1570
+ const rawTokens = [];
1571
+ let braceDepth = 1; // We've already consumed the opening {
1572
+ const startPos = this.pos;
1573
+
1574
+ while (braceDepth > 0 && !this.is(TokenType.EOF)) {
1575
+ const token = this.current();
1576
+ if (token.type === TokenType.LBRACE) braceDepth++;
1577
+ if (token.type === TokenType.RBRACE) braceDepth--;
1578
+
1579
+ if (braceDepth > 0) {
1580
+ rawTokens.push(token);
1581
+ this.advance();
1582
+ }
1553
1583
  }
1554
1584
 
1555
1585
  this.expect(TokenType.RBRACE);
1556
- return new ASTNode(NodeType.StyleBlock, { rules });
1586
+
1587
+ // Reconstruct raw CSS from tokens for preprocessor
1588
+ const rawCSS = this.reconstructCSS(rawTokens);
1589
+
1590
+ // Try to parse as structured CSS (will work for plain CSS)
1591
+ // If parsing fails, fall back to raw mode for preprocessors
1592
+ let rules = [];
1593
+ let parseError = null;
1594
+
1595
+ // Reset to try parsing
1596
+ const savedPos = this.pos;
1597
+ this.pos = startPos;
1598
+
1599
+ try {
1600
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
1601
+ rules.push(this.parseStyleRule());
1602
+ }
1603
+ } catch (error) {
1604
+ // Parsing failed - likely preprocessor syntax (LESS/SASS/Stylus)
1605
+ parseError = error;
1606
+ rules = []; // Clear any partial parse
1607
+ }
1608
+
1609
+ // Restore position to after the closing }
1610
+ this.pos = savedPos;
1611
+
1612
+ return new ASTNode(NodeType.StyleBlock, {
1613
+ rules,
1614
+ raw: rawCSS,
1615
+ parseError: parseError ? parseError.message : null
1616
+ });
1617
+ }
1618
+
1619
+ /**
1620
+ * Reconstruct CSS from tokens, preserving formatting
1621
+ */
1622
+ reconstructCSS(tokens) {
1623
+ if (!tokens.length) return '';
1624
+
1625
+ const lines = [];
1626
+ let currentLine = [];
1627
+ let lastLine = tokens[0].line;
1628
+
1629
+ for (const token of tokens) {
1630
+ if (token.line !== lastLine) {
1631
+ lines.push(currentLine.join(''));
1632
+ currentLine = [];
1633
+ lastLine = token.line;
1634
+ }
1635
+ currentLine.push(token.raw || token.value);
1636
+ }
1637
+
1638
+ if (currentLine.length > 0) {
1639
+ lines.push(currentLine.join(''));
1640
+ }
1641
+
1642
+ return lines.join('\n').trim();
1557
1643
  }
1558
1644
 
1559
1645
  /**
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Generate a unique scope ID for CSS scoping */
8
- export const generateScopeId = () => 'p' + Math.random().toString(36).substring(2, 8);
8
+ export const generateScopeId = () => 'p' + Math.random().toString(36).substring(2, 12);
9
9
 
10
10
  /** Token types that should not have space after them */
11
11
  export const NO_SPACE_AFTER = new Set([
@@ -82,12 +82,12 @@ export function transformExpression(transformer, node) {
82
82
 
83
83
  case NodeType.UpdateExpression: {
84
84
  const argument = transformExpression(transformer, node.argument);
85
- // For state variables, convert x++ to x.set(x.get() + 1)
85
+ // For state variables, convert x++ to x.update(x => x + 1)
86
86
  if (node.argument.type === NodeType.Identifier &&
87
87
  transformer.stateVars.has(node.argument.name)) {
88
88
  const name = node.argument.name;
89
- const delta = node.operator === '++' ? 1 : -1;
90
- return `${name}.set(${name}.get() + ${delta})`;
89
+ const op = node.operator === '++' ? '+' : '-';
90
+ return `${name}.update(_${name} => _${name} ${op} 1)`;
91
91
  }
92
92
  return node.prefix
93
93
  ? `${node.operator}${argument}`
@@ -117,12 +117,32 @@ export function transformExpression(transformer, node) {
117
117
  case NodeType.AssignmentExpression: {
118
118
  const left = transformExpression(transformer, node.left);
119
119
  const right = transformExpression(transformer, node.right);
120
- // For state variables, convert to .set()
120
+ const operator = node.operator || '=';
121
+
122
+ // For state variables, convert to .set() or .update()
121
123
  if (node.left.type === NodeType.Identifier &&
122
124
  transformer.stateVars.has(node.left.name)) {
123
- return `${node.left.name}.set(${right})`;
125
+ const varName = node.left.name;
126
+
127
+ // Compound assignment operators (+=, -=, *=, /=, &&=, ||=, ??=)
128
+ if (operator !== '=') {
129
+ // Convert compound assignment to update
130
+ // a += b => a.update(_a => _a + b)
131
+ // a -= b => a.update(_a => _a - b)
132
+ const baseOp = operator.slice(0, -1); // Remove trailing '='
133
+ return `${varName}.update(_${varName} => _${varName} ${baseOp} ${right})`;
134
+ }
135
+
136
+ // Simple assignment: a = b => a.set(b)
137
+ return `${varName}.set(${right})`;
138
+ }
139
+
140
+ // Regular assignment (non-state vars)
141
+ if (operator === '=') {
142
+ return `(${left} = ${right})`;
124
143
  }
125
- return `(${left} = ${right})`;
144
+ // Compound assignment for non-state vars
145
+ return `(${left} ${operator} ${right})`;
126
146
  }
127
147
 
128
148
  case NodeType.ArrayLiteral: {
@@ -162,14 +182,54 @@ export function transformExpression(transformer, node) {
162
182
  * @returns {string} Transformed expression string
163
183
  */
164
184
  export function transformExpressionString(transformer, exprStr) {
165
- // Simple transformation: wrap state and prop vars with .get()
185
+ // Transform state and prop vars in expression strings (interpolations, attribute bindings)
166
186
  // Both are now reactive (useProp returns computed for uniform interface)
167
187
  let result = exprStr;
168
188
 
169
- // Transform state vars
189
+ // First, handle assignments to state vars: stateVar = expr -> stateVar.set(expr)
190
+ // This must happen before the generic .get() replacement to avoid generating
191
+ // invalid code like stateVar.get() = expr (LHS of assignment is not a reference)
192
+ for (const stateVar of transformer.stateVars) {
193
+ // Compound assignment: stateVar += expr -> stateVar.update(_v => _v + expr)
194
+ result = result.replace(
195
+ new RegExp(`\\b${stateVar}\\s*(\\+=|-=|\\*=|\\/=|&&=|\\|\\|=|\\?\\?=)\\s*`, 'g'),
196
+ (_match, op) => {
197
+ const baseOp = op.slice(0, -1); // Remove trailing '='
198
+ return `${stateVar}.update(_v => _v ${baseOp} `;
199
+ }
200
+ );
201
+ // Close the .update() call - find the end of the expression after the replacement
202
+ // This is handled by the fact that the expression continues after the replacement text
203
+ // and the closing paren is added by wrapping logic below.
204
+
205
+ // Simple assignment: stateVar = expr -> stateVar.set(expr)
206
+ // Use negative lookbehind to skip compound assignments (already handled)
207
+ // Use negative lookahead to skip == and ===
208
+ result = result.replace(
209
+ new RegExp(`\\b${stateVar}\\s*=(?!=)`, 'g'),
210
+ `${stateVar}.set(`
211
+ );
212
+ }
213
+
214
+ // If we inserted .set( or .update(, we need to close the parenthesis
215
+ // Find unclosed .set( and .update( calls and close them at end of expression
216
+ if (result.includes('.set(') || result.includes('.update(_v =>')) {
217
+ // For .update(_v => _v op expr), close with )
218
+ result = result.replace(
219
+ /\.update\(_v => _v [^\)]*$/,
220
+ (m) => m + ')'
221
+ );
222
+ // For .set(expr), close with )
223
+ result = result.replace(
224
+ /\.set\(([^)]*$)/,
225
+ (_m, expr) => `.set(${expr})`
226
+ );
227
+ }
228
+
229
+ // Transform state var reads (not already transformed to .get/.set/.update)
170
230
  for (const stateVar of transformer.stateVars) {
171
231
  result = result.replace(
172
- new RegExp(`\\b${stateVar}\\b`, 'g'),
232
+ new RegExp(`\\b${stateVar}\\b(?!\\.(?:get|set|update))`, 'g'),
173
233
  `${stateVar}.get()`
174
234
  );
175
235
  }