cdom 0.0.12 → 0.0.13

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/README.md CHANGED
@@ -241,29 +241,146 @@ oncreate: {
241
241
 
242
242
  ### 7. Persistence
243
243
 
244
- Sync your signals and state automatically to `localStorage` or `sessionStorage`.
244
+ You can store named session or state objects in Storage objects (e.g. `sessionStorage` or `localStorage`) for persistence. It will be saved any time there is a change. Objects are automatically serialized to JSON and deserialized back to objects.
245
+
246
+ Both objects and strings are supported for the `storage` value (e.g., `localStorage` or `"localStorage"`).
245
247
 
246
248
  ```javascript
247
- // Retrieve on every access, update on every change
248
- oncreate: {
249
- "=signal": [
250
- 'light',
251
- { name: 'site-theme', storage: localStorage }
252
- ]
253
- }
249
+ // cDOM.session is a shortcut for state with sessionStorage
250
+ const user = session({name:'Guest', theme:'dark'}, {name:'user'});
251
+
252
+ // Retrieve it elsewhere (even in another file)
253
+ const sameUser = session.get('user');
254
+
255
+ // Get or create with default value
256
+ const score = session.get('user', {
257
+ defaultValue: { name: 'Guest', theme: 'dark' }
258
+ });
259
+ ```
260
+
261
+ #### How Storage Persistence Works
262
+
263
+ **Important:** Storage (localStorage/sessionStorage) is used **for persistence only**, not as a reactive data source.
264
+
265
+ - **On initialization**: State is loaded from storage if it exists
266
+ - **On updates**: Changes to the state proxy automatically write to storage AND trigger reactive updates
267
+ - **On reads**: Values are read from the in-memory reactive proxy (not from storage)
268
+
269
+ **The in-memory state proxy is the source of truth for reactivity.** Storage is only used to persist state across page reloads.
270
+
271
+ ⚠️ **This means:**
272
+ - Updating storage directly via `localStorage.setItem()` will **NOT** trigger UI updates
273
+ - Updating storage via browser dev tools will **NOT** trigger UI updates
274
+ - Changes will only be reflected after a page reload or when the state is re-initialized
275
+
276
+ **To trigger reactive updates, always modify the state object itself:**
277
+
278
+ ```javascript
279
+ // ✅ CORRECT - Triggers reactivity
280
+ const user = state.get('user');
281
+ user.name = 'Alice'; // Updates in-memory state, writes to storage, triggers UI update
282
+
283
+ // ❌ WRONG - Does NOT trigger reactivity
284
+ localStorage.setItem('user', JSON.stringify({ name: 'Alice' })); // Only updates storage
254
285
  ```
255
286
 
256
- ### 8. Persistence & Transformations
287
+ ### 8. Transformations
257
288
 
258
- Automatically cast incoming values or sync with storage.
289
+ Automatically cast incoming values or sync with storage. Built-in transforms include: `Integer`, `Number`, `String`, `Boolean`.
259
290
 
260
291
  ```javascript
261
292
  cDOM.signal(0, {
262
293
  name: 'count',
263
- transform: 'Integer' // Built-in: Integer, Number, String, Boolean
294
+ transform: 'Integer'
264
295
  });
265
296
  ```
266
297
 
298
+ ### 9. Macros
299
+
300
+ Macros allow you to define reusable logic templates entirely in JSON, without writing JavaScript. They are perfect for domain-specific calculations, complex formulas, or frequently-used patterns.
301
+
302
+ #### Defining a Macro
303
+
304
+ ```json
305
+ {
306
+ "=macro": {
307
+ "name": "adjusted_price",
308
+ "schema": {
309
+ "type": "object",
310
+ "required": ["basePrice", "taxRate"],
311
+ "properties": {
312
+ "basePrice": { "type": "number", "minimum": 0 },
313
+ "taxRate": { "type": "number", "minimum": 0, "maximum": 1 },
314
+ "discount": { "type": "number", "minimum": 0, "maximum": 1 }
315
+ }
316
+ },
317
+ "body": {
318
+ "*": [
319
+ "$.basePrice",
320
+ { "+": [1, "$.taxRate"] },
321
+ { "-": [1, "$.discount"] }
322
+ ]
323
+ }
324
+ }
325
+ }
326
+ ```
327
+
328
+ **Fields:**
329
+ - **`name`**: The macro identifier (becomes a callable helper)
330
+ - **`schema`** (optional): JSON Schema for input validation
331
+ - **`body`**: The template structure using `$.propertyName` to reference inputs
332
+
333
+ #### Calling a Macro
334
+
335
+ Macros are called like any helper, but always with an object argument:
336
+
337
+ ```json
338
+ {
339
+ "=adjusted_price": {
340
+ "basePrice": 100,
341
+ "taxRate": 0.08,
342
+ "discount": 0.10
343
+ }
344
+ }
345
+ ```
346
+
347
+ Result: `97.2` (100 × 1.08 × 0.90)
348
+
349
+ #### Using State in Macros
350
+
351
+ ```json
352
+ {
353
+ "=adjusted_price": {
354
+ "basePrice": "=/product/price",
355
+ "taxRate": "=/settings/tax",
356
+ "discount": 0.10
357
+ }
358
+ }
359
+ ```
360
+
361
+ ### 10. Object-Based Helper Arguments
362
+
363
+ Helpers can now accept either **positional arguments** (array) or **named arguments** (object):
364
+
365
+ **Positional (traditional):**
366
+ ```json
367
+ { "=sum": [1, 2, 3] }
368
+ ```
369
+
370
+ **Named (new):**
371
+ ```json
372
+ {
373
+ "=webservice": {
374
+ "url": "/api/users",
375
+ "method": "POST",
376
+ "body": "=/formData"
377
+ }
378
+ }
379
+ ```
380
+
381
+ When an object is passed, it's treated as a single argument. To pass an array as a single argument, wrap it: `[[1, 2, 3]]`.
382
+
383
+
267
384
  ## Supported Operators and Helpers
268
385
 
269
386
  ### Operators (Structural Keys)
@@ -28,16 +28,20 @@
28
28
  schema('LogList', { type: 'array', items: 'LogEntry' });
29
29
 
30
30
  // --- STATE ---
31
- const cpu = signal(45, { name: 'cpuUsage', schema: 'Percentage', transform: 'Integer', storage: localStorage });
32
- const mem = signal(62, { name: 'memUsage', schema: 'Percentage', transform: 'Number', storage: localStorage });
33
- const req = signal(850, { name: 'reqCount', schema: 'Metric', transform: 'Integer', storage: localStorage });
34
- const lat = signal(24, { name: 'latency', schema: 'Metric', transform: 'Integer', storage: localStorage });
31
+ const cpu = signal.get('cpuUsage', { defaultValue: 45, schema: 'Percentage', transform: 'Integer', storage: 'localStorage' });
32
+ const mem = signal.get('memUsage', { defaultValue: 62, schema: 'Percentage', transform: 'Number', storage: 'localStorage' });
33
+ const req = signal.get('reqCount', { defaultValue: 850, schema: 'Metric', transform: 'Integer', storage: 'localStorage' });
34
+ const lat = signal.get('latency', { defaultValue: 24, schema: 'Metric', transform: 'Integer', storage: 'localStorage' });
35
35
 
36
- const history = state(Array(30).fill(30), { name: 'perfHistory', storage: localStorage });
37
- const logs = state([
38
- { t: '08:00:00.00', m: 'Security protocol initialized', s: 'info' },
39
- { t: '08:01:00.00', m: 'Node cluster synced', s: 'info' }
40
- ], { name: 'liveLogs', schema: 'LogList', storage: localStorage });
36
+ const history = state.get('perfHistory', { defaultValue: Array(30).fill(30), storage: 'localStorage' });
37
+ const logs = state.get('liveLogs', {
38
+ defaultValue: [
39
+ { t: '08:00:00.00', m: 'Security protocol initialized', s: 'info' },
40
+ { t: '08:01:00.00', m: 'Node cluster synced', s: 'info' }
41
+ ],
42
+ schema: 'LogList',
43
+ storage: 'localStorage'
44
+ });
41
45
 
42
46
  // --- CUSTOM HELPERS ---
43
47
  // Only keeping the one specific to business logic (Class generation)
@@ -0,0 +1,341 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>cDOM Macro System Test</title>
8
+ <script src="../index.js"></script>
9
+ <style>
10
+ body {
11
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
12
+ max-width: 800px;
13
+ margin: 40px auto;
14
+ padding: 20px;
15
+ background: #f5f5f5;
16
+ }
17
+
18
+ .test-section {
19
+ background: white;
20
+ padding: 20px;
21
+ margin: 20px 0;
22
+ border-radius: 8px;
23
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
24
+ }
25
+
26
+ h2 {
27
+ color: #333;
28
+ border-bottom: 2px solid #4CAF50;
29
+ padding-bottom: 10px;
30
+ }
31
+
32
+ .result {
33
+ background: #e8f5e9;
34
+ padding: 10px;
35
+ border-left: 4px solid #4CAF50;
36
+ margin: 10px 0;
37
+ font-weight: bold;
38
+ }
39
+
40
+ code {
41
+ background: #f0f0f0;
42
+ padding: 2px 6px;
43
+ border-radius: 3px;
44
+ font-family: 'Courier New', monospace;
45
+ }
46
+
47
+ pre {
48
+ background: #1e1e1e;
49
+ color: #d4d4d4;
50
+ padding: 15px;
51
+ border-radius: 5px;
52
+ overflow-x: auto;
53
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
54
+ font-size: 14px;
55
+ line-height: 1.4;
56
+ margin: 10px 0;
57
+ }
58
+
59
+ .json-label {
60
+ font-size: 12px;
61
+ color: #888;
62
+ margin-bottom: 4px;
63
+ text-transform: uppercase;
64
+ letter-spacing: 0.05em;
65
+ }
66
+ </style>
67
+ </head>
68
+
69
+ <body>
70
+ <h1>🔧 cDOM Macro System Test</h1>
71
+
72
+ <div id="app"></div>
73
+
74
+ <script>
75
+ const { state } = cDOM;
76
+
77
+ // Create test state
78
+ const testState = state({
79
+ basePrice: 100,
80
+ taxRate: 0.08,
81
+ discount: 0.10
82
+ }, { name: 'pricing' });
83
+
84
+ cDOM({
85
+ "div": {
86
+ "children": [
87
+ {
88
+ "div": {
89
+ "class": "test-section",
90
+ "children": [
91
+ { "h2": { "children": ["Test 1: Define Macro"] } },
92
+ { "p": { "children": ["Defining a macro called 'adjusted_price' with schema validation..."] } },
93
+ { "div": { "class": "json-label", "children": ["Macro Definition:"] } },
94
+ {
95
+ "pre": {
96
+ "children": [
97
+ JSON.stringify({
98
+ "=macro": {
99
+ "name": "adjusted_price",
100
+ "schema": {
101
+ "type": "object",
102
+ "required": ["basePrice", "taxRate"],
103
+ "properties": {
104
+ "basePrice": { "type": "number", "minimum": 0 },
105
+ "taxRate": { "type": "number", "minimum": 0, "maximum": 1 },
106
+ "discount": { "type": "number", "minimum": 0, "maximum": 1 }
107
+ }
108
+ },
109
+ "body": {
110
+ "*": [
111
+ "$.basePrice",
112
+ { "+": [1, "$.taxRate"] },
113
+ { "-": [1, "$.discount"] }
114
+ ]
115
+ }
116
+ }
117
+ }, null, 4)
118
+ ]
119
+ }
120
+ },
121
+ {
122
+ "=macro": {
123
+ "name": "adjusted_price",
124
+ "schema": {
125
+ "type": "object",
126
+ "required": ["basePrice", "taxRate"],
127
+ "properties": {
128
+ "basePrice": { "type": "number", "minimum": 0 },
129
+ "taxRate": { "type": "number", "minimum": 0, "maximum": 1 },
130
+ "discount": { "type": "number", "minimum": 0, "maximum": 1 }
131
+ }
132
+ },
133
+ "body": {
134
+ "*": [
135
+ "$.basePrice",
136
+ { "+": [1, "$.taxRate"] },
137
+ { "-": [1, "$.discount"] }
138
+ ]
139
+ }
140
+ }
141
+ },
142
+ {
143
+ "div": {
144
+ "class": "result",
145
+ "children": ["✓ Macro registered successfully"]
146
+ }
147
+ }
148
+ ]
149
+ }
150
+ },
151
+ {
152
+ "div": {
153
+ "class": "test-section",
154
+ "children": [
155
+ { "h2": { "children": ["Test 2: Call Macro with Literal Values"] } },
156
+ { "p": { "children": ["Calling macro with basePrice=100, taxRate=0.08, discount=0.10"] } },
157
+ { "div": { "class": "json-label", "children": ["Macro Invocation:"] } },
158
+ {
159
+ "pre": {
160
+ "children": [
161
+ JSON.stringify({
162
+ "=adjusted_price": {
163
+ "basePrice": 100,
164
+ "taxRate": 0.08,
165
+ "discount": 0.10
166
+ }
167
+ }, null, 4)
168
+ ]
169
+ }
170
+ },
171
+ {
172
+ "div": {
173
+ "class": "result",
174
+ "children": [
175
+ "Result: $",
176
+ {
177
+ "=adjusted_price": {
178
+ "basePrice": 100,
179
+ "taxRate": 0.08,
180
+ "discount": 0.10
181
+ }
182
+ }
183
+ ]
184
+ }
185
+ },
186
+ { "p": { "children": ["Expected: $97.20 (100 × 1.08 × 0.90)"] } }
187
+ ]
188
+ }
189
+ },
190
+ {
191
+ "div": {
192
+ "class": "test-section",
193
+ "children": [
194
+ { "h2": { "children": ["Test 3: Call Macro with State References"] } },
195
+ { "p": { "children": ["Calling macro with values from state..."] } },
196
+ { "div": { "class": "json-label", "children": ["Macro Invocation (State References):"] } },
197
+ {
198
+ "pre": {
199
+ "children": [
200
+ JSON.stringify({
201
+ "=adjusted_price": {
202
+ "basePrice": "=/pricing/basePrice",
203
+ "taxRate": "=/pricing/taxRate",
204
+ "discount": "=/pricing/discount"
205
+ }
206
+ }, null, 4)
207
+ ]
208
+ }
209
+ },
210
+ {
211
+ "div": {
212
+ "class": "result",
213
+ "children": [
214
+ "Result: $",
215
+ {
216
+ "=adjusted_price": {
217
+ "basePrice": "=/pricing/basePrice",
218
+ "taxRate": "=/pricing/taxRate",
219
+ "discount": "=/pricing/discount"
220
+ }
221
+ }
222
+ ]
223
+ }
224
+ }
225
+ ]
226
+ }
227
+ },
228
+ {
229
+ "div": {
230
+ "class": "test-section",
231
+ "children": [
232
+ { "h2": { "children": ["Test 4: Macro Without Schema"] } },
233
+ { "p": { "children": ["Defining a simple macro without schema validation..."] } },
234
+ { "div": { "class": "json-label", "children": ["Macro Definition:"] } },
235
+ {
236
+ "pre": {
237
+ "children": [
238
+ JSON.stringify({
239
+ "=macro": {
240
+ "name": "simple_sum",
241
+ "body": { "+": ["$.a", "$.b"] }
242
+ }
243
+ }, null, 4)
244
+ ]
245
+ }
246
+ },
247
+ {
248
+ "=macro": {
249
+ "name": "simple_sum",
250
+ "body": { "+": ["$.a", "$.b"] }
251
+ }
252
+ },
253
+ { "p": { "class": "success", "children": ["✓ simple_sum macro registered (no schema)"] } },
254
+ { "p": { "children": ["Calling: simple_sum(a=10, b=25)"] } },
255
+ { "div": { "class": "json-label", "children": ["Macro Invocation:"] } },
256
+ {
257
+ "pre": {
258
+ "children": [
259
+ JSON.stringify({
260
+ "=simple_sum": {
261
+ "a": 10,
262
+ "b": 25
263
+ }
264
+ }, null, 4)
265
+ ]
266
+ }
267
+ },
268
+ {
269
+ "div": {
270
+ "class": "result",
271
+ "children": [
272
+ "Result: ",
273
+ {
274
+ "=simple_sum": {
275
+ "a": 10,
276
+ "b": 25
277
+ }
278
+ },
279
+ " (Expected: 35)"
280
+ ]
281
+ }
282
+ }
283
+ ]
284
+ }
285
+ },
286
+ {
287
+ "div": {
288
+ "class": "test-section",
289
+ "children": [
290
+ { "h2": { "children": ["Test 5: Macro With String Operations"] } },
291
+ { "p": { "children": ["A macro that formats a greeting..."] } },
292
+ { "div": { "class": "json-label", "children": ["Macro Definition:"] } },
293
+ {
294
+ "pre": {
295
+ "children": [
296
+ JSON.stringify({
297
+ "=macro": {
298
+ "name": "greeting",
299
+ "body": { "=concat": ["Hello, ", "$.name", "!"] }
300
+ }
301
+ }, null, 4)
302
+ ]
303
+ }
304
+ },
305
+ {
306
+ "=macro": {
307
+ "name": "greeting",
308
+ "body": { "=concat": ["Hello, ", "$.name", "!"] }
309
+ }
310
+ },
311
+ { "p": { "class": "success", "children": ["✓ greeting macro registered"] } },
312
+ { "div": { "class": "json-label", "children": ["Macro Invocation:"] } },
313
+ {
314
+ "pre": {
315
+ "children": [
316
+ JSON.stringify({
317
+ "=greeting": { "name": "World" }
318
+ }, null, 4)
319
+ ]
320
+ }
321
+ },
322
+ {
323
+ "div": {
324
+ "class": "result",
325
+ "children": [
326
+ {
327
+ "=greeting": { "name": "World" }
328
+ }
329
+ ]
330
+ }
331
+ }
332
+ ]
333
+ }
334
+ }
335
+ ]
336
+ }
337
+ }, document.getElementById('app'));
338
+ </script>
339
+ </body>
340
+
341
+ </html>
@@ -21,7 +21,8 @@
21
21
  }
