ripple 0.2.34 → 0.2.36

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.34",
6
+ "version": "0.2.36",
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;
@@ -8,6 +8,7 @@ import {
8
8
  is_ripple_import,
9
9
  is_tracked_computed_property,
10
10
  is_tracked_name,
11
+ is_void_element,
11
12
  } from '../../utils.js';
12
13
  import { extract_paths } from '../../../utils/ast.js';
13
14
  import is_reference from 'is-reference';
@@ -25,20 +26,24 @@ function visit_function(node, context) {
25
26
  if (node.params.length > 0) {
26
27
  for (let i = 0; i < node.params.length; i += 1) {
27
28
  const param = node.params[i];
28
- if (param.type === 'ObjectPattern') {
29
+
30
+ if (param.type === 'ObjectPattern' || param.type === 'ArrayPattern') {
29
31
  const paths = extract_paths(param);
32
+ const id = context.state.scope.generate('__arg');
33
+ const arg_id = b.id(id);
30
34
 
31
35
  for (const path of paths) {
32
36
  const name = path.node.name;
37
+
38
+ const expression = path.expression(arg_id);
33
39
  const binding = context.state.scope.get(name);
34
40
 
35
41
  if (binding !== null && is_tracked_name(name)) {
36
- const id = context.state.scope.generate('arg');
37
42
  node.params[i] = b.id(id);
38
43
  binding.kind = path.has_default_value ? 'prop_fallback' : 'prop';
39
44
 
40
45
  binding.transform = {
41
- read: (_) => b.call('$.get_property', b.id(id), b.literal(name)),
46
+ read: (_, __, visit) => visit(expression),
42
47
  };
43
48
  }
44
49
  }
@@ -54,7 +59,7 @@ function visit_function(node, context) {
54
59
  }
55
60
 
56
61
  function mark_as_tracked(path) {
57
- for (let i = 0; i < path.length; i += 1) {
62
+ for (let i = path.length - 1; i >= 0; i -= 1) {
58
63
  const node = path[i];
59
64
 
60
65
  if (node.type === 'Component') {
@@ -432,6 +437,8 @@ const visitors = {
432
437
  const attribute_names = new Set();
433
438
 
434
439
  if (is_dom_element) {
440
+ const is_void = is_void_element(node.id.name);
441
+
435
442
  if (state.elements) {
436
443
  state.elements.push(node);
437
444
  }
@@ -468,6 +475,14 @@ const visitors = {
468
475
  );
469
476
  }
470
477
  }
478
+
479
+ if (is_void && node.children.length > 0) {
480
+ error(
481
+ `The <${node.id.name}> element is a void element and cannot have children`,
482
+ state.analysis.module.filename,
483
+ node,
484
+ );
485
+ }
471
486
  } else {
472
487
  for (const attr of node.attributes) {
473
488
  if (attr.type === 'Attribute') {
@@ -21,6 +21,7 @@ import {
21
21
  is_inside_call_expression,
22
22
  is_tracked_computed_property,
23
23
  is_value_static,
24
+ is_void_element,
24
25
  } from '../../utils.js';
25
26
  import is_reference from 'is-reference';
26
27
  import { extract_paths, object } from '../../../utils/ast.js';
@@ -74,7 +75,7 @@ function build_getter(node, context) {
74
75
 
75
76
  // don't transform the declaration itself
76
77
  if (node !== binding?.node && binding?.transform?.read) {
77
- return binding.transform.read(node, context.state?.metadata?.spread);
78
+ return binding.transform.read(node, context.state?.metadata?.spread, context.visit);
78
79
  }
79
80
  }
80
81
 
@@ -438,6 +439,7 @@ const visitors = {
438
439
  if (is_dom_element) {
439
440
  let class_attribute = null;
440
441
  const local_updates = [];
442
+ const is_void = is_void_element(node.id.name);
441
443
 
442
444
  state.template.push(`<${node.id.name}`);
443
445
 
@@ -629,7 +631,14 @@ const visitors = {
629
631
  const init = [];
630
632
  const update = [];
631
633
 
632
- transform_children(node.children, { visit, state: { ...state, init, update }, root: false });
634
+ if (!is_void) {
635
+ transform_children(node.children, {
636
+ visit,
637
+ state: { ...state, init, update },
638
+ root: false,
639
+ });
640
+ state.template.push(`</${node.id.name}>`);
641
+ }
633
642
 
634
643
  update.push(...local_updates);
635
644
 
@@ -640,8 +649,6 @@ const visitors = {
640
649
  if (update.length > 0) {
641
650
  state.init.push(b.stmt(b.call('$.render', b.thunk(b.block(update)))));
642
651
  }
643
-
644
- state.template.push(`</${node.id.name}>`);
645
652
  } else {
646
653
  const id = state.flush_node();
647
654
 
@@ -3,6 +3,33 @@ import * as b from '../utils/builders.js';
3
3
 
4
4
  const regex_return_characters = /\r/g;
5
5
 
6
+ const VOID_ELEMENT_NAMES = [
7
+ 'area',
8
+ 'base',
9
+ 'br',
10
+ 'col',
11
+ 'command',
12
+ 'embed',
13
+ 'hr',
14
+ 'img',
15
+ 'input',
16
+ 'keygen',
17
+ 'link',
18
+ 'meta',
19
+ 'param',
20
+ 'source',
21
+ 'track',
22
+ 'wbr'
23
+ ];
24
+
25
+ /**
26
+ * Returns `true` if `name` is of a void element
27
+ * @param {string} name
28
+ */
29
+ export function is_void_element(name) {
30
+ return VOID_ELEMENT_NAMES.includes(name) || name.toLowerCase() === '!doctype';
31
+ }
32
+
6
33
  const RESERVED_WORDS = [
7
34
  'arguments',
8
35
  'await',
@@ -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>