pulse-js-framework 1.7.21 → 1.7.23
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/compiler/lexer.js +65 -6
- package/compiler/parser.js +231 -62
- package/compiler/transformer/style.js +86 -12
- package/package.json +3 -2
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,28 +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
|
|
1614
|
+
// Or if last token was '-' (the next token should attach to it)
|
|
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 ||
|
|
1589
1631
|
isDescendantSelector;
|
|
1590
1632
|
|
|
1591
1633
|
if (needsSpace) {
|
|
@@ -1670,27 +1712,52 @@ export class Parser {
|
|
|
1670
1712
|
|
|
1671
1713
|
/**
|
|
1672
1714
|
* Parse style property
|
|
1715
|
+
* Handles CSS property names (including custom properties like --var-name)
|
|
1716
|
+
* and complex CSS values with proper spacing
|
|
1673
1717
|
*/
|
|
1674
1718
|
parseStyleProperty() {
|
|
1719
|
+
// Parse property name (including custom properties with --)
|
|
1675
1720
|
let name = '';
|
|
1721
|
+
let nameTokens = [];
|
|
1676
1722
|
while (!this.is(TokenType.COLON) && !this.is(TokenType.EOF)) {
|
|
1677
|
-
|
|
1723
|
+
nameTokens.push(this.advance());
|
|
1678
1724
|
}
|
|
1679
|
-
name
|
|
1725
|
+
// Join name tokens without spaces (property names don't have spaces)
|
|
1726
|
+
name = nameTokens.map(t => t.value).join('').trim();
|
|
1680
1727
|
|
|
1681
1728
|
this.expect(TokenType.COLON);
|
|
1682
1729
|
|
|
1683
|
-
//
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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 = [];
|
|
1690
1760
|
let lastTokenLine = this.current()?.line || 0;
|
|
1691
|
-
let afterHash = false; // Track if we're collecting a hex color
|
|
1692
|
-
let hexColorLength = 0; // Track hex color length (max 8 for RRGGBBAA)
|
|
1693
|
-
let inCssVar = false; // Track if we're inside var(--...)
|
|
1694
1761
|
|
|
1695
1762
|
while (!this.is(TokenType.SEMICOLON) && !this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
|
|
1696
1763
|
const currentToken = this.current();
|
|
@@ -1700,64 +1767,165 @@ export class Parser {
|
|
|
1700
1767
|
if (this.isPropertyStart() || this.isNestedRule()) {
|
|
1701
1768
|
break;
|
|
1702
1769
|
}
|
|
1770
|
+
lastTokenLine = currentToken.line;
|
|
1703
1771
|
}
|
|
1704
|
-
const token = this.advance();
|
|
1705
|
-
// Use raw value if available to preserve original representation
|
|
1706
|
-
// This is important for numbers that might be parsed as scientific notation
|
|
1707
|
-
let tokenValue = token.raw || String(token.value);
|
|
1708
1772
|
|
|
1709
|
-
|
|
1710
|
-
|
|
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 === '(') {
|
|
1711
1803
|
inCssVar = true;
|
|
1712
1804
|
} else if (inCssVar && tokenValue === ')') {
|
|
1713
1805
|
inCssVar = false;
|
|
1714
1806
|
}
|
|
1715
1807
|
|
|
1716
|
-
//
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
|
1723
1830
|
const tokenStr = String(tokenValue);
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
|
1733
1848
|
}
|
|
1734
1849
|
} else {
|
|
1735
|
-
|
|
1850
|
+
// This token is not part of hex, end hex color collection
|
|
1851
|
+
inHexColor = false;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
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
|
+
}
|
|
1736
1919
|
}
|
|
1737
1920
|
}
|
|
1738
1921
|
|
|
1739
|
-
|
|
1740
|
-
// - It's the first token
|
|
1741
|
-
// - Last token was in noSpaceAfter
|
|
1742
|
-
// - This token is in noSpaceBefore
|
|
1743
|
-
// - We're collecting a hex color (afterHash is true)
|
|
1744
|
-
// - We're inside var(--...) and this is part of the variable name
|
|
1745
|
-
// - Last was '-' and current is an identifier (hyphenated name)
|
|
1746
|
-
const skipSpace = noSpaceAfter.has(String(lastTokenValue)) ||
|
|
1747
|
-
noSpaceBefore.has(tokenValue) ||
|
|
1748
|
-
afterHash ||
|
|
1749
|
-
inCssVar ||
|
|
1750
|
-
(lastTokenValue === '-' || lastTokenValue === '--') ||
|
|
1751
|
-
(tokenValue === '-' && /^[a-zA-Z]/.test(String(this.current()?.value || '')));
|
|
1752
|
-
|
|
1753
|
-
if (value.length > 0 && !skipSpace) {
|
|
1922
|
+
if (needsSpace) {
|
|
1754
1923
|
value += ' ';
|
|
1755
|
-
afterHash = false; // Space ends hex collection
|
|
1756
1924
|
}
|
|
1757
1925
|
|
|
1758
1926
|
value += tokenValue;
|
|
1759
|
-
lastTokenValue = tokenValue;
|
|
1760
1927
|
}
|
|
1928
|
+
|
|
1761
1929
|
value = value.trim();
|
|
1762
1930
|
|
|
1763
1931
|
if (this.is(TokenType.SEMICOLON)) {
|
|
@@ -2061,14 +2229,15 @@ export class Parser {
|
|
|
2061
2229
|
*/
|
|
2062
2230
|
isPropertyStart() {
|
|
2063
2231
|
// Check if it looks like: identifier (with possible hyphens) followed by :
|
|
2064
|
-
// CSS properties can be: margin, margin-bottom, -webkit-transform, etc.
|
|
2065
|
-
|
|
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;
|
|
2066
2235
|
|
|
2067
2236
|
let i = 0;
|
|
2068
|
-
// Skip over property name tokens (IDENT
|
|
2237
|
+
// Skip over property name tokens (IDENT, MINUS, MINUSMINUS for hyphenated/custom props)
|
|
2069
2238
|
while (this.peek(i)) {
|
|
2070
2239
|
const token = this.peek(i);
|
|
2071
|
-
if (token.type === TokenType.IDENT || token.type === TokenType.MINUS) {
|
|
2240
|
+
if (token.type === TokenType.IDENT || token.type === TokenType.MINUS || token.type === TokenType.MINUSMINUS) {
|
|
2072
2241
|
i++;
|
|
2073
2242
|
} else {
|
|
2074
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.23",
|
|
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",
|