leanweb 3.10.2 → 3.10.4

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/commands/build.js CHANGED
@@ -48,13 +48,14 @@ const buildModule = (projectPath) => {
48
48
  const globalCssPath = `${utils.dirs.build}/global-styles.css`;
49
49
  const globalCss = fs.existsSync(globalCssPath) ? fs.readFileSync(globalCssPath, 'utf8') : '';
50
50
 
51
- // Extract @import statements and create a JS module that sets up the global styles, including the inline styles and the imports, and write it to global-styles.js
51
+ // Strip CSS comments, then extract @import statements and create a JS module that sets up the global styles
52
52
  const imports = [];
53
- const inlineCss = globalCss.replace(/@import\s+(?:url\()?['"]?([^'"\)]+)['"]?\)?[^;]*;/g, (_, url) => {
53
+ const uncommented = globalCss.replace(/\/\*[\s\S]*?\*\//g, '');
54
+ const inlineCss = uncommented.replace(/@import\s+(?:url\()?['"]?([^'"\)]+)['"]?\)?[^;]*;/g, (_, url) => {
54
55
  imports.push(url);
55
56
  return '';
56
57
  });
57
- const jsModule = `globalThis.leanweb = globalThis.leanweb ?? {};\nconst sheet = new CSSStyleSheet();\nsheet.replaceSync(${JSON.stringify(inlineCss)});\nglobalThis.leanweb.__lw_globalStyleSheet = sheet;\nglobalThis.leanweb.__lw_globalStyleImports = ${JSON.stringify(imports)};\n`;
58
+ const jsModule = `globalThis.leanweb ??= {};\nconst sheet = new CSSStyleSheet();\nsheet.replaceSync(${JSON.stringify(inlineCss)});\nglobalThis.leanweb.__lw_globalStyleSheet = sheet;\nglobalThis.leanweb.__lw_globalStyleImports = ${JSON.stringify(imports)};\n`;
58
59
  utils.writeIfChanged(`${utils.dirs.build}/global-styles.js`, jsModule);
59
60
  };
60
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leanweb",
3
- "version": "3.10.2",
3
+ "version": "3.10.4",
4
4
  "description": "Builds framework agnostic web components.",
5
5
  "bin": {
6
6
  "leanweb": "leanweb.js",
@@ -18,14 +18,17 @@
18
18
  "web components"
19
19
  ],
20
20
  "author": "Qian Chen",
21
+ "scripts": {
22
+ "test": "node --test test/*.test.js"
23
+ },
21
24
  "license": "MIT",
22
25
  "dependencies": {
23
- "@babel/parser": "^7.29.0",
24
- "esbuild": "^0.27.3",
26
+ "@babel/parser": "^7.29.2",
27
+ "esbuild": "^0.27.4",
25
28
  "fs-extra": "^11.3.4",
26
29
  "globby": "^16.1.1",
27
30
  "html-minifier": "^4.0.0",
28
- "isomorphic-git": "^1.37.2",
31
+ "isomorphic-git": "^1.37.4",
29
32
  "live-server": "^1.2.2",
30
33
  "node-watch": "^0.7.4",
31
34
  "parse5": "^8.0.0",
@@ -1,77 +1,74 @@
1
1
  import * as parser from './lw-expr-parser.js';
2
2
  import LWEventBus from './lw-event-bus.js';
3
3
 
4
- globalThis.leanweb = globalThis.leanweb ?? {
5
- componentsListeningOnUrlChanges: [],
6
- eventBus: new LWEventBus(),
7
- updateComponents(...tagNames) {
8
- if (tagNames?.length) {
9
- tagNames.forEach(tagName => {
10
- leanweb.eventBus.dispatchEvent(tagName);
11
- });
12
- } else {
13
- leanweb.eventBus.dispatchEvent('update');
14
- }
15
- },
16
-
17
- set urlHash(hash) {
18
- location.hash = hash;
19
- },
20
-
21
- get urlHash() {
22
- return location.hash;
23
- },
24
-
25
- set urlHashPath(hashPath) {
26
- const s = this.urlHash.split('?');
27
- if (s.length === 1) {
28
- this.urlHash = hashPath;
29
- } else if (s.length > 1) {
30
- this.urlHash = hashPath + '?' + s[1];
31
- }
32
- },
4
+ globalThis.leanweb ??= {};
5
+
6
+ leanweb.componentsListeningOnUrlChanges = [];
7
+ leanweb.eventBus = new LWEventBus();
8
+ leanweb.updateComponents = function (...tagNames) {
9
+ if (tagNames?.length) {
10
+ tagNames.forEach(tagName => {
11
+ leanweb.eventBus.dispatchEvent(tagName);
12
+ });
13
+ } else {
14
+ leanweb.eventBus.dispatchEvent('update');
15
+ }
16
+ };
33
17
 
34
- get urlHashPath() {
35
- return this.urlHash.split('?')[0];
18
+ Object.defineProperties(leanweb, {
19
+ urlHash: {
20
+ set(hash) { location.hash = hash; },
21
+ get() { return location.hash; }
36
22
  },
37
-
38
- set urlHashParams(hashParams) {
39
- if (!hashParams) {
40
- return;
41
- }
42
-
43
- const paramArray = [];
44
- Object.keys(hashParams).forEach(key => {
45
- const value = hashParams[key];
46
- if (Array.isArray(value)) {
47
- value.forEach(v => {
48
- paramArray.push(key + '=' + encodeURIComponent(v));
49
- });
50
- } else {
51
- paramArray.push(key + '=' + encodeURIComponent(value));
23
+ urlHashPath: {
24
+ set(hashPath) {
25
+ const s = this.urlHash.split('?');
26
+ if (s.length === 1) {
27
+ this.urlHash = hashPath;
28
+ } else if (s.length > 1) {
29
+ this.urlHash = hashPath + '?' + s[1];
52
30
  }
53
- });
54
- this.urlHash = this.urlHashPath + '?' + paramArray.join('&');
31
+ },
32
+ get() { return this.urlHash.split('?')[0]; }
55
33
  },
34
+ urlHashParams: {
35
+ set(hashParams) {
36
+ if (!hashParams) {
37
+ return;
38
+ }
56
39
 
57
- get urlHashParams() {
58
- const ret = {};
59
- const s = this.urlHash.split('?');
60
- if (s.length > 1) {
61
- const p = new URLSearchParams(s[1]);
62
- p.forEach((v, k) => {
63
- if (ret[k] === undefined) {
64
- ret[k] = v;
65
- } else if (Array.isArray(ret[k])) {
66
- ret[k].push(v);
40
+ const paramArray = [];
41
+ Object.keys(hashParams).forEach(key => {
42
+ const value = hashParams[key];
43
+ if (Array.isArray(value)) {
44
+ value.forEach(v => {
45
+ paramArray.push(key + '=' + encodeURIComponent(v));
46
+ });
67
47
  } else {
68
- ret[k] = [ret[k], v];
48
+ paramArray.push(key + '=' + encodeURIComponent(value));
69
49
  }
70
50
  });
51
+ this.urlHash = this.urlHashPath + '?' + paramArray.join('&');
52
+ },
53
+ get() {
54
+ const ret = {};
55
+ const s = this.urlHash.split('?');
56
+ if (s.length > 1) {
57
+ const p = new URLSearchParams(s[1]);
58
+ p.forEach((v, k) => {
59
+ if (ret[k] === undefined) {
60
+ ret[k] = v;
61
+ } else if (Array.isArray(ret[k])) {
62
+ ret[k].push(v);
63
+ } else {
64
+ ret[k] = [ret[k], v];
65
+ }
66
+ });
67
+ }
68
+ return ret;
71
69
  }
72
- return ret;
73
70
  }
74
- };
71
+ });
75
72
 
76
73
  globalThis.addEventListener('hashchange', () => {
77
74
  leanweb.componentsListeningOnUrlChanges.forEach(component => {
@@ -110,7 +107,7 @@ export default class LWElement extends HTMLElement {
110
107
  componentSheet.replaceSync(ast.css);
111
108
  node.innerHTML = ast.html;
112
109
  this.attachShadow({ mode: 'open' }).appendChild(node.content);
113
- this.shadowRoot.adoptedStyleSheets = [globalThis.leanweb.__lw_globalStyleSheet, componentSheet];
110
+ this.shadowRoot.adoptedStyleSheets = [globalThis.leanweb.__lw_globalStyleSheet, componentSheet].filter(Boolean);
114
111
  globalThis.leanweb.__lw_globalStyleImports?.forEach(url => {
115
112
  const link = document.createElement('link');
116
113
  link.rel = 'stylesheet';
@@ -0,0 +1,104 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import LWEventBus from '../templates/lib/lw-event-bus.js';
4
+
5
+ describe('LWEventBus', () => {
6
+ it('should create an instance with empty listeners', () => {
7
+ const bus = new LWEventBus();
8
+ assert.deepEqual(bus.listeners, {});
9
+ });
10
+
11
+ describe('addEventListener', () => {
12
+ it('should register a listener and return it', () => {
13
+ const bus = new LWEventBus();
14
+ const callback = () => {};
15
+ const listener = bus.addEventListener('test', callback);
16
+ assert.equal(listener.eventName, 'test');
17
+ assert.equal(listener.callback, callback);
18
+ assert.ok(listener.key);
19
+ });
20
+
21
+ it('should register multiple listeners for same event', () => {
22
+ const bus = new LWEventBus();
23
+ bus.addEventListener('test', () => {});
24
+ bus.addEventListener('test', () => {});
25
+ assert.equal(Object.keys(bus.listeners['test']).length, 2);
26
+ });
27
+
28
+ it('should register listeners for different events', () => {
29
+ const bus = new LWEventBus();
30
+ bus.addEventListener('event1', () => {});
31
+ bus.addEventListener('event2', () => {});
32
+ assert.ok(bus.listeners['event1']);
33
+ assert.ok(bus.listeners['event2']);
34
+ });
35
+ });
36
+
37
+ describe('removeEventListener', () => {
38
+ it('should remove a registered listener', () => {
39
+ const bus = new LWEventBus();
40
+ const listener = bus.addEventListener('test', () => {});
41
+ bus.removeEventListener(listener);
42
+ assert.equal(Object.keys(bus.listeners['test']).length, 0);
43
+ });
44
+
45
+ it('should not throw when removing from non-existent event', () => {
46
+ const bus = new LWEventBus();
47
+ assert.doesNotThrow(() => {
48
+ bus.removeEventListener({ eventName: 'nonexistent', key: 999 });
49
+ });
50
+ });
51
+
52
+ it('should only remove the specified listener', () => {
53
+ const bus = new LWEventBus();
54
+ const listener1 = bus.addEventListener('test', () => {});
55
+ bus.addEventListener('test', () => {});
56
+ bus.removeEventListener(listener1);
57
+ assert.equal(Object.keys(bus.listeners['test']).length, 1);
58
+ });
59
+ });
60
+
61
+ describe('dispatchEvent', () => {
62
+ it('should call listener callback with event data', async () => {
63
+ const bus = new LWEventBus();
64
+ let receivedEvent = null;
65
+ bus.addEventListener('test', event => {
66
+ receivedEvent = event;
67
+ });
68
+ bus.dispatchEvent('test', { message: 'hello' });
69
+ // dispatchEvent uses setTimeout, so we need to wait
70
+ await new Promise(r => setTimeout(r, 50));
71
+ assert.ok(receivedEvent);
72
+ assert.equal(receivedEvent.eventName, 'test');
73
+ assert.deepEqual(receivedEvent.data, { message: 'hello' });
74
+ });
75
+
76
+ it('should call all listeners for the event', async () => {
77
+ const bus = new LWEventBus();
78
+ let count = 0;
79
+ bus.addEventListener('test', () => count++);
80
+ bus.addEventListener('test', () => count++);
81
+ bus.dispatchEvent('test');
82
+ await new Promise(r => setTimeout(r, 50));
83
+ assert.equal(count, 2);
84
+ });
85
+
86
+ it('should not throw when dispatching event with no listeners', () => {
87
+ const bus = new LWEventBus();
88
+ assert.doesNotThrow(() => {
89
+ bus.dispatchEvent('nonexistent', { data: 'test' });
90
+ });
91
+ });
92
+
93
+ it('should default data to null', async () => {
94
+ const bus = new LWEventBus();
95
+ let receivedEvent = null;
96
+ bus.addEventListener('test', event => {
97
+ receivedEvent = event;
98
+ });
99
+ bus.dispatchEvent('test');
100
+ await new Promise(r => setTimeout(r, 50));
101
+ assert.equal(receivedEvent.data, null);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,303 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import * as parser from '@babel/parser';
4
+ import { evaluate } from '../templates/lib/lw-expr-parser.js';
5
+
6
+ const getAST = expr => {
7
+ const parsedProgram = parser.parse(expr).program;
8
+ if (parsedProgram.directives.length > 0 && parsedProgram.body.length === 0) {
9
+ return parsedProgram.directives;
10
+ }
11
+ return parsedProgram.body;
12
+ };
13
+
14
+ const eval1 = (expr, context = {}) => evaluate(getAST(expr), context)[0];
15
+
16
+ describe('lw-expr-parser', () => {
17
+ describe('literals', () => {
18
+ it('should evaluate numeric literals', () => {
19
+ assert.equal(eval1('42'), 42);
20
+ assert.equal(eval1('3.14'), 3.14);
21
+ });
22
+
23
+ it('should evaluate string literals', () => {
24
+ assert.equal(eval1('"hello"'), 'hello');
25
+ assert.equal(eval1("'world'"), 'world');
26
+ });
27
+
28
+ it('should evaluate boolean literals', () => {
29
+ assert.equal(eval1('true'), true);
30
+ assert.equal(eval1('false'), false);
31
+ });
32
+
33
+ it('should evaluate null literal', () => {
34
+ assert.equal(eval1('null'), null);
35
+ });
36
+
37
+ it('should evaluate regex literals', () => {
38
+ const result = eval1('/abc/gi');
39
+ assert.ok(result instanceof RegExp);
40
+ assert.equal(result.source, 'abc');
41
+ assert.equal(result.flags, 'gi');
42
+ });
43
+ });
44
+
45
+ describe('binary operations', () => {
46
+ it('should handle arithmetic', () => {
47
+ assert.equal(eval1('2 + 3'), 5);
48
+ assert.equal(eval1('10 - 4'), 6);
49
+ assert.equal(eval1('3 * 4'), 12);
50
+ assert.equal(eval1('15 / 3'), 5);
51
+ assert.equal(eval1('10 % 3'), 1);
52
+ assert.equal(eval1('2 ** 3'), 8);
53
+ });
54
+
55
+ it('should handle comparison operators', () => {
56
+ assert.equal(eval1('1 == 1'), true);
57
+ assert.equal(eval1('1 === 1'), true);
58
+ assert.equal(eval1('1 != 2'), true);
59
+ assert.equal(eval1('1 !== 2'), true);
60
+ assert.equal(eval1('1 < 2'), true);
61
+ assert.equal(eval1('2 <= 2'), true);
62
+ assert.equal(eval1('3 > 2'), true);
63
+ assert.equal(eval1('3 >= 3'), true);
64
+ });
65
+
66
+ it('should handle bitwise operators', () => {
67
+ assert.equal(eval1('5 & 3'), 1);
68
+ assert.equal(eval1('5 | 3'), 7);
69
+ assert.equal(eval1('5 ^ 3'), 6);
70
+ assert.equal(eval1('1 << 2'), 4);
71
+ assert.equal(eval1('8 >> 2'), 2);
72
+ });
73
+
74
+ it('should handle string concatenation', () => {
75
+ assert.equal(eval1('"hello" + " " + "world"'), 'hello world');
76
+ });
77
+ });
78
+
79
+ describe('unary operations', () => {
80
+ it('should handle negation', () => {
81
+ assert.equal(eval1('-5'), -5);
82
+ });
83
+
84
+ it('should handle logical not', () => {
85
+ assert.equal(eval1('!true'), false);
86
+ assert.equal(eval1('!false'), true);
87
+ });
88
+
89
+ it('should handle typeof', () => {
90
+ assert.equal(eval1('typeof 42'), 'number');
91
+ assert.equal(eval1('typeof "hi"'), 'string');
92
+ });
93
+
94
+ it('should handle void', () => {
95
+ assert.equal(eval1('void 0'), undefined);
96
+ });
97
+
98
+ it('should handle unary plus', () => {
99
+ assert.equal(eval1('+"5"'), 5);
100
+ });
101
+
102
+ it('should handle bitwise NOT', () => {
103
+ assert.equal(eval1('~0'), -1);
104
+ });
105
+ });
106
+
107
+ describe('logical operations', () => {
108
+ it('should handle && operator', () => {
109
+ assert.equal(eval1('true && "yes"'), 'yes');
110
+ assert.equal(eval1('false && "yes"'), false);
111
+ });
112
+
113
+ it('should handle || operator', () => {
114
+ assert.equal(eval1('false || "fallback"'), 'fallback');
115
+ assert.equal(eval1('"first" || "second"'), 'first');
116
+ });
117
+
118
+ it('should handle ?? operator', () => {
119
+ assert.equal(eval1('null ?? "default"'), 'default');
120
+ assert.equal(eval1('0 ?? "default"'), 0);
121
+ });
122
+ });
123
+
124
+ describe('conditional (ternary) expression', () => {
125
+ it('should return consequent when true', () => {
126
+ assert.equal(eval1('true ? "yes" : "no"'), 'yes');
127
+ });
128
+
129
+ it('should return alternate when false', () => {
130
+ assert.equal(eval1('false ? "yes" : "no"'), 'no');
131
+ });
132
+ });
133
+
134
+ describe('identifiers and context', () => {
135
+ it('should resolve identifiers from context object', () => {
136
+ assert.equal(eval1('name', { name: 'Alice' }), 'Alice');
137
+ });
138
+
139
+ it('should resolve identifiers from context array', () => {
140
+ assert.equal(eval1('x', [{ x: 10 }, { y: 20 }]), 10);
141
+ });
142
+
143
+ it('should return undefined for missing identifiers', () => {
144
+ assert.equal(eval1('missing', {}), undefined);
145
+ });
146
+
147
+ it('should resolve from first matching context in array', () => {
148
+ assert.equal(eval1('x', [{ x: 'first' }, { x: 'second' }]), 'first');
149
+ });
150
+ });
151
+
152
+ describe('member expressions', () => {
153
+ it('should access object properties', () => {
154
+ assert.equal(eval1('obj.name', { obj: { name: 'test' } }), 'test');
155
+ });
156
+
157
+ it('should access computed properties', () => {
158
+ assert.equal(eval1('obj["key"]', { obj: { key: 'value' } }), 'value');
159
+ });
160
+
161
+ it('should access nested properties', () => {
162
+ assert.equal(eval1('a.b.c', { a: { b: { c: 42 } } }), 42);
163
+ });
164
+
165
+ it('should bind functions to their object', () => {
166
+ const result = eval1('arr.join', { arr: [1, 2, 3] });
167
+ assert.equal(typeof result, 'function');
168
+ });
169
+ });
170
+
171
+ describe('optional member expressions', () => {
172
+ it('should return undefined for null object', () => {
173
+ assert.equal(eval1('obj?.name', { obj: null }), undefined);
174
+ });
175
+
176
+ it('should return undefined for undefined object', () => {
177
+ assert.equal(eval1('obj?.name', {}), undefined);
178
+ });
179
+
180
+ it('should return value when object exists', () => {
181
+ assert.equal(eval1('obj?.name', { obj: { name: 'test' } }), 'test');
182
+ });
183
+ });
184
+
185
+ describe('function calls', () => {
186
+ it('should call functions', () => {
187
+ const ctx = { greet: name => `Hello ${name}` };
188
+ assert.equal(eval1('greet("World")', ctx), 'Hello World');
189
+ });
190
+
191
+ it('should call method on object', () => {
192
+ assert.equal(eval1('arr.join(",")', { arr: [1, 2, 3] }), '1,2,3');
193
+ });
194
+
195
+ it('should handle spread arguments', () => {
196
+ const ctx = { sum: (...args) => args.reduce((a, b) => a + b, 0), nums: [1, 2, 3] };
197
+ assert.equal(eval1('sum(...nums)', ctx), 6);
198
+ });
199
+
200
+ it('should call string methods', () => {
201
+ assert.equal(eval1('name.toUpperCase()', { name: 'hello' }), 'HELLO');
202
+ });
203
+ });
204
+
205
+ describe('assignment operations', () => {
206
+ it('should handle simple assignment', () => {
207
+ const ctx = { x: 0 };
208
+ eval1('x = 5', ctx);
209
+ assert.equal(ctx.x, 5);
210
+ });
211
+
212
+ it('should handle += assignment', () => {
213
+ const ctx = { x: 10 };
214
+ eval1('x += 5', ctx);
215
+ assert.equal(ctx.x, 15);
216
+ });
217
+
218
+ it('should handle -= assignment', () => {
219
+ const ctx = { x: 10 };
220
+ eval1('x -= 3', ctx);
221
+ assert.equal(ctx.x, 7);
222
+ });
223
+
224
+ it('should handle member expression assignment', () => {
225
+ const ctx = { obj: { val: 0 } };
226
+ eval1('obj.val = 42', ctx);
227
+ assert.equal(ctx.obj.val, 42);
228
+ });
229
+ });
230
+
231
+ describe('update operations', () => {
232
+ it('should handle prefix increment', () => {
233
+ const ctx = { x: 5 };
234
+ const result = eval1('++x', ctx);
235
+ assert.equal(result, 6);
236
+ assert.equal(ctx.x, 6);
237
+ });
238
+
239
+ it('should handle postfix increment', () => {
240
+ const ctx = { x: 5 };
241
+ const result = eval1('x++', ctx);
242
+ assert.equal(result, 5);
243
+ assert.equal(ctx.x, 6);
244
+ });
245
+
246
+ it('should handle prefix decrement', () => {
247
+ const ctx = { x: 5 };
248
+ const result = eval1('--x', ctx);
249
+ assert.equal(result, 4);
250
+ assert.equal(ctx.x, 4);
251
+ });
252
+
253
+ it('should handle postfix decrement', () => {
254
+ const ctx = { x: 5 };
255
+ const result = eval1('x--', ctx);
256
+ assert.equal(result, 5);
257
+ assert.equal(ctx.x, 4);
258
+ });
259
+ });
260
+
261
+ describe('array expressions', () => {
262
+ it('should create arrays', () => {
263
+ assert.deepEqual(eval1('[1, 2, 3]'), [1, 2, 3]);
264
+ });
265
+
266
+ it('should handle spread in arrays', () => {
267
+ assert.deepEqual(eval1('[...arr, 4]', { arr: [1, 2, 3] }), [1, 2, 3, 4]);
268
+ });
269
+ });
270
+
271
+ describe('object expressions', () => {
272
+ it('should create objects', () => {
273
+ assert.deepEqual(eval1('({ a: 1, b: 2 })'), { a: 1, b: 2 });
274
+ });
275
+
276
+ it('should handle computed keys', () => {
277
+ assert.deepEqual(eval1('({ [key]: "value" })', { key: 'dynamic' }), { dynamic: 'value' });
278
+ });
279
+ });
280
+
281
+ describe('new expressions', () => {
282
+ it('should create new instances', () => {
283
+ const result = eval1('new Date(2024, 0, 1)', { Date });
284
+ assert.ok(result instanceof Date);
285
+ });
286
+ });
287
+
288
+ describe('this expression', () => {
289
+ it('should resolve this from context', () => {
290
+ const self = { name: 'component' };
291
+ assert.equal(eval1('this.name', [{ this: self }]), 'component');
292
+ });
293
+ });
294
+
295
+ describe('error handling', () => {
296
+ it('should throw with location info on error', () => {
297
+ const loc = { startLine: 1, endLine: 1 };
298
+ assert.throws(() => {
299
+ evaluate(getAST('nonexistent()'), {}, loc);
300
+ });
301
+ });
302
+ });
303
+ });
@@ -0,0 +1,151 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parse } from '../lib/lw-html-parser.js';
4
+
5
+ describe('lw-html-parser', () => {
6
+ describe('parse', () => {
7
+ it('should return an object with html property', () => {
8
+ const result = parse('<div>hello</div>');
9
+ assert.ok(result.html);
10
+ assert.equal(typeof result.html, 'string');
11
+ });
12
+
13
+ it('should pass through plain HTML unchanged', () => {
14
+ const result = parse('<div>hello</div>');
15
+ assert.equal(result.html, '<div>hello</div>');
16
+ // Only the html key, no interpolation keys
17
+ assert.deepEqual(Object.keys(result), ['html']);
18
+ });
19
+
20
+ it('should handle lw text interpolation', () => {
21
+ const result = parse('<span lw>name</span>');
22
+ const keys = Object.keys(result).filter(k => k !== 'html');
23
+ assert.equal(keys.length, 1);
24
+
25
+ const interp = result[keys[0]];
26
+ assert.ok(interp.ast);
27
+ assert.ok(interp.loc);
28
+ // The content should be cleared from the HTML
29
+ assert.ok(!result.html.includes('>name<'));
30
+ });
31
+
32
+ it('should handle lw with empty content', () => {
33
+ const result = parse('<span lw></span>');
34
+ const keys = Object.keys(result).filter(k => k !== 'html');
35
+ assert.equal(keys.length, 1);
36
+ });
37
+
38
+ it('should handle lw with complex expressions', () => {
39
+ const result = parse('<span lw>1 + 2 + 3</span>');
40
+ const keys = Object.keys(result).filter(k => k !== 'html');
41
+ assert.equal(keys.length, 1);
42
+
43
+ const interp = result[keys[0]];
44
+ assert.ok(interp.ast.length > 0);
45
+ });
46
+
47
+ it('should handle lw-on:click event binding', () => {
48
+ const result = parse('<button lw-on:click="handleClick()">Click</button>');
49
+ const keys = Object.keys(result).filter(k => k !== 'html');
50
+ assert.equal(keys.length, 1);
51
+
52
+ const interp = result[keys[0]];
53
+ assert.equal(interp.lwType, 'lw-on');
54
+ assert.equal(interp.lwValue, 'click');
55
+ });
56
+
57
+ it('should handle lw-class binding', () => {
58
+ const result = parse('<div lw-class:active="isActive"></div>');
59
+ const keys = Object.keys(result).filter(k => k !== 'html');
60
+ assert.equal(keys.length, 1);
61
+
62
+ const interp = result[keys[0]];
63
+ assert.equal(interp.lwType, 'lw-class');
64
+ assert.equal(interp.lwValue, 'active');
65
+ });
66
+
67
+ it('should handle lw-bind binding', () => {
68
+ const result = parse('<input lw-bind:value="name">');
69
+ const keys = Object.keys(result).filter(k => k !== 'html');
70
+ assert.equal(keys.length, 1);
71
+
72
+ const interp = result[keys[0]];
73
+ assert.equal(interp.lwType, 'lw-bind');
74
+ assert.equal(interp.lwValue, 'value');
75
+ });
76
+
77
+ it('should handle lw-bind:class with existing class attr', () => {
78
+ const result = parse('<div class="base" lw-bind:class="dynamicClass"></div>');
79
+ assert.ok(result.html.includes('lw-init-class'));
80
+ });
81
+
82
+ it('should handle lw-model binding', () => {
83
+ const result = parse('<input lw-model="username">');
84
+ const keys = Object.keys(result).filter(k => k !== 'html');
85
+ assert.equal(keys.length, 1);
86
+
87
+ // lw-model adds both lw-elem-bind and lw-elem
88
+ assert.ok(result.html.includes('lw-elem-bind'));
89
+ assert.ok(result.html.includes('lw-elem'));
90
+ });
91
+
92
+ it('should handle lw-for loop', () => {
93
+ const result = parse('<li lw-for="item in items">text</li>');
94
+ const keys = Object.keys(result).filter(k => k !== 'html');
95
+ assert.equal(keys.length, 1);
96
+
97
+ const interp = result[keys[0]];
98
+ assert.equal(interp.itemExpr, 'item');
99
+ assert.equal(interp.itemsExpr, 'items');
100
+ assert.ok(interp.astItems);
101
+ });
102
+
103
+ it('should handle lw-for with index', () => {
104
+ const result = parse('<li lw-for="item, index in items">text</li>');
105
+ const keys = Object.keys(result).filter(k => k !== 'html');
106
+ const interp = result[keys[0]];
107
+ assert.equal(interp.itemExpr, 'item');
108
+ assert.equal(interp.indexExpr, 'index');
109
+ assert.equal(interp.itemsExpr, 'items');
110
+ });
111
+
112
+ it('should handle lw-input binding', () => {
113
+ const result = parse('<input lw-input:change="onInputChange()">');
114
+ const keys = Object.keys(result).filter(k => k !== 'html');
115
+ assert.equal(keys.length, 1);
116
+
117
+ // lw-input adds lw-elem-bind
118
+ assert.ok(result.html.includes('lw-elem-bind'));
119
+ });
120
+
121
+ it('should handle multiple lw attributes on different elements', () => {
122
+ const result = parse('<div><span lw>a</span><span lw>b</span></div>');
123
+ const keys = Object.keys(result).filter(k => k !== 'html');
124
+ assert.equal(keys.length, 2);
125
+ });
126
+
127
+ it('should handle nested elements with lw attributes', () => {
128
+ const result = parse('<div lw-on:click="handler()"><span lw>text</span></div>');
129
+ const keys = Object.keys(result).filter(k => k !== 'html');
130
+ assert.equal(keys.length, 2);
131
+ });
132
+
133
+ it('should strip AST location info', () => {
134
+ const result = parse('<span lw>name</span>');
135
+ const keys = Object.keys(result).filter(k => k !== 'html');
136
+ const interp = result[keys[0]];
137
+ const astStr = JSON.stringify(interp.ast);
138
+ // Should not contain loc, start, end from babel
139
+ assert.ok(!astStr.includes('"loc"'));
140
+ assert.ok(!astStr.includes('"start"'));
141
+ assert.ok(!astStr.includes('"end"'));
142
+ });
143
+
144
+ it('should handle generic lw- prefixed attributes', () => {
145
+ const result = parse('<div lw-if="visible"></div>');
146
+ const keys = Object.keys(result).filter(k => k !== 'html');
147
+ assert.equal(keys.length, 1);
148
+ assert.ok(result.html.includes('lw-elem'));
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { getComponentName, getComponentPath, getPathLevels, throttle, portInUse } from '../commands/utils.js';
4
+
5
+ describe('utils', () => {
6
+ describe('getComponentName', () => {
7
+ it('should return the component name after last slash', () => {
8
+ assert.equal(getComponentName('path/to/my-component'), 'my-component');
9
+ });
10
+
11
+ it('should return the full string when no slash', () => {
12
+ assert.equal(getComponentName('my-component'), 'my-component');
13
+ });
14
+
15
+ it('should handle multiple slashes', () => {
16
+ assert.equal(getComponentName('a/b/c/deep-component'), 'deep-component');
17
+ });
18
+
19
+ it('should handle trailing component after single slash', () => {
20
+ assert.equal(getComponentName('dir/comp'), 'comp');
21
+ });
22
+ });
23
+
24
+ describe('getComponentPath', () => {
25
+ it('should return the path before last slash', () => {
26
+ assert.equal(getComponentPath('path/to/my-component'), 'path/to');
27
+ });
28
+
29
+ it('should return empty string when no slash', () => {
30
+ assert.equal(getComponentPath('my-component'), '');
31
+ });
32
+
33
+ it('should handle single directory', () => {
34
+ assert.equal(getComponentPath('dir/comp'), 'dir');
35
+ });
36
+ });
37
+
38
+ describe('getPathLevels', () => {
39
+ it('should return empty string for file without directory', () => {
40
+ assert.equal(getPathLevels('file.html'), '');
41
+ });
42
+
43
+ it('should return ../ for one level deep', () => {
44
+ assert.equal(getPathLevels('dir/file.html'), '../');
45
+ });
46
+
47
+ it('should return ../../ for two levels deep', () => {
48
+ assert.equal(getPathLevels('a/b/file.html'), '../../');
49
+ });
50
+
51
+ it('should handle deeper paths', () => {
52
+ assert.equal(getPathLevels('a/b/c/file.html'), '../../../');
53
+ });
54
+ });
55
+
56
+ describe('throttle', () => {
57
+ it('should call function after delay', async () => {
58
+ let called = false;
59
+ const fn = throttle(() => { called = true; }, 50);
60
+ fn();
61
+ assert.equal(called, false); // not called immediately
62
+ await new Promise(r => setTimeout(r, 100));
63
+ assert.equal(called, true);
64
+ });
65
+
66
+ it('should not call function again within the limit', async () => {
67
+ let callCount = 0;
68
+ const fn = throttle(() => { callCount++; }, 50);
69
+ fn();
70
+ fn();
71
+ fn();
72
+ await new Promise(r => setTimeout(r, 100));
73
+ assert.equal(callCount, 1);
74
+ });
75
+
76
+ it('should allow call again after limit expires', async () => {
77
+ let callCount = 0;
78
+ const fn = throttle(() => { callCount++; }, 30);
79
+ fn();
80
+ await new Promise(r => setTimeout(r, 50));
81
+ assert.equal(callCount, 1);
82
+ fn();
83
+ await new Promise(r => setTimeout(r, 50));
84
+ assert.equal(callCount, 2);
85
+ });
86
+ });
87
+
88
+ describe('portInUse', () => {
89
+ it('should return false for an available port', async () => {
90
+ const result = await portInUse(0);
91
+ assert.equal(result, false);
92
+ });
93
+ });
94
+ });