22
22
  },
23
23
  {
24
- "name": "appState"
24
+ "name": "appState",
25
+ "storage": "localStorage"
25
26
  }
26
27
  ]
27
28
  },
package/index.js CHANGED
@@ -24,30 +24,16 @@
24
24
  return val;
25
25
  }
26
26
 
27
- function createPathFunction(pathStr, rootType) {
28
- const parts = pathStr.split(/[./]/).filter(p => p !== '');
29
- return (ctx, ev) => {
30
- let current;
31
- let start = 0;
32
-
33
- if (rootType === '$this') current = ctx;
34
- else if (rootType === '$event') current = ev;
35
- else {
36
- current = findInScope(ctx, parts[0]);
37
- if (current === undefined) current = getHelper(parts[0]);
38
-
39
- if (current !== undefined) start = 1;
40
- else return undefined;
41
- }
27
+ const getStorage = (s) => {
28
+ if (!s) return null;
29
+ if (typeof s === 'object') return s;
30
+ try {
31
+ if (s === 'localStorage' || s === 'window.localStorage') return globalThis.localStorage;
32
+ if (s === 'sessionStorage' || s === 'window.sessionStorage') return globalThis.sessionStorage;
33
+ } catch (e) { /* Storage might be blocked */ }
34
+ return null;
35
+ };
42
36
 
43
- for (let i = start; i < parts.length; i++) {
44
- if (current === null || current === undefined) return undefined;
45
- current = unwrap(current);
46
- current = current[parts[i]];
47
- }
48
- return current;
49
- };
50
- }
51
37
 
