ripple 0.2.89 → 0.2.91
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 +1 -1
- package/src/compiler/phases/1-parse/index.js +23 -1
- package/src/compiler/phases/3-transform/client/index.js +53 -25
- package/src/compiler/phases/3-transform/segments.js +194 -34
- package/src/runtime/date.js +73 -0
- package/src/runtime/index-client.js +23 -21
- package/src/runtime/internal/client/blocks.js +15 -10
- package/src/runtime/internal/client/html.js +41 -0
- package/src/runtime/internal/client/index.js +3 -1
- package/src/runtime/internal/client/template.js +1 -1
- package/tests/client/__snapshots__/html.test.ripple.snap +40 -0
- package/tests/client/date.test.ripple +392 -0
- package/tests/client/html.test.ripple +52 -0
- package/types/index.d.ts +35 -28
package/package.json
CHANGED
|
@@ -455,10 +455,31 @@ function RipplePlugin(config) {
|
|
|
455
455
|
jsx_parseExpressionContainer() {
|
|
456
456
|
let node = this.startNode();
|
|
457
457
|
this.next();
|
|
458
|
+
let tracked = false;
|
|
459
|
+
|
|
460
|
+
if (this.value === 'html') {
|
|
461
|
+
node.html = true;
|
|
462
|
+
this.next();
|
|
463
|
+
if (this.type === tt.braceR) {
|
|
464
|
+
this.raise(
|
|
465
|
+
this.start,
|
|
466
|
+
'"html" is a Ripple keyword and must be used in the form {html some_content}',
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (this.type.label === '@') {
|
|
470
|
+
this.next(); // consume @
|
|
471
|
+
tracked = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
458
474
|
|
|
459
475
|
node.expression =
|
|
460
476
|
this.type === tt.braceR ? this.jsx_parseEmptyExpression() : this.parseExpression();
|
|
461
477
|
this.expect(tt.braceR);
|
|
478
|
+
|
|
479
|
+
if (tracked && node.expression.type === 'Identifier') {
|
|
480
|
+
node.expression.tracked = true;
|
|
481
|
+
}
|
|
482
|
+
|
|
462
483
|
return this.finishNode(node, 'JSXExpressionContainer');
|
|
463
484
|
}
|
|
464
485
|
|
|
@@ -954,7 +975,8 @@ function RipplePlugin(config) {
|
|
|
954
975
|
|
|
955
976
|
if (this.type.label === '{') {
|
|
956
977
|
const node = this.jsx_parseExpressionContainer();
|
|
957
|
-
node.type = 'Text';
|
|
978
|
+
node.type = node.html ? 'Html' : 'Text';
|
|
979
|
+
delete node.html;
|
|
958
980
|
body.push(node);
|
|
959
981
|
} else if (this.type.label === '}') {
|
|
960
982
|
return;
|
|
@@ -46,8 +46,7 @@ function add_ripple_internal_import(context) {
|
|
|
46
46
|
|
|
47
47
|
function visit_function(node, context) {
|
|
48
48
|
if (context.state.to_ts) {
|
|
49
|
-
context.next(context.state);
|
|
50
|
-
return;
|
|
49
|
+
return context.next(context.state);
|
|
51
50
|
}
|
|
52
51
|
const metadata = node.metadata;
|
|
53
52
|
const state = context.state;
|
|
@@ -133,9 +132,7 @@ function visit_title_element(node, context) {
|
|
|
133
132
|
),
|
|
134
133
|
);
|
|
135
134
|
} else {
|
|
136
|
-
context.state.init.push(
|
|
137
|
-
b.stmt(b.assignment('=', b.id('_$_.document.title'), result)),
|
|
138
|
-
);
|
|
135
|
+
context.state.init.push(b.stmt(b.assignment('=', b.id('_$_.document.title'), result)));
|
|
139
136
|
}
|
|
140
137
|
}
|
|
141
138
|
|
|
@@ -346,7 +343,7 @@ const visitors = {
|
|
|
346
343
|
|
|
347
344
|
return b.new(
|
|
348
345
|
b.id('TrackedObject'),
|
|
349
|
-
b.object(node.properties.map((prop) => context.visit(prop)))
|
|
346
|
+
b.object(node.properties.map((prop) => context.visit(prop))),
|
|
350
347
|
);
|
|
351
348
|
}
|
|
352
349
|
|
|
@@ -723,9 +720,10 @@ const visitors = {
|
|
|
723
720
|
const metadata = { tracking: false, await: false };
|
|
724
721
|
let expression = visit(class_attribute.value, { ...state, metadata });
|
|
725
722
|
|
|
726
|
-
const hash_arg =
|
|
727
|
-
|
|
728
|
-
|
|
723
|
+
const hash_arg =
|
|
724
|
+
node.metadata.scoped && state.component.css
|
|
725
|
+
? b.literal(state.component.css.hash)
|
|
726
|
+
: undefined;
|
|
729
727
|
const is_html = context.state.metadata.namespace === 'html' && node.id.name !== 'svg';
|
|
730
728
|
|
|
731
729
|
if (metadata.tracking) {
|
|
@@ -897,7 +895,11 @@ const visitors = {
|
|
|
897
895
|
}),
|
|
898
896
|
];
|
|
899
897
|
|
|
900
|
-
return b.function(
|
|
898
|
+
return b.function(
|
|
899
|
+
node.id,
|
|
900
|
+
node.params.map((param) => context.visit(param, { ...context.state, metadata })),
|
|
901
|
+
b.block(body_statements),
|
|
902
|
+
);
|
|
901
903
|
}
|
|
902
904
|
|
|
903
905
|
let props = b.id('__props');
|
|
@@ -1001,8 +1003,7 @@ const visitors = {
|
|
|
1001
1003
|
|
|
1002
1004
|
UpdateExpression(node, context) {
|
|
1003
1005
|
if (context.state.to_ts) {
|
|
1004
|
-
context.next();
|
|
1005
|
-
return;
|
|
1006
|
+
return context.next();
|
|
1006
1007
|
}
|
|
1007
1008
|
const argument = node.argument;
|
|
1008
1009
|
|
|
@@ -1048,8 +1049,7 @@ const visitors = {
|
|
|
1048
1049
|
|
|
1049
1050
|
ForOfStatement(node, context) {
|
|
1050
1051
|
if (!is_inside_component(context)) {
|
|
1051
|
-
context.next();
|
|
1052
|
-
return;
|
|
1052
|
+
return context.next();
|
|
1053
1053
|
}
|
|
1054
1054
|
const is_controlled = node.is_controlled;
|
|
1055
1055
|
const index = node.index;
|
|
@@ -1091,8 +1091,7 @@ const visitors = {
|
|
|
1091
1091
|
|
|
1092
1092
|
IfStatement(node, context) {
|
|
1093
1093
|
if (!is_inside_component(context)) {
|
|
1094
|
-
context.next();
|
|
1095
|
-
return;
|
|
1094
|
+
return context.next();
|
|
1096
1095
|
}
|
|
1097
1096
|
context.state.template.push('<!>');
|
|
1098
1097
|
|
|
@@ -1175,8 +1174,7 @@ const visitors = {
|
|
|
1175
1174
|
|
|
1176
1175
|
TryStatement(node, context) {
|
|
1177
1176
|
if (!is_inside_component(context)) {
|
|
1178
|
-
context.next();
|
|
1179
|
-
return;
|
|
1177
|
+
return context.next();
|
|
1180
1178
|
}
|
|
1181
1179
|
context.state.template.push('<!>');
|
|
1182
1180
|
|
|
@@ -1311,6 +1309,9 @@ function transform_ts_child(node, context) {
|
|
|
1311
1309
|
|
|
1312
1310
|
if (node.type === 'Text') {
|
|
1313
1311
|
state.init.push(b.stmt(visit(node.expression, { ...state })));
|
|
1312
|
+
} else if (node.type === 'Html') {
|
|
1313
|
+
// Do we need to do something special here?
|
|
1314
|
+
state.init.push(b.stmt(visit(node.expression, { ...state })));
|
|
1314
1315
|
} else if (node.type === 'Element') {
|
|
1315
1316
|
const type = node.id.name;
|
|
1316
1317
|
const children = [];
|
|
@@ -1330,12 +1331,25 @@ function transform_ts_child(node, context) {
|
|
|
1330
1331
|
if (attr.type === 'Attribute') {
|
|
1331
1332
|
const metadata = { await: false };
|
|
1332
1333
|
const name = visit(attr.name, { ...state, metadata });
|
|
1333
|
-
const value =
|
|
1334
|
-
|
|
1335
|
-
|
|
1334
|
+
const value =
|
|
1335
|
+
attr.value === null ? b.literal(true) : visit(attr.value, { ...state, metadata });
|
|
1336
|
+
|
|
1337
|
+
// Handle both regular identifiers and tracked identifiers
|
|
1338
|
+
let prop_name;
|
|
1339
|
+
if (name.type === 'Identifier') {
|
|
1340
|
+
prop_name = name.name;
|
|
1341
|
+
} else if (name.type === 'MemberExpression' && name.object.type === 'Identifier') {
|
|
1342
|
+
// For tracked attributes like {@count}, use the original name
|
|
1343
|
+
prop_name = name.object.name;
|
|
1344
|
+
} else {
|
|
1345
|
+
prop_name = attr.name.name || 'unknown';
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const jsx_name = b.jsx_id(prop_name);
|
|
1349
|
+
if (prop_name === 'children') {
|
|
1336
1350
|
has_children_props = true;
|
|
1337
1351
|
}
|
|
1338
|
-
jsx_name.loc = name.loc;
|
|
1352
|
+
jsx_name.loc = attr.name.loc || name.loc;
|
|
1339
1353
|
|
|
1340
1354
|
return b.jsx_attribute(jsx_name, b.jsx_expression_container(value));
|
|
1341
1355
|
} else if (attr.type === 'SpreadAttribute') {
|
|
@@ -1469,7 +1483,7 @@ function transform_ts_child(node, context) {
|
|
|
1469
1483
|
|
|
1470
1484
|
state.init.push(b.try(try_body, catch_handler, finally_block));
|
|
1471
1485
|
} else if (node.type === 'Component') {
|
|
1472
|
-
const component = visit(node,
|
|
1486
|
+
const component = visit(node, state);
|
|
1473
1487
|
|
|
1474
1488
|
state.init.push(component);
|
|
1475
1489
|
} else {
|
|
@@ -1491,6 +1505,7 @@ function transform_children(children, context) {
|
|
|
1491
1505
|
node.type === 'IfStatement' ||
|
|
1492
1506
|
node.type === 'TryStatement' ||
|
|
1493
1507
|
node.type === 'ForOfStatement' ||
|
|
1508
|
+
node.type === 'Html' ||
|
|
1494
1509
|
(node.type === 'Element' &&
|
|
1495
1510
|
(node.id.type !== 'Identifier' || !is_element_dom_element(node))),
|
|
1496
1511
|
) ||
|
|
@@ -1585,6 +1600,14 @@ function transform_children(children, context) {
|
|
|
1585
1600
|
visit(node, { ...state, flush_node, namespace: state.namespace });
|
|
1586
1601
|
} else if (node.type === 'HeadElement') {
|
|
1587
1602
|
visit(node, { ...state, flush_node, namespace: state.namespace });
|
|
1603
|
+
} else if (node.type === 'Html') {
|
|
1604
|
+
const metadata = { tracking: false, await: false };
|
|
1605
|
+
const expression = visit(node.expression, { ...state, metadata });
|
|
1606
|
+
|
|
1607
|
+
context.state.template.push('<!>');
|
|
1608
|
+
|
|
1609
|
+
const id = flush_node();
|
|
1610
|
+
state.update.push(b.stmt(b.call('_$_.html', id, b.thunk(expression))));
|
|
1588
1611
|
} else if (node.type === 'Text') {
|
|
1589
1612
|
const metadata = { tracking: false, await: false };
|
|
1590
1613
|
const expression = visit(node.expression, { ...state, metadata });
|
|
@@ -1633,7 +1656,7 @@ function transform_children(children, context) {
|
|
|
1633
1656
|
visit_head_element(head_element, context);
|
|
1634
1657
|
}
|
|
1635
1658
|
|
|
1636
|
-
if (context.state.inside_head) {
|
|
1659
|
+
if (context.state.inside_head) {
|
|
1637
1660
|
const title_element = children.find(
|
|
1638
1661
|
(node) =>
|
|
1639
1662
|
node.type === 'Element' && node.id.type === 'Identifier' && node.id.name === 'title',
|
|
@@ -1676,7 +1699,12 @@ function transform_body(body, { visit, state }) {
|
|
|
1676
1699
|
transform_children(body, { visit, state: body_state, root: true });
|
|
1677
1700
|
|
|
1678
1701
|
if (body_state.update.length > 0) {
|
|
1679
|
-
|
|
1702
|
+
if (state.to_ts) {
|
|
1703
|
+
// In TypeScript mode, just add the update statements directly
|
|
1704
|
+
body_state.init.push(...body_state.update);
|
|
1705
|
+
} else {
|
|
1706
|
+
body_state.init.push(b.stmt(b.call('_$_.render', b.thunk(b.block(body_state.update)))));
|
|
1707
|
+
}
|
|
1680
1708
|
}
|
|
1681
1709
|
|
|
1682
1710
|
return [...body_state.setup, ...body_state.init, ...body_state.final];
|
|
@@ -7,14 +7,87 @@ export const mapping_data = {
|
|
|
7
7
|
navigation: true,
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Helper to find a meaningful token boundary by looking for word boundaries,
|
|
12
|
+
* punctuation, or whitespace
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @param {number} start
|
|
15
|
+
* @param {number} direction
|
|
16
|
+
*/
|
|
17
|
+
function findTokenBoundary(text, start, direction = 1) {
|
|
18
|
+
if (start < 0 || start >= text.length) return start;
|
|
19
|
+
|
|
20
|
+
let pos = start;
|
|
21
|
+
/** @param {string} c */
|
|
22
|
+
const isAlphaNum = (c) => /[a-zA-Z0-9_$]/.test(c);
|
|
23
|
+
|
|
24
|
+
// If we're at whitespace or punctuation, find the next meaningful character
|
|
25
|
+
while (pos >= 0 && pos < text.length && /\s/.test(text[pos])) {
|
|
26
|
+
pos += direction;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (pos < 0 || pos >= text.length) return start;
|
|
30
|
+
|
|
31
|
+
// If we're in the middle of a word/identifier, find the boundary
|
|
32
|
+
if (isAlphaNum(text[pos])) {
|
|
33
|
+
if (direction > 0) {
|
|
34
|
+
while (pos < text.length && isAlphaNum(text[pos])) pos++;
|
|
35
|
+
} else {
|
|
36
|
+
while (pos >= 0 && isAlphaNum(text[pos])) pos--;
|
|
37
|
+
pos++; // Adjust back to start of token
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
// For punctuation, just move one character in the given direction
|
|
41
|
+
pos += direction;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return Math.max(0, Math.min(text.length, pos));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if source and generated content are meaningfully similar
|
|
49
|
+
* @param {string} sourceContent
|
|
50
|
+
* @param {string} generatedContent
|
|
51
|
+
*/
|
|
52
|
+
function isValidMapping(sourceContent, generatedContent) {
|
|
53
|
+
// Remove whitespace for comparison
|
|
54
|
+
const cleanSource = sourceContent.replace(/\s+/g, '');
|
|
55
|
+
const cleanGenerated = generatedContent.replace(/\s+/g, '');
|
|
56
|
+
|
|
57
|
+
// If either is empty, skip
|
|
58
|
+
if (!cleanSource || !cleanGenerated) return false;
|
|
59
|
+
|
|
60
|
+
// Skip obvious template transformations that don't make sense to map
|
|
61
|
+
const templateTransforms = [
|
|
62
|
+
/^\{.*\}$/, // Curly brace expressions
|
|
63
|
+
/^<.*>$/, // HTML tags
|
|
64
|
+
/^\(\(\)\s*=>\s*\{$/, // Generated function wrappers
|
|
65
|
+
/^\}\)\(\)\}$/, // Generated function closures
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
for (const transform of templateTransforms) {
|
|
69
|
+
if (transform.test(cleanSource) || transform.test(cleanGenerated)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if content is similar (exact match, or generated contains source)
|
|
75
|
+
if (cleanSource === cleanGenerated) return true;
|
|
76
|
+
if (cleanGenerated.includes(cleanSource)) return true;
|
|
77
|
+
if (cleanSource.includes(cleanGenerated) && cleanGenerated.length > 2) return true;
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
10
82
|
/**
|
|
11
83
|
* Convert esrap SourceMap to Volar mappings
|
|
12
|
-
* @param {
|
|
84
|
+
* @param {{ mappings: string }} source_map
|
|
13
85
|
* @param {string} source
|
|
14
86
|
* @param {string} generated_code
|
|
15
87
|
* @returns {object}
|
|
16
88
|
*/
|
|
17
89
|
export function convert_source_map_to_mappings(source_map, source, generated_code) {
|
|
90
|
+
/** @type {Array<{sourceOffsets: number[], generatedOffsets: number[], lengths: number[], data: any}>} */
|
|
18
91
|
const mappings = [];
|
|
19
92
|
|
|
20
93
|
// Decode the VLQ mappings from esrap
|
|
@@ -22,6 +95,7 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
|
|
|
22
95
|
|
|
23
96
|
let generated_offset = 0;
|
|
24
97
|
const generated_lines = generated_code.split('\n');
|
|
98
|
+
const source_lines = source.split('\n');
|
|
25
99
|
|
|
26
100
|
// Process each line of generated code
|
|
27
101
|
for (let generated_line = 0; generated_line < generated_lines.length; generated_line++) {
|
|
@@ -38,7 +112,6 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
|
|
|
38
112
|
}
|
|
39
113
|
|
|
40
114
|
// Calculate source offset
|
|
41
|
-
const source_lines = source.split('\n');
|
|
42
115
|
let source_offset = 0;
|
|
43
116
|
for (let i = 0; i < Math.min(source_line, source_lines.length - 1); i++) {
|
|
44
117
|
source_offset += source_lines[i].length + 1; // +1 for newline
|
|
@@ -48,41 +121,125 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
|
|
|
48
121
|
// Calculate generated offset
|
|
49
122
|
const current_generated_offset = generated_offset + generated_column;
|
|
50
123
|
|
|
51
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
124
|
+
// Find meaningful token boundaries for source content
|
|
125
|
+
const source_token_end = findTokenBoundary(source, source_offset, 1);
|
|
126
|
+
const source_token_start = findTokenBoundary(source, source_offset, -1);
|
|
127
|
+
|
|
128
|
+
// Find meaningful token boundaries for generated content
|
|
129
|
+
const generated_token_end = findTokenBoundary(generated_code, current_generated_offset, 1);
|
|
130
|
+
const generated_token_start = findTokenBoundary(generated_code, current_generated_offset, -1);
|
|
131
|
+
|
|
132
|
+
// Extract potential source content (prefer forward boundary but try both directions)
|
|
133
|
+
let best_source_content = source.substring(source_offset, source_token_end);
|
|
134
|
+
let best_generated_content = generated_code.substring(current_generated_offset, generated_token_end);
|
|
135
|
+
|
|
136
|
+
// Try different segment boundaries to find the best match
|
|
137
|
+
const candidates = [
|
|
138
|
+
// Forward boundaries
|
|
139
|
+
{
|
|
140
|
+
source: source.substring(source_offset, source_token_end),
|
|
141
|
+
generated: generated_code.substring(current_generated_offset, generated_token_end)
|
|
142
|
+
},
|
|
143
|
+
// Backward boundaries
|
|
144
|
+
{
|
|
145
|
+
source: source.substring(source_token_start, source_offset + 1),
|
|
146
|
+
generated: generated_code.substring(generated_token_start, current_generated_offset + 1)
|
|
147
|
+
},
|
|
148
|
+
// Single character
|
|
149
|
+
{
|
|
150
|
+
source: source.charAt(source_offset),
|
|
151
|
+
generated: generated_code.charAt(current_generated_offset)
|
|
152
|
+
},
|
|
153
|
+
// Try to find exact matches in nearby content
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// Look for the best candidate match
|
|
157
|
+
let best_match = null;
|
|
158
|
+
for (const candidate of candidates) {
|
|
159
|
+
if (isValidMapping(candidate.source, candidate.generated)) {
|
|
160
|
+
best_match = candidate;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
67
163
|
}
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
164
|
+
|
|
165
|
+
// If no good match found, try extracting identifiers/keywords
|
|
166
|
+
if (!best_match) {
|
|
167
|
+
const sourceIdMatch = source.substring(source_offset).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
|
|
168
|
+
const generatedIdMatch = generated_code.substring(current_generated_offset).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
|
|
169
|
+
|
|
170
|
+
if (sourceIdMatch && generatedIdMatch && sourceIdMatch[0] === generatedIdMatch[0]) {
|
|
171
|
+
best_match = {
|
|
172
|
+
source: sourceIdMatch[0],
|
|
173
|
+
generated: generatedIdMatch[0]
|
|
174
|
+
};
|
|
77
175
|
}
|
|
78
176
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
177
|
+
|
|
178
|
+
// Handle special cases for Ripple keywords that might not have generated equivalents
|
|
179
|
+
if (!best_match || best_match.source.length === 0) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Special handling for Ripple-specific syntax that may be omitted in generated code
|
|
184
|
+
const sourceAtOffset = source.substring(source_offset, source_offset + 10);
|
|
185
|
+
if (sourceAtOffset.includes('index ')) {
|
|
186
|
+
// For the 'index' keyword, create a mapping even if there's no generated equivalent
|
|
187
|
+
const indexMatch = sourceAtOffset.match(/index\s+/);
|
|
188
|
+
if (indexMatch) {
|
|
189
|
+
best_match = {
|
|
190
|
+
source: indexMatch[0].trim(),
|
|
191
|
+
generated: '' // Empty generated content for keywords that are transformed away
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Skip if we still don't have a valid source match
|
|
197
|
+
if (!best_match || best_match.source.length === 0) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Skip mappings for complex RefAttribute syntax to avoid overlapping sourcemaps,
|
|
202
|
+
// but allow simple 'ref' keyword mappings for IntelliSense
|
|
203
|
+
if (best_match.source.includes('{ref ') && best_match.source.length > 10) {
|
|
204
|
+
// Skip complex ref expressions like '{ref (node) => { ... }}'
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Allow simple 'ref' keyword mappings for IntelliSense
|
|
209
|
+
if (best_match.source.trim() === 'ref' && best_match.generated.length === 0) {
|
|
210
|
+
// This is just the ref keyword, allow it for syntax support
|
|
211
|
+
// but map it to current position since there's no generated equivalent
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Calculate actual offsets and lengths for the best match
|
|
215
|
+
let actual_source_offset, actual_generated_offset;
|
|
216
|
+
|
|
217
|
+
if (best_match.generated.length > 0) {
|
|
218
|
+
actual_source_offset = source.indexOf(best_match.source, source_offset - best_match.source.length);
|
|
219
|
+
actual_generated_offset = generated_code.indexOf(best_match.generated, current_generated_offset - best_match.generated.length);
|
|
220
|
+
} else {
|
|
221
|
+
// For keywords with no generated equivalent, use the exact source position
|
|
222
|
+
actual_source_offset = source_offset;
|
|
223
|
+
actual_generated_offset = current_generated_offset; // Map to current position in generated code
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Use the match we found, but fall back to original positions if indexOf fails
|
|
227
|
+
const final_source_offset = actual_source_offset !== -1 ? actual_source_offset : source_offset;
|
|
228
|
+
const final_generated_offset = actual_generated_offset !== -1 ? actual_generated_offset : current_generated_offset; // Avoid duplicate mappings by checking if we already have this exact mapping
|
|
229
|
+
const isDuplicate = mappings.some(existing =>
|
|
230
|
+
existing.sourceOffsets[0] === final_source_offset &&
|
|
231
|
+
existing.generatedOffsets[0] === final_generated_offset &&
|
|
232
|
+
existing.lengths[0] === best_match.source.length
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (!isDuplicate) {
|
|
236
|
+
mappings.push({
|
|
237
|
+
sourceOffsets: [final_source_offset],
|
|
238
|
+
generatedOffsets: [final_generated_offset],
|
|
239
|
+
lengths: [best_match.source.length],
|
|
240
|
+
data: mapping_data,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
86
243
|
}
|
|
87
244
|
|
|
88
245
|
// Add line length + 1 for newline (except for last line)
|
|
@@ -92,6 +249,9 @@ export function convert_source_map_to_mappings(source_map, source, generated_cod
|
|
|
92
249
|
}
|
|
93
250
|
}
|
|
94
251
|
|
|
252
|
+
// Sort mappings by source offset for better organization
|
|
253
|
+
mappings.sort((a, b) => a.sourceOffsets[0] - b.sourceOffsets[0]);
|
|
254
|
+
|
|
95
255
|
return {
|
|
96
256
|
code: generated_code,
|
|
97
257
|
mappings,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** @import { Block, Derived } from '#client' */
|
|
2
|
+
import { safe_scope, tracked, get, derived, set } from './internal/client/runtime.js';
|
|
3
|
+
|
|
4
|
+
var init = false;
|
|
5
|
+
|
|
6
|
+
export class TrackedDate extends Date {
|
|
7
|
+
#time;
|
|
8
|
+
/** @type {Map<keyof Date, Derived>} */
|
|
9
|
+
#deriveds = new Map();
|
|
10
|
+
/** @type {Block} */
|
|
11
|
+
#block;
|
|
12
|
+
|
|
13
|
+
/** @param {any[]} params */
|
|
14
|
+
constructor(...params) {
|
|
15
|
+
// @ts-ignore
|
|
16
|
+
super(...params);
|
|
17
|
+
|
|
18
|
+
var block = this.#block = safe_scope();
|
|
19
|
+
this.#time = tracked(super.getTime(), block);
|
|
20
|
+
|
|
21
|
+
if (!init) this.#init();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#init() {
|
|
25
|
+
init = true;
|
|
26
|
+
|
|
27
|
+
var proto = TrackedDate.prototype;
|
|
28
|
+
var date_proto = Date.prototype;
|
|
29
|
+
|
|
30
|
+
var methods = /** @type {Array<keyof Date & string>} */ (
|
|
31
|
+
Object.getOwnPropertyNames(date_proto)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
for (const method of methods) {
|
|
35
|
+
if (method.startsWith('get') || method.startsWith('to') || method === 'valueOf') {
|
|
36
|
+
// @ts-ignore
|
|
37
|
+
proto[method] = function (...args) {
|
|
38
|
+
// don't memoize if there are arguments
|
|
39
|
+
// @ts-ignore
|
|
40
|
+
if (args.length > 0) {
|
|
41
|
+
get(this.#time);
|
|
42
|
+
// @ts-ignore
|
|
43
|
+
return date_proto[method].apply(this, args);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var d = this.#deriveds.get(method);
|
|
47
|
+
|
|
48
|
+
if (d === undefined) {
|
|
49
|
+
d = derived(() => {
|
|
50
|
+
get(this.#time);
|
|
51
|
+
// @ts-ignore
|
|
52
|
+
return date_proto[method].apply(this, args);
|
|
53
|
+
}, this.#block);
|
|
54
|
+
|
|
55
|
+
this.#deriveds.set(method, d);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return get(d);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (method.startsWith('set')) {
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
proto[method] = function (...args) {
|
|
65
|
+
// @ts-ignore
|
|
66
|
+
var result = date_proto[method].apply(this, args);
|
|
67
|
+
set(this.#time, date_proto.getTime.call(this), this.#block);
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -15,38 +15,38 @@ export { jsx, jsxs, Fragment } from '../jsx-runtime.js';
|
|
|
15
15
|
* @returns {() => void}
|
|
16
16
|
*/
|
|
17
17
|
export function mount(component, options) {
|
|
18
|
-
|
|
18
|
+
init_operations();
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const props = options.props || {};
|
|
21
|
+
const target = options.target;
|
|
22
|
+
const anchor = create_anchor();
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// Clear target content in case of SSR
|
|
25
|
+
if (target.firstChild) {
|
|
26
|
+
target.textContent = '';
|
|
27
|
+
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
target.append(anchor);
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
const cleanup_events = handle_root_events(target);
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const _root = root(() => {
|
|
34
|
+
component(anchor, props, active_block);
|
|
35
|
+
});
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
return () => {
|
|
38
|
+
cleanup_events();
|
|
39
|
+
destroy_block(_root);
|
|
40
|
+
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export { create_context as createContext } from './internal/client/context.js';
|
|
44
44
|
|
|
45
45
|
export {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
flush_sync as flushSync,
|
|
47
|
+
track,
|
|
48
|
+
track_split as trackSplit,
|
|
49
|
+
untrack,
|
|
50
50
|
} from './internal/client/runtime.js';
|
|
51
51
|
|
|
52
52
|
export { TrackedArray } from './array.js';
|
|
@@ -57,6 +57,8 @@ export { TrackedSet } from './set.js';
|
|
|
57
57
|
|
|
58
58
|
export { TrackedMap } from './map.js';
|
|
59
59
|
|
|
60
|
+
export { TrackedDate } from './date.js';
|
|
61
|
+
|
|
60
62
|
export { keyed } from './internal/client/for.js';
|
|
61
63
|
|
|
62
64
|
export { user_effect as effect } from './internal/client/blocks.js';
|
|
@@ -318,6 +318,20 @@ export function is_destroyed(target_block) {
|
|
|
318
318
|
return true;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* @param {Node | null} node
|
|
323
|
+
* @param {Node} end
|
|
324
|
+
*/
|
|
325
|
+
export function remove_block_dom(node, end) {
|
|
326
|
+
while (node !== null) {
|
|
327
|
+
/** @type {Node | null} */
|
|
328
|
+
var next = node === end ? null : next_sibling(node);
|
|
329
|
+
|
|
330
|
+
/** @type {Element | Text | Comment} */ (node).remove();
|
|
331
|
+
node = next;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
321
335
|
/**
|
|
322
336
|
* @param {Block} block
|
|
323
337
|
* @param {boolean} [remove_dom]
|
|
@@ -330,16 +344,7 @@ export function destroy_block(block, remove_dom = true) {
|
|
|
330
344
|
|
|
331
345
|
if ((remove_dom && (f & (BRANCH_BLOCK | ROOT_BLOCK)) !== 0) || (f & HEAD_BLOCK) !== 0) {
|
|
332
346
|
var s = block.s;
|
|
333
|
-
|
|
334
|
-
var end = s.end;
|
|
335
|
-
|
|
336
|
-
while (node !== null) {
|
|
337
|
-
var next = node === end ? null : next_sibling(node);
|
|
338
|
-
|
|
339
|
-
node.remove();
|
|
340
|
-
node = next;
|
|
341
|
-
}
|
|
342
|
-
|
|
347
|
+
remove_block_dom(s.start, s.end);
|
|
343
348
|
removed = true;
|
|
344
349
|
}
|
|
345
350
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** @import { Block } from '#client' */
|
|
2
|
+
|
|
3
|
+
import { remove_block_dom, render } from './blocks.js';
|
|
4
|
+
import { first_child } from './operations.js';
|
|
5
|
+
import { active_block } from './runtime.js';
|
|
6
|
+
import { assign_nodes, create_fragment_from_html } from './template.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders dynamic HTML content into the DOM by inserting it before the anchor node.
|
|
10
|
+
* Manages the lifecycle of HTML blocks, removing old content and inserting new content.
|
|
11
|
+
*
|
|
12
|
+
* TODO handle SVG/MathML
|
|
13
|
+
*
|
|
14
|
+
* @param {ChildNode} node
|
|
15
|
+
* @param {() => string} get_html
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function html(node, get_html) {
|
|
19
|
+
/** @type {ChildNode} */
|
|
20
|
+
var anchor = node;
|
|
21
|
+
/** @type {string} */
|
|
22
|
+
var html = '';
|
|
23
|
+
|
|
24
|
+
render(() => {
|
|
25
|
+
var block = /** @type {Block} */ (active_block);
|
|
26
|
+
html = get_html() + '';
|
|
27
|
+
|
|
28
|
+
if (block.s !== null && block.s.start !== null) {
|
|
29
|
+
remove_block_dom(block.s.start, /** @type {Node} */ (block.s.end));
|
|
30
|
+
block.s.start = block.s.end = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (html === '') return;
|
|
34
|
+
/** @type {DocumentFragment} */
|
|
35
|
+
var node = create_fragment_from_html(html);
|
|
36
|
+
|
|
37
|
+
assign_nodes(/** @type {Node } */ (first_child(node)), /** @type {Node} */ (node.lastChild));
|
|
38
|
+
|
|
39
|
+
anchor.before(node);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -35,7 +35,7 @@ export function assign_nodes(start, end) {
|
|
|
35
35
|
* @param {boolean} use_mathml_namespace - Whether to use MathML namespace.
|
|
36
36
|
* @returns {DocumentFragment}
|
|
37
37
|
*/
|
|
38
|
-
function create_fragment_from_html(html, use_svg_namespace = false, use_mathml_namespace = false) {
|
|
38
|
+
export function create_fragment_from_html(html, use_svg_namespace = false, use_mathml_namespace = false) {
|
|
39
39
|
if (use_svg_namespace) {
|
|
40
40
|
return from_namespace(html, 'svg');
|
|
41
41
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`html directive > renders dynamic html 1`] = `
|
|
4
|
+
<div>
|
|
5
|
+
<!---->
|
|
6
|
+
<div>
|
|
7
|
+
Test
|
|
8
|
+
</div>
|
|
9
|
+
<!---->
|
|
10
|
+
<button>
|
|
11
|
+
Update
|
|
12
|
+
</button>
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
exports[`html directive > renders dynamic html 2`] = `
|
|
18
|
+
<div>
|
|
19
|
+
<!---->
|
|
20
|
+
<div>
|
|
21
|
+
Updated
|
|
22
|
+
</div>
|
|
23
|
+
<!---->
|
|
24
|
+
<button>
|
|
25
|
+
Update
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
exports[`html directive > renders static html 1`] = `
|
|
32
|
+
<div>
|
|
33
|
+
<!---->
|
|
34
|
+
<div>
|
|
35
|
+
Test
|
|
36
|
+
</div>
|
|
37
|
+
<!---->
|
|
38
|
+
|
|
39
|
+
</div>
|
|
40
|
+
`;
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mount, flushSync, TrackedDate, track } from 'ripple';
|
|
3
|
+
|
|
4
|
+
describe('TrackedDate', () => {
|
|
5
|
+
let container;
|
|
6
|
+
|
|
7
|
+
function render(component) {
|
|
8
|
+
mount(component, {
|
|
9
|
+
target: container,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
container = document.createElement('div');
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.removeChild(container);
|
|
20
|
+
container = null;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('handles getTime() with reactive updates', () => {
|
|
24
|
+
component DateTest() {
|
|
25
|
+
let date = new TrackedDate(2025, 0, 1);
|
|
26
|
+
let time = track(() => date.getTime());
|
|
27
|
+
|
|
28
|
+
<button onClick={() => date.setFullYear(2026)}>{'Change Year'}</button>
|
|
29
|
+
<pre>{@time}</pre>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
render(DateTest);
|
|
33
|
+
|
|
34
|
+
const button = container.querySelector('button');
|
|
35
|
+
const initialTime = container.querySelector('pre').textContent;
|
|
36
|
+
|
|
37
|
+
button.click();
|
|
38
|
+
flushSync();
|
|
39
|
+
|
|
40
|
+
const newTime = container.querySelector('pre').textContent;
|
|
41
|
+
expect(newTime).not.toBe(initialTime);
|
|
42
|
+
expect(parseInt(newTime)).toBeGreaterThan(parseInt(initialTime));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles getFullYear() with reactive updates', () => {
|
|
46
|
+
component DateTest() {
|
|
47
|
+
let date = new TrackedDate(2025, 5, 15);
|
|
48
|
+
let year = track(() => date.getFullYear());
|
|
49
|
+
|
|
50
|
+
<button onClick={() => date.setFullYear(2030)}>{'Change Year'}</button>
|
|
51
|
+
<pre>{@year}</pre>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
render(DateTest);
|
|
55
|
+
|
|
56
|
+
const button = container.querySelector('button');
|
|
57
|
+
|
|
58
|
+
expect(container.querySelector('pre').textContent).toBe('2025');
|
|
59
|
+
|
|
60
|
+
button.click();
|
|
61
|
+
flushSync();
|
|
62
|
+
|
|
63
|
+
expect(container.querySelector('pre').textContent).toBe('2030');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('handles getMonth() with reactive updates', () => {
|
|
67
|
+
component DateTest() {
|
|
68
|
+
let date = new TrackedDate(2025, 0, 15);
|
|
69
|
+
let month = track(() => date.getMonth());
|
|
70
|
+
|
|
71
|
+
<button onClick={() => date.setMonth(11)}>{'Change to December'}</button>
|
|
72
|
+
<pre>{@month}</pre>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
render(DateTest);
|
|
76
|
+
|
|
77
|
+
const button = container.querySelector('button');
|
|
78
|
+
|
|
79
|
+
expect(container.querySelector('pre').textContent).toBe('0');
|
|
80
|
+
|
|
81
|
+
button.click();
|
|
82
|
+
flushSync();
|
|
83
|
+
|
|
84
|
+
expect(container.querySelector('pre').textContent).toBe('11');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('handles getDate() with reactive updates', () => {
|
|
88
|
+
component DateTest() {
|
|
89
|
+
let date = new TrackedDate(2025, 0, 1);
|
|
90
|
+
let day = track(() => date.getDate());
|
|
91
|
+
|
|
92
|
+
<button onClick={() => date.setDate(15)}>{'Change Day'}</button>
|
|
93
|
+
<pre>{@day}</pre>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
render(DateTest);
|
|
97
|
+
|
|
98
|
+
const button = container.querySelector('button');
|
|
99
|
+
|
|
100
|
+
expect(container.querySelector('pre').textContent).toBe('1');
|
|
101
|
+
|
|
102
|
+
button.click();
|
|
103
|
+
flushSync();
|
|
104
|
+
|
|
105
|
+
expect(container.querySelector('pre').textContent).toBe('15');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles getDay() with reactive updates', () => {
|
|
109
|
+
component DateTest() {
|
|
110
|
+
let date = new TrackedDate(2025, 0, 1);
|
|
111
|
+
let dayOfWeek = track(() => date.getDay());
|
|
112
|
+
|
|
113
|
+
<button onClick={() => date.setDate(2)}>{'Next Day'}</button>
|
|
114
|
+
<pre>{@dayOfWeek}</pre>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
render(DateTest);
|
|
118
|
+
|
|
119
|
+
const button = container.querySelector('button');
|
|
120
|
+
|
|
121
|
+
expect(container.querySelector('pre').textContent).toBe('3');
|
|
122
|
+
|
|
123
|
+
button.click();
|
|
124
|
+
flushSync();
|
|
125
|
+
|
|
126
|
+
expect(container.querySelector('pre').textContent).toBe('4');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('handles getHours() with reactive updates', () => {
|
|
130
|
+
component DateTest() {
|
|
131
|
+
let date = new TrackedDate(2025, 0, 1, 10, 30, 0);
|
|
132
|
+
let hours = track(() => date.getHours());
|
|
133
|
+
|
|
134
|
+
<button onClick={() => date.setHours(15)}>{'Change to 3 PM'}</button>
|
|
135
|
+
<pre>{@hours}</pre>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
render(DateTest);
|
|
139
|
+
|
|
140
|
+
const button = container.querySelector('button');
|
|
141
|
+
|
|
142
|
+
expect(container.querySelector('pre').textContent).toBe('10');
|
|
143
|
+
|
|
144
|
+
button.click();
|
|
145
|
+
flushSync();
|
|
146
|
+
|
|
147
|
+
expect(container.querySelector('pre').textContent).toBe('15');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('handles getMinutes() with reactive updates', () => {
|
|
151
|
+
component DateTest() {
|
|
152
|
+
let date = new TrackedDate(2025, 0, 1, 10, 15, 0);
|
|
153
|
+
let minutes = track(() => date.getMinutes());
|
|
154
|
+
|
|
155
|
+
<button onClick={() => date.setMinutes(45)}>{'Change Minutes'}</button>
|
|
156
|
+
<pre>{@minutes}</pre>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
render(DateTest);
|
|
160
|
+
|
|
161
|
+
const button = container.querySelector('button');
|
|
162
|
+
|
|
163
|
+
expect(container.querySelector('pre').textContent).toBe('15');
|
|
164
|
+
|
|
165
|
+
button.click();
|
|
166
|
+
flushSync();
|
|
167
|
+
|
|
168
|
+
expect(container.querySelector('pre').textContent).toBe('45');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('handles getSeconds() with reactive updates', () => {
|
|
172
|
+
component DateTest() {
|
|
173
|
+
let date = new TrackedDate(2025, 0, 1, 10, 15, 30);
|
|
174
|
+
let seconds = track(() => date.getSeconds());
|
|
175
|
+
|
|
176
|
+
<button onClick={() => date.setSeconds(45)}>{'Change Seconds'}</button>
|
|
177
|
+
<pre>{@seconds}</pre>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
render(DateTest);
|
|
181
|
+
|
|
182
|
+
const button = container.querySelector('button');
|
|
183
|
+
|
|
184
|
+
expect(container.querySelector('pre').textContent).toBe('30');
|
|
185
|
+
|
|
186
|
+
button.click();
|
|
187
|
+
flushSync();
|
|
188
|
+
|
|
189
|
+
expect(container.querySelector('pre').textContent).toBe('45');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('handles toISOString() with reactive updates', () => {
|
|
193
|
+
component DateTest() {
|
|
194
|
+
let date = new TrackedDate(2025, 0, 1, 12, 0, 0);
|
|
195
|
+
let isoString = track(() => date.toISOString());
|
|
196
|
+
|
|
197
|
+
<button onClick={() => date.setFullYear(2026)}>{'Change Year'}</button>
|
|
198
|
+
<pre>{@isoString}</pre>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
render(DateTest);
|
|
202
|
+
|
|
203
|
+
const button = container.querySelector('button');
|
|
204
|
+
const initialISO = container.querySelector('pre').textContent;
|
|
205
|
+
|
|
206
|
+
expect(initialISO).toContain('2025');
|
|
207
|
+
|
|
208
|
+
button.click();
|
|
209
|
+
flushSync();
|
|
210
|
+
|
|
211
|
+
const newISO = container.querySelector('pre').textContent;
|
|
212
|
+
|
|
213
|
+
// Just verify that the ISO string changed after the year was updated
|
|
214
|
+
expect(newISO).not.toBe(initialISO);
|
|
215
|
+
expect(newISO.length).toBeGreaterThan(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('handles toDateString() with reactive updates', () => {
|
|
219
|
+
component DateTest() {
|
|
220
|
+
let date = new TrackedDate(2025, 0, 1);
|
|
221
|
+
let dateString = track(() => date.toDateString());
|
|
222
|
+
|
|
223
|
+
<button onClick={() => date.setMonth(11)}>{'Change to December'}</button>
|
|
224
|
+
<pre>{@dateString}</pre>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
render(DateTest);
|
|
228
|
+
|
|
229
|
+
const button = container.querySelector('button');
|
|
230
|
+
const initialDateString = container.querySelector('pre').textContent;
|
|
231
|
+
|
|
232
|
+
expect(initialDateString).toContain('Jan');
|
|
233
|
+
|
|
234
|
+
button.click();
|
|
235
|
+
flushSync();
|
|
236
|
+
|
|
237
|
+
const newDateString = container.querySelector('pre').textContent;
|
|
238
|
+
expect(newDateString).toContain('Dec');
|
|
239
|
+
expect(newDateString).not.toBe(initialDateString);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('handles valueOf() with reactive updates', () => {
|
|
243
|
+
component DateTest() {
|
|
244
|
+
let date = new TrackedDate(2025, 0, 1);
|
|
245
|
+
let valueOf = track(() => date.valueOf());
|
|
246
|
+
|
|
247
|
+
<button onClick={() => date.setDate(2)}>{'Next Day'}</button>
|
|
248
|
+
<pre>{@valueOf}</pre>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
render(DateTest);
|
|
252
|
+
|
|
253
|
+
const button = container.querySelector('button');
|
|
254
|
+
const initialValue = parseInt(container.querySelector('pre').textContent);
|
|
255
|
+
|
|
256
|
+
button.click();
|
|
257
|
+
flushSync();
|
|
258
|
+
|
|
259
|
+
const newValue = parseInt(container.querySelector('pre').textContent);
|
|
260
|
+
expect(newValue).toBeGreaterThan(initialValue);
|
|
261
|
+
expect(newValue - initialValue).toBe(24 * 60 * 60 * 1000);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('handles multiple get methods reacting to same setTime change', () => {
|
|
265
|
+
component DateTest() {
|
|
266
|
+
let date = new TrackedDate(2025, 0, 1, 10, 30, 15);
|
|
267
|
+
let year = track(() => date.getFullYear());
|
|
268
|
+
let month = track(() => date.getMonth());
|
|
269
|
+
let day = track(() => date.getDate());
|
|
270
|
+
let hours = track(() => date.getHours());
|
|
271
|
+
|
|
272
|
+
<button onClick={() => date.setTime(new Date(2026, 5, 15, 14, 45, 30).getTime())}>{'Change All'}</button>
|
|
273
|
+
<div>
|
|
274
|
+
{'Year: '}
|
|
275
|
+
{@year}
|
|
276
|
+
</div>
|
|
277
|
+
<div>
|
|
278
|
+
{'Month: '}
|
|
279
|
+
{@month}
|
|
280
|
+
</div>
|
|
281
|
+
<div>
|
|
282
|
+
{'Day: '}
|
|
283
|
+
{@day}
|
|
284
|
+
</div>
|
|
285
|
+
<div>
|
|
286
|
+
{'Hours: '}
|
|
287
|
+
{@hours}
|
|
288
|
+
</div>
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
render(DateTest);
|
|
292
|
+
|
|
293
|
+
const button = container.querySelector('button');
|
|
294
|
+
const divs = container.querySelectorAll('div');
|
|
295
|
+
|
|
296
|
+
expect(divs[0].textContent).toBe('Year: 2025');
|
|
297
|
+
expect(divs[1].textContent).toBe('Month: 0');
|
|
298
|
+
expect(divs[2].textContent).toBe('Day: 1');
|
|
299
|
+
expect(divs[3].textContent).toBe('Hours: 10');
|
|
300
|
+
|
|
301
|
+
button.click();
|
|
302
|
+
flushSync();
|
|
303
|
+
|
|
304
|
+
expect(divs[0].textContent).toBe('Year: 2026');
|
|
305
|
+
expect(divs[1].textContent).toBe('Month: 5');
|
|
306
|
+
expect(divs[2].textContent).toBe('Day: 15');
|
|
307
|
+
expect(divs[3].textContent).toBe('Hours: 14');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('handles constructor with different parameter combinations', () => {
|
|
311
|
+
component DateTest() {
|
|
312
|
+
let dateNow = new TrackedDate();
|
|
313
|
+
let dateFromString = new TrackedDate('2025-01-01');
|
|
314
|
+
let dateFromNumbers = new TrackedDate(2025, 0, 1);
|
|
315
|
+
let dateFromTimestamp = new TrackedDate(1735689600000);
|
|
316
|
+
|
|
317
|
+
let nowYear = track(() => dateNow.getFullYear());
|
|
318
|
+
let stringYear = track(() => dateFromString.getFullYear());
|
|
319
|
+
let numbersYear = track(() => dateFromNumbers.getFullYear());
|
|
320
|
+
let timestampYear = track(() => dateFromTimestamp.getFullYear());
|
|
321
|
+
|
|
322
|
+
<div>
|
|
323
|
+
{'Now: '}
|
|
324
|
+
{@nowYear}
|
|
325
|
+
</div>
|
|
326
|
+
<div>
|
|
327
|
+
{'String: '}
|
|
328
|
+
{@stringYear}
|
|
329
|
+
</div>
|
|
330
|
+
<div>
|
|
331
|
+
{'Numbers: '}
|
|
332
|
+
{@numbersYear}
|
|
333
|
+
</div>
|
|
334
|
+
<div>
|
|
335
|
+
{'Timestamp: '}
|
|
336
|
+
{@timestampYear}
|
|
337
|
+
</div>
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
render(DateTest);
|
|
341
|
+
|
|
342
|
+
const divs = container.querySelectorAll('div');
|
|
343
|
+
const currentYear = new Date().getFullYear();
|
|
344
|
+
|
|
345
|
+
expect(parseInt(divs[0].textContent.split(': ')[1])).toBe(currentYear);
|
|
346
|
+
|
|
347
|
+
// String date parsing may vary by timezone, just check it's a reasonable year
|
|
348
|
+
const stringYear = parseInt(divs[1].textContent.split(': ')[1]);
|
|
349
|
+
expect(stringYear).toBeGreaterThanOrEqual(2024);
|
|
350
|
+
expect(stringYear).toBeLessThanOrEqual(2025);
|
|
351
|
+
expect(divs[2].textContent).toBe('Numbers: 2025');
|
|
352
|
+
|
|
353
|
+
// Timestamp parsing may also vary by timezone
|
|
354
|
+
const timestampYear = parseInt(divs[3].textContent.split(': ')[1]);
|
|
355
|
+
expect(timestampYear).toBeGreaterThanOrEqual(2024);
|
|
356
|
+
expect(timestampYear).toBeLessThanOrEqual(2025);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('handles get methods with arguments non-memoized', () => {
|
|
360
|
+
component DateTest() {
|
|
361
|
+
let date = new TrackedDate();
|
|
362
|
+
let localeDateString = track(() => date.toLocaleDateString('en-US'));
|
|
363
|
+
let localeTimeString = track(() => date.toLocaleTimeString('en-US'));
|
|
364
|
+
|
|
365
|
+
<button onClick={() => date.setFullYear(date.getFullYear() + 1)}>{'Next Year'}</button>
|
|
366
|
+
<div>
|
|
367
|
+
{'Date: '}
|
|
368
|
+
{@localeDateString}
|
|
369
|
+
</div>
|
|
370
|
+
<div>
|
|
371
|
+
{'Time: '}
|
|
372
|
+
{@localeTimeString}
|
|
373
|
+
</div>
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
render(DateTest);
|
|
377
|
+
|
|
378
|
+
const button = container.querySelector('button');
|
|
379
|
+
const divs = container.querySelectorAll('div');
|
|
380
|
+
const initialDate = divs[0].textContent;
|
|
381
|
+
const initialTime = divs[1].textContent;
|
|
382
|
+
|
|
383
|
+
button.click();
|
|
384
|
+
flushSync();
|
|
385
|
+
|
|
386
|
+
const newDate = divs[0].textContent;
|
|
387
|
+
const newTime = divs[1].textContent;
|
|
388
|
+
|
|
389
|
+
expect(newDate).not.toBe(initialDate);
|
|
390
|
+
expect(newTime).toBe(initialTime);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mount, flushSync, track } from 'ripple';
|
|
3
|
+
|
|
4
|
+
describe('html directive', () => {
|
|
5
|
+
let container;
|
|
6
|
+
|
|
7
|
+
function render(component) {
|
|
8
|
+
mount(component, {
|
|
9
|
+
target: container
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
container = document.createElement('div');
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.removeChild(container);
|
|
20
|
+
container = null;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders static html', () => {
|
|
24
|
+
component App() {
|
|
25
|
+
let str = '<div>Test</div>';
|
|
26
|
+
|
|
27
|
+
{html str}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render(App);
|
|
31
|
+
expect(container).toMatchSnapshot();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders dynamic html', () => {
|
|
35
|
+
component App() {
|
|
36
|
+
let str = track('<div>Test</div>');
|
|
37
|
+
|
|
38
|
+
{html @str}
|
|
39
|
+
|
|
40
|
+
<button onClick={() => { @str = '<div>Updated</div>'; }}>{'Update'}</button>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(App);
|
|
44
|
+
expect(container).toMatchSnapshot();
|
|
45
|
+
|
|
46
|
+
const button = container.querySelector('button');
|
|
47
|
+
button.click();
|
|
48
|
+
flushSync();
|
|
49
|
+
|
|
50
|
+
expect(container).toMatchSnapshot();
|
|
51
|
+
});
|
|
52
|
+
});
|
package/types/index.d.ts
CHANGED
|
@@ -12,13 +12,13 @@ export declare function flushSync<T>(fn: () => T): T;
|
|
|
12
12
|
export declare function effect(fn: (() => void) | (() => () => void)): void;
|
|
13
13
|
|
|
14
14
|
export interface TrackedArrayConstructor {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
new <T>(...elements: T[]): TrackedArray<T>; // must be used with `new`
|
|
16
|
+
from<T>(arrayLike: ArrayLike<T>): TrackedArray<T>;
|
|
17
|
+
of<T>(...items: T[]): TrackedArray<T>;
|
|
18
|
+
fromAsync<T>(iterable: AsyncIterable<T>): Promise<TrackedArray<T>>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export interface TrackedArray<T> extends Array<T> {}
|
|
21
|
+
export interface TrackedArray<T> extends Array<T> { }
|
|
22
22
|
|
|
23
23
|
export declare const TrackedArray: TrackedArrayConstructor;
|
|
24
24
|
|
|
@@ -38,10 +38,12 @@ export declare class TrackedSet<T> extends Set<T> {
|
|
|
38
38
|
symmetricDifference(other: TrackedSet<T> | Set<T>): TrackedSet<T>;
|
|
39
39
|
union(other: TrackedSet<T> | Set<T>): TrackedSet<T>;
|
|
40
40
|
toJSON(): T[];
|
|
41
|
+
#private;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export declare class TrackedMap<K, V> extends Map<K, V> {
|
|
44
45
|
toJSON(): [K, V][];
|
|
46
|
+
#private;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// Compiler-injected runtime symbols (for Ripple component development)
|
|
@@ -74,17 +76,17 @@ export type Tracked<V> = { '#v': V };
|
|
|
74
76
|
export type Props<K extends PropertyKey = any, V = unknown> = Record<K, V>;
|
|
75
77
|
export type PropsWithExtras<T extends object> = Props & T & Record<string, unknown>;
|
|
76
78
|
export type PropsWithChildren<T extends object = {}> =
|
|
77
|
-
|
|
79
|
+
Expand<Omit<Props, 'children'> & { children: Component } & T>;
|
|
78
80
|
|
|
79
81
|
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
|
80
82
|
|
|
81
83
|
type PickKeys<T, K extends readonly (keyof T)[]> =
|
|
82
|
-
|
|
84
|
+
{ [I in keyof K]: Tracked<T[K[I] & keyof T]> };
|
|
83
85
|
|
|
84
86
|
type RestKeys<T, K extends readonly (keyof T)[]> = Expand<Omit<T, K[number]>>;
|
|
85
87
|
|
|
86
88
|
type SplitResult<T extends Props, K extends readonly (keyof T)[]> =
|
|
87
|
-
|
|
89
|
+
[...PickKeys<T, K>, Tracked<RestKeys<T, K>>];
|
|
88
90
|
|
|
89
91
|
export declare function track<V>(value?: V | (() => V), get?: (v: V) => V, set?: (next: V, prev: V) => V): Tracked<V>;
|
|
90
92
|
|
|
@@ -129,33 +131,38 @@ export function on(
|
|
|
129
131
|
): () => void;
|
|
130
132
|
|
|
131
133
|
export type TrackedObjectShallow<T> = {
|
|
132
|
-
|
|
134
|
+
[K in keyof T]: T[K] | Tracked<T[K]>;
|
|
133
135
|
};
|
|
134
136
|
|
|
135
137
|
export type TrackedObjectDeep<T> =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
138
|
+
T extends string | number | boolean | null | undefined | symbol | bigint
|
|
139
|
+
? T | Tracked<T>
|
|
140
|
+
: T extends TrackedArray<infer U>
|
|
141
|
+
? TrackedArray<U> | Tracked<TrackedArray<U>>
|
|
142
|
+
: T extends TrackedSet<infer U>
|
|
143
|
+
? TrackedSet<U> | Tracked<TrackedSet<U>>
|
|
144
|
+
: T extends TrackedMap<infer K, infer V>
|
|
145
|
+
? TrackedMap<K, V> | Tracked<TrackedMap<K, V>>
|
|
146
|
+
: T extends Array<infer U>
|
|
147
|
+
? Array<TrackedObjectDeep<U>> | Tracked<Array<TrackedObjectDeep<U>>>
|
|
148
|
+
: T extends Set<infer U>
|
|
149
|
+
? Set<TrackedObjectDeep<U>> | Tracked<Set<TrackedObjectDeep<U>>>
|
|
150
|
+
: T extends Map<infer K, infer V>
|
|
151
|
+
? Map<TrackedObjectDeep<K>, TrackedObjectDeep<V>> |
|
|
152
|
+
Tracked<Map<TrackedObjectDeep<K>, TrackedObjectDeep<V>>>
|
|
153
|
+
: T extends object
|
|
154
|
+
? { [K in keyof T]: TrackedObjectDeep<T[K]> | Tracked<TrackedObjectDeep<T[K]>> }
|
|
155
|
+
: T | Tracked<T>;
|
|
154
156
|
|
|
155
157
|
export type TrackedObject<T extends object> = T & {};
|
|
156
158
|
|
|
157
159
|
export interface TrackedObjectConstructor {
|
|
158
|
-
|
|
160
|
+
new <T extends object>(obj: T): TrackedObject<T>;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
export declare const TrackedObject: TrackedObjectConstructor;
|
|
164
|
+
|
|
165
|
+
export class SvelteDate extends Date {
|
|
166
|
+
constructor(...params: any[]);
|
|
167
|
+
#private;
|
|
168
|
+
}
|