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 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.175",
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.175"
86
+ "ripple": "0.2.176"
86
87
  }
87
88
  }
@@ -0,0 +1,2 @@
1
+ // Re-export Rollup module types WITHOUT globals
2
+ export * from "rollup/dist/rollup.js";
@@ -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
- /** Array of code mappings for Volar integration */
26
- [key: string]: any;
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;
@@ -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 { MappingsResult } from './phases/3-transform/segments.js' */
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
- * @returns {MappingsResult} Volar mappings object
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
- /** @import {
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 {any} */ Parser) => {
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
- // If we reach here and this element is still in the path, it means it was never closed
1594
- const tagName = this.getElementName(element.id);
1595
-
1596
- this.raise(
1597
- this.start,
1598
- `Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`,
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
- node.type = node.html ? 'Html' : 'Text';
1682
- delete node.html;
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.raise(
1724
- this.start,
1725
- `Expected closing tag to match opening tag. Expected '</${openingTagName}>' but found '</${closingTagName}>'`,
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
- node.type = 'Text';
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
- e.css_empty_declaration({ start, end: index });
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;
@@ -878,6 +878,7 @@ export function analyze(ast, filename, options = {}) {
878
878
  inside_head: false,
879
879
  inside_server_block: options.mode === 'server',
880
880
  to_ts: options.to_ts ?? false,
881
+ loose: options.loose ?? false,
881
882
  },
882
883
  visitors,
883
884
  );
@@ -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 opening_type, closing_type;
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
- opening_type = type_expression;
1972
- closing_type = node.selfClosing ? undefined : type_expression;
1981
+ opening_name_element = type_expression;
1982
+ closing_name_element = node.selfClosing || node.unclosed ? undefined : type_expression;
1973
1983
  } else {
1974
- opening_type = b.jsx_id(type_expression);
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
- opening_type.loc = {
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
- opening_type.metadata = {
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
- opening_type.loc = node.id.loc || {
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
- closing_type = b.jsx_id(type_expression);
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
- closing_type.loc = {
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
- closing_type.metadata = {
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
- closing_type.loc = {
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
- const jsxElement = b.jsx_element(
2050
- opening_type,
2059
+ let jsxElement = b.jsx_element(
2060
+ opening_name_element,
2061
+ node.loc,
2051
2062
  attributes,
2052
2063
  children,
2053
2064
  node.selfClosing,
2054
- closing_type,
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
- state.init.push(b.stmt(jsxElement));
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 {VolarCodeMapping['data'] & {customData: CustomMappingData}} MappingData
11
- * @typedef {VolarCodeMapping & {data: MappingData}} CodeMapping
12
- * @typedef {{code: string, mappings: CodeMapping[]}} MappingsResult
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 {MappingsResult}
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 remove_listener = create_event(type, element, handler, options);
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
  }
@@ -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 {ESTree.JSXIdentifier | ESTree.JSXNamespacedName} name
709
- * @param {ESTree.Literal | ESTree.JSXExpressionContainer | null} value
710
- * @returns {ESTree.JSXAttribute}
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 {ESTree.JSXIdentifier | ESTree.JSXMemberExpression | ESTree.JSXNamespacedName} name
723
- * @param {Array<ESTree.JSXAttribute | ESTree.JSXSpreadAttribute>} attributes
724
- * @param {Array<ESTree.JSXText | ESTree.JSXExpressionContainer | ESTree.JSXSpreadChild | ESTree.JSXElement | ESTree.JSXFragment>} children
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
- * @returns {{ element: ESTree.JSXElement, opening_element: ESTree.JSXOpeningElement }}
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<ESTree.JSXText | ESTree.JSXExpressionContainer | ESTree.JSXSpreadChild | ESTree.JSXElement | ESTree.JSXFragment>} children
759
- * @returns {ESTree.JSXFragment}
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 | ESTree.JSXEmptyExpression} expression
776
- * @returns {ESTree.JSXExpressionContainer}
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 {ESTree.JSXIdentifier}
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 {ESTree.JSXIdentifier | ESTree.JSXMemberExpression} object
798
- * @param {ESTree.JSXIdentifier} property
799
- * @returns {ESTree.JSXMemberExpression}
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 {ESTree.JSXSpreadAttribute}
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
- return {
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', {
@@ -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
  });
package/tsconfig.json CHANGED
@@ -18,6 +18,8 @@
18
18
  "checkJs": true,
19
19
  "paths": {
20
20
  "ripple": ["./types/index.d.ts"],
21
+ "rollup": ["./shims/rollup-estree-types.d.ts"],
22
+ "rollup/*": ["./shims/rollup-estree-types.d.ts"]
21
23
  }
22
24
  },
23
25
  "include": [