52
38
  function createExpression(text) {
53
39
  let at = 0;
@@ -312,6 +298,32 @@
312
298
  if (path.startsWith('$event.') || path.startsWith('$event/')) return createPathFunction(path.slice(7), '$event');
313
299
  }
314
300
 
301
+ // Handle $.propertyName (macro argument reference)
302
+ if (t === '$') {
303
+ const p = peek();
304
+ if (p === '.') {
305
+ next(); // consume '.'
306
+ let path = '';
307
+ while (true) {
308
+ const tok = peek();
309
+ if (tok && /^[\w$]/.test(tok)) {
310
+ path += next();
311
+ } else if (tok === '.' || tok === '/') {
312
+ const nextTok = peek(1);
313
+ if (nextTok && /^[\w$]/.test(nextTok)) {
314
+ path += next(); // eat . or /
315
+ path += next(); // eat identifier
316
+ } else {
317
+ break;
318
+ }
319
+ } else {
320
+ break;
321
+ }
322
+ }
323
+ return createPathFunction(path, '$macro');
324
+ }
325
+ }
326
+
315
327
  // Bareword: helper or error
316
328
  return (ctx, ev) => {
317
329
  const val = findInScope(ctx, t) || getHelper(t);
@@ -422,7 +434,7 @@
422
434
 
423
435
  function signal(val, options = {}) {
424
436
  const name = options.name;
425
- const storage = options.storage;
437
+ const storage = getStorage(options.storage);
426
438
  const schema = options.schema;
427
439
  const transform = options.transform;
428
440
 
@@ -437,16 +449,27 @@
437
449
  if (name && storage) {
438
450
  const stored = storage.getItem(name);
439
451
  if (stored !== null) {
440
- try { value = JSON.parse(stored); } catch (e) { /* ignore */ }
452
+ try {
453
+ const parsed = JSON.parse(stored);
454
+ value = applyTransform(parsed);
455
+ } catch (e) { /* ignore */ }
441
456
  }
442
457
  }
458
+ if (schema) validate(value, schema);
443
459
 
444
460
  const s = {
445
461
  get value() {
446
462
  if (name && storage) {
447
463
  const stored = storage.getItem(name);
448
464
  if (stored !== null) {
449
- try { value = JSON.parse(stored); } catch (e) { /* ignore */ }
465
+ try {
466
+ const parsed = JSON.parse(stored);
467
+ const transformed = applyTransform(parsed);
468
+ if (transformed !== value) {
469
+ value = transformed;
470
+ if (schema) validate(value, schema);
471
+ }
472
+ } catch (e) { /* ignore */ }
450
473
  }
451
474
  }
452
475
  if (currentSubscriber) registerDependency(name);
@@ -469,9 +492,15 @@
469
492
  return s;
470
493
  }
471
494
 
495
+ signal.get = function (name, options = {}) {
496
+ if (registry.has(name)) return registry.get(name);
497
+ const val = options.defaultValue !== undefined ? options.defaultValue : null;
498
+ return signal(val, { ...options, name });
499
+ };
500
+
472
501
  function state(val, options = {}) {
473
502
  const name = options.name;
474
- const storage = options.storage;
503
+ const storage = getStorage(options.storage);
475
504
  const schema = options.schema;
476
505
  const transform = options.transform;
477
506
 
@@ -488,15 +517,27 @@
488
517
  if (name && storage) {
489
518
  const stored = storage.getItem(name);
490
519
  if (stored !== null) {
491
- try { value = JSON.parse(stored); } catch (e) { /* ignore */ }
520
+ try {
521
+ const parsed = JSON.parse(stored);
522
+ value = applyTransform(parsed);
523
+ } catch (e) { /* ignore */ }
492
524
  }
493
525
  }
526
+ if (schema) validate(value, schema);
527
+
494
528
  if (name && storage) {
495
529
  result = {
496
530
  get value() {
497
531
  const stored = storage.getItem(name);
498
532
  if (stored !== null) {
499
- try { value = JSON.parse(stored); } catch (e) { /* ignore */ }
533
+ try {
534
+ const parsed = JSON.parse(stored);
535
+ const transformed = applyTransform(parsed);
536
+ if (transformed !== value) {
537
+ value = transformed;
538
+ if (schema) validate(value, schema);
539
+ }
540
+ } catch (e) { /* ignore */ }
500
541
  }
501
542
  if (currentSubscriber) registerDependency(name);
502
543
  return value;
@@ -515,7 +556,7 @@
515
556
  }
516
557
  } else {
517
558
  // Deep reactive proxy
518
- const makeReactive = (target, path = [], rootObj = val) => {
559
+ const makeReactive = (target, path = [], rootObj = target) => {
519
560
  // Initial load for root
520
561
  if (path.length === 0 && name && storage) {
521
562
  const stored = storage.getItem(name);
@@ -528,34 +569,18 @@
528
569
  } catch (e) { /* ignore */ }
529
570
  }
530
571
  }
572
+ if (path.length === 0 && schema) validate(target, schema);
531
573
 
532
574
  return new Proxy(target, {
533
575
  get(t, prop) {
534
576
  if (typeof prop !== 'string' || prop === 'constructor' || prop === 'toJSON') return t[prop];
535
577
  if (prop === '_lv_is_proxy') return true;
536
578
 
537
- let currentVal = t[prop];
538
- if (name && storage) {
539
- const stored = storage.getItem(name);
540
- if (stored !== null) {
541
- try {
542
- const root = JSON.parse(stored);
543
- let nav = root;
544
- for (const p of path) {
545
- if (nav && typeof nav === 'object') nav = nav[p];
546
- else { nav = undefined; break; }
547
- }
548
- if (nav && typeof nav === 'object' && nav[prop] !== undefined) {
549
- currentVal = nav[prop];
550
- }
551
- } catch (e) { /* ignore */ }
552
- }
553
- }
554
-
555
579
  if (currentSubscriber) {
556
580
  registerDependency(name);
557
581
  }
558
582
 
583
+ const currentVal = t[prop];
559
584
  // Recursively wrap nested objects
560
585
  if (currentVal && typeof currentVal === 'object' && !Array.isArray(currentVal)) {
561
586
  return makeReactive(currentVal, [...path, prop], rootObj);
@@ -566,7 +591,6 @@
566
591
  const transformed = applyTransform(value);
567
592
  if (schema) {
568
593
  // Validate the entire root object after this change would be applied
569
- // but since we want to be safe, we can dry-run the change
570
594
  const backup = t[prop];
571
595
  t[prop] = transformed;
572
596
  try {
@@ -595,7 +619,18 @@
595
619
  return result;
596
620
  }
597
621
 
598
- state.get = (name) => registry.get(name);
622
+ state.get = function (name, options = {}) {
623
+ if (registry.has(name)) return registry.get(name);
624
+ const val = options.defaultValue !== undefined ? options.defaultValue : null;
625
+ return state(val, { ...options, name });
626
+ };
627
+
628
+ const session = (val, options = {}) => state(val, { ...options, storage: globalThis.sessionStorage });
629
+ session.get = function (name, options = {}) {
630
+ if (registry.has(name)) return registry.get(name);
631
+ const val = options.defaultValue !== undefined ? options.defaultValue : null;
632
+ return state(val, { ...options, name, storage: globalThis.sessionStorage });
633
+ };
599
634
 
600
635
  function registerDependency(name) {
601
636
  if (!name || !currentSubscriber) return;
@@ -717,6 +752,56 @@
717
752
  helpers.set('Number', v => Number(v));
718
753
  helpers.set('String', v => String(v));
719
754
  helpers.set('Boolean', v => !!v);
755
+ helpers.set('signal', signal);
756
+ helpers.set('state', state);
757
+ helpers.set('session', session);
758
+ helpers.set('signal.get', signal.get);
759
+ helpers.set('state.get', state.get);
760
+ helpers.set('session.get', session.get);
761
+
762
+ // Macro helper - creates reusable JSON templates
763
+ helpers.set('macro', function (definition) {
764
+ const { name, schema: macroSchema, body } = definition;
765
+ if (!name || !body) {
766
+ console.error('[cDOM] Macro definition requires "name" and "body"');
767
+ return;
768
+ }
769
+
770
+ // Register the macro as a new helper
771
+ const macroFn = function (input) {
772
+ // Note: input is already resolved by evaluateStructural
773
+ // Validate input if schema is provided
774
+ if (macroSchema) {
775
+ try {
776
+ validate(input, macroSchema);
777
+ } catch (e) {
778
+ console.error(`[cDOM] Macro "${name}" validation error:`, e);
779
+ throw e;
780
+ }
781
+ }
782
+
783
+ // Evaluate the body with macro context
784
+ return evaluateMacroBody(body, this, null, input);
785
+ };
786
+
787
+ // Mark this as a macro so evaluateStructural knows to resolve the object first
788
+ macroFn.isMacro = true;
789
+
790
+ helpers.set(name, macroFn);
791
+ return `[Macro ${name} registered]`;
792
+ });
793
+
794
+ // Mark macro helper to skip object resolution - it needs the raw definition
795
+ helpers.get('macro').skipObjectResolution = true;
796
+
797
+ // Helper function to evaluate macro body with $ context
798
+ function evaluateMacroBody(body, context, event, macroContext) {
799
+ // Store the current macro context on the element
800
+ // This persists so async helper loading can still access it
801
+ context._macroContext = macroContext;
802
+ return evaluateStructural(body, context, event, false);
803
+ }
804
+
720
805
 
721
806
  function getHelper(name, unsafe) {
722
807
  if (helpers.has(name)) return helpers.get(name);
@@ -795,7 +880,13 @@
795
880
 
796
881
 
797
882
  function evaluateStructural(obj, context, event, unsafe) {
798
- if (typeof obj !== 'object' || obj === null || obj.nodeType) return obj;
883
+ if (typeof obj !== 'object' || obj === null || obj.nodeType) {
884
+ // Check if it's a string starting with '$.' (macro argument reference)
885
+ if (typeof obj === 'string' && obj.startsWith('$.')) {
886
+ return evaluateStateExpression(obj, context, event);
887
+ }
888
+ return obj;
889
+ }
799
890
  obj = unwrap(obj);
800
891
 
801
892
  if (Array.isArray(obj)) {
@@ -827,10 +918,42 @@
827
918
  const helperName = alias || key.slice(1);
828
919
  const helperFn = getHelper(helperName, unsafe);
829
920
  if (helperFn) {
921
+ // If val is an object (not array), treat as single named-argument object
922
+ // UNLESS the helper has skipObjectResolution flag (like macro)
923
+ if (!Array.isArray(val) && typeof val === 'object' && val !== null) {
924
+ // If helper wants raw object, pass it directly
925
+ if (helperFn.skipObjectResolution) {
926
+ return helperFn.call(context, val);
927
+ }
928
+
929
+ // Otherwise resolve the object properties
930
+ const resolvedObj = {};
931
+ for (const k of Object.keys(val)) {
932
+ const v = val[k];
933
+ const isPath = typeof v === 'string' && (
934
+ v.startsWith('=/') ||
935
+ v.startsWith('$.') ||
936
+ v === '$this' || v.startsWith('$this/') || v.startsWith('$this.') ||
937
+ v === '$event' || v.startsWith('$event/') || v.startsWith('$event.')
938
+ );
939
+
940
+ if (isPath) {
941
+ resolvedObj[k] = unwrap(evaluateStateExpression(v, context, event));
942
+ } else if (typeof v === 'object' && v !== null && !v.nodeType) {
943
+ resolvedObj[k] = evaluateStructural(v, context, event, unsafe);
944
+ } else {
945
+ resolvedObj[k] = v;
946
+ }
947
+ }
948
+ return helperFn.call(context, resolvedObj);
949
+ }
950
+
951
+ // Otherwise, treat as array of positional arguments
830
952
  const args = Array.isArray(val) ? val : [val];
831
953
  const resolvedArgs = args.map(arg => {
832
954
  const isPath = typeof arg === 'string' && (
833
955
  arg.startsWith('=/') ||
956
+ arg.startsWith('$.') ||
834
957
  arg === '$this' || arg.startsWith('$this/') || arg.startsWith('$this.') ||
835
958
  arg === '$event' || arg.startsWith('$event/') || arg.startsWith('$event.')
836
959
  );
@@ -842,10 +965,10 @@
842
965
  if (p) {
843
966
  let path = arg;
844
967
  if (p === 'state') path = arg.slice(2); // Remove =/ prefix
845
- return createPathFunction(path, p)(context, event);
968
+ return createPathFunction(path, p)(context, event, context._macroContext);
846
969
  }
847
970
  }
848
- return evaluateStateExpression(arg, context, event);
971
+ return unwrap(evaluateStateExpression(arg, context, event));
849
972
  }
850
973
  if (typeof arg === 'object' && arg !== null && !arg.nodeType) {
851
974
  return evaluateStructural(arg, context, event, unsafe);
@@ -929,10 +1052,22 @@
929
1052
  let root;
930
1053
  if (type === '$this') root = ctx;
931
1054
  else if (type === '$event') root = ev;
1055
+ else if (type === '$macro') root = ctx?._macroContext;
932
1056
  else root = findInScope(ctx, name);
933
1057
 
934
1058
  if (!root) return (type === 'state') ? `[Unknown: ${name}]` : undefined;
935
1059
 
1060
+ // For $macro, we access the property directly on the macro context
1061
+ if (type === '$macro') {
1062
+ if (parts.length === 0) return root;
1063
+ let target = root;
1064
+ for (const part of parts) {
1065
+ if (target === undefined || target === null) return undefined;
1066
+ target = target[part];
1067
+ }
1068
+ return target;
1069
+ }
1070
+
936
1071
  // For root-only access
937
1072
  if (path.length === 0) {
938
1073
  if (type === 'state' && currentSubscriber) registerDependency(name);
@@ -1460,6 +1595,7 @@
1460
1595
  // Export to global scope
1461
1596
  cDOM.signal = signal;
1462
1597
  cDOM.state = state;
1598
+ cDOM.session = session;
1463
1599
  cDOM.helper = helper;
1464
1600
  cDOM.schema = schema;
1465
1601
  cDOM.validate = validate;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdom",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Safe, reactive UIs based on JSON Pointer, XPath, JSON Schema",
5
5
  "keywords": [
6
6
  "JPRX",