pulse-js-framework 1.7.22 → 1.7.24
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/release.js +173 -25
- package/compiler/lexer.js +65 -6
- package/compiler/parser.js +230 -68
- package/compiler/transformer/style.js +86 -12
- package/loader/vite-plugin.js +62 -17
- package/package.json +3 -2
package/cli/release.js
CHANGED
|
@@ -508,6 +508,46 @@ function buildCommitMessage(newVersion, title, changes) {
|
|
|
508
508
|
return message;
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Verify that a git tag exists locally
|
|
513
|
+
*/
|
|
514
|
+
function verifyTagExists(version) {
|
|
515
|
+
try {
|
|
516
|
+
const tags = execSync('git tag -l', { cwd: root, encoding: 'utf-8' });
|
|
517
|
+
return tags.split('\n').includes(`v${version}`);
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Verify that a git tag exists on remote
|
|
525
|
+
*/
|
|
526
|
+
function verifyTagOnRemote(version) {
|
|
527
|
+
try {
|
|
528
|
+
const remoteTags = execSync('git ls-remote --tags origin', { cwd: root, encoding: 'utf-8' });
|
|
529
|
+
return remoteTags.includes(`refs/tags/v${version}`);
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Execute a git command with error handling
|
|
537
|
+
*/
|
|
538
|
+
function execGitCommand(command, description) {
|
|
539
|
+
log.info(` Running: ${description}...`);
|
|
540
|
+
try {
|
|
541
|
+
execSync(command, { cwd: root, stdio: 'inherit' });
|
|
542
|
+
return { success: true };
|
|
543
|
+
} catch (error) {
|
|
544
|
+
log.error(` Failed: ${description}`);
|
|
545
|
+
log.error(` Command: ${command}`);
|
|
546
|
+
log.error(` Error: ${error.message}`);
|
|
547
|
+
return { success: false, error };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
511
551
|
/**
|
|
512
552
|
* Execute git commands
|
|
513
553
|
*/
|
|
@@ -521,33 +561,142 @@ function gitCommitTagPush(newVersion, title, changes, dryRun = false) {
|
|
|
521
561
|
log.info(` [dry-run] git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
522
562
|
log.info(' [dry-run] git push');
|
|
523
563
|
log.info(' [dry-run] git push --tags');
|
|
524
|
-
return;
|
|
564
|
+
return { success: true };
|
|
525
565
|
}
|
|
526
566
|
|
|
527
567
|
// git add
|
|
528
|
-
|
|
529
|
-
|
|
568
|
+
let result = execGitCommand('git add -A', 'git add -A');
|
|
569
|
+
if (!result.success) {
|
|
570
|
+
return { success: false, stage: 'add', error: result.error };
|
|
571
|
+
}
|
|
530
572
|
|
|
531
573
|
// git commit using temp file for cross-platform compatibility
|
|
532
|
-
log.info(' Running: git commit...');
|
|
533
574
|
const tempFile = join(tmpdir(), `pulse-release-${Date.now()}.txt`);
|
|
534
575
|
writeFileSync(tempFile, commitMessage, 'utf-8');
|
|
535
576
|
try {
|
|
536
|
-
|
|
577
|
+
result = execGitCommand(`git commit -F "${tempFile}"`, 'git commit');
|
|
578
|
+
if (!result.success) {
|
|
579
|
+
return { success: false, stage: 'commit', error: result.error };
|
|
580
|
+
}
|
|
537
581
|
} finally {
|
|
538
|
-
|
|
582
|
+
try {
|
|
583
|
+
unlinkSync(tempFile);
|
|
584
|
+
} catch {
|
|
585
|
+
// Ignore cleanup errors
|
|
586
|
+
}
|
|
539
587
|
}
|
|
540
588
|
|
|
541
589
|
// git tag
|
|
542
|
-
|
|
543
|
-
|
|
590
|
+
result = execGitCommand(
|
|
591
|
+
`git tag -a v${newVersion} -m "Release v${newVersion}"`,
|
|
592
|
+
`git tag v${newVersion}`
|
|
593
|
+
);
|
|
594
|
+
if (!result.success) {
|
|
595
|
+
log.error('');
|
|
596
|
+
log.error('Tag creation failed. The commit was created but not tagged.');
|
|
597
|
+
log.error('You can manually create the tag with:');
|
|
598
|
+
log.error(` git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
599
|
+
return { success: false, stage: 'tag', error: result.error };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Verify tag was created
|
|
603
|
+
if (!verifyTagExists(newVersion)) {
|
|
604
|
+
log.error('');
|
|
605
|
+
log.error('Tag creation appeared to succeed but tag not found locally.');
|
|
606
|
+
log.error('You can manually create the tag with:');
|
|
607
|
+
log.error(` git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
608
|
+
return { success: false, stage: 'tag-verify', error: new Error('Tag not found after creation') };
|
|
609
|
+
}
|
|
610
|
+
log.info(` Verified: tag v${newVersion} exists locally`);
|
|
544
611
|
|
|
545
612
|
// git push
|
|
546
|
-
|
|
547
|
-
|
|
613
|
+
result = execGitCommand('git push', 'git push');
|
|
614
|
+
if (!result.success) {
|
|
615
|
+
log.error('');
|
|
616
|
+
log.error('Push failed. Commit and tag were created locally.');
|
|
617
|
+
log.error('You can manually push with:');
|
|
618
|
+
log.error(' git push && git push --tags');
|
|
619
|
+
return { success: false, stage: 'push', error: result.error };
|
|
620
|
+
}
|
|
621
|
+
|
|
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');
|
|
638
|
+
} else {
|
|
639
|
+
log.info(` Verified: tag v${newVersion} exists on remote`);
|
|
640
|
+
}
|
|
548
641
|
|
|
549
|
-
|
|
550
|
-
|
|
642
|
+
return { success: true };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Execute git commands without pushing (--no-push mode)
|
|
647
|
+
*/
|
|
648
|
+
function gitCommitTagNoPush(newVersion, title, changes) {
|
|
649
|
+
const commitMessage = buildCommitMessage(newVersion, title, changes);
|
|
650
|
+
|
|
651
|
+
// git add
|
|
652
|
+
let result = execGitCommand('git add -A', 'git add -A');
|
|
653
|
+
if (!result.success) {
|
|
654
|
+
return { success: false, stage: 'add', error: result.error };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// git commit using temp file for cross-platform compatibility
|
|
658
|
+
const tempFile = join(tmpdir(), `pulse-release-${Date.now()}.txt`);
|
|
659
|
+
writeFileSync(tempFile, commitMessage, 'utf-8');
|
|
660
|
+
try {
|
|
661
|
+
result = execGitCommand(`git commit -F "${tempFile}"`, 'git commit');
|
|
662
|
+
if (!result.success) {
|
|
663
|
+
return { success: false, stage: 'commit', error: result.error };
|
|
664
|
+
}
|
|
665
|
+
} finally {
|
|
666
|
+
try {
|
|
667
|
+
unlinkSync(tempFile);
|
|
668
|
+
} catch {
|
|
669
|
+
// Ignore cleanup errors
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// git tag
|
|
674
|
+
result = execGitCommand(
|
|
675
|
+
`git tag -a v${newVersion} -m "Release v${newVersion}"`,
|
|
676
|
+
`git tag v${newVersion}`
|
|
677
|
+
);
|
|
678
|
+
if (!result.success) {
|
|
679
|
+
log.error('');
|
|
680
|
+
log.error('Tag creation failed. The commit was created but not tagged.');
|
|
681
|
+
log.error('You can manually create the tag with:');
|
|
682
|
+
log.error(` git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
683
|
+
return { success: false, stage: 'tag', error: result.error };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Verify tag was created
|
|
687
|
+
if (!verifyTagExists(newVersion)) {
|
|
688
|
+
log.error('');
|
|
689
|
+
log.error('Tag creation appeared to succeed but tag not found locally.');
|
|
690
|
+
log.error('You can manually create the tag with:');
|
|
691
|
+
log.error(` git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
692
|
+
return { success: false, stage: 'tag-verify', error: new Error('Tag not found after creation') };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
log.info(` Verified: tag v${newVersion} exists locally`);
|
|
696
|
+
log.info(' Created commit and tag (--no-push specified)');
|
|
697
|
+
log.info(' To push later, run: git push && git push --tags');
|
|
698
|
+
|
|
699
|
+
return { success: true };
|
|
551
700
|
}
|
|
552
701
|
|
|
553
702
|
/**
|
|
@@ -811,25 +960,24 @@ export async function runRelease(args) {
|
|
|
811
960
|
log.info('');
|
|
812
961
|
log.info('Git operations...');
|
|
813
962
|
|
|
963
|
+
let gitResult = { success: true };
|
|
964
|
+
|
|
814
965
|
if (!dryRun) {
|
|
815
966
|
if (noPush) {
|
|
816
967
|
// Only commit and tag, no push
|
|
817
|
-
|
|
818
|
-
execSync('git add -A', { cwd: root, stdio: 'inherit' });
|
|
819
|
-
const tempFile = join(tmpdir(), `pulse-release-${Date.now()}.txt`);
|
|
820
|
-
writeFileSync(tempFile, commitMessage, 'utf-8');
|
|
821
|
-
try {
|
|
822
|
-
execSync(`git commit -F "${tempFile}"`, { cwd: root, stdio: 'inherit' });
|
|
823
|
-
} finally {
|
|
824
|
-
unlinkSync(tempFile);
|
|
825
|
-
}
|
|
826
|
-
execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
|
|
827
|
-
log.info(' Created commit and tag (--no-push specified)');
|
|
968
|
+
gitResult = gitCommitTagNoPush(newVersion, title, changes);
|
|
828
969
|
} else {
|
|
829
|
-
gitCommitTagPush(newVersion, title, changes, false);
|
|
970
|
+
gitResult = gitCommitTagPush(newVersion, title, changes, false);
|
|
830
971
|
}
|
|
831
972
|
} else {
|
|
832
|
-
gitCommitTagPush(newVersion, title, changes, true);
|
|
973
|
+
gitResult = gitCommitTagPush(newVersion, title, changes, true);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (!gitResult.success) {
|
|
977
|
+
log.error('');
|
|
978
|
+
log.error(`Release failed at stage: ${gitResult.stage}`);
|
|
979
|
+
log.error('Please fix the issue and retry, or complete the release manually.');
|
|
980
|
+
process.exit(1);
|
|
833
981
|
}
|
|
834
982
|
|
|
835
983
|
log.info('');
|
package/compiler/lexer.js
CHANGED
|
@@ -99,6 +99,7 @@ export const TokenType = {
|
|
|
99
99
|
// Identifiers and selectors
|
|
100
100
|
IDENT: 'IDENT',
|
|
101
101
|
SELECTOR: 'SELECTOR', // CSS selector like .class, #id, tag.class#id
|
|
102
|
+
HEX_COLOR: 'HEX_COLOR', // CSS hex color like #fff, #667eea
|
|
102
103
|
|
|
103
104
|
// Special
|
|
104
105
|
INTERPOLATION_START: 'INTERPOLATION_START', // {
|
|
@@ -409,8 +410,9 @@ export class Lexer {
|
|
|
409
410
|
|
|
410
411
|
/**
|
|
411
412
|
* Read an identifier or keyword
|
|
413
|
+
* @param {boolean} forceIdent - If true, always return IDENT type (ignore keywords)
|
|
412
414
|
*/
|
|
413
|
-
readIdentifier() {
|
|
415
|
+
readIdentifier(forceIdent = false) {
|
|
414
416
|
const startLine = this.line;
|
|
415
417
|
const startColumn = this.column;
|
|
416
418
|
let value = '';
|
|
@@ -419,7 +421,7 @@ export class Lexer {
|
|
|
419
421
|
value += this.advance();
|
|
420
422
|
}
|
|
421
423
|
|
|
422
|
-
const type = KEYWORDS[value] || TokenType.IDENT;
|
|
424
|
+
const type = forceIdent ? TokenType.IDENT : (KEYWORDS[value] || TokenType.IDENT);
|
|
423
425
|
return new Token(type, value, startLine, startColumn);
|
|
424
426
|
}
|
|
425
427
|
|
|
@@ -694,8 +696,13 @@ export class Lexer {
|
|
|
694
696
|
continue;
|
|
695
697
|
}
|
|
696
698
|
|
|
697
|
-
// Hash outside selector
|
|
699
|
+
// Hash outside selector - check for hex color in style context
|
|
698
700
|
if (char === '#') {
|
|
701
|
+
// In style context, check if this is a hex color
|
|
702
|
+
if (this.isStyleContext() && /[0-9a-fA-F]/.test(this.peek())) {
|
|
703
|
+
this.tokens.push(this.readHexColor());
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
699
706
|
this.advance();
|
|
700
707
|
this.tokens.push(new Token(TokenType.HASH, '#', startLine, startColumn));
|
|
701
708
|
continue;
|
|
@@ -704,14 +711,29 @@ export class Lexer {
|
|
|
704
711
|
// Identifiers, keywords, and selectors
|
|
705
712
|
if (/[a-zA-Z_$]/.test(char)) {
|
|
706
713
|
// First check if this is a keyword - keywords take precedence
|
|
714
|
+
// BUT in style context, most keywords should be treated as identifiers
|
|
715
|
+
// (e.g., 'style' in 'transform-style', 'in' in 'ease-in-out')
|
|
707
716
|
const word = this.peekWord();
|
|
708
|
-
|
|
709
|
-
|
|
717
|
+
const inStyle = this.isStyleContext();
|
|
718
|
+
|
|
719
|
+
// Keywords that should NEVER be treated as keywords in style context
|
|
720
|
+
// These appear in CSS property names and values
|
|
721
|
+
const cssReservedWords = new Set([
|
|
722
|
+
'style', 'in', 'from', 'to', 'if', 'else', 'for', 'as', 'of',
|
|
723
|
+
'true', 'false', 'null', 'export', 'import'
|
|
724
|
+
]);
|
|
725
|
+
|
|
726
|
+
const shouldBeKeyword = KEYWORDS[word] && (!inStyle || !cssReservedWords.has(word));
|
|
727
|
+
// Force identifier if in style context and word is a CSS reserved word
|
|
728
|
+
const forceIdent = inStyle && cssReservedWords.has(word);
|
|
729
|
+
|
|
730
|
+
if (shouldBeKeyword) {
|
|
731
|
+
this.tokens.push(this.readIdentifier(false));
|
|
710
732
|
} else if (this.isViewContext() && this.couldBeSelector()) {
|
|
711
733
|
// Only treat as selector if not a keyword
|
|
712
734
|
this.tokens.push(this.readSelector());
|
|
713
735
|
} else {
|
|
714
|
-
this.tokens.push(this.readIdentifier());
|
|
736
|
+
this.tokens.push(this.readIdentifier(forceIdent));
|
|
715
737
|
}
|
|
716
738
|
continue;
|
|
717
739
|
}
|
|
@@ -725,6 +747,43 @@ export class Lexer {
|
|
|
725
747
|
return this.tokens;
|
|
726
748
|
}
|
|
727
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Check if we're in a style context (inside style block)
|
|
752
|
+
*/
|
|
753
|
+
isStyleContext() {
|
|
754
|
+
// Look back through tokens for 'style' keyword
|
|
755
|
+
for (let i = this.tokens.length - 1; i >= 0; i--) {
|
|
756
|
+
const token = this.tokens[i];
|
|
757
|
+
if (token.type === TokenType.STYLE) {
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
if (token.type === TokenType.STATE ||
|
|
761
|
+
token.type === TokenType.VIEW ||
|
|
762
|
+
token.type === TokenType.ACTIONS ||
|
|
763
|
+
token.type === TokenType.ROUTER ||
|
|
764
|
+
token.type === TokenType.STORE) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Read a CSS hex color (#fff, #ffffff, #667eea, etc.)
|
|
773
|
+
*/
|
|
774
|
+
readHexColor() {
|
|
775
|
+
const startLine = this.line;
|
|
776
|
+
const startColumn = this.column;
|
|
777
|
+
let value = this.advance(); // #
|
|
778
|
+
|
|
779
|
+
// Read hex characters (0-9, a-f, A-F)
|
|
780
|
+
while (!this.isEOF() && /[0-9a-fA-F]/.test(this.current())) {
|
|
781
|
+
value += this.advance();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return new Token(TokenType.HEX_COLOR, value, startLine, startColumn);
|
|
785
|
+
}
|
|
786
|
+
|
|
728
787
|
/**
|
|
729
788
|
* Check if we're in a view context where selectors are expected
|
|
730
789
|
*/
|
package/compiler/parser.js
CHANGED
|
@@ -1564,35 +1564,70 @@ export class Parser {
|
|
|
1564
1564
|
const selectorParts = [];
|
|
1565
1565
|
let lastLine = this.current()?.line;
|
|
1566
1566
|
let lastToken = null;
|
|
1567
|
+
let inAtRule = false; // Track if we're inside an @-rule like @media
|
|
1568
|
+
let inParens = 0; // Track parenthesis depth
|
|
1567
1569
|
|
|
1568
1570
|
while (!this.is(TokenType.LBRACE) && !this.is(TokenType.EOF)) {
|
|
1569
1571
|
const token = this.advance();
|
|
1570
1572
|
const currentLine = token.line;
|
|
1571
1573
|
const tokenValue = String(token.value);
|
|
1572
1574
|
|
|
1575
|
+
// Track @-rules (media queries, keyframes, etc.)
|
|
1576
|
+
if (tokenValue === '@') {
|
|
1577
|
+
inAtRule = true;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Track parenthesis depth for media queries
|
|
1581
|
+
if (tokenValue === '(') inParens++;
|
|
1582
|
+
if (tokenValue === ')') inParens--;
|
|
1583
|
+
|
|
1573
1584
|
// Determine if we need a space before this token
|
|
1574
1585
|
if (selectorParts.length > 0 && currentLine === lastLine) {
|
|
1575
1586
|
const lastPart = selectorParts[selectorParts.length - 1];
|
|
1576
1587
|
|
|
1577
1588
|
// Don't add space after these (they attach to what follows)
|
|
1578
|
-
const noSpaceAfter = ['.', '#', '
|
|
1589
|
+
const noSpaceAfter = new Set(['.', '#', '[', '(', '>', '+', '~', '-', '@', ':']);
|
|
1590
|
+
|
|
1579
1591
|
// Don't add space before these (they attach to what precedes)
|
|
1580
|
-
|
|
1592
|
+
// In @media queries inside parens: "max-width:" should not have space before ":"
|
|
1593
|
+
const noSpaceBefore = new Set([']', ')', ',', '.', '#', '-', ':']);
|
|
1594
|
+
|
|
1595
|
+
// CSS units that should attach to numbers (no space before)
|
|
1596
|
+
const cssUnits = new Set(['px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax', '%', 'fr', 's', 'ms', 'deg', 'rad', 'turn', 'grad', 'ex', 'ch', 'pt', 'pc', 'in', 'cm', 'mm', 'dvh', 'dvw', 'svh', 'svw', 'lvh', 'lvw']);
|
|
1581
1597
|
|
|
1582
1598
|
// Special case: . or # after an identifier needs space (descendant selector)
|
|
1583
1599
|
// e.g., ".school .date" - need space between "school" and "."
|
|
1584
1600
|
const isDescendantSelector = (tokenValue === '.' || tokenValue === '#') &&
|
|
1585
|
-
lastToken?.type === TokenType.IDENT
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
//
|
|
1601
|
+
lastToken?.type === TokenType.IDENT &&
|
|
1602
|
+
!inAtRule; // Don't add space in @media selectors
|
|
1603
|
+
|
|
1604
|
+
// Special case: hyphenated class/id names like .job-title, .card-3d, max-width
|
|
1605
|
+
// Check if we're continuing a class/id name - the last part should end with alphanumeric
|
|
1606
|
+
// that was started by . or # (no space in between)
|
|
1607
|
+
const lastPartJoined = selectorParts.join('');
|
|
1608
|
+
// Check if we're in the middle of a class/id name (last char is alphanumeric or -)
|
|
1609
|
+
// AND there's a . or # that started this name (not separated by space)
|
|
1610
|
+
const lastSegmentMatch = lastPartJoined.match(/[.#]([a-zA-Z0-9_-]*)$/);
|
|
1611
|
+
const inClassName = lastSegmentMatch && lastSegmentMatch[1].length > 0;
|
|
1612
|
+
|
|
1613
|
+
// Don't add space if current token is '-' and last token was IDENT or NUMBER
|
|
1589
1614
|
// Or if last token was '-' (the next token should attach to it)
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1615
|
+
// Also handle .card-3d where we have NUMBER followed by IDENT (but only if in class name context)
|
|
1616
|
+
const isHyphenatedIdent = (tokenValue === '-' && (lastToken?.type === TokenType.IDENT || lastToken?.type === TokenType.NUMBER)) ||
|
|
1617
|
+
(lastToken?.type === TokenType.MINUS) ||
|
|
1618
|
+
(inClassName && lastToken?.type === TokenType.NUMBER && token.type === TokenType.IDENT);
|
|
1619
|
+
|
|
1620
|
+
// Special case: CSS units after numbers (768px, 1.5em)
|
|
1621
|
+
const isUnitAfterNumber = cssUnits.has(tokenValue) && lastToken?.type === TokenType.NUMBER;
|
|
1622
|
+
|
|
1623
|
+
// Special case: @-rule keywords (media, keyframes, etc.) should attach to @
|
|
1624
|
+
const isAtRuleKeyword = lastPart === '@' && /^[a-zA-Z]/.test(tokenValue);
|
|
1625
|
+
|
|
1626
|
+
const needsSpace = !noSpaceAfter.has(lastPart) &&
|
|
1627
|
+
!noSpaceBefore.has(tokenValue) &&
|
|
1628
|
+
!isHyphenatedIdent &&
|
|
1629
|
+
!isUnitAfterNumber &&
|
|
1630
|
+
!isAtRuleKeyword ||
|
|
1596
1631
|
isDescendantSelector;
|
|
1597
1632
|
|
|
1598
1633
|
if (needsSpace) {
|
|
@@ -1677,27 +1712,52 @@ export class Parser {
|
|
|
1677
1712
|
|
|
1678
1713
|
/**
|
|
1679
1714
|
* Parse style property
|
|
1715
|
+
* Handles CSS property names (including custom properties like --var-name)
|
|
1716
|
+
* and complex CSS values with proper spacing
|
|
1680
1717
|
*/
|
|
1681
1718
|
parseStyleProperty() {
|
|
1719
|
+
// Parse property name (including custom properties with --)
|
|
1682
1720
|
let name = '';
|
|
1721
|
+
let nameTokens = [];
|
|
1683
1722
|
while (!this.is(TokenType.COLON) && !this.is(TokenType.EOF)) {
|
|
1684
|
-
|
|
1723
|
+
nameTokens.push(this.advance());
|
|
1685
1724
|
}
|
|
1686
|
-
name
|
|
1725
|
+
// Join name tokens without spaces (property names don't have spaces)
|
|
1726
|
+
name = nameTokens.map(t => t.value).join('').trim();
|
|
1687
1727
|
|
|
1688
1728
|
this.expect(TokenType.COLON);
|
|
1689
1729
|
|
|
1690
|
-
//
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1730
|
+
// CSS functions that should not have space before (
|
|
1731
|
+
const cssFunctions = new Set([
|
|
1732
|
+
'rgba', 'rgb', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklch', 'oklab',
|
|
1733
|
+
'var', 'calc', 'min', 'max', 'clamp', 'url', 'attr', 'env', 'counter', 'counters',
|
|
1734
|
+
'linear-gradient', 'radial-gradient', 'conic-gradient', 'repeating-linear-gradient', 'repeating-radial-gradient',
|
|
1735
|
+
'translate', 'translateX', 'translateY', 'translateZ', 'translate3d',
|
|
1736
|
+
'rotate', 'rotateX', 'rotateY', 'rotateZ', 'rotate3d',
|
|
1737
|
+
'scale', 'scaleX', 'scaleY', 'scaleZ', 'scale3d',
|
|
1738
|
+
'skew', 'skewX', 'skewY', 'matrix', 'matrix3d', 'perspective',
|
|
1739
|
+
'cubic-bezier', 'steps', 'drop-shadow', 'blur', 'brightness', 'contrast',
|
|
1740
|
+
'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia',
|
|
1741
|
+
'minmax', 'repeat', 'fit-content', 'image', 'element', 'cross-fade',
|
|
1742
|
+
'color-mix', 'light-dark'
|
|
1743
|
+
]);
|
|
1744
|
+
|
|
1745
|
+
// CSS units that should attach to preceding number (no space before)
|
|
1746
|
+
const cssUnits = new Set([
|
|
1747
|
+
'%', 'px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax', 'dvh', 'dvw', 'svh', 'svw', 'lvh', 'lvw',
|
|
1748
|
+
'fr', 's', 'ms', 'deg', 'rad', 'turn', 'grad',
|
|
1749
|
+
'ex', 'ch', 'cap', 'ic', 'lh', 'rlh',
|
|
1750
|
+
'pt', 'pc', 'in', 'cm', 'mm', 'Q',
|
|
1751
|
+
'dpi', 'dpcm', 'dppx', 'x'
|
|
1752
|
+
]);
|
|
1753
|
+
|
|
1754
|
+
// Tokens that should not have space before them
|
|
1755
|
+
const noSpaceBefore = new Set([')', ',', '(', ';']);
|
|
1756
|
+
cssUnits.forEach(u => noSpaceBefore.add(u));
|
|
1757
|
+
|
|
1758
|
+
// Collect value tokens
|
|
1759
|
+
let valueTokens = [];
|
|
1697
1760
|
let lastTokenLine = this.current()?.line || 0;
|
|
1698
|
-
let afterHash = false; // Track if we're collecting a hex color
|
|
1699
|
-
let hexColorLength = 0; // Track hex color length (max 8 for RRGGBBAA)
|
|
1700
|
-
let inCssVar = false; // Track if we're inside var(--...)
|
|
1701
1761
|
|
|
1702
1762
|
while (!this.is(TokenType.SEMICOLON) && !this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
|
|
1703
1763
|
const currentToken = this.current();
|
|
@@ -1707,64 +1767,165 @@ export class Parser {
|
|
|
1707
1767
|
if (this.isPropertyStart() || this.isNestedRule()) {
|
|
1708
1768
|
break;
|
|
1709
1769
|
}
|
|
1770
|
+
lastTokenLine = currentToken.line;
|
|
1710
1771
|
}
|
|
1711
|
-
const token = this.advance();
|
|
1712
|
-
// Use raw value if available to preserve original representation
|
|
1713
|
-
// This is important for numbers that might be parsed as scientific notation
|
|
1714
|
-
let tokenValue = token.raw || String(token.value);
|
|
1715
1772
|
|
|
1716
|
-
|
|
1717
|
-
|
|
1773
|
+
valueTokens.push(this.advance());
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Build value string with proper spacing
|
|
1777
|
+
let value = '';
|
|
1778
|
+
let inHexColor = false;
|
|
1779
|
+
let hexLength = 0;
|
|
1780
|
+
let parenDepth = 0;
|
|
1781
|
+
let inCssVar = false;
|
|
1782
|
+
let inCalc = false; // Track if we're inside calc(), min(), max(), clamp() where operators need spaces
|
|
1783
|
+
let calcDepth = 0; // Track nested calc depth
|
|
1784
|
+
|
|
1785
|
+
// Functions where arithmetic operators need spaces
|
|
1786
|
+
const mathFunctions = new Set(['calc', 'min', 'max', 'clamp']);
|
|
1787
|
+
|
|
1788
|
+
// Helper to check if a string is valid hex
|
|
1789
|
+
const isValidHex = (str) => /^[0-9a-fA-F]+$/.test(String(str));
|
|
1790
|
+
|
|
1791
|
+
for (let i = 0; i < valueTokens.length; i++) {
|
|
1792
|
+
const token = valueTokens[i];
|
|
1793
|
+
const tokenValue = token.raw || String(token.value);
|
|
1794
|
+
const prevToken = i > 0 ? valueTokens[i - 1] : null;
|
|
1795
|
+
const prevValue = prevToken ? (prevToken.raw || String(prevToken.value)) : '';
|
|
1796
|
+
|
|
1797
|
+
// Track parenthesis depth
|
|
1798
|
+
if (tokenValue === '(') parenDepth++;
|
|
1799
|
+
if (tokenValue === ')') parenDepth--;
|
|
1800
|
+
|
|
1801
|
+
// Track CSS var() context
|
|
1802
|
+
if (prevValue === 'var' && tokenValue === '(') {
|
|
1718
1803
|
inCssVar = true;
|
|
1719
1804
|
} else if (inCssVar && tokenValue === ')') {
|
|
1720
1805
|
inCssVar = false;
|
|
1721
1806
|
}
|
|
1722
1807
|
|
|
1723
|
-
//
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1808
|
+
// Track calc/min/max/clamp context - operators need spaces in these
|
|
1809
|
+
if (mathFunctions.has(prevValue) && tokenValue === '(') {
|
|
1810
|
+
inCalc = true;
|
|
1811
|
+
calcDepth = parenDepth;
|
|
1812
|
+
} else if (inCalc && tokenValue === ')' && parenDepth < calcDepth) {
|
|
1813
|
+
inCalc = false;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// Handle HEX_COLOR token (from lexer) - it's already a complete hex color
|
|
1817
|
+
if (token.type === TokenType.HEX_COLOR) {
|
|
1818
|
+
// HEX_COLOR is already complete, no tracking needed
|
|
1819
|
+
inHexColor = false;
|
|
1820
|
+
}
|
|
1821
|
+
// Track hex colors for legacy cases - look for # followed by hex digits
|
|
1822
|
+
// Handle cases like #667eea being tokenized as # 667 eea
|
|
1823
|
+
// Valid hex colors are 3, 4, 6, or 8 chars long
|
|
1824
|
+
else if (tokenValue === '#') {
|
|
1825
|
+
inHexColor = true;
|
|
1826
|
+
hexLength = 0;
|
|
1827
|
+
} else if (inHexColor) {
|
|
1828
|
+
// Check if this token could be part of hex color
|
|
1829
|
+
// Numbers and identifiers that are valid hex chars continue the color
|
|
1730
1830
|
const tokenStr = String(tokenValue);
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1831
|
+
if (isValidHex(tokenStr) && hexLength + tokenStr.length <= 8) {
|
|
1832
|
+
hexLength += tokenStr.length;
|
|
1833
|
+
|
|
1834
|
+
// Check if we should stop collecting hex color now
|
|
1835
|
+
// Stop if: we have 6+ chars, OR the next token is likely a CSS value (%, px, etc.)
|
|
1836
|
+
const nextToken = valueTokens[i + 1];
|
|
1837
|
+
const nextValue = nextToken ? String(nextToken.raw || nextToken.value) : '';
|
|
1838
|
+
|
|
1839
|
+
// CSS units/symbols that indicate the hex color is complete
|
|
1840
|
+
const cssValueIndicators = new Set(['%', 'px', 'em', 'rem', 'vh', 'vw', ',', ')', ' ', '']);
|
|
1841
|
+
|
|
1842
|
+
// End hex color if:
|
|
1843
|
+
// - We've reached 6 or 8 chars (complete hex)
|
|
1844
|
+
// - Next token is a CSS unit/punctuation (like %, px, comma, paren)
|
|
1845
|
+
// - Next token is empty (end of value)
|
|
1846
|
+
if (hexLength >= 6 || cssValueIndicators.has(nextValue) || nextToken?.type === TokenType.PERCENT || nextToken?.type === TokenType.COMMA || nextToken?.type === TokenType.RPAREN) {
|
|
1847
|
+
inHexColor = false; // Done collecting hex color
|
|
1740
1848
|
}
|
|
1741
1849
|
} else {
|
|
1742
|
-
|
|
1850
|
+
// This token is not part of hex, end hex color collection
|
|
1851
|
+
inHexColor = false;
|
|
1743
1852
|
}
|
|
1744
1853
|
}
|
|
1745
1854
|
|
|
1746
|
-
//
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1855
|
+
// Determine if we need space before this token
|
|
1856
|
+
let needsSpace = value.length > 0;
|
|
1857
|
+
|
|
1858
|
+
if (needsSpace) {
|
|
1859
|
+
// No space after # (hex color start)
|
|
1860
|
+
if (prevValue === '#') {
|
|
1861
|
+
needsSpace = false;
|
|
1862
|
+
}
|
|
1863
|
+
// No space after these
|
|
1864
|
+
else if (prevValue === '(' || prevValue === '.' || prevValue === '/' || prevValue === '@') {
|
|
1865
|
+
needsSpace = false;
|
|
1866
|
+
}
|
|
1867
|
+
// No space after ! for !important
|
|
1868
|
+
else if (prevValue === '!' && tokenValue === 'important') {
|
|
1869
|
+
needsSpace = false;
|
|
1870
|
+
}
|
|
1871
|
+
// No space after CSS functions before (
|
|
1872
|
+
else if (cssFunctions.has(prevValue) && tokenValue === '(') {
|
|
1873
|
+
needsSpace = false;
|
|
1874
|
+
}
|
|
1875
|
+
// No space before these
|
|
1876
|
+
else if (noSpaceBefore.has(tokenValue)) {
|
|
1877
|
+
needsSpace = false;
|
|
1878
|
+
}
|
|
1879
|
+
// No space in hex colors (continuing after #)
|
|
1880
|
+
else if (inHexColor && hexLength > 0) {
|
|
1881
|
+
needsSpace = false;
|
|
1882
|
+
}
|
|
1883
|
+
// No space in CSS var() content
|
|
1884
|
+
else if (inCssVar) {
|
|
1885
|
+
needsSpace = false;
|
|
1886
|
+
}
|
|
1887
|
+
// No space for hyphenated identifiers (ease-in-out, sans-serif, -apple-system)
|
|
1888
|
+
// BUT in calc(), min(), max(), clamp() - operators need spaces around them
|
|
1889
|
+
// Note: Some CSS keywords like 'in' are also Pulse keywords, so check token value too
|
|
1890
|
+
// Check if current token is '-' and should attach to previous identifier-like token
|
|
1891
|
+
else if (tokenValue === '-' && !inCalc && (prevToken?.type === TokenType.IDENT || prevToken?.type === TokenType.NUMBER || /^[a-zA-Z]/.test(prevValue))) {
|
|
1892
|
+
needsSpace = false;
|
|
1893
|
+
}
|
|
1894
|
+
// Check if current token follows a '-' (either prevValue is '-' or value ends with '-')
|
|
1895
|
+
// Include keywords that might appear in CSS values (in, from, to, etc.)
|
|
1896
|
+
// BUT in calc(), don't attach numbers to '-' (keep space for operators)
|
|
1897
|
+
else if (!inCalc && (prevValue === '-' || value.endsWith('-')) && (token.type === TokenType.IDENT || token.type === TokenType.NUMBER || /^[a-zA-Z]/.test(tokenValue))) {
|
|
1898
|
+
needsSpace = false;
|
|
1899
|
+
}
|
|
1900
|
+
// No space for -- (CSS custom property reference)
|
|
1901
|
+
else if (prevValue === '-' && tokenValue === '-') {
|
|
1902
|
+
needsSpace = false;
|
|
1903
|
+
}
|
|
1904
|
+
else if (prevValue === '--' || value.endsWith('--')) {
|
|
1905
|
+
needsSpace = false;
|
|
1906
|
+
}
|
|
1907
|
+
// CSS unit after number
|
|
1908
|
+
else if (cssUnits.has(tokenValue) && prevToken?.type === TokenType.NUMBER) {
|
|
1909
|
+
needsSpace = false;
|
|
1910
|
+
}
|
|
1911
|
+
// Handle cases like preserve-3d where identifier follows number in hyphenated value
|
|
1912
|
+
// Check if we're continuing a hyphenated identifier (value ends with number after hyphen)
|
|
1913
|
+
else if (token.type === TokenType.IDENT && prevToken?.type === TokenType.NUMBER) {
|
|
1914
|
+
// Check if the value looks like it's a hyphenated pattern: word-NUM + IDENT (e.g., preserve-3 + d, card-3 + d)
|
|
1915
|
+
const hyphenNumberPattern = /-\d+$/;
|
|
1916
|
+
if (hyphenNumberPattern.test(value)) {
|
|
1917
|
+
needsSpace = false;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
if (needsSpace) {
|
|
1761
1923
|
value += ' ';
|
|
1762
|
-
afterHash = false; // Space ends hex collection
|
|
1763
1924
|
}
|
|
1764
1925
|
|
|
1765
1926
|
value += tokenValue;
|
|
1766
|
-
lastTokenValue = tokenValue;
|
|
1767
1927
|
}
|
|
1928
|
+
|
|
1768
1929
|
value = value.trim();
|
|
1769
1930
|
|
|
1770
1931
|
if (this.is(TokenType.SEMICOLON)) {
|
|
@@ -2068,14 +2229,15 @@ export class Parser {
|
|
|
2068
2229
|
*/
|
|
2069
2230
|
isPropertyStart() {
|
|
2070
2231
|
// Check if it looks like: identifier (with possible hyphens) followed by :
|
|
2071
|
-
// CSS properties can be: margin, margin-bottom, -webkit-transform, etc.
|
|
2072
|
-
|
|
2232
|
+
// CSS properties can be: margin, margin-bottom, -webkit-transform, --custom-prop, etc.
|
|
2233
|
+
// Include MINUSMINUS for CSS custom properties (--var-name)
|
|
2234
|
+
if (!this.is(TokenType.IDENT) && !this.is(TokenType.MINUS) && !this.is(TokenType.MINUSMINUS)) return false;
|
|
2073
2235
|
|
|
2074
2236
|
let i = 0;
|
|
2075
|
-
// Skip over property name tokens (IDENT
|
|
2237
|
+
// Skip over property name tokens (IDENT, MINUS, MINUSMINUS for hyphenated/custom props)
|
|
2076
2238
|
while (this.peek(i)) {
|
|
2077
2239
|
const token = this.peek(i);
|
|
2078
|
-
if (token.type === TokenType.IDENT || token.type === TokenType.MINUS) {
|
|
2240
|
+
if (token.type === TokenType.IDENT || token.type === TokenType.MINUS || token.type === TokenType.MINUSMINUS) {
|
|
2079
2241
|
i++;
|
|
2080
2242
|
} else {
|
|
2081
2243
|
break;
|
|
@@ -45,51 +45,125 @@ export function transformStyle(transformer, styleBlock) {
|
|
|
45
45
|
return lines.join('\n');
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Check if a selector is an @-rule (media query, keyframes, etc.)
|
|
50
|
+
* @param {string} selector - CSS selector
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isAtRule(selector) {
|
|
54
|
+
return selector.trim().startsWith('@');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a selector is @keyframes
|
|
59
|
+
* @param {string} selector - CSS selector
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function isKeyframesRule(selector) {
|
|
63
|
+
return selector.trim().startsWith('@keyframes');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a selector is a keyframe step (from, to, or percentage)
|
|
68
|
+
* @param {string} selector - CSS selector
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function isKeyframeStep(selector) {
|
|
72
|
+
const trimmed = selector.trim();
|
|
73
|
+
return trimmed === 'from' || trimmed === 'to' || /^\d+%$/.test(trimmed);
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
/**
|
|
49
77
|
* Flatten nested CSS rules by combining selectors
|
|
50
78
|
* Handles CSS nesting by prepending parent selector to nested rules
|
|
79
|
+
* Special handling for @-rules (media queries, keyframes, etc.)
|
|
51
80
|
* @param {Object} transformer - Transformer instance
|
|
52
81
|
* @param {Object} rule - CSS rule from AST
|
|
53
82
|
* @param {string} parentSelector - Parent selector to prepend (empty for top-level)
|
|
54
83
|
* @param {Array<string>} output - Array to collect flattened CSS rules
|
|
84
|
+
* @param {string} atRuleWrapper - Optional @-rule wrapper (e.g., "@media (max-width: 768px)")
|
|
85
|
+
* @param {boolean} inKeyframes - Whether we're inside @keyframes (don't scope keyframe steps)
|
|
55
86
|
*/
|
|
56
|
-
export function flattenStyleRule(transformer, rule, parentSelector, output) {
|
|
87
|
+
export function flattenStyleRule(transformer, rule, parentSelector, output, atRuleWrapper = '', inKeyframes = false) {
|
|
88
|
+
const selector = rule.selector;
|
|
89
|
+
|
|
90
|
+
// Check if this is an @-rule
|
|
91
|
+
if (isAtRule(selector)) {
|
|
92
|
+
const isKeyframes = isKeyframesRule(selector);
|
|
93
|
+
|
|
94
|
+
// @keyframes should be output as a complete block, not flattened
|
|
95
|
+
if (isKeyframes) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
lines.push(` ${selector} {`);
|
|
98
|
+
|
|
99
|
+
// Output all keyframe steps
|
|
100
|
+
for (const nested of rule.nestedRules) {
|
|
101
|
+
lines.push(` ${nested.selector} {`);
|
|
102
|
+
for (const prop of nested.properties) {
|
|
103
|
+
lines.push(` ${prop.name}: ${prop.value};`);
|
|
104
|
+
}
|
|
105
|
+
lines.push(' }');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
lines.push(' }');
|
|
109
|
+
output.push(lines.join('\n'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Other @-rules (@media, @supports) wrap their nested rules
|
|
114
|
+
for (const nested of rule.nestedRules) {
|
|
115
|
+
flattenStyleRule(transformer, nested, '', output, selector, false);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
57
120
|
// Build the full selector by combining parent and current
|
|
58
|
-
let fullSelector =
|
|
121
|
+
let fullSelector = selector;
|
|
59
122
|
|
|
60
123
|
if (parentSelector) {
|
|
61
124
|
// Handle & (parent reference) in nested selectors
|
|
62
|
-
if (
|
|
125
|
+
if (selector.includes('&')) {
|
|
63
126
|
// Replace & with parent selector
|
|
64
|
-
fullSelector =
|
|
127
|
+
fullSelector = selector.replace(/&/g, parentSelector);
|
|
65
128
|
} else {
|
|
66
129
|
// Combine parent and child with space (descendant combinator)
|
|
67
|
-
fullSelector = `${parentSelector} ${
|
|
130
|
+
fullSelector = `${parentSelector} ${selector}`;
|
|
68
131
|
}
|
|
69
132
|
}
|
|
70
133
|
|
|
71
|
-
// Apply scope to selector if enabled
|
|
134
|
+
// Apply scope to selector if enabled (but not for keyframe steps)
|
|
72
135
|
let scopedSelector = fullSelector;
|
|
73
|
-
if (transformer.scopeId) {
|
|
136
|
+
if (transformer.scopeId && !inKeyframes && !isKeyframeStep(selector)) {
|
|
74
137
|
scopedSelector = scopeStyleSelector(transformer, fullSelector);
|
|
75
138
|
}
|
|
76
139
|
|
|
77
140
|
// Only output rule if it has properties
|
|
78
141
|
if (rule.properties.length > 0) {
|
|
79
142
|
const lines = [];
|
|
80
|
-
lines.push(` ${scopedSelector} {`);
|
|
81
143
|
|
|
82
|
-
|
|
83
|
-
|
|
144
|
+
// If wrapped in an @-rule, output the wrapper
|
|
145
|
+
if (atRuleWrapper) {
|
|
146
|
+
lines.push(` ${atRuleWrapper} {`);
|
|
147
|
+
lines.push(` ${scopedSelector} {`);
|
|
148
|
+
for (const prop of rule.properties) {
|
|
149
|
+
lines.push(` ${prop.name}: ${prop.value};`);
|
|
150
|
+
}
|
|
151
|
+
lines.push(' }');
|
|
152
|
+
lines.push(' }');
|
|
153
|
+
} else {
|
|
154
|
+
lines.push(` ${scopedSelector} {`);
|
|
155
|
+
for (const prop of rule.properties) {
|
|
156
|
+
lines.push(` ${prop.name}: ${prop.value};`);
|
|
157
|
+
}
|
|
158
|
+
lines.push(' }');
|
|
84
159
|
}
|
|
85
160
|
|
|
86
|
-
lines.push(' }');
|
|
87
161
|
output.push(lines.join('\n'));
|
|
88
162
|
}
|
|
89
163
|
|
|
90
164
|
// Recursively flatten nested rules with combined selector
|
|
91
165
|
for (const nested of rule.nestedRules) {
|
|
92
|
-
flattenStyleRule(transformer, nested, fullSelector, output);
|
|
166
|
+
flattenStyleRule(transformer, nested, fullSelector, output, atRuleWrapper, inKeyframes);
|
|
93
167
|
}
|
|
94
168
|
}
|
|
95
169
|
|
package/loader/vite-plugin.js
CHANGED
|
@@ -2,32 +2,42 @@
|
|
|
2
2
|
* Pulse Vite Plugin
|
|
3
3
|
*
|
|
4
4
|
* Enables .pulse file support in Vite projects
|
|
5
|
+
* Extracts CSS to virtual .css modules so Vite's CSS pipeline handles them
|
|
6
|
+
* (prevents JS minifier from corrupting CSS in template literals)
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { compile } from '../compiler/index.js';
|
|
8
10
|
import { existsSync } from 'fs';
|
|
9
11
|
import { resolve, dirname } from 'path';
|
|
10
12
|
|
|
13
|
+
// Virtual module ID for extracted CSS (uses .css extension so Vite treats it as CSS)
|
|
14
|
+
const VIRTUAL_CSS_SUFFIX = '.pulse.css';
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* Create Pulse Vite plugin
|
|
13
18
|
*/
|
|
14
19
|
export default function pulsePlugin(options = {}) {
|
|
15
20
|
const {
|
|
16
|
-
include = /\.pulse$/,
|
|
17
21
|
exclude = /node_modules/,
|
|
18
22
|
sourceMap = true
|
|
19
23
|
} = options;
|
|
20
24
|
|
|
25
|
+
// Store extracted CSS for each .pulse module
|
|
26
|
+
const cssMap = new Map();
|
|
27
|
+
|
|
21
28
|
return {
|
|
22
29
|
name: 'vite-plugin-pulse',
|
|
23
30
|
enforce: 'pre',
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
|
-
* Resolve .pulse files and
|
|
27
|
-
* The compiler transforms .pulse imports to .js, so we need to
|
|
28
|
-
* resolve them back to .pulse for Vite to process them
|
|
33
|
+
* Resolve .pulse files and virtual CSS modules
|
|
29
34
|
*/
|
|
30
35
|
resolveId(id, importer) {
|
|
36
|
+
// Handle virtual CSS module resolution
|
|
37
|
+
if (id.endsWith(VIRTUAL_CSS_SUFFIX)) {
|
|
38
|
+
return '\0' + id;
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// Direct .pulse imports - resolve to absolute path
|
|
32
42
|
if (id.endsWith('.pulse') && importer) {
|
|
33
43
|
const importerDir = dirname(importer);
|
|
@@ -38,7 +48,6 @@ export default function pulsePlugin(options = {}) {
|
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
// Check if a .js import has a corresponding .pulse file
|
|
41
|
-
// This handles the compiler's transformation of .pulse -> .js imports
|
|
42
51
|
if (id.endsWith('.js') && importer) {
|
|
43
52
|
const pulseId = id.replace(/\.js$/, '.pulse');
|
|
44
53
|
const importerDir = dirname(importer);
|
|
@@ -52,8 +61,22 @@ export default function pulsePlugin(options = {}) {
|
|
|
52
61
|
return null;
|
|
53
62
|
},
|
|
54
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Load virtual CSS modules
|
|
66
|
+
*/
|
|
67
|
+
load(id) {
|
|
68
|
+
// Virtual modules start with \0
|
|
69
|
+
if (id.startsWith('\0') && id.endsWith(VIRTUAL_CSS_SUFFIX)) {
|
|
70
|
+
const pulseId = id.slice(1, -VIRTUAL_CSS_SUFFIX.length + '.pulse'.length);
|
|
71
|
+
const css = cssMap.get(pulseId);
|
|
72
|
+
return css || '';
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
},
|
|
76
|
+
|
|
55
77
|
/**
|
|
56
78
|
* Transform .pulse files to JavaScript
|
|
79
|
+
* CSS is extracted to a virtual .css module that Vite processes separately
|
|
57
80
|
*/
|
|
58
81
|
transform(code, id) {
|
|
59
82
|
if (!id.endsWith('.pulse')) {
|
|
@@ -79,8 +102,27 @@ export default function pulsePlugin(options = {}) {
|
|
|
79
102
|
return null;
|
|
80
103
|
}
|
|
81
104
|
|
|
105
|
+
let outputCode = result.code;
|
|
106
|
+
|
|
107
|
+
// Extract CSS from compiled output and move to virtual CSS module
|
|
108
|
+
const stylesMatch = outputCode.match(/const styles = `([\s\S]*?)`;/);
|
|
109
|
+
if (stylesMatch) {
|
|
110
|
+
const css = stylesMatch[1];
|
|
111
|
+
const virtualCssId = id + '.css';
|
|
112
|
+
|
|
113
|
+
// Store CSS for the virtual module loader
|
|
114
|
+
cssMap.set(id, css);
|
|
115
|
+
|
|
116
|
+
// Replace inline style injection with CSS import
|
|
117
|
+
// Vite will process this through its CSS pipeline (not JS minifier)
|
|
118
|
+
outputCode = outputCode.replace(
|
|
119
|
+
/\/\/ Styles\nconst styles = `[\s\S]*?`;\n\/\/ Inject styles\nconst styleEl = document\.createElement\("style"\);\nstyleEl\.textContent = styles;\ndocument\.head\.appendChild\(styleEl\);/,
|
|
120
|
+
`// Styles extracted to virtual CSS module\nimport "${virtualCssId}";`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
82
124
|
return {
|
|
83
|
-
code:
|
|
125
|
+
code: outputCode,
|
|
84
126
|
map: result.map || null
|
|
85
127
|
};
|
|
86
128
|
} catch (error) {
|
|
@@ -92,7 +134,7 @@ export default function pulsePlugin(options = {}) {
|
|
|
92
134
|
/**
|
|
93
135
|
* Handle hot module replacement
|
|
94
136
|
*/
|
|
95
|
-
handleHotUpdate({ file, server
|
|
137
|
+
handleHotUpdate({ file, server }) {
|
|
96
138
|
if (file.endsWith('.pulse')) {
|
|
97
139
|
console.log(`[Pulse] HMR update: ${file}`);
|
|
98
140
|
|
|
@@ -102,8 +144,14 @@ export default function pulsePlugin(options = {}) {
|
|
|
102
144
|
server.moduleGraph.invalidateModule(module);
|
|
103
145
|
}
|
|
104
146
|
|
|
147
|
+
// Also invalidate the associated virtual CSS module
|
|
148
|
+
const virtualCssId = '\0' + file + '.css';
|
|
149
|
+
const cssModule = server.moduleGraph.getModuleById(virtualCssId);
|
|
150
|
+
if (cssModule) {
|
|
151
|
+
server.moduleGraph.invalidateModule(cssModule);
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
// Send HMR update instead of full reload
|
|
106
|
-
// The module will handle its own state preservation via hmrRuntime
|
|
107
155
|
server.ws.send({
|
|
108
156
|
type: 'update',
|
|
109
157
|
updates: [{
|
|
@@ -123,7 +171,7 @@ export default function pulsePlugin(options = {}) {
|
|
|
123
171
|
* Configure dev server
|
|
124
172
|
*/
|
|
125
173
|
configureServer(server) {
|
|
126
|
-
server.middlewares.use((
|
|
174
|
+
server.middlewares.use((_req, _res, next) => {
|
|
127
175
|
// Add any custom middleware here
|
|
128
176
|
next();
|
|
129
177
|
});
|
|
@@ -133,11 +181,8 @@ export default function pulsePlugin(options = {}) {
|
|
|
133
181
|
* Build hooks
|
|
134
182
|
*/
|
|
135
183
|
buildStart() {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
buildEnd() {
|
|
140
|
-
console.log('[Pulse] Build completed');
|
|
184
|
+
// Clear CSS map on new build
|
|
185
|
+
cssMap.clear();
|
|
141
186
|
}
|
|
142
187
|
};
|
|
143
188
|
}
|
|
@@ -191,9 +236,9 @@ export const utils = {
|
|
|
191
236
|
},
|
|
192
237
|
|
|
193
238
|
/**
|
|
194
|
-
*
|
|
239
|
+
* Get the virtual CSS module ID for a Pulse file
|
|
195
240
|
*/
|
|
196
|
-
|
|
197
|
-
return
|
|
241
|
+
getVirtualCssId(id) {
|
|
242
|
+
return id + '.css';
|
|
198
243
|
}
|
|
199
244
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.24",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -109,9 +109,10 @@
|
|
|
109
109
|
"LICENSE"
|
|
110
110
|
],
|
|
111
111
|
"scripts": {
|
|
112
|
-
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-adapter && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress && npm run test:ssr",
|
|
112
|
+
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:css-parsing && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-adapter && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress && npm run test:ssr",
|
|
113
113
|
"test:compiler": "node test/compiler.test.js",
|
|
114
114
|
"test:sourcemap": "node test/sourcemap.test.js",
|
|
115
|
+
"test:css-parsing": "node test/css-parsing.test.js",
|
|
115
116
|
"test:pulse": "node test/pulse.test.js",
|
|
116
117
|
"test:dom": "node test/dom.test.js",
|
|
117
118
|
"test:dom-element": "node test/dom-element.test.js",
|