ripple 0.2.175 → 0.2.176
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/package.json +3 -2
- package/shims/rollup-estree-types.d.ts +2 -0
- package/src/compiler/index.d.ts +30 -8
- package/src/compiler/index.js +10 -7
- package/src/compiler/phases/1-parse/index.js +117 -18
- package/src/compiler/phases/1-parse/style.js +4 -4
- package/src/compiler/phases/2-analyze/index.js +1 -0
- package/src/compiler/phases/3-transform/client/index.js +144 -18
- package/src/compiler/phases/3-transform/segments.js +123 -34
- package/src/compiler/types/index.d.ts +23 -0
- package/src/runtime/internal/client/events.js +18 -1
- package/src/utils/builders.js +49 -24
- package/tests/client/events.test.ripple +109 -1
- package/tests/client/media-query.test.ripple +5 -2
- package/tests/setup-client.js +4 -0
- package/tsconfig.json +2 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Ripple is an elegant TypeScript UI framework",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dominic Gannaway",
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.176",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"module": "src/runtime/index-client.js",
|
|
9
9
|
"main": "src/runtime/index-client.js",
|
|
@@ -77,11 +77,12 @@
|
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@types/estree": "^1.0.8",
|
|
80
|
+
"@types/estree-jsx": "^1.0.5",
|
|
80
81
|
"@types/node": "^24.3.0",
|
|
81
82
|
"typescript": "^5.9.2",
|
|
82
83
|
"@volar/language-core": "~2.4.23"
|
|
83
84
|
},
|
|
84
85
|
"peerDependencies": {
|
|
85
|
-
"ripple": "0.2.
|
|
86
|
+
"ripple": "0.2.176"
|
|
86
87
|
}
|
|
87
88
|
}
|
package/src/compiler/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { Program } from 'estree';
|
|
2
|
+
import type {
|
|
3
|
+
CodeInformation as VolarCodeInformation,
|
|
4
|
+
Mapping as VolarMapping,
|
|
5
|
+
} from '@volar/language-core';
|
|
2
6
|
|
|
3
7
|
// ============================================================================
|
|
4
8
|
// Compiler API Exports
|
|
@@ -18,12 +22,26 @@ export interface CompileResult {
|
|
|
18
22
|
css: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
export interface CustomMappingData {
|
|
26
|
+
generatedLengths: number[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MappingData extends VolarCodeInformation {
|
|
30
|
+
customData: CustomMappingData;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CodeMapping extends VolarMapping<MappingData> {
|
|
34
|
+
data: MappingData;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/**
|
|
22
38
|
* Result of Volar mappings compilation
|
|
23
39
|
*/
|
|
24
40
|
export interface VolarMappingsResult {
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
code: string;
|
|
42
|
+
mappings: CodeMapping[];
|
|
43
|
+
cssMappings: CodeMapping[];
|
|
44
|
+
cssSources: string[];
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
/**
|
|
@@ -34,12 +52,18 @@ export interface CompileOptions {
|
|
|
34
52
|
mode?: 'client' | 'server';
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
export interface ParseOptions {
|
|
56
|
+
/** Enable loose mode */
|
|
57
|
+
loose?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
/**
|
|
38
61
|
* Parse Ripple source code to ESTree AST
|
|
39
62
|
* @param source - The Ripple source code to parse
|
|
63
|
+
* @param options - Parse options
|
|
40
64
|
* @returns The parsed ESTree Program AST
|
|
41
65
|
*/
|
|
42
|
-
export function parse(source: string): Program;
|
|
66
|
+
export function parse(source: string, options?: ParseOptions): Program;
|
|
43
67
|
|
|
44
68
|
/**
|
|
45
69
|
* Compile Ripple source code to JS/CSS output
|
|
@@ -48,19 +72,17 @@ export function parse(source: string): Program;
|
|
|
48
72
|
* @param options - Compilation options (mode: 'client' or 'server')
|
|
49
73
|
* @returns The compilation result with AST, JS, and CSS
|
|
50
74
|
*/
|
|
51
|
-
export function compile(
|
|
52
|
-
source: string,
|
|
53
|
-
filename: string,
|
|
54
|
-
options?: CompileOptions,
|
|
55
|
-
): CompileResult;
|
|
75
|
+
export function compile(source: string, filename: string, options?: CompileOptions): CompileResult;
|
|
56
76
|
|
|
57
77
|
/**
|
|
58
78
|
* Compile Ripple source to Volar mappings for editor integration
|
|
59
79
|
* @param source - The Ripple source code
|
|
60
80
|
* @param filename - The filename for source map generation
|
|
81
|
+
* @param options - Parse options
|
|
61
82
|
* @returns Volar mappings object for editor integration
|
|
62
83
|
*/
|
|
63
84
|
export function compile_to_volar_mappings(
|
|
64
85
|
source: string,
|
|
65
86
|
filename: string,
|
|
87
|
+
options?: ParseOptions,
|
|
66
88
|
): VolarMappingsResult;
|
package/src/compiler/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @import { Program } from 'estree' */
|
|
2
|
+
/** @import { ParseOptions } from 'ripple/compiler' */
|
|
2
3
|
|
|
3
4
|
import { parse as parse_module } from './phases/1-parse/index.js';
|
|
4
5
|
import { analyze } from './phases/2-analyze/index.js';
|
|
@@ -12,7 +13,7 @@ import { convert_source_map_to_mappings } from './phases/3-transform/segments.js
|
|
|
12
13
|
* @returns {Program}
|
|
13
14
|
*/
|
|
14
15
|
export function parse(source) {
|
|
15
|
-
return parse_module(source);
|
|
16
|
+
return parse_module(source, undefined);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -23,7 +24,7 @@ export function parse(source) {
|
|
|
23
24
|
* @returns {object}
|
|
24
25
|
*/
|
|
25
26
|
export function compile(source, filename, options = {}) {
|
|
26
|
-
const ast = parse_module(source);
|
|
27
|
+
const ast = parse_module(source, undefined);
|
|
27
28
|
const analysis = analyze(ast, filename, options);
|
|
28
29
|
const result =
|
|
29
30
|
options.mode === 'server'
|
|
@@ -34,22 +35,24 @@ export function compile(source, filename, options = {}) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** @import { PostProcessingChanges, LineOffsets } from './phases/3-transform/client/index.js' */
|
|
37
|
-
/** @import {
|
|
38
|
+
/** @import { VolarMappingsResult } from 'ripple/compiler' */
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Compile Ripple component to Volar virtual code with TypeScript mappings
|
|
41
42
|
* @param {string} source
|
|
42
43
|
* @param {string} filename
|
|
43
|
-
* @
|
|
44
|
+
* @param {ParseOptions} [options] - Compiler options
|
|
45
|
+
* @returns {VolarMappingsResult} Volar mappings object
|
|
44
46
|
*/
|
|
45
|
-
export function compile_to_volar_mappings(source, filename) {
|
|
46
|
-
const ast = parse_module(source);
|
|
47
|
-
const analysis = analyze(ast, filename, { to_ts: true });
|
|
47
|
+
export function compile_to_volar_mappings(source, filename, options) {
|
|
48
|
+
const ast = parse_module(source, options);
|
|
49
|
+
const analysis = analyze(ast, filename, { to_ts: true, loose: !!options?.loose });
|
|
48
50
|
const transformed = transform_client(filename, source, analysis, true);
|
|
49
51
|
|
|
50
52
|
// Create volar mappings with esrap source map for accurate positioning
|
|
51
53
|
return convert_source_map_to_mappings(
|
|
52
54
|
transformed.ast,
|
|
55
|
+
ast,
|
|
53
56
|
source,
|
|
54
57
|
transformed.js.code,
|
|
55
58
|
transformed.js.map,
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
/** @import { Program } from 'estree' */
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* @import {
|
|
4
5
|
* CommentWithLocation,
|
|
5
6
|
* RipplePluginConfig
|
|
6
7
|
* } from '#compiler' */
|
|
7
8
|
|
|
9
|
+
/** @import { ParseOptions } from 'ripple/compiler' */
|
|
10
|
+
|
|
8
11
|
import * as acorn from 'acorn';
|
|
9
12
|
import { tsPlugin } from '@sveltejs/acorn-typescript';
|
|
10
13
|
import { parse_style } from './style.js';
|
|
@@ -74,7 +77,7 @@ function isWhitespaceTextNode(node) {
|
|
|
74
77
|
* @returns {function(any): any} Parser extension function
|
|
75
78
|
*/
|
|
76
79
|
function RipplePlugin(config) {
|
|
77
|
-
return (/** @type {
|
|
80
|
+
return (/** @type {typeof acorn.Parser} */ Parser) => {
|
|
78
81
|
const original = acorn.Parser.prototype;
|
|
79
82
|
const tt = Parser.tokTypes || acorn.tokTypes;
|
|
80
83
|
const tc = Parser.tokContexts || acorn.tokContexts;
|
|
@@ -85,6 +88,12 @@ function RipplePlugin(config) {
|
|
|
85
88
|
/** @type {any[]} */
|
|
86
89
|
#path = [];
|
|
87
90
|
#commentContextId = 0;
|
|
91
|
+
#loose = false;
|
|
92
|
+
|
|
93
|
+
constructor(options, input) {
|
|
94
|
+
super(options, input);
|
|
95
|
+
this.#loose = options?.rippleOptions.loose === true;
|
|
96
|
+
}
|
|
88
97
|
|
|
89
98
|
#createCommentMetadata() {
|
|
90
99
|
if (this.#path.length === 0) {
|
|
@@ -1419,6 +1428,9 @@ function RipplePlugin(config) {
|
|
|
1419
1428
|
|
|
1420
1429
|
// Position after the '>'
|
|
1421
1430
|
this.pos = tagEndPos + 1;
|
|
1431
|
+
|
|
1432
|
+
// Add opening and closing for easier location tracking
|
|
1433
|
+
// TODO: we should also parse attributes inside the opening tag
|
|
1422
1434
|
} else {
|
|
1423
1435
|
open = this.jsx_parseOpeningElementAt();
|
|
1424
1436
|
}
|
|
@@ -1468,6 +1480,41 @@ function RipplePlugin(config) {
|
|
|
1468
1480
|
element.attributes = open.attributes;
|
|
1469
1481
|
element.metadata ??= {};
|
|
1470
1482
|
element.metadata.commentContainerId = ++this.#commentContextId;
|
|
1483
|
+
// Store opening tag's end position for use in loose mode when element is unclosed
|
|
1484
|
+
element.metadata.openingTagEnd = open.end;
|
|
1485
|
+
element.metadata.openingTagEndLoc = open.loc.end;
|
|
1486
|
+
|
|
1487
|
+
function addOpeningAndClosing() {
|
|
1488
|
+
//TODO: once parse attributes of style and script
|
|
1489
|
+
// we should the open element loc properly
|
|
1490
|
+
const name = open.name.name;
|
|
1491
|
+
open.loc = {
|
|
1492
|
+
start: {
|
|
1493
|
+
line: element.loc.start.line,
|
|
1494
|
+
column: element.loc.start.column,
|
|
1495
|
+
},
|
|
1496
|
+
end: {
|
|
1497
|
+
line: element.loc.start.line,
|
|
1498
|
+
column: element.loc.start.column + `${name}>`.length,
|
|
1499
|
+
},
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
element.openingElement = open;
|
|
1503
|
+
element.closingElement = {
|
|
1504
|
+
type: 'JSXClosingElement',
|
|
1505
|
+
name: open.name,
|
|
1506
|
+
loc: {
|
|
1507
|
+
start: {
|
|
1508
|
+
line: element.loc.end.line,
|
|
1509
|
+
column: element.loc.end.column - `</${name}>`.length,
|
|
1510
|
+
},
|
|
1511
|
+
end: {
|
|
1512
|
+
line: element.loc.end.line,
|
|
1513
|
+
column: element.loc.end.column,
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1471
1518
|
|
|
1472
1519
|
if (element.selfClosing) {
|
|
1473
1520
|
this.#path.pop();
|
|
@@ -1506,6 +1553,7 @@ function RipplePlugin(config) {
|
|
|
1506
1553
|
|
|
1507
1554
|
element.content = content;
|
|
1508
1555
|
this.finishNode(element, 'Element');
|
|
1556
|
+
addOpeningAndClosing(element, open);
|
|
1509
1557
|
} else if (open.name.name === 'style') {
|
|
1510
1558
|
// jsx_parseOpeningElementAt treats ID selectors (ie. #myid) or type selectors (ie. div) as identifier and read it
|
|
1511
1559
|
// So backtrack to the end of the <style> tag to make sure everything is included
|
|
@@ -1515,7 +1563,7 @@ function RipplePlugin(config) {
|
|
|
1515
1563
|
const content = input.slice(0, end);
|
|
1516
1564
|
|
|
1517
1565
|
const component = this.#path.findLast((n) => n.type === 'Component');
|
|
1518
|
-
const parsed_css = parse_style(content);
|
|
1566
|
+
const parsed_css = parse_style(content, { loose: this.#loose });
|
|
1519
1567
|
|
|
1520
1568
|
if (!inside_head) {
|
|
1521
1569
|
if (component.css !== null) {
|
|
@@ -1553,6 +1601,7 @@ function RipplePlugin(config) {
|
|
|
1553
1601
|
|
|
1554
1602
|
element.css = content;
|
|
1555
1603
|
this.finishNode(element, 'Element');
|
|
1604
|
+
addOpeningAndClosing(element, open);
|
|
1556
1605
|
return element;
|
|
1557
1606
|
} else {
|
|
1558
1607
|
this.enterScope(0);
|
|
@@ -1590,15 +1639,24 @@ function RipplePlugin(config) {
|
|
|
1590
1639
|
this.next();
|
|
1591
1640
|
} else if (this.#path[this.#path.length - 1] === element) {
|
|
1592
1641
|
// Check if this element was properly closed
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1642
|
+
if (!this.#loose) {
|
|
1643
|
+
const tagName = this.getElementName(element.id);
|
|
1644
|
+
this.raise(
|
|
1645
|
+
this.start,
|
|
1646
|
+
`Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`,
|
|
1647
|
+
);
|
|
1648
|
+
} else {
|
|
1649
|
+
element.unclosed = true;
|
|
1650
|
+
const position = this.curPosition();
|
|
1651
|
+
position.line = element.metadata.openingTagEndLoc.line;
|
|
1652
|
+
position.column = element.metadata.openingTagEndLoc.column;
|
|
1653
|
+
element.loc.end = position;
|
|
1654
|
+
element.end = element.metadata.openingTagEnd;
|
|
1655
|
+
this.#path.pop();
|
|
1656
|
+
}
|
|
1600
1657
|
}
|
|
1601
1658
|
}
|
|
1659
|
+
|
|
1602
1660
|
// Ensure we escape JSX <tag></tag> context
|
|
1603
1661
|
const curContext = this.curContext();
|
|
1604
1662
|
|
|
@@ -1678,8 +1736,12 @@ function RipplePlugin(config) {
|
|
|
1678
1736
|
}
|
|
1679
1737
|
if (this.type === tt.braceL) {
|
|
1680
1738
|
const node = this.jsx_parseExpressionContainer();
|
|
1681
|
-
|
|
1682
|
-
|
|
1739
|
+
// Keep JSXEmptyExpression as-is (for prettier to handle comments)
|
|
1740
|
+
// but convert other expressions to Text/Html nodes
|
|
1741
|
+
if (node.expression.type !== 'JSXEmptyExpression') {
|
|
1742
|
+
node.type = node.html ? 'Html' : 'Text';
|
|
1743
|
+
delete node.html;
|
|
1744
|
+
}
|
|
1683
1745
|
body.push(node);
|
|
1684
1746
|
} else if (this.type === tt.braceR) {
|
|
1685
1747
|
return;
|
|
@@ -1720,10 +1782,40 @@ function RipplePlugin(config) {
|
|
|
1720
1782
|
}
|
|
1721
1783
|
|
|
1722
1784
|
if (openingTagName !== closingTagName) {
|
|
1723
|
-
this
|
|
1724
|
-
this.
|
|
1725
|
-
|
|
1726
|
-
|
|
1785
|
+
if (!this.#loose) {
|
|
1786
|
+
this.raise(
|
|
1787
|
+
this.start,
|
|
1788
|
+
`Expected closing tag to match opening tag. Expected '</${openingTagName}>' but found '</${closingTagName}>'`,
|
|
1789
|
+
);
|
|
1790
|
+
} else {
|
|
1791
|
+
// Loop through all unclosed elements on the stack
|
|
1792
|
+
while (this.#path.length > 0) {
|
|
1793
|
+
const elem = this.#path[this.#path.length - 1];
|
|
1794
|
+
|
|
1795
|
+
// Stop at non-Element boundaries (Component, etc.)
|
|
1796
|
+
if (elem.type !== 'Element' && elem.type !== 'TsxCompat') {
|
|
1797
|
+
break;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const elemName =
|
|
1801
|
+
elem.type === 'TsxCompat' ? 'tsx:' + elem.kind : this.getElementName(elem.id);
|
|
1802
|
+
|
|
1803
|
+
// Found matching opening tag
|
|
1804
|
+
if (elemName === closingTagName) {
|
|
1805
|
+
break;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Mark as unclosed and adjust location
|
|
1809
|
+
elem.unclosed = true;
|
|
1810
|
+
const position = this.curPosition();
|
|
1811
|
+
position.line = elem.metadata.openingTagEndLoc.line;
|
|
1812
|
+
position.column = elem.metadata.openingTagEndLoc.column;
|
|
1813
|
+
elem.loc.end = position;
|
|
1814
|
+
elem.end = elem.metadata.openingTagEnd;
|
|
1815
|
+
|
|
1816
|
+
this.#path.pop(); // Remove from stack
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1727
1819
|
}
|
|
1728
1820
|
|
|
1729
1821
|
this.#path.pop();
|
|
@@ -1759,7 +1851,10 @@ function RipplePlugin(config) {
|
|
|
1759
1851
|
) {
|
|
1760
1852
|
this.next();
|
|
1761
1853
|
const node = this.jsx_parseExpressionContainer();
|
|
1762
|
-
|
|
1854
|
+
// Keep JSXEmptyExpression as-is (don't convert to Text)
|
|
1855
|
+
if (node.expression.type !== 'JSXEmptyExpression') {
|
|
1856
|
+
node.type = 'Text';
|
|
1857
|
+
}
|
|
1763
1858
|
this.next();
|
|
1764
1859
|
this.context.pop();
|
|
1765
1860
|
this.context.pop();
|
|
@@ -2256,9 +2351,10 @@ function get_comment_handlers(source, comments, index = 0) {
|
|
|
2256
2351
|
/**
|
|
2257
2352
|
* Parse Ripple source code into an AST
|
|
2258
2353
|
* @param {string} source
|
|
2354
|
+
* @param {ParseOptions} [options]
|
|
2259
2355
|
* @returns {Program}
|
|
2260
2356
|
*/
|
|
2261
|
-
export function parse(source) {
|
|
2357
|
+
export function parse(source, options) {
|
|
2262
2358
|
/** @type {CommentWithLocation[]} */
|
|
2263
2359
|
const comments = [];
|
|
2264
2360
|
|
|
@@ -2315,6 +2411,9 @@ export function parse(source) {
|
|
|
2315
2411
|
ecmaVersion: 13,
|
|
2316
2412
|
locations: true,
|
|
2317
2413
|
onComment: /** @type {any} */ (onComment),
|
|
2414
|
+
rippleOptions: {
|
|
2415
|
+
loose: options?.loose || false,
|
|
2416
|
+
},
|
|
2318
2417
|
});
|
|
2319
2418
|
} catch (e) {
|
|
2320
2419
|
throw e;
|
|
@@ -88,8 +88,8 @@ class Parser {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
export function parse_style(content) {
|
|
92
|
-
const parser = new Parser(content, false);
|
|
91
|
+
export function parse_style(content, options) {
|
|
92
|
+
const parser = new Parser(content, options.loose || false);
|
|
93
93
|
|
|
94
94
|
return {
|
|
95
95
|
source: content,
|
|
@@ -232,8 +232,8 @@ function read_declaration(parser) {
|
|
|
232
232
|
|
|
233
233
|
const value = read_value(parser);
|
|
234
234
|
|
|
235
|
-
if (!value && !property.startsWith('--')) {
|
|
236
|
-
|
|
235
|
+
if (!value && !property.startsWith('--') && !parser.loose) {
|
|
236
|
+
throw new Error('CSS Declaration cannot be empty');
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
const end = parser.index;
|
|
@@ -736,6 +736,17 @@ const visitors = {
|
|
|
736
736
|
return context.visit(node.expression);
|
|
737
737
|
},
|
|
738
738
|
|
|
739
|
+
JSXEmptyExpression(node, context) {
|
|
740
|
+
// JSX comments like {/* ... */} are represented as JSXEmptyExpression
|
|
741
|
+
// In TypeScript mode, preserve them as-is for prettier
|
|
742
|
+
// In JavaScript mode, they're removed (which is correct since they're comments)
|
|
743
|
+
if (context.state.to_ts) {
|
|
744
|
+
return context.next();
|
|
745
|
+
}
|
|
746
|
+
// In JS mode, return empty - comments are stripped
|
|
747
|
+
return b.empty;
|
|
748
|
+
},
|
|
749
|
+
|
|
739
750
|
JSXFragment(node, context) {
|
|
740
751
|
if (context.state.to_ts) {
|
|
741
752
|
return context.next();
|
|
@@ -1901,7 +1912,6 @@ function transform_ts_child(node, context) {
|
|
|
1901
1912
|
const children = [];
|
|
1902
1913
|
let has_children_props = false;
|
|
1903
1914
|
|
|
1904
|
-
const ref_attributes = [];
|
|
1905
1915
|
const attributes = node.attributes.map((attr) => {
|
|
1906
1916
|
if (attr.type === 'Attribute') {
|
|
1907
1917
|
const metadata = { await: false };
|
|
@@ -1943,7 +1953,7 @@ function transform_ts_child(node, context) {
|
|
|
1943
1953
|
}
|
|
1944
1954
|
});
|
|
1945
1955
|
|
|
1946
|
-
if (!node.selfClosing && !has_children_props && node.children.length > 0) {
|
|
1956
|
+
if (!node.selfClosing && !node.unclosed && !has_children_props && node.children.length > 0) {
|
|
1947
1957
|
const is_dom_element = is_element_dom_element(node);
|
|
1948
1958
|
|
|
1949
1959
|
const component_scope = context.state.scopes.get(node);
|
|
@@ -1963,20 +1973,20 @@ function transform_ts_child(node, context) {
|
|
|
1963
1973
|
}
|
|
1964
1974
|
}
|
|
1965
1975
|
|
|
1966
|
-
let
|
|
1976
|
+
let opening_name_element, closing_name_element;
|
|
1967
1977
|
|
|
1968
1978
|
if (type_is_expression) {
|
|
1969
1979
|
// For dynamic/expression-based components (e.g., props.children),
|
|
1970
1980
|
// use JSX expression instead of identifier
|
|
1971
|
-
|
|
1972
|
-
|
|
1981
|
+
opening_name_element = type_expression;
|
|
1982
|
+
closing_name_element = node.selfClosing || node.unclosed ? undefined : type_expression;
|
|
1973
1983
|
} else {
|
|
1974
|
-
|
|
1984
|
+
opening_name_element = b.jsx_id(type_expression);
|
|
1975
1985
|
// For tracked identifiers (dynamic components), adjust the loc to skip the '@' prefix
|
|
1976
1986
|
// and add metadata for mapping
|
|
1977
1987
|
if (node.id.tracked && node.id.loc) {
|
|
1978
1988
|
// The original identifier loc includes the '@', so we need to skip it
|
|
1979
|
-
|
|
1989
|
+
opening_name_element.loc = {
|
|
1980
1990
|
start: {
|
|
1981
1991
|
line: node.id.loc.start.line,
|
|
1982
1992
|
column: node.id.loc.start.column + 1, // Skip '@'
|
|
@@ -1985,14 +1995,14 @@ function transform_ts_child(node, context) {
|
|
|
1985
1995
|
};
|
|
1986
1996
|
// Add metadata if this was capitalized
|
|
1987
1997
|
if (node.metadata?.ts_name && node.metadata?.original_name) {
|
|
1988
|
-
|
|
1998
|
+
opening_name_element.metadata = {
|
|
1989
1999
|
original_name: node.metadata.original_name,
|
|
1990
2000
|
is_capitalized: true,
|
|
1991
2001
|
};
|
|
1992
2002
|
}
|
|
1993
2003
|
} else {
|
|
1994
2004
|
// Use node.id.loc if available, otherwise create a loc based on the element's position
|
|
1995
|
-
|
|
2005
|
+
opening_name_element.loc = node.id.loc || {
|
|
1996
2006
|
start: {
|
|
1997
2007
|
line: node.loc.start.line,
|
|
1998
2008
|
column: node.loc.start.column + 2, // After "<@"
|
|
@@ -2004,14 +2014,14 @@ function transform_ts_child(node, context) {
|
|
|
2004
2014
|
};
|
|
2005
2015
|
}
|
|
2006
2016
|
|
|
2007
|
-
if (!node.selfClosing) {
|
|
2008
|
-
|
|
2017
|
+
if (!node.selfClosing && !node.unclosed) {
|
|
2018
|
+
closing_name_element = b.jsx_id(type_expression);
|
|
2009
2019
|
// For tracked identifiers, also adjust closing tag location
|
|
2010
2020
|
if (node.id.tracked && node.id.loc) {
|
|
2011
2021
|
// Calculate position relative to closing tag
|
|
2012
2022
|
// Format: </@identifier>
|
|
2013
2023
|
const closing_tag_start = node.loc.end.column - type_expression.length - 3; // </@
|
|
2014
|
-
|
|
2024
|
+
closing_name_element.loc = {
|
|
2015
2025
|
start: {
|
|
2016
2026
|
line: node.loc.end.line,
|
|
2017
2027
|
column: closing_tag_start + 3, // Skip '</@'
|
|
@@ -2026,13 +2036,13 @@ function transform_ts_child(node, context) {
|
|
|
2026
2036
|
};
|
|
2027
2037
|
// Add metadata if this was capitalized
|
|
2028
2038
|
if (node.metadata?.ts_name && node.metadata?.original_name) {
|
|
2029
|
-
|
|
2039
|
+
closing_name_element.metadata = {
|
|
2030
2040
|
original_name: node.metadata.original_name,
|
|
2031
2041
|
is_capitalized: true,
|
|
2032
2042
|
};
|
|
2033
2043
|
}
|
|
2034
2044
|
} else {
|
|
2035
|
-
|
|
2045
|
+
closing_name_element.loc = {
|
|
2036
2046
|
start: {
|
|
2037
2047
|
line: node.loc.end.line,
|
|
2038
2048
|
column: node.loc.end.column - type_expression.length - 1,
|
|
@@ -2046,13 +2056,38 @@ function transform_ts_child(node, context) {
|
|
|
2046
2056
|
}
|
|
2047
2057
|
}
|
|
2048
2058
|
|
|
2049
|
-
|
|
2050
|
-
|
|
2059
|
+
let jsxElement = b.jsx_element(
|
|
2060
|
+
opening_name_element,
|
|
2061
|
+
node.loc,
|
|
2051
2062
|
attributes,
|
|
2052
2063
|
children,
|
|
2053
2064
|
node.selfClosing,
|
|
2054
|
-
|
|
2065
|
+
node.unclosed,
|
|
2066
|
+
closing_name_element,
|
|
2055
2067
|
);
|
|
2068
|
+
|
|
2069
|
+
// Calculate the location for the entire JSXClosingElement (including </ and >)
|
|
2070
|
+
if (jsxElement.closingElement && !node.selfClosing && !node.unclosed) {
|
|
2071
|
+
// The closing element starts with '</' and ends with '>'
|
|
2072
|
+
// For a tag like </div>, if node.loc.end is right after '>', then:
|
|
2073
|
+
// - '<' is at node.loc.end.column - type_expression.length - 3
|
|
2074
|
+
// - '>' is at node.loc.end.column - 1
|
|
2075
|
+
const tag_name_length = node.id.tracked
|
|
2076
|
+
? (node.metadata?.original_name?.length || type_expression.length) + 1 // +1 for '@'
|
|
2077
|
+
: type_expression.length;
|
|
2078
|
+
|
|
2079
|
+
jsxElement.closingElement.loc = {
|
|
2080
|
+
start: {
|
|
2081
|
+
line: node.loc.end.line,
|
|
2082
|
+
column: node.loc.end.column - tag_name_length - 2, // at '</'
|
|
2083
|
+
},
|
|
2084
|
+
end: {
|
|
2085
|
+
line: node.loc.end.line,
|
|
2086
|
+
column: node.loc.end.column, // at '>'
|
|
2087
|
+
},
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2056
2091
|
// Preserve metadata from Element node for mapping purposes
|
|
2057
2092
|
if (node.metadata && (node.metadata.ts_name || node.metadata.original_name)) {
|
|
2058
2093
|
jsxElement.metadata = {
|
|
@@ -2060,7 +2095,13 @@ function transform_ts_child(node, context) {
|
|
|
2060
2095
|
original_name: node.metadata.original_name,
|
|
2061
2096
|
};
|
|
2062
2097
|
}
|
|
2063
|
-
|
|
2098
|
+
// For unclosed elements, push the JSXElement directly without wrapping in ExpressionStatement
|
|
2099
|
+
// This keeps it in the AST for mappings but avoids adding a semicolon
|
|
2100
|
+
if (node.unclosed) {
|
|
2101
|
+
state.init.push(jsxElement);
|
|
2102
|
+
} else {
|
|
2103
|
+
state.init.push(b.stmt(jsxElement));
|
|
2104
|
+
}
|
|
2064
2105
|
} else if (node.type === 'IfStatement') {
|
|
2065
2106
|
const consequent_scope = context.state.scopes.get(node.consequent);
|
|
2066
2107
|
const consequent = b.block(
|
|
@@ -2164,6 +2205,10 @@ function transform_ts_child(node, context) {
|
|
|
2164
2205
|
.filter((child) => child.type !== 'JSXText' || child.value.trim() !== '');
|
|
2165
2206
|
|
|
2166
2207
|
state.init.push(b.stmt(b.jsx_fragment(children)));
|
|
2208
|
+
} else if (node.type === 'JSXExpressionContainer') {
|
|
2209
|
+
// JSX comments {/* ... */} are JSXExpressionContainer with JSXEmptyExpression
|
|
2210
|
+
// These should be preserved in the output as-is for prettier to handle
|
|
2211
|
+
state.init.push(b.stmt(b.jsx_expression_container(visit(node.expression, state))));
|
|
2167
2212
|
} else {
|
|
2168
2213
|
throw new Error('TODO');
|
|
2169
2214
|
}
|
|
@@ -2496,11 +2541,62 @@ function create_tsx_with_typescript_support() {
|
|
|
2496
2541
|
}
|
|
2497
2542
|
context.write(': ');
|
|
2498
2543
|
context.visit(node.value);
|
|
2544
|
+
} else if (!node.shorthand) {
|
|
2545
|
+
// If property is already longhand in source, keep it longhand
|
|
2546
|
+
// to prevent source map issues when parts of the syntax disappear in shorthand conversion
|
|
2547
|
+
// This applies to:
|
|
2548
|
+
// - { media: media } -> would become { media } (value identifier disappears)
|
|
2549
|
+
// - { fn: function() {} } -> would become { fn() {} } ('function' keyword disappears)
|
|
2550
|
+
const value = node.value.type === 'AssignmentPattern' ? node.value.left : node.value;
|
|
2551
|
+
|
|
2552
|
+
// Check if esrap would convert this to shorthand property or method
|
|
2553
|
+
const wouldBeShorthand =
|
|
2554
|
+
!node.computed &&
|
|
2555
|
+
node.kind === 'init' &&
|
|
2556
|
+
node.key.type === 'Identifier' &&
|
|
2557
|
+
value.type === 'Identifier' &&
|
|
2558
|
+
node.key.name === value.name;
|
|
2559
|
+
|
|
2560
|
+
const wouldBeMethodShorthand =
|
|
2561
|
+
!node.computed &&
|
|
2562
|
+
node.value.type === 'FunctionExpression' &&
|
|
2563
|
+
node.kind !== 'get' &&
|
|
2564
|
+
node.kind !== 'set';
|
|
2565
|
+
|
|
2566
|
+
if (wouldBeShorthand || wouldBeMethodShorthand) {
|
|
2567
|
+
// Force longhand: write key: value explicitly to preserve source positions
|
|
2568
|
+
if (node.computed) context.write('[');
|
|
2569
|
+
context.visit(node.key);
|
|
2570
|
+
context.write(node.computed ? ']: ' : ': ');
|
|
2571
|
+
context.visit(node.value);
|
|
2572
|
+
} else {
|
|
2573
|
+
base_tsx.Property(node, context);
|
|
2574
|
+
}
|
|
2499
2575
|
} else {
|
|
2500
2576
|
// Use default handler for non-component properties
|
|
2501
2577
|
base_tsx.Property(node, context);
|
|
2502
2578
|
}
|
|
2503
2579
|
},
|
|
2580
|
+
// Custom handler for JSXClosingElement to ensure closing tag brackets have source mappings
|
|
2581
|
+
JSXClosingElement(node, context) {
|
|
2582
|
+
// Set location for '<' then write '</'
|
|
2583
|
+
if (node.loc) {
|
|
2584
|
+
context.location(node.loc.start.line, node.loc.start.column);
|
|
2585
|
+
context.write('</');
|
|
2586
|
+
} else {
|
|
2587
|
+
context.write('</');
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
context.visit(node.name);
|
|
2591
|
+
|
|
2592
|
+
// Set location for '>' then write it
|
|
2593
|
+
if (node.loc) {
|
|
2594
|
+
context.location(node.loc.end.line, node.loc.end.column - 1);
|
|
2595
|
+
context.write('>');
|
|
2596
|
+
} else {
|
|
2597
|
+
context.write('>');
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2504
2600
|
// Custom handler for ArrayPattern to ensure typeAnnotation is visited
|
|
2505
2601
|
// esrap's TypeScript handler doesn't visit typeAnnotation for ArrayPattern (only for ObjectPattern)
|
|
2506
2602
|
ArrayPattern(node, context) {
|
|
@@ -2583,6 +2679,36 @@ function create_tsx_with_typescript_support() {
|
|
|
2583
2679
|
// Write source
|
|
2584
2680
|
context.visit(node.source);
|
|
2585
2681
|
},
|
|
2682
|
+
// Custom handler for JSXOpeningElement to ensure '<' and '>' have source mappings
|
|
2683
|
+
// Esrap's default handler only maps the tag name, not the brackets
|
|
2684
|
+
// This creates mappings for the brackets so auto-close can find the cursor position
|
|
2685
|
+
JSXOpeningElement(node, context) {
|
|
2686
|
+
// Set location for '<'
|
|
2687
|
+
if (node.loc) {
|
|
2688
|
+
context.location(node.loc.start.line, node.loc.start.column);
|
|
2689
|
+
}
|
|
2690
|
+
context.write('<');
|
|
2691
|
+
|
|
2692
|
+
context.visit(node.name);
|
|
2693
|
+
|
|
2694
|
+
// Write attributes
|
|
2695
|
+
for (const attr of node.attributes || []) {
|
|
2696
|
+
context.write(' ');
|
|
2697
|
+
context.visit(attr);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
if (node.selfClosing) {
|
|
2701
|
+
context.write(' />');
|
|
2702
|
+
} else {
|
|
2703
|
+
// Set the source location for the '>'
|
|
2704
|
+
// node.loc.end points AFTER the '>', so subtract 1 to get the position OF the '>'
|
|
2705
|
+
if (node.loc) {
|
|
2706
|
+
// TODO: why do we need to subtract 1 from column here?
|
|
2707
|
+
context.location(node.loc.end.line, node.loc.end.column - 1);
|
|
2708
|
+
}
|
|
2709
|
+
context.write('>');
|
|
2710
|
+
}
|
|
2711
|
+
},
|
|
2586
2712
|
// Custom handler for TSParenthesizedType: (Type)
|
|
2587
2713
|
TSParenthesizedType(node, context) {
|
|
2588
2714
|
context.write('(');
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
* @typedef {import('estree').Position} Position
|
|
8
8
|
* @typedef {{start: Position, end: Position}} Location
|
|
9
9
|
* @typedef {import('@volar/language-core').CodeMapping} VolarCodeMapping
|
|
10
|
-
* @typedef {
|
|
11
|
-
* @typedef {
|
|
12
|
-
* @typedef {
|
|
10
|
+
* @typedef {import('ripple/compiler').MappingData} MappingData
|
|
11
|
+
* @typedef {import('ripple/compiler').CodeMapping} CodeMapping
|
|
12
|
+
* @typedef {import('ripple/compiler').VolarMappingsResult} VolarMappingsResult
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* start: number,
|
|
15
|
+
* end: number,
|
|
16
|
+
* content: string
|
|
17
|
+
* }} CssSourceRegion
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { walk } from 'zimmerframe';
|
|
@@ -25,6 +30,72 @@ export const mapping_data = {
|
|
|
25
30
|
format: false,
|
|
26
31
|
};
|
|
27
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Converts line/column positions to byte offsets
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @returns {number[]}
|
|
37
|
+
*/
|
|
38
|
+
function build_line_offsets(text) {
|
|
39
|
+
const offsets = [0]; // Line 1 starts at offset 0
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
if (text[i] === '\n') {
|
|
42
|
+
offsets.push(i + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return offsets;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert line/column to byte offset
|
|
50
|
+
* @param {number} line
|
|
51
|
+
* @param {number} column
|
|
52
|
+
* @param {number[]} line_offsets
|
|
53
|
+
* @returns {number}
|
|
54
|
+
*/
|
|
55
|
+
function loc_to_offset(line, column, line_offsets) {
|
|
56
|
+
if (line < 1 || line > line_offsets.length) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Location line or line offsets length is out of bounds, line: ${line}, line offsets length: ${line_offsets.length}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return line_offsets[line - 1] + column;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract CSS source regions from style elements in the AST
|
|
66
|
+
* @param {any} ast - The parsed AST
|
|
67
|
+
* @param {string} source - Original source code
|
|
68
|
+
* @param {number[]} source_line_offsets
|
|
69
|
+
* @returns {CssSourceRegion[]}
|
|
70
|
+
*/
|
|
71
|
+
function extractCssSourceRegions(ast, source, source_line_offsets) {
|
|
72
|
+
/** @type {CssSourceRegion[]} */
|
|
73
|
+
const regions = [];
|
|
74
|
+
|
|
75
|
+
walk(ast, null, {
|
|
76
|
+
Element(node) {
|
|
77
|
+
// Check if this is a style element with CSS content
|
|
78
|
+
if (node.id?.name === 'style' && node.css) {
|
|
79
|
+
const openLoc = node.openingElement.loc;
|
|
80
|
+
const cssStart =
|
|
81
|
+
loc_to_offset(openLoc.end.line, openLoc.end.column, source_line_offsets) + 1;
|
|
82
|
+
|
|
83
|
+
const closeLoc = node.closingElement.loc;
|
|
84
|
+
const cssEnd =
|
|
85
|
+
loc_to_offset(closeLoc.start.line, closeLoc.start.column, source_line_offsets) - 1;
|
|
86
|
+
|
|
87
|
+
regions.push({
|
|
88
|
+
start: cssStart,
|
|
89
|
+
end: cssEnd,
|
|
90
|
+
content: node.css,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return regions;
|
|
97
|
+
}
|
|
98
|
+
|
|
28
99
|
/**
|
|
29
100
|
* @import { PostProcessingChanges } from './client/index.js';
|
|
30
101
|
*/
|
|
@@ -32,15 +103,17 @@ export const mapping_data = {
|
|
|
32
103
|
/**
|
|
33
104
|
* Create Volar mappings by walking the transformed AST
|
|
34
105
|
* @param {any} ast - The transformed AST
|
|
106
|
+
* @param {any} ast_from_source - The original AST from source
|
|
35
107
|
* @param {string} source - Original source code
|
|
36
108
|
* @param {string} generated_code - Generated code (returned in output, not used for searching)
|
|
37
109
|
* @param {object} esrap_source_map - Esrap source map for accurate position lookup
|
|
38
110
|
* @param {PostProcessingChanges } post_processing_changes - Optional post-processing changes
|
|
39
111
|
* @param {number[]} line_offsets - Pre-computed line offsets array for generated code
|
|
40
|
-
* @returns {
|
|
112
|
+
* @returns {VolarMappingsResult}
|
|
41
113
|
*/
|
|
42
114
|
export function convert_source_map_to_mappings(
|
|
43
115
|
ast,
|
|
116
|
+
ast_from_source,
|
|
44
117
|
source,
|
|
45
118
|
generated_code,
|
|
46
119
|
esrap_source_map,
|
|
@@ -51,38 +124,8 @@ export function convert_source_map_to_mappings(
|
|
|
51
124
|
const mappings = [];
|
|
52
125
|
let isImportDeclarationPresent = false;
|
|
53
126
|
|
|
54
|
-
/**
|
|
55
|
-
* Converts line/column positions to byte offsets
|
|
56
|
-
* @param {string} text
|
|
57
|
-
* @returns {number[]}
|
|
58
|
-
*/
|
|
59
|
-
const build_line_offsets = (text) => {
|
|
60
|
-
const offsets = [0]; // Line 1 starts at offset 0
|
|
61
|
-
for (let i = 0; i < text.length; i++) {
|
|
62
|
-
if (text[i] === '\n') {
|
|
63
|
-
offsets.push(i + 1);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return offsets;
|
|
67
|
-
};
|
|
68
127
|
const source_line_offsets = build_line_offsets(source);
|
|
69
128
|
|
|
70
|
-
/**
|
|
71
|
-
* Convert line/column to byte offset
|
|
72
|
-
* @param {number} line
|
|
73
|
-
* @param {number} column
|
|
74
|
-
* @param {number[]} line_offsets
|
|
75
|
-
* @returns {number}
|
|
76
|
-
*/
|
|
77
|
-
const loc_to_offset = (line, column, line_offsets) => {
|
|
78
|
-
if (line < 1 || line > line_offsets.length) {
|
|
79
|
-
throw new Error(
|
|
80
|
-
`Location line or line offsets length is out of bounds, line: ${line}, line offsets length: ${line_offsets.length}`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
return line_offsets[line - 1] + column;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
129
|
/**
|
|
87
130
|
* Convert generated line/column to byte offset using pre-computed line_offsets
|
|
88
131
|
* @param {number} line
|
|
@@ -280,7 +323,34 @@ export function convert_source_map_to_mappings(
|
|
|
280
323
|
|
|
281
324
|
// 1. Visit opening element (name and attributes)
|
|
282
325
|
if (node.openingElement) {
|
|
326
|
+
// Add tokens for '<' and '>' brackets to ensure auto-close feature works
|
|
327
|
+
const openingElem = node.openingElement;
|
|
328
|
+
|
|
329
|
+
// Add '<' bracket
|
|
330
|
+
if (openingElem.loc) {
|
|
331
|
+
tokens.push({
|
|
332
|
+
source: '<',
|
|
333
|
+
generated: '<',
|
|
334
|
+
loc: {
|
|
335
|
+
start: { line: openingElem.loc.start.line, column: openingElem.loc.start.column },
|
|
336
|
+
end: { line: openingElem.loc.start.line, column: openingElem.loc.start.column + 1 },
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
283
341
|
visit(node.openingElement);
|
|
342
|
+
|
|
343
|
+
// Add '>' bracket (or '/>' for self-closing)
|
|
344
|
+
if (openingElem.loc && !openingElem.selfClosing) {
|
|
345
|
+
tokens.push({
|
|
346
|
+
source: '>',
|
|
347
|
+
generated: '>',
|
|
348
|
+
loc: {
|
|
349
|
+
start: { line: openingElem.loc.end.line, column: openingElem.loc.end.column - 1 },
|
|
350
|
+
end: { line: openingElem.loc.end.line, column: openingElem.loc.end.column },
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
284
354
|
}
|
|
285
355
|
|
|
286
356
|
// 2. Visit children in order
|
|
@@ -1300,8 +1370,27 @@ export function convert_source_map_to_mappings(
|
|
|
1300
1370
|
});
|
|
1301
1371
|
}
|
|
1302
1372
|
|
|
1373
|
+
/** @type {{ cssMappings: CodeMapping[], cssSources: string[] }} */
|
|
1374
|
+
const cssResult = { cssMappings: [], cssSources: [] };
|
|
1375
|
+
for (const region of extractCssSourceRegions(ast_from_source, source, source_line_offsets)) {
|
|
1376
|
+
cssResult.cssMappings.push({
|
|
1377
|
+
sourceOffsets: [region.start],
|
|
1378
|
+
generatedOffsets: [0],
|
|
1379
|
+
lengths: [region.content.length],
|
|
1380
|
+
data: {
|
|
1381
|
+
...mapping_data,
|
|
1382
|
+
customData: {
|
|
1383
|
+
generatedLengths: [region.content.length],
|
|
1384
|
+
},
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
cssResult.cssSources.push(region.content);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1303
1391
|
return {
|
|
1304
1392
|
code: generated_code,
|
|
1305
1393
|
mappings,
|
|
1394
|
+
...cssResult,
|
|
1306
1395
|
};
|
|
1307
1396
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SourceLocation } from 'estree';
|
|
2
|
+
|
|
1
3
|
// Ripple augmentation for ESTree function nodes
|
|
2
4
|
declare module 'estree' {
|
|
3
5
|
interface FunctionDeclaration {
|
|
@@ -12,7 +14,28 @@ declare module 'estree' {
|
|
|
12
14
|
interface Identifier {
|
|
13
15
|
tracked?: boolean;
|
|
14
16
|
}
|
|
17
|
+
interface Component {
|
|
18
|
+
type: 'Component';
|
|
19
|
+
id: Identifier;
|
|
20
|
+
params: Pattern[];
|
|
21
|
+
body: BlockStatement;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare module 'estree-jsx' {
|
|
26
|
+
interface JSXAttribute {
|
|
27
|
+
shorthand: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface JSXOpeningElement {
|
|
31
|
+
loc: SourceLocation;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface JSXClosingElement {
|
|
35
|
+
loc: SourceLocation;
|
|
36
|
+
}
|
|
15
37
|
}
|
|
38
|
+
|
|
16
39
|
import type { Comment, Position } from 'acorn';
|
|
17
40
|
import type {
|
|
18
41
|
Program,
|
|
@@ -25,6 +25,9 @@ var all_registered_events = new Set();
|
|
|
25
25
|
/** @type {Set<(events: Array<string>) => void>} */
|
|
26
26
|
var root_event_handles = new Set();
|
|
27
27
|
|
|
28
|
+
/** @type {Element | null} */
|
|
29
|
+
var root_target = null;
|
|
30
|
+
|
|
28
31
|
/**
|
|
29
32
|
* @param {AddEventOptions} options
|
|
30
33
|
* @returns {AddEventListenerOptions}
|
|
@@ -55,7 +58,19 @@ function get_event_options(options) {
|
|
|
55
58
|
* @param {ExtendedEventOptions} [options]
|
|
56
59
|
*/
|
|
57
60
|
export function on(element, type, handler, options = {}) {
|
|
58
|
-
var
|
|
61
|
+
var opts = { ...options };
|
|
62
|
+
if (
|
|
63
|
+
element === window ||
|
|
64
|
+
element === document ||
|
|
65
|
+
element === document.body ||
|
|
66
|
+
element === root_target ||
|
|
67
|
+
element instanceof MediaQueryList ||
|
|
68
|
+
/** @type {Element} */ (element).contains(root_target)
|
|
69
|
+
) {
|
|
70
|
+
opts.delegated = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var remove_listener = create_event(type, element, handler, opts);
|
|
59
74
|
|
|
60
75
|
return () => {
|
|
61
76
|
remove_listener();
|
|
@@ -374,6 +389,7 @@ export function delegate(events) {
|
|
|
374
389
|
/** @param {Element} target */
|
|
375
390
|
export function handle_root_events(target) {
|
|
376
391
|
var registered_events = new Set();
|
|
392
|
+
root_target = target;
|
|
377
393
|
|
|
378
394
|
/**
|
|
379
395
|
* @typedef {Object} EventHandleOptions
|
|
@@ -412,5 +428,6 @@ export function handle_root_events(target) {
|
|
|
412
428
|
target.removeEventListener(event_name, handle_event_propagation);
|
|
413
429
|
}
|
|
414
430
|
root_event_handles.delete(event_handle);
|
|
431
|
+
root_target = null;
|
|
415
432
|
};
|
|
416
433
|
}
|
package/src/utils/builders.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @import * as ESTree from 'estree' */
|
|
2
|
+
/** @import * as ESTreeJSX from 'estree-jsx' */
|
|
2
3
|
|
|
3
4
|
import { regex_is_valid_identifier } from './patterns.js';
|
|
4
5
|
import { sanitize_template_string } from './sanitize_template_string.js';
|
|
@@ -45,6 +46,12 @@ export function arrow(params, body, async = false) {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* @param {ESTree.Identifier} id
|
|
51
|
+
* @param {ESTree.Pattern[]} params
|
|
52
|
+
* @param {ESTree.BlockStatement} body
|
|
53
|
+
* @returns {ESTree.Component}
|
|
54
|
+
*/
|
|
48
55
|
export function component(id, params, body) {
|
|
49
56
|
return {
|
|
50
57
|
type: 'Component',
|
|
@@ -620,25 +627,29 @@ function if_builder(test, consequent, alternate) {
|
|
|
620
627
|
/**
|
|
621
628
|
* @param {string} as
|
|
622
629
|
* @param {string} source
|
|
630
|
+
* @param {Array<ESTree.ImportAttribute>} attributes
|
|
623
631
|
* @returns {ESTree.ImportDeclaration}
|
|
624
632
|
*/
|
|
625
|
-
export function import_all(as, source) {
|
|
633
|
+
export function import_all(as, source, attributes = []) {
|
|
626
634
|
return {
|
|
627
635
|
type: 'ImportDeclaration',
|
|
628
636
|
source: literal(source),
|
|
629
637
|
specifiers: [import_namespace(as)],
|
|
638
|
+
attributes,
|
|
630
639
|
};
|
|
631
640
|
}
|
|
632
641
|
|
|
633
642
|
/**
|
|
634
643
|
* @param {Array<[string, string]>} parts
|
|
635
644
|
* @param {string} source
|
|
645
|
+
* @param {Array<ESTree.ImportAttribute>} attributes
|
|
636
646
|
* @returns {ESTree.ImportDeclaration}
|
|
637
647
|
*/
|
|
638
|
-
export function imports(parts, source) {
|
|
648
|
+
export function imports(parts, source, attributes = []) {
|
|
639
649
|
return {
|
|
640
650
|
type: 'ImportDeclaration',
|
|
641
651
|
source: literal(source),
|
|
652
|
+
attributes,
|
|
642
653
|
specifiers: parts.map((p) => ({
|
|
643
654
|
type: 'ImportSpecifier',
|
|
644
655
|
imported: id(p[0]),
|
|
@@ -705,9 +716,9 @@ export function key(name) {
|
|
|
705
716
|
}
|
|
706
717
|
|
|
707
718
|
/**
|
|
708
|
-
* @param {
|
|
709
|
-
* @param {ESTree.Literal |
|
|
710
|
-
* @returns {
|
|
719
|
+
* @param {ESTreeJSX.JSXIdentifier | ESTreeJSX.JSXNamespacedName} name
|
|
720
|
+
* @param {ESTree.Literal | ESTreeJSX.JSXExpressionContainer | null} value
|
|
721
|
+
* @returns {ESTreeJSX.JSXAttribute}
|
|
711
722
|
*/
|
|
712
723
|
export function jsx_attribute(name, value = null) {
|
|
713
724
|
return {
|
|
@@ -719,44 +730,54 @@ export function jsx_attribute(name, value = null) {
|
|
|
719
730
|
}
|
|
720
731
|
|
|
721
732
|
/**
|
|
722
|
-
* @param {
|
|
723
|
-
* @param {
|
|
724
|
-
* @param {
|
|
733
|
+
* @param {ESTreeJSX.JSXOpeningElement['name']} name
|
|
734
|
+
* @param {ESTree.SourceLocation} loc
|
|
735
|
+
* @param {ESTreeJSX.JSXOpeningElement['attributes']} attributes
|
|
736
|
+
* @param {ESTreeJSX.JSXElement['children']} children
|
|
725
737
|
* @param {boolean} self_closing
|
|
726
|
-
* @
|
|
738
|
+
* @param {boolean} unclosed
|
|
739
|
+
* @param {ESTreeJSX.JSXClosingElement['name']} closing_name
|
|
740
|
+
* @returns {ESTreeJSX.JSXElement}
|
|
727
741
|
*/
|
|
728
742
|
export function jsx_element(
|
|
729
743
|
name,
|
|
744
|
+
loc,
|
|
730
745
|
attributes = [],
|
|
731
746
|
children = [],
|
|
732
747
|
self_closing = false,
|
|
748
|
+
unclosed = false,
|
|
733
749
|
closing_name = name,
|
|
734
750
|
) {
|
|
751
|
+
/** @type {ESTreeJSX.JSXOpeningElement} */
|
|
735
752
|
const opening_element = {
|
|
736
753
|
type: 'JSXOpeningElement',
|
|
737
754
|
name,
|
|
738
755
|
attributes,
|
|
739
756
|
selfClosing: self_closing,
|
|
757
|
+
loc: loc,
|
|
740
758
|
};
|
|
741
759
|
|
|
760
|
+
/** @type {ESTreeJSX.JSXElement} */
|
|
742
761
|
const element = {
|
|
743
762
|
type: 'JSXElement',
|
|
744
763
|
openingElement: opening_element,
|
|
745
|
-
closingElement: self_closing
|
|
746
|
-
? null
|
|
747
|
-
: {
|
|
748
|
-
type: 'JSXClosingElement',
|
|
749
|
-
name: closing_name,
|
|
750
|
-
},
|
|
751
764
|
children,
|
|
765
|
+
closingElement:
|
|
766
|
+
self_closing || unclosed
|
|
767
|
+
? null
|
|
768
|
+
: {
|
|
769
|
+
type: 'JSXClosingElement',
|
|
770
|
+
name: closing_name,
|
|
771
|
+
loc,
|
|
772
|
+
},
|
|
752
773
|
};
|
|
753
774
|
|
|
754
775
|
return element;
|
|
755
776
|
}
|
|
756
777
|
|
|
757
778
|
/**
|
|
758
|
-
* @param {Array<
|
|
759
|
-
* @returns {
|
|
779
|
+
* @param {Array<ESTreeJSX.JSXText | ESTreeJSX.JSXExpressionContainer | ESTreeJSX.JSXSpreadChild | ESTreeJSX.JSXElement | ESTreeJSX.JSXFragment>} children
|
|
780
|
+
* @returns {ESTreeJSX.JSXFragment}
|
|
760
781
|
*/
|
|
761
782
|
export function jsx_fragment(children = []) {
|
|
762
783
|
return {
|
|
@@ -772,8 +793,8 @@ export function jsx_fragment(children = []) {
|
|
|
772
793
|
}
|
|
773
794
|
|
|
774
795
|
/**
|
|
775
|
-
* @param {ESTree.Expression |
|
|
776
|
-
* @returns {
|
|
796
|
+
* @param {ESTree.Expression | ESTreeJSX.JSXEmptyExpression} expression
|
|
797
|
+
* @returns {ESTreeJSX.JSXExpressionContainer}
|
|
777
798
|
*/
|
|
778
799
|
export function jsx_expression_container(expression) {
|
|
779
800
|
return {
|
|
@@ -784,7 +805,7 @@ export function jsx_expression_container(expression) {
|
|
|
784
805
|
|
|
785
806
|
/**
|
|
786
807
|
* @param {string} name
|
|
787
|
-
* @returns {
|
|
808
|
+
* @returns {ESTreeJSX.JSXIdentifier}
|
|
788
809
|
*/
|
|
789
810
|
export function jsx_id(name) {
|
|
790
811
|
return {
|
|
@@ -794,9 +815,9 @@ export function jsx_id(name) {
|
|
|
794
815
|
}
|
|
795
816
|
|
|
796
817
|
/**
|
|
797
|
-
* @param {
|
|
798
|
-
* @param {
|
|
799
|
-
* @returns {
|
|
818
|
+
* @param {ESTreeJSX.JSXIdentifier | ESTreeJSX.JSXMemberExpression} object
|
|
819
|
+
* @param {ESTreeJSX.JSXIdentifier} property
|
|
820
|
+
* @returns {ESTreeJSX.JSXMemberExpression}
|
|
800
821
|
*/
|
|
801
822
|
export function jsx_member(object, property) {
|
|
802
823
|
return {
|
|
@@ -808,7 +829,7 @@ export function jsx_member(object, property) {
|
|
|
808
829
|
|
|
809
830
|
/**
|
|
810
831
|
* @param {ESTree.Expression} argument
|
|
811
|
-
* @returns {
|
|
832
|
+
* @returns {ESTreeJSX.JSXSpreadAttribute}
|
|
812
833
|
*/
|
|
813
834
|
export function jsx_spread_attribute(argument) {
|
|
814
835
|
return {
|
|
@@ -818,6 +839,8 @@ export function jsx_spread_attribute(argument) {
|
|
|
818
839
|
}
|
|
819
840
|
|
|
820
841
|
/**
|
|
842
|
+
* @param {ESTree.Expression} discriminant
|
|
843
|
+
* @param {ESTree.SwitchCase[]} cases
|
|
821
844
|
* @returns {ESTree.SwitchStatement}
|
|
822
845
|
*/
|
|
823
846
|
export function switch_builder(discriminant, cases) {
|
|
@@ -829,6 +852,8 @@ export function switch_builder(discriminant, cases) {
|
|
|
829
852
|
}
|
|
830
853
|
|
|
831
854
|
/**
|
|
855
|
+
* @param {ESTree.Expression | null} test
|
|
856
|
+
* @param {ESTree.Statement[]} consequent
|
|
832
857
|
* @returns {ESTree.SwitchCase}
|
|
833
858
|
*/
|
|
834
859
|
export function switch_case(test = null, consequent = []) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { track, flushSync, on } from 'ripple';
|
|
1
|
+
import { track, flushSync, on, effect } from 'ripple';
|
|
2
2
|
|
|
3
3
|
describe('on() event handler', () => {
|
|
4
4
|
it('should attach multiple handlers via onClick attribute (delegated)', () => {
|
|
@@ -559,4 +559,112 @@ describe('on() event handler', () => {
|
|
|
559
559
|
expect(onceDiv.textContent).toBe('1'); // Still 1
|
|
560
560
|
expect(permanentDiv.textContent).toBe('3');
|
|
561
561
|
});
|
|
562
|
+
|
|
563
|
+
it('should handle click events on window', () => {
|
|
564
|
+
component Basic() {
|
|
565
|
+
let windowClickCount = track(0);
|
|
566
|
+
|
|
567
|
+
effect(() => {
|
|
568
|
+
const removeWindowListener = on(window, 'click', () => {
|
|
569
|
+
@windowClickCount++;
|
|
570
|
+
});
|
|
571
|
+
return removeWindowListener;
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
<div>
|
|
575
|
+
<button class="test-btn">{'Click me'}</button>
|
|
576
|
+
<div class="window-count">{@windowClickCount}</div>
|
|
577
|
+
</div>
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
render(Basic);
|
|
581
|
+
flushSync();
|
|
582
|
+
|
|
583
|
+
const button = container.querySelector('.test-btn');
|
|
584
|
+
const windowCountDiv = container.querySelector('.window-count');
|
|
585
|
+
|
|
586
|
+
expect(windowCountDiv.textContent).toBe('0');
|
|
587
|
+
|
|
588
|
+
// Click on button should bubble to window
|
|
589
|
+
button.click();
|
|
590
|
+
flushSync();
|
|
591
|
+
expect(windowCountDiv.textContent).toBe('1');
|
|
592
|
+
|
|
593
|
+
// Click directly on window
|
|
594
|
+
window.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
595
|
+
flushSync();
|
|
596
|
+
expect(windowCountDiv.textContent).toBe('2');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('should handle click events on document', () => {
|
|
600
|
+
component Basic() {
|
|
601
|
+
let documentClickCount = track(0);
|
|
602
|
+
|
|
603
|
+
effect(() => {
|
|
604
|
+
const removeDocumentListener = on(document, 'click', () => {
|
|
605
|
+
@documentClickCount++;
|
|
606
|
+
});
|
|
607
|
+
return removeDocumentListener;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
<div>
|
|
611
|
+
<button class="test-btn">{'Click me'}</button>
|
|
612
|
+
<div class="document-count">{@documentClickCount}</div>
|
|
613
|
+
</div>
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
render(Basic);
|
|
617
|
+
flushSync();
|
|
618
|
+
|
|
619
|
+
const button = container.querySelector('.test-btn');
|
|
620
|
+
const documentCountDiv = container.querySelector('.document-count');
|
|
621
|
+
|
|
622
|
+
expect(documentCountDiv.textContent).toBe('0');
|
|
623
|
+
|
|
624
|
+
// Click on button should bubble to document
|
|
625
|
+
button.click();
|
|
626
|
+
flushSync();
|
|
627
|
+
expect(documentCountDiv.textContent).toBe('1');
|
|
628
|
+
|
|
629
|
+
// Click directly on document
|
|
630
|
+
document.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
631
|
+
flushSync();
|
|
632
|
+
expect(documentCountDiv.textContent).toBe('2');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should handle click events on body', () => {
|
|
636
|
+
component Basic() {
|
|
637
|
+
let bodyClickCount = track(0);
|
|
638
|
+
|
|
639
|
+
effect(() => {
|
|
640
|
+
const removeBodyListener = on(document.body, 'click', () => {
|
|
641
|
+
@bodyClickCount++;
|
|
642
|
+
});
|
|
643
|
+
return removeBodyListener;
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
<div>
|
|
647
|
+
<button class="test-btn">{'Click me'}</button>
|
|
648
|
+
<div class="body-count">{@bodyClickCount}</div>
|
|
649
|
+
</div>
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
render(Basic);
|
|
653
|
+
flushSync();
|
|
654
|
+
|
|
655
|
+
const button = container.querySelector('.test-btn');
|
|
656
|
+
const bodyCountDiv = container.querySelector('.body-count');
|
|
657
|
+
|
|
658
|
+
expect(bodyCountDiv.textContent).toBe('0');
|
|
659
|
+
|
|
660
|
+
// Click on button should bubble to body
|
|
661
|
+
button.click();
|
|
662
|
+
flushSync();
|
|
663
|
+
expect(bodyCountDiv.textContent).toBe('1');
|
|
664
|
+
|
|
665
|
+
// Click directly on body
|
|
666
|
+
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
667
|
+
flushSync();
|
|
668
|
+
expect(bodyCountDiv.textContent).toBe('2');
|
|
669
|
+
});
|
|
562
670
|
});
|
|
@@ -7,7 +7,9 @@ function setupMatchMedia() {
|
|
|
7
7
|
|
|
8
8
|
// A mock implementation of matchMedia
|
|
9
9
|
const mockMatchMedia = vi.fn().mockImplementation((query) => {
|
|
10
|
-
|
|
10
|
+
// Create a mock that extends MediaQueryList so instanceof works
|
|
11
|
+
const mock = Object.create(MediaQueryList.prototype);
|
|
12
|
+
Object.assign(mock, {
|
|
11
13
|
media: query,
|
|
12
14
|
matches: false, // default value
|
|
13
15
|
addEventListener: (type: string, cb: Callback) => {
|
|
@@ -26,7 +28,8 @@ function setupMatchMedia() {
|
|
|
26
28
|
listeners.forEach((cb) => cb(event));
|
|
27
29
|
},
|
|
28
30
|
listenersCount: () => listeners.size,
|
|
29
|
-
};
|
|
31
|
+
});
|
|
32
|
+
return mock;
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
Object.defineProperty(window, 'matchMedia', {
|
package/tests/setup-client.js
CHANGED
|
@@ -15,6 +15,8 @@ beforeEach(() => {
|
|
|
15
15
|
document.body.appendChild(globalThis.container);
|
|
16
16
|
|
|
17
17
|
globalThis.error = undefined;
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
globalThis.MediaQueryList = class MediaQueryList {};
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
afterEach(() => {
|
|
@@ -25,4 +27,6 @@ afterEach(() => {
|
|
|
25
27
|
globalThis.container = /** @type {HTMLDivElement} */ (/** @type {unknown} */ (undefined));
|
|
26
28
|
|
|
27
29
|
globalThis.error = undefined;
|
|
30
|
+
// @ts-ignore
|
|
31
|
+
globalThis.MediaQueryList = undefined;
|
|
28
32
|
});
|