ripple 0.2.35 → 0.2.37

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 a TypeScript UI framework for the web",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.35",
6
+ "version": "0.2.37",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index.js",
9
9
  "main": "src/runtime/index.js",
@@ -25,6 +25,18 @@ function RipplePlugin(config) {
25
25
  class RippleParser extends Parser {
26
26
  #path = [];
27
27
 
28
+ // Helper method to get the element name from a JSX identifier or member expression
29
+ getElementName(node) {
30
+ if (!node) return null;
31
+ if (node.type === 'Identifier' || node.type === 'JSXIdentifier') {
32
+ return node.name;
33
+ } else if (node.type === 'MemberExpression' || node.type === 'JSXMemberExpression') {
34
+ // For components like <Foo.Bar>, return "Foo.Bar"
35
+ return this.getElementName(node.object) + '.' + this.getElementName(node.property);
36
+ }
37
+ return null;
38
+ }
39
+
28
40
  parseExportDefaultDeclaration() {
29
41
  // Check if this is "export default component"
30
42
  if (this.value === 'component') {
@@ -432,11 +444,19 @@ function RipplePlugin(config) {
432
444
  this.context.pop();
433
445
  }
434
446
 
447
+ this.finishNode(element, 'Element');
435
448
  return element;
436
449
  } else {
437
450
  this.enterScope(0);
438
451
  this.parseTemplateBody(element.children);
439
452
  this.exitScope();
453
+
454
+ // Check if this element was properly closed
455
+ // If we reach here and this element is still in the path, it means it was never closed
456
+ if (this.#path[this.#path.length - 1] === element) {
457
+ const tagName = this.getElementName(element.id);
458
+ this.raise(this.start, `Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`);
459
+ }
440
460
  }
441
461
  // Ensure we escape JSX <tag></tag> context
442
462
  const tokContexts = this.acornTypeScript.tokContexts;
@@ -517,8 +537,22 @@ function RipplePlugin(config) {
517
537
  this.next();
518
538
  if (this.value === '/') {
519
539
  this.next();
520
- this.jsx_parseElementName();
540
+ const closingTag = this.jsx_parseElementName();
521
541
  this.exprAllowed = true;
542
+
543
+ // Validate that the closing tag matches the opening tag
544
+ const currentElement = this.#path[this.#path.length - 1];
545
+ if (!currentElement || currentElement.type !== 'Element') {
546
+ this.raise(this.start, 'Unexpected closing tag');
547
+ }
548
+
549
+ const openingTagName = this.getElementName(currentElement.id);
550
+ const closingTagName = this.getElementName(closingTag);
551
+
552
+ if (openingTagName !== closingTagName) {
553
+ this.raise(this.start, `Expected closing tag to match opening tag. Expected '</${openingTagName}>' but found '</${closingTagName}>'`);
554
+ }
555
+
522
556
  this.#path.pop();
523
557
  this.next();
524
558
  return;
@@ -15,6 +15,29 @@ import is_reference from 'is-reference';
15
15
  import { prune_css } from './prune.js';
16
16
  import { error } from '../../errors.js';
17
17
 
18
+ function mark_for_loop_has_template(path) {
19
+ for (let i = path.length - 1; i >= 0; i -= 1) {
20
+ const node = path[i];
21
+
22
+ if (
23
+ node.type === 'Component' ||
24
+ node.type === 'FunctionExpression' ||
25
+ node.type === 'ArrowFunctionExpression' ||
26
+ node.type === 'FunctionDeclaration'
27
+ ) {
28
+ break;
29
+ }
30
+ if (
31
+ node.type === 'ForStatement' ||
32
+ node.type === 'ForInStatement' ||
33
+ node.type === 'ForOfStatement'
34
+ ) {
35
+ node.metadata.has_template = true;
36
+ break;
37
+ }
38
+ }
39
+ }
40
+
18
41
  function visit_function(node, context) {
19
42
  node.metadata = {
20
43
  hoisted: false,
@@ -26,20 +49,24 @@ function visit_function(node, context) {
26
49
  if (node.params.length > 0) {
27
50
  for (let i = 0; i < node.params.length; i += 1) {
28
51
  const param = node.params[i];
29
- if (param.type === 'ObjectPattern') {
52
+
53
+ if (param.type === 'ObjectPattern' || param.type === 'ArrayPattern') {
30
54
  const paths = extract_paths(param);
55
+ const id = context.state.scope.generate('__arg');
56
+ const arg_id = b.id(id);
31
57
 
32
58
  for (const path of paths) {
33
59
  const name = path.node.name;
60
+
61
+ const expression = path.expression(arg_id);
34
62
  const binding = context.state.scope.get(name);
35
63
 
36
64
  if (binding !== null && is_tracked_name(name)) {
37
- const id = context.state.scope.generate('arg');
38
65
  node.params[i] = b.id(id);
39
66
  binding.kind = path.has_default_value ? 'prop_fallback' : 'prop';
40
67
 
41
68
  binding.transform = {
42
- read: (_) => b.call('$.get_property', b.id(id), b.literal(name)),
69
+ read: (_, __, visit) => visit(expression),
43
70
  };
44
71
  }
45
72
  }
@@ -55,7 +82,7 @@ function visit_function(node, context) {
55
82
  }
56
83
 
57
84
  function mark_as_tracked(path) {
58
- for (let i = 0; i < path.length; i += 1) {
85
+ for (let i = path.length - 1; i >= 0; i -= 1) {
59
86
  const node = path[i];
60
87
 
61
88
  if (node.type === 'Component') {
@@ -403,6 +430,20 @@ const visitors = {
403
430
  context.next();
404
431
  },
405
432
 
433
+ ForOfStatement(node, context) {
434
+ node.metadata = {
435
+ has_template: false,
436
+ };
437
+ context.next();
438
+ if (!node.metadata.has_template) {
439
+ error(
440
+ 'For...of loops must contain a template in their body. Move the for loop into an effect if it does not render anything.',
441
+ context.state.analysis.module.filename,
442
+ node,
443
+ );
444
+ }
445
+ },
446
+
406
447
  ForInStatement(node, context) {
407
448
  if (is_inside_component(context)) {
408
449
  error(
@@ -425,13 +466,15 @@ const visitors = {
425
466
  }
426
467
  },
427
468
 
428
- Element(node, { state, visit }) {
469
+ Element(node, { state, visit, path }) {
429
470
  const is_dom_element =
430
471
  node.id.type === 'Identifier' &&
431
472
  node.id.name[0].toLowerCase() === node.id.name[0] &&
432
473
  node.id.name[0] !== '$';
433
474
  const attribute_names = new Set();
434
475
 
476
+ mark_for_loop_has_template(path);
477
+
435
478
  if (is_dom_element) {
436
479
  const is_void = is_void_element(node.id.name);
437
480
 
@@ -563,6 +606,11 @@ const visitors = {
563
606
  };
564
607
  },
565
608
 
609
+ Text(node, context) {
610
+ mark_for_loop_has_template(context.path);
611
+ context.next();
612
+ },
613
+
566
614
  AwaitExpression(node, context) {
567
615
  if (is_inside_component(context)) {
568
616
  if (context.state.metadata?.await === false) {
@@ -75,7 +75,7 @@ function build_getter(node, context) {
75
75
 
76
76
  // don't transform the declaration itself
77
77
  if (node !== binding?.node && binding?.transform?.read) {
78
- return binding.transform.read(node, context.state?.metadata?.spread);
78
+ return binding.transform.read(node, context.state?.metadata?.spread, context.visit);
79
79
  }
80
80
  }
81
81
 
@@ -328,34 +328,52 @@ const visitors = {
328
328
  delete declarator.id.typeAnnotation;
329
329
  }
330
330
 
331
- if (binding !== null && binding.kind === 'tracked' && !context.state.to_ts) {
331
+ if (binding !== null && binding.kind === 'tracked') {
332
332
  let expression;
333
333
 
334
- if (metadata.tracking && metadata.await) {
335
- expression = b.call(
336
- b.await(
337
- b.call(
338
- '$.resume_context',
334
+ if (context.state.to_ts) {
335
+ // TypeScript mode: lighter transformation
336
+ if (metadata.tracking && !metadata.await) {
337
+ expression = b.call(
338
+ '$.computed',
339
+ b.thunk(context.visit(declarator.init)),
340
+ b.id('__block'),
341
+ );
342
+ } else {
343
+ expression = b.call(
344
+ '$.tracked',
345
+ declarator.init === null ? undefined : context.visit(declarator.init),
346
+ b.id('__block'),
347
+ );
348
+ }
349
+ } else {
350
+ // Runtime mode: full transformation
351
+ if (metadata.tracking && metadata.await) {
352
+ expression = b.call(
353
+ b.await(
339
354
  b.call(
340
- '$.async_computed',
341
- b.thunk(context.visit(declarator.init), true),
342
- b.id('__block'),
355
+ '$.resume_context',
356
+ b.call(
357
+ '$.async_computed',
358
+ b.thunk(context.visit(declarator.init), true),
359
+ b.id('__block'),
360
+ ),
343
361
  ),
344
362
  ),
345
- ),
346
- );
347
- } else if (metadata.tracking && !metadata.await) {
348
- expression = b.call(
349
- '$.computed',
350
- b.thunk(context.visit(declarator.init)),
351
- b.id('__block'),
352
- );
353
- } else {
354
- expression = b.call(
355
- '$.tracked',
356
- declarator.init === null ? undefined : context.visit(declarator.init),
357
- b.id('__block'),
358
- );
363
+ );
364
+ } else if (metadata.tracking && !metadata.await) {
365
+ expression = b.call(
366
+ '$.computed',
367
+ b.thunk(context.visit(declarator.init)),
368
+ b.id('__block'),
369
+ );
370
+ } else {
371
+ expression = b.call(
372
+ '$.tracked',
373
+ declarator.init === null ? undefined : context.visit(declarator.init),
374
+ b.id('__block'),
375
+ );
376
+ }
359
377
  }
360
378
 
361
379
  declarations.push(b.declarator(declarator.id, expression));
@@ -372,11 +390,32 @@ const visitors = {
372
390
  delete declarator.id.typeAnnotation;
373
391
  }
374
392
 
375
- if (!has_tracked || context.state.to_ts) {
393
+ if (!has_tracked) {
376
394
  declarations.push(context.visit(declarator));
377
395
  continue;
378
396
  }
379
397
 
398
+ // For TypeScript mode, we still need to transform tracked variables
399
+ // but use a lighter approach that maintains type information
400
+ if (context.state.to_ts) {
401
+ const transformed = declarator.transformed || declarator.id;
402
+ let expression;
403
+
404
+ if (metadata.tracking && !metadata.await) {
405
+ expression = b.call(
406
+ '$.computed',
407
+ b.thunk(context.visit(declarator.init)),
408
+ b.id('__block'),
409
+ );
410
+ } else {
411
+ // Simple tracked variable - always use $.tracked for $ prefixed variables
412
+ expression = b.call('$.tracked', context.visit(declarator.init), b.id('__block'));
413
+ }
414
+
415
+ declarations.push(b.declarator(transformed, expression));
416
+ continue;
417
+ }
418
+
380
419
  const transformed = declarator.transformed;
381
420
  let expression;
382
421
 
@@ -64,7 +64,7 @@ export {
64
64
  array_entries,
65
65
  } from './array.js';
66
66
 
67
- export { for_block as for } from './for.js';
67
+ export { for_block as for, for_effect } from './for.js';
68
68
 
69
69
  export { if_block as if } from './if.js';
70
70
 
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { mount, flushSync, effect } from 'ripple';
3
+ import { compile } from 'ripple/compiler';
3
4
 
4
5
  describe('basic', () => {
5
6
  let container;
@@ -833,6 +834,25 @@ describe('basic', () => {
833
834
  expect(paragraphs[1].className).toBe('nested-class');
834
835
  });
835
836
 
837
+ it('should throw error for unclosed tag', () => {
838
+ const malformedCode = `export default component Example() {
839
+ <div></span>
840
+ }`;
841
+ expect(() => {
842
+ compile(malformedCode, 'test.ripple', 'test.ripple');
843
+ }).toThrow('Expected closing tag to match opening tag');
844
+ });
845
+
846
+ it('should throw error for completely unclosed tag', () => {
847
+ const malformedCode = `export default component Example() {
848
+ <div>content
849
+ }`;
850
+
851
+ expect(() => {
852
+ compile(malformedCode, 'test.ripple', 'test.ripple');
853
+ }).toThrow('Unclosed tag');
854
+ });
855
+
836
856
  it('basic reactivity with standard arrays should work', () => {
837
857
  let logs = [];
838
858
 
@@ -897,5 +917,5 @@ describe('basic', () => {
897
917
  expect(divs[2].textContent).toBe('Number to string: 1,1');
898
918
  expect(divs[3].textContent).toBe('Even numbers: ');
899
919
  expect(logs).toEqual(['0, 0', '1, 0', 'arr includes 1', '1, 1']);
900
- })
901
- });
920
+ });
921
+ });
@@ -35,7 +35,7 @@ describe('passing reactivity between boundaries tests', () => {
35
35
 
36
36
  component App() {
37
37
  let $count = 0;
38
-
38
+
39
39
  const [ $double ] = createDouble([ $count ]);
40
40
 
41
41
  <div>{'Double: ' + $double}</div>
package/types/index.d.ts CHANGED
@@ -55,3 +55,27 @@ export class RippleMap<K, V> extends Map<K, V> {
55
55
  get $size(): number;
56
56
  toJSON(): [K, V][];
57
57
  }
58
+
59
+ // Compiler-injected runtime symbols (for Ripple component development)
60
+ declare global {
61
+ /**
62
+ * Runtime block context injected by the Ripple compiler.
63
+ * This is automatically available in component scopes and passed to runtime functions.
64
+ */
65
+ var __block: any;
66
+
67
+ /**
68
+ * Ripple runtime namespace - injected by the compiler
69
+ * These functions are available in compiled Ripple components for TypeScript analysis
70
+ */
71
+ var $: {
72
+ tracked<T>(value: T, block?: any): T;
73
+ tracked_object<T extends Record<string, any>>(obj: T, props: string[], block?: any): T;
74
+ computed<T>(fn: () => T, block?: any): T;
75
+ scope(): any;
76
+ get_tracked(node: any): any;
77
+ get_computed(node: any): any;
78
+ set(node: any, value: any, block?: any): any;
79
+ // Add other runtime functions as needed for TypeScript analysis
80
+ };
81
+ }