ripple 0.2.104 → 0.2.105
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 +2 -1
- package/src/compiler/phases/1-parse/index.js +40 -1
- package/src/compiler/phases/3-transform/client/index.js +14 -0
- package/src/compiler/types/index.d.ts +5 -0
- package/src/runtime/internal/client/css.js +4 -2
- package/tests/client/__snapshots__/tracked-expression.test.ripple.snap +34 -0
- package/tests/client/compiler.test.ripple +98 -10
- package/tests/client/tracked-expression.test.ripple +46 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Ripple is an elegant TypeScript UI framework",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dominic Gannaway",
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.105",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"module": "src/runtime/index-client.js",
|
|
9
9
|
"main": "src/runtime/index-client.js",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"@sveltejs/acorn-typescript": "^1.0.6",
|
|
68
68
|
"acorn": "^8.15.0",
|
|
69
69
|
"clsx": "^2.1.1",
|
|
70
|
+
"esm-env": "^1.2.2",
|
|
70
71
|
"esrap": "^2.1.0",
|
|
71
72
|
"is-reference": "^3.0.3",
|
|
72
73
|
"magic-string": "^0.30.18",
|
|
@@ -167,9 +167,16 @@ function RipplePlugin(config) {
|
|
|
167
167
|
|
|
168
168
|
if (code === 64) {
|
|
169
169
|
// @ character
|
|
170
|
-
// Look ahead to see if this is followed by a valid identifier character
|
|
170
|
+
// Look ahead to see if this is followed by a valid identifier character or opening paren
|
|
171
171
|
if (this.pos + 1 < this.input.length) {
|
|
172
172
|
const nextChar = this.input.charCodeAt(this.pos + 1);
|
|
173
|
+
|
|
174
|
+
// Check if this is @( for unboxing expression syntax
|
|
175
|
+
if (nextChar === 40) { // ( character
|
|
176
|
+
this.pos += 2; // skip '@('
|
|
177
|
+
return this.finishToken(tt.parenL, '@(');
|
|
178
|
+
}
|
|
179
|
+
|
|
173
180
|
// Check if the next character can start an identifier
|
|
174
181
|
if (
|
|
175
182
|
(nextChar >= 65 && nextChar <= 90) || // A-Z
|
|
@@ -348,6 +355,11 @@ function RipplePlugin(config) {
|
|
|
348
355
|
* @returns {any} Parsed expression atom
|
|
349
356
|
*/
|
|
350
357
|
parseExprAtom(refDestructuringErrors, forNew, forInit) {
|
|
358
|
+
// Check if this is @(expression) for unboxing tracked values
|
|
359
|
+
if (this.type === tt.parenL && this.value === '@(') {
|
|
360
|
+
return this.parseTrackedExpression();
|
|
361
|
+
}
|
|
362
|
+
|
|
351
363
|
// Check if this is a tuple literal starting with #[
|
|
352
364
|
if (this.type === tt.bracketL && this.value === '#[') {
|
|
353
365
|
return this.parseTrackedArrayExpression();
|
|
@@ -358,6 +370,33 @@ function RipplePlugin(config) {
|
|
|
358
370
|
return super.parseExprAtom(refDestructuringErrors, forNew, forInit);
|
|
359
371
|
}
|
|
360
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Parse `@(expression)` syntax for unboxing tracked values
|
|
375
|
+
* Creates a TrackedExpression node with the argument property
|
|
376
|
+
* @returns {any} TrackedExpression node
|
|
377
|
+
*/
|
|
378
|
+
parseTrackedExpression() {
|
|
379
|
+
const node = this.startNode();
|
|
380
|
+
this.next(); // consume '@(' token
|
|
381
|
+
node.argument = this.parseExpression();
|
|
382
|
+
this.expect(tt.parenR); // expect ')'
|
|
383
|
+
return this.finishNode(node, 'TrackedExpression');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Override to allow TrackedExpression as a valid lvalue for update expressions
|
|
388
|
+
* @param {any} expr - Expression to check
|
|
389
|
+
* @param {any} bindingType - Binding type
|
|
390
|
+
* @param {any} checkClashes - Check for clashes
|
|
391
|
+
*/
|
|
392
|
+
checkLValSimple(expr, bindingType, checkClashes) {
|
|
393
|
+
// Allow TrackedExpression as a valid lvalue for ++/-- operators
|
|
394
|
+
if (expr.type === 'TrackedExpression') {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
return super.checkLValSimple(expr, bindingType, checkClashes);
|
|
398
|
+
}
|
|
399
|
+
|
|
361
400
|
parseTrackedArrayExpression() {
|
|
362
401
|
const node = this.startNode();
|
|
363
402
|
this.next(); // consume the '#['
|
|
@@ -362,6 +362,10 @@ const visitors = {
|
|
|
362
362
|
);
|
|
363
363
|
},
|
|
364
364
|
|
|
365
|
+
TrackedExpression(node, context) {
|
|
366
|
+
return b.call('_$_.get', context.visit(node.argument));
|
|
367
|
+
},
|
|
368
|
+
|
|
365
369
|
MemberExpression(node, context) {
|
|
366
370
|
const parent = context.path.at(-1);
|
|
367
371
|
|
|
@@ -1001,6 +1005,16 @@ const visitors = {
|
|
|
1001
1005
|
);
|
|
1002
1006
|
}
|
|
1003
1007
|
|
|
1008
|
+
if (argument.type === 'TrackedExpression') {
|
|
1009
|
+
return b.call(
|
|
1010
|
+
node.prefix ? '_$_.update_pre' : '_$_.update',
|
|
1011
|
+
context.visit(argument.argument, { ...context.state, metadata: { tracking: null } }),
|
|
1012
|
+
b.id('__block'),
|
|
1013
|
+
node.operator === '--' ? b.literal(-1) : undefined,
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
|
|
1004
1018
|
const left = object(argument);
|
|
1005
1019
|
const binding = context.state.scope.get(left.name);
|
|
1006
1020
|
const transformers = left && binding?.transform;
|
|
@@ -65,6 +65,11 @@ export interface TrackedArrayExpression extends Omit<ArrayExpression, 'type'> {
|
|
|
65
65
|
elements: (Expression | null)[];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export interface TrackedExpression extends Omit<Expression, 'type'> {
|
|
69
|
+
argument: Expression;
|
|
70
|
+
type: 'TrackedExpression';
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
/**
|
|
69
74
|
* Tracked object expression node
|
|
70
75
|
*/
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
|
|
1
3
|
export function remove_ssr_css() {
|
|
2
4
|
if (!document || typeof requestAnimationFrame !== "function") {
|
|
3
5
|
return;
|
|
@@ -7,14 +9,14 @@ export function remove_ssr_css() {
|
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
function remove_styles() {
|
|
10
|
-
if (
|
|
12
|
+
if (DEV) {
|
|
11
13
|
const styles = document.querySelector('style[data-vite-dev-id]');
|
|
12
14
|
if (styles) {
|
|
13
15
|
remove();
|
|
14
16
|
} else {
|
|
15
17
|
requestAnimationFrame(remove_styles);
|
|
16
18
|
}
|
|
17
|
-
} else
|
|
19
|
+
} else {
|
|
18
20
|
remove_when_css_loaded(() => requestAnimationFrame(remove));
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`TrackedExpression tests > should handle the syntax correctly 1`] = `
|
|
4
|
+
<div>
|
|
5
|
+
<div>
|
|
6
|
+
0
|
|
7
|
+
</div>
|
|
8
|
+
<div>
|
|
9
|
+
0
|
|
10
|
+
</div>
|
|
11
|
+
<div>
|
|
12
|
+
1
|
|
13
|
+
</div>
|
|
14
|
+
<div>
|
|
15
|
+
2
|
|
16
|
+
</div>
|
|
17
|
+
<div>
|
|
18
|
+
2
|
|
19
|
+
</div>
|
|
20
|
+
<div>
|
|
21
|
+
3
|
|
22
|
+
</div>
|
|
23
|
+
<div>
|
|
24
|
+
4
|
|
25
|
+
</div>
|
|
26
|
+
<div>
|
|
27
|
+
false
|
|
28
|
+
</div>
|
|
29
|
+
<div>
|
|
30
|
+
true
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
@@ -287,13 +287,13 @@ describe('compiler success tests', () => {
|
|
|
287
287
|
component Child(props) {
|
|
288
288
|
<div />
|
|
289
289
|
}
|
|
290
|
-
|
|
290
|
+
|
|
291
291
|
export default component App() {
|
|
292
292
|
<Child data-scope="test" aria-label="accessible" class="valid" />
|
|
293
293
|
}`;
|
|
294
294
|
|
|
295
295
|
const result = compile(source, 'test.ripple', { mode: 'client' });
|
|
296
|
-
|
|
296
|
+
|
|
297
297
|
// Should contain properly quoted hyphenated properties and unquoted valid identifiers
|
|
298
298
|
expect(result.js.code).toMatch(/'data-scope': "test"/);
|
|
299
299
|
expect(result.js.code).toMatch(/'aria-label': "accessible"/);
|
|
@@ -323,9 +323,9 @@ describe('compiler success tests', () => {
|
|
|
323
323
|
component Child(props) {
|
|
324
324
|
<div />
|
|
325
325
|
}
|
|
326
|
-
|
|
326
|
+
|
|
327
327
|
export default component App() {
|
|
328
|
-
<Child
|
|
328
|
+
<Child
|
|
329
329
|
validProp="valid"
|
|
330
330
|
class="valid"
|
|
331
331
|
id="valid"
|
|
@@ -336,12 +336,12 @@ describe('compiler success tests', () => {
|
|
|
336
336
|
}`;
|
|
337
337
|
|
|
338
338
|
const result = compile(source, 'test.ripple', { mode: 'client' });
|
|
339
|
-
|
|
339
|
+
|
|
340
340
|
// Valid identifiers should not be quoted
|
|
341
341
|
expect(result.js.code).toMatch(/validProp: "valid"/);
|
|
342
342
|
expect(result.js.code).toMatch(/class: "valid"/);
|
|
343
343
|
expect(result.js.code).toMatch(/id: "valid"/);
|
|
344
|
-
|
|
344
|
+
|
|
345
345
|
// Invalid identifiers (with hyphens) should be quoted
|
|
346
346
|
expect(result.js.code).toMatch(/'data-invalid': "invalid"/);
|
|
347
347
|
expect(result.js.code).toMatch(/'aria-invalid': "invalid"/);
|
|
@@ -353,25 +353,113 @@ describe('compiler success tests', () => {
|
|
|
353
353
|
component Child(props) {
|
|
354
354
|
<div />
|
|
355
355
|
}
|
|
356
|
-
|
|
356
|
+
|
|
357
357
|
export default component App() {
|
|
358
358
|
<Child data-scope="test" />
|
|
359
359
|
}`;
|
|
360
360
|
|
|
361
361
|
const result = compile(source, 'test.ripple', { mode: 'client' });
|
|
362
|
-
|
|
362
|
+
|
|
363
363
|
// Extract the props object from the generated code and test it's valid JavaScript
|
|
364
364
|
const match = result.js.code.match(/Child\([^,]+,\s*(\{[^}]+\})/);
|
|
365
365
|
expect(match).toBeTruthy();
|
|
366
|
-
|
|
366
|
+
|
|
367
367
|
const propsObject = match[1];
|
|
368
368
|
expect(() => {
|
|
369
369
|
// Test that the object literal is syntactically valid
|
|
370
370
|
new Function(`return ${propsObject}`);
|
|
371
371
|
}).not.toThrow();
|
|
372
|
-
|
|
372
|
+
|
|
373
373
|
// Also verify it contains the expected quoted property
|
|
374
374
|
expect(propsObject).toMatch(/'data-scope': "test"/);
|
|
375
375
|
});
|
|
376
376
|
});
|
|
377
|
+
|
|
378
|
+
describe('regex handling', () => {
|
|
379
|
+
it('renders without crashing using regex literals in method calls', () => {
|
|
380
|
+
component App() {
|
|
381
|
+
let text = 'Hello <span>world</span> and <div>content</div>';
|
|
382
|
+
|
|
383
|
+
// Test various regex patterns in method calls that previously failed
|
|
384
|
+
let matchResult = text.match(/<span>/);
|
|
385
|
+
let replaceResult = text.replace(/<div>/g, '[DIV]');
|
|
386
|
+
let searchResult = text.search(/<span>/);
|
|
387
|
+
|
|
388
|
+
// Test regex literals in variable assignments (should work)
|
|
389
|
+
let spanRegex = /<span>/g;
|
|
390
|
+
let divRegex = /<div.*?>/;
|
|
391
|
+
|
|
392
|
+
// Test more complex regex patterns
|
|
393
|
+
let complexMatch = text.match(/<[^>]*>/g);
|
|
394
|
+
let htmlTags = text.replace(/<(\/*)(\w+)[^>]*>/g, '[$1$2]');
|
|
395
|
+
|
|
396
|
+
// Test edge cases with multiple angle brackets
|
|
397
|
+
let multiAngle = '<<test>> <span>content</span>'.match(/<span>/);
|
|
398
|
+
|
|
399
|
+
<div>
|
|
400
|
+
<span>{String(matchResult)}</span>
|
|
401
|
+
<span>{replaceResult}</span>
|
|
402
|
+
<span>{String(searchResult)}</span>
|
|
403
|
+
<span>{String(spanRegex)}</span>
|
|
404
|
+
<span>{String(divRegex)}</span>
|
|
405
|
+
<span>{String(complexMatch)}</span>
|
|
406
|
+
<span>{htmlTags}</span>
|
|
407
|
+
<span>{String(multiAngle)}</span>
|
|
408
|
+
</div>
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
render(App);
|
|
412
|
+
|
|
413
|
+
const matchResult = container.querySelectorAll('span')[0];
|
|
414
|
+
const replaceResult = container.querySelectorAll('span')[1];
|
|
415
|
+
const searchResult = container.querySelectorAll('span')[2];
|
|
416
|
+
const spanRegex = container.querySelectorAll('span')[3];
|
|
417
|
+
const divRegex = container.querySelectorAll('span')[4];
|
|
418
|
+
const complexMatch = container.querySelectorAll('span')[5];
|
|
419
|
+
const htmlTags = container.querySelectorAll('span')[6];
|
|
420
|
+
const multiAngle = container.querySelectorAll('span')[7];
|
|
421
|
+
|
|
422
|
+
expect(matchResult.textContent).toBe('<span>');
|
|
423
|
+
expect(replaceResult.textContent).toBe('Hello <span>world</span> and [DIV]content</div>');
|
|
424
|
+
expect(searchResult.textContent).toBe('6');
|
|
425
|
+
expect(spanRegex.textContent).toBe('/<span>/g');
|
|
426
|
+
expect(divRegex.textContent).toBe('/<div.*?>/');
|
|
427
|
+
expect(complexMatch.textContent).toBe('<span>,</span>,<div>,</div>');
|
|
428
|
+
expect(htmlTags.textContent).toBe('Hello [span]world[/span] and [div]content[/div]');
|
|
429
|
+
expect(multiAngle.textContent).toBe('<span>');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('renders without crashing mixing regex and JSX syntax', () => {
|
|
433
|
+
component App() {
|
|
434
|
+
let htmlString = '<p>Paragraph</p><div>Content</div>';
|
|
435
|
+
|
|
436
|
+
// Mix of regex parsing and legitimate JSX
|
|
437
|
+
let paragraphs = htmlString.match(/<p[^>]*>.*?<\/p>/g);
|
|
438
|
+
let cleaned = htmlString.replace(/<\/?[^>]+>/g, '');
|
|
439
|
+
let splitArray = htmlString.split(/<\/?\w+>/g).filter(s => s.trim());
|
|
440
|
+
|
|
441
|
+
<div class='container'>
|
|
442
|
+
<span class='result'>{String(paragraphs)}</span>
|
|
443
|
+
<span class='cleaned'>{cleaned}</span>
|
|
444
|
+
<p>{'This is real JSX'}</p>
|
|
445
|
+
<div><span>
|
|
446
|
+
{'Split result: '}
|
|
447
|
+
{splitArray.join(', ')}
|
|
448
|
+
</span></div>
|
|
449
|
+
</div>
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
render(App);
|
|
453
|
+
|
|
454
|
+
const result = container.querySelector('.result');
|
|
455
|
+
const cleaned = container.querySelector('.cleaned');
|
|
456
|
+
const jsxParagraph = container.querySelector('p');
|
|
457
|
+
const splitResult = container.querySelector('.container > div > span');
|
|
458
|
+
|
|
459
|
+
expect(result.textContent).toBe('<p>Paragraph</p>');
|
|
460
|
+
expect(cleaned.textContent).toBe('ParagraphContent');
|
|
461
|
+
expect(jsxParagraph.textContent).toBe('This is real JSX');
|
|
462
|
+
expect(splitResult.textContent).toBe('Split result: Paragraph, Content');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
377
465
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mount, flushSync, TrackedSet, track } from 'ripple';
|
|
3
|
+
|
|
4
|
+
describe('TrackedExpression tests', () => {
|
|
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('should handle the syntax correctly', () => {
|
|
24
|
+
component App() {
|
|
25
|
+
let count = track(0);
|
|
26
|
+
|
|
27
|
+
function get_count() {
|
|
28
|
+
return count;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
<div>{@(count)}</div>
|
|
33
|
+
<div>{@(get_count())}</div>
|
|
34
|
+
<div>{++@(count)}</div>
|
|
35
|
+
<div>{++@(get_count())}</div>
|
|
36
|
+
<div>{@(count)++}</div>
|
|
37
|
+
<div>{@(get_count())++}</div>
|
|
38
|
+
<div>{@(count)}</div>
|
|
39
|
+
<div>{!@(count)}</div>
|
|
40
|
+
<div>{!!@(count)}</div>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(App);
|
|
44
|
+
expect(container).toMatchSnapshot();
|
|
45
|
+
});
|
|
46
|
+
});
|