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 +128 -11
- package/examples/dashboard.html +13 -9
- package/examples/macros.html +341 -0
- package/examples/spreadsheet.cdom +2 -1
- package/index.js +189 -53
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -241,29 +241,146 @@ oncreate: {
|
|
|
241
241
|
|
|
242
242
|
### 7. Persistence
|
|
243
243
|
|
|
244
|
-
|
|
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
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
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'
|
|
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)
|
package/examples/dashboard.html
CHANGED
|
@@ -28,16 +28,20 @@
|
|
|
28
28
|
schema('LogList', { type: 'array', items: 'LogEntry' });
|
|
29
29
|
|
|
30
30
|
// --- STATE ---
|
|
31
|
-
const cpu = signal(
|
|
32
|
-
const mem = signal(
|
|
33
|
-
const req = signal(
|
|
34
|
-
const lat = signal(
|
|
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),
|
|
37
|
-
const logs = state(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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>
|
package/index.js
CHANGED
|
@@ -24,30 +24,16 @@
|
|
|
24
24
|
return val;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
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)
|
|
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;
|