lightview 2.2.2 → 2.3.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/cDOMIntro.md +279 -0
- package/docs/about.html +15 -12
- package/docs/api/computed.html +1 -1
- package/docs/api/effects.html +1 -1
- package/docs/api/elements.html +56 -25
- package/docs/api/enhance.html +1 -1
- package/docs/api/hypermedia.html +1 -1
- package/docs/api/index.html +1 -1
- package/docs/api/nav.html +28 -3
- package/docs/api/signals.html +1 -1
- package/docs/api/state.html +283 -85
- package/docs/assets/js/examplify.js +2 -1
- package/docs/cdom-nav.html +3 -2
- package/docs/cdom.html +383 -114
- package/jprx/README.md +112 -71
- package/jprx/helpers/state.js +21 -0
- package/jprx/package.json +1 -1
- package/jprx/parser.js +136 -86
- package/jprx/specs/expressions.json +71 -0
- package/jprx/specs/helpers.json +150 -0
- package/lightview-all.js +618 -431
- package/lightview-cdom.js +311 -605
- package/lightview-router.js +6 -0
- package/lightview-x.js +226 -54
- package/lightview.js +351 -42
- package/package.json +2 -1
- package/src/lightview-cdom.js +211 -315
- package/src/lightview-router.js +10 -0
- package/src/lightview-x.js +121 -1
- package/src/lightview.js +88 -16
- package/src/reactivity/signal.js +73 -29
- package/src/reactivity/state.js +84 -21
- package/tests/cdom/fixtures/helpers.cdomc +24 -24
- package/tests/cdom/helpers.test.js +28 -28
- package/tests/cdom/parser.test.js +39 -114
- package/tests/cdom/reactivity.test.js +32 -29
- package/tests/jprx/spec.test.js +99 -0
- package/tests/cdom/loader.test.js +0 -125
package/src/lightview-x.js
CHANGED
|
@@ -622,12 +622,16 @@ const elementsFromSelector = (selector, element) => {
|
|
|
622
622
|
}
|
|
623
623
|
};
|
|
624
624
|
|
|
625
|
-
const updateTargetContent = (el, elements, raw, loc, contentHash,
|
|
625
|
+
const updateTargetContent = (el, elements, raw, loc, contentHash, options, targetHash = null) => {
|
|
626
|
+
const { element, setupChildren, saveScrolls, restoreScrolls } = { ...options, ...globalThis.Lightview?.internals };
|
|
626
627
|
const markerId = `${loc}-${contentHash.slice(0, 8)}`;
|
|
627
628
|
let track = getOrSet(insertedContentMap, el.domEl, () => ({}));
|
|
628
629
|
if (track[loc]) removeInsertedContent(el.domEl, `${loc}-${track[loc].slice(0, 8)}`);
|
|
629
630
|
track[loc] = contentHash;
|
|
630
631
|
|
|
632
|
+
// Snapshot scroll positions document-wide before updating
|
|
633
|
+
const scrollMap = saveScrolls ? saveScrolls() : null;
|
|
634
|
+
|
|
631
635
|
const performScroll = (root) => {
|
|
632
636
|
if (!targetHash) return;
|
|
633
637
|
requestAnimationFrame(() => {
|
|
@@ -642,18 +646,25 @@ const updateTargetContent = (el, elements, raw, loc, contentHash, { element, set
|
|
|
642
646
|
});
|
|
643
647
|
};
|
|
644
648
|
|
|
649
|
+
const runRestore = (root) => {
|
|
650
|
+
if (restoreScrolls && scrollMap) restoreScrolls(scrollMap, root);
|
|
651
|
+
};
|
|
652
|
+
|
|
645
653
|
if (loc === 'shadow') {
|
|
646
654
|
if (!el.domEl.shadowRoot) el.domEl.attachShadow({ mode: 'open' });
|
|
647
655
|
setupChildren(elements, el.domEl.shadowRoot);
|
|
648
656
|
executeScripts(el.domEl.shadowRoot);
|
|
649
657
|
performScroll(el.domEl.shadowRoot);
|
|
658
|
+
runRestore(el.domEl.shadowRoot);
|
|
650
659
|
} else if (loc === 'innerhtml') {
|
|
651
660
|
el.children = elements;
|
|
652
661
|
executeScripts(el.domEl);
|
|
653
662
|
performScroll(document);
|
|
663
|
+
runRestore(el.domEl);
|
|
654
664
|
} else {
|
|
655
665
|
insert(elements, el.domEl, loc, markerId, { element, setupChildren });
|
|
656
666
|
performScroll(document);
|
|
667
|
+
runRestore(el.domEl);
|
|
657
668
|
}
|
|
658
669
|
};
|
|
659
670
|
|
|
@@ -1573,6 +1584,114 @@ const customElementWrapper = (Component, config = {}) => {
|
|
|
1573
1584
|
};
|
|
1574
1585
|
};
|
|
1575
1586
|
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* JSON Schema Lite Validator (Draft 7 Compatible)
|
|
1590
|
+
* Implements a lightweight validation engine for LightviewX.
|
|
1591
|
+
*/
|
|
1592
|
+
const validateJSONSchema = (value, schema) => {
|
|
1593
|
+
if (!schema) return true;
|
|
1594
|
+
const errors = [];
|
|
1595
|
+
const internals = globalThis.Lightview?.internals;
|
|
1596
|
+
|
|
1597
|
+
const check = (val, sch, path = '') => {
|
|
1598
|
+
if (!sch) return true;
|
|
1599
|
+
|
|
1600
|
+
// Resolve named schemas
|
|
1601
|
+
if (typeof sch === 'string') {
|
|
1602
|
+
const registered = internals?.schemas?.get(sch);
|
|
1603
|
+
if (registered) return check(val, registered, path);
|
|
1604
|
+
return true; // Unknown named schema passes by default or could throw
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const type = sch.type;
|
|
1608
|
+
const getType = (v) => {
|
|
1609
|
+
if (v === null) return 'null';
|
|
1610
|
+
if (Array.isArray(v)) return 'array';
|
|
1611
|
+
return typeof v;
|
|
1612
|
+
};
|
|
1613
|
+
const currentType = getType(val);
|
|
1614
|
+
|
|
1615
|
+
// 1. Type Validation
|
|
1616
|
+
if (type && type !== currentType) {
|
|
1617
|
+
if (type === 'integer' && Number.isInteger(val)) { /* OK */ }
|
|
1618
|
+
else if (!(type === 'number' && typeof val === 'number')) {
|
|
1619
|
+
errors.push({ path, message: `Expected type ${type}, got ${currentType}`, keyword: 'type' });
|
|
1620
|
+
return false;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// 2. String Validation
|
|
1625
|
+
if (currentType === 'string') {
|
|
1626
|
+
if (sch.minLength !== undefined && val.length < sch.minLength) errors.push({ path, keyword: 'minLength' });
|
|
1627
|
+
if (sch.maxLength !== undefined && val.length > sch.maxLength) errors.push({ path, keyword: 'maxLength' });
|
|
1628
|
+
if (sch.pattern !== undefined && !new RegExp(sch.pattern).test(val)) errors.push({ path, keyword: 'pattern' });
|
|
1629
|
+
if (sch.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) errors.push({ path, keyword: 'format' });
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// 3. Number Validation
|
|
1633
|
+
if (currentType === 'number') {
|
|
1634
|
+
if (sch.minimum !== undefined && val < sch.minimum) errors.push({ path, keyword: 'minimum' });
|
|
1635
|
+
if (sch.maximum !== undefined && val > sch.maximum) errors.push({ path, keyword: 'maximum' });
|
|
1636
|
+
if (sch.multipleOf !== undefined && val % sch.multipleOf !== 0) errors.push({ path, keyword: 'multipleOf' });
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// 4. Object Validation
|
|
1640
|
+
if (currentType === 'object') {
|
|
1641
|
+
if (sch.required && Array.isArray(sch.required)) {
|
|
1642
|
+
for (const key of sch.required) {
|
|
1643
|
+
if (!(key in val)) errors.push({ path: path ? `${path}.${key}` : key, keyword: 'required' });
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (sch.properties) {
|
|
1647
|
+
for (const key in sch.properties) {
|
|
1648
|
+
if (key in val) check(val[key], sch.properties[key], path ? `${path}.${key}` : key);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (sch.additionalProperties === false) {
|
|
1652
|
+
for (const key in val) {
|
|
1653
|
+
if (!sch.properties || !(key in sch.properties)) errors.push({ path: path ? `${path}.${key}` : key, keyword: 'additionalProperties' });
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// 5. Array Validation
|
|
1659
|
+
if (currentType === 'array') {
|
|
1660
|
+
if (sch.minItems !== undefined && val.length < sch.minItems) errors.push({ path, keyword: 'minItems' });
|
|
1661
|
+
if (sch.maxItems !== undefined && val.length > sch.maxItems) errors.push({ path, keyword: 'maxItems' });
|
|
1662
|
+
if (sch.uniqueItems && new Set(val).size !== val.length) errors.push({ path, keyword: 'uniqueItems' });
|
|
1663
|
+
if (sch.items) {
|
|
1664
|
+
val.forEach((item, i) => check(item, sch.items, `${path}[${i}]`));
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// 6. Const & Enum
|
|
1669
|
+
if (sch.const !== undefined && val !== sch.const) errors.push({ path, keyword: 'const' });
|
|
1670
|
+
if (sch.enum && !sch.enum.includes(val)) errors.push({ path, keyword: 'enum' });
|
|
1671
|
+
|
|
1672
|
+
return errors.length === 0;
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1675
|
+
const valid = check(value, schema);
|
|
1676
|
+
return valid || errors; // Return true or the array of errors
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
// Hook into Lightview core validation
|
|
1680
|
+
const lvInternals = globalThis.__LIGHTVIEW_INTERNALS__ || globalThis.Lightview?.internals;
|
|
1681
|
+
if (lvInternals) {
|
|
1682
|
+
const hooks = lvInternals.hooks || (globalThis.Lightview?.hooks);
|
|
1683
|
+
if (hooks) {
|
|
1684
|
+
hooks.validate = (value, schema) => {
|
|
1685
|
+
const result = validateJSONSchema(value, schema);
|
|
1686
|
+
if (result === true) return true;
|
|
1687
|
+
// If validation fails, we throw to prevent state update
|
|
1688
|
+
const msg = result.map(e => `${e.path || 'root'}: failed ${e.keyword}${e.message ? ' (' + e.message + ')' : ''}`).join(', ');
|
|
1689
|
+
throw new Error(`Lightview Validation Error: ${msg}`);
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
if (globalThis.Lightview) globalThis.Lightview.validate = validateJSONSchema;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1576
1695
|
// Export for module usage
|
|
1577
1696
|
const LightviewX = {
|
|
1578
1697
|
state,
|
|
@@ -1591,6 +1710,7 @@ const LightviewX = {
|
|
|
1591
1710
|
preloadComponentCSS,
|
|
1592
1711
|
createCustomElement,
|
|
1593
1712
|
customElementWrapper,
|
|
1713
|
+
validate: validateJSONSchema,
|
|
1594
1714
|
internals: {
|
|
1595
1715
|
handleSrcAttribute,
|
|
1596
1716
|
parseElements
|
package/src/lightview.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { signal, effect, computed, getRegistry } from './reactivity/signal.js';
|
|
1
|
+
import { signal, effect, computed, getRegistry, internals } from './reactivity/signal.js';
|
|
2
|
+
import { state, getState } from './reactivity/state.js';
|
|
2
3
|
|
|
3
4
|
const core = {
|
|
4
5
|
get currentEffect() {
|
|
@@ -13,6 +14,58 @@ const nodeStateFactory = () => ({ effects: [], onmount: null, onunmount: null })
|
|
|
13
14
|
|
|
14
15
|
const registry = getRegistry();
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Persistent scroll memory - tracks scroll positions continuously via event listeners.
|
|
19
|
+
* Much more reliable than point-in-time snapshots.
|
|
20
|
+
*/
|
|
21
|
+
const scrollMemory = new Map();
|
|
22
|
+
|
|
23
|
+
const initScrollMemory = () => {
|
|
24
|
+
if (typeof document === 'undefined') return;
|
|
25
|
+
|
|
26
|
+
// Use event delegation on document for scroll events
|
|
27
|
+
document.addEventListener('scroll', (e) => {
|
|
28
|
+
const el = e.target;
|
|
29
|
+
if (el === document || el === document.documentElement) return;
|
|
30
|
+
|
|
31
|
+
const key = el.id || (el.getAttribute && el.getAttribute('data-preserve-scroll'));
|
|
32
|
+
if (key) {
|
|
33
|
+
scrollMemory.set(key, { top: el.scrollTop, left: el.scrollLeft });
|
|
34
|
+
}
|
|
35
|
+
}, true); // Capture phase to catch all scroll events
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Initialize when DOM is ready
|
|
39
|
+
if (typeof document !== 'undefined') {
|
|
40
|
+
if (document.readyState === 'loading') {
|
|
41
|
+
document.addEventListener('DOMContentLoaded', initScrollMemory);
|
|
42
|
+
} else {
|
|
43
|
+
initScrollMemory();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the current scroll memory (a snapshot of all tracked positions).
|
|
49
|
+
*/
|
|
50
|
+
const saveScrolls = () => new Map(scrollMemory);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Restores scroll positions from a saved map.
|
|
54
|
+
*/
|
|
55
|
+
const restoreScrolls = (map, root = document) => {
|
|
56
|
+
if (!map || map.size === 0) return;
|
|
57
|
+
requestAnimationFrame(() => {
|
|
58
|
+
map.forEach((pos, key) => {
|
|
59
|
+
const node = document.getElementById(key) ||
|
|
60
|
+
document.querySelector(`[data-preserve-scroll="${key}"]`);
|
|
61
|
+
if (node) {
|
|
62
|
+
node.scrollTop = pos.top;
|
|
63
|
+
node.scrollLeft = pos.left;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
16
69
|
/**
|
|
17
70
|
* Assocates an effect with a DOM node for automatic cleanup when the node is removed.
|
|
18
71
|
*/
|
|
@@ -305,6 +358,25 @@ const makeReactiveAttributes = (attributes, domNode) => {
|
|
|
305
358
|
domNode.setAttribute(key, value);
|
|
306
359
|
}
|
|
307
360
|
reactiveAttrs[key] = value;
|
|
361
|
+
} else if (typeof value === 'object' && value !== null && Lightview.hooks.processAttribute) {
|
|
362
|
+
const processed = Lightview.hooks.processAttribute(domNode, key, value);
|
|
363
|
+
if (processed !== undefined) {
|
|
364
|
+
reactiveAttrs[key] = processed;
|
|
365
|
+
} else if (key === 'style') {
|
|
366
|
+
// Style object support (merged from below)
|
|
367
|
+
Object.entries(value).forEach(([styleKey, styleValue]) => {
|
|
368
|
+
if (typeof styleValue === 'function') {
|
|
369
|
+
const runner = effect(() => { domNode.style[styleKey] = styleValue(); });
|
|
370
|
+
trackEffect(domNode, runner);
|
|
371
|
+
} else {
|
|
372
|
+
domNode.style[styleKey] = styleValue;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
reactiveAttrs[key] = value;
|
|
376
|
+
} else {
|
|
377
|
+
setAttributeValue(domNode, key, value);
|
|
378
|
+
reactiveAttrs[key] = value;
|
|
379
|
+
}
|
|
308
380
|
} else if (typeof value === 'function') {
|
|
309
381
|
// Reactive binding
|
|
310
382
|
const runner = effect(() => {
|
|
@@ -317,19 +389,6 @@ const makeReactiveAttributes = (attributes, domNode) => {
|
|
|
317
389
|
});
|
|
318
390
|
trackEffect(domNode, runner);
|
|
319
391
|
reactiveAttrs[key] = value;
|
|
320
|
-
} else if (key === 'style' && typeof value === 'object') {
|
|
321
|
-
// Handle style object which may contain reactive values
|
|
322
|
-
Object.entries(value).forEach(([styleKey, styleValue]) => {
|
|
323
|
-
if (typeof styleValue === 'function') {
|
|
324
|
-
const runner = effect(() => {
|
|
325
|
-
domNode.style[styleKey] = styleValue();
|
|
326
|
-
});
|
|
327
|
-
trackEffect(domNode, runner);
|
|
328
|
-
} else {
|
|
329
|
-
domNode.style[styleKey] = styleValue;
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
reactiveAttrs[key] = value;
|
|
333
392
|
} else {
|
|
334
393
|
// Static attribute - handle undefined/null/boolean properly
|
|
335
394
|
setAttributeValue(domNode, key, value);
|
|
@@ -595,6 +654,9 @@ const tags = new Proxy({}, {
|
|
|
595
654
|
});
|
|
596
655
|
|
|
597
656
|
const Lightview = {
|
|
657
|
+
state,
|
|
658
|
+
getState,
|
|
659
|
+
registerSchema: (name, definition) => internals.schemas.set(name, definition),
|
|
598
660
|
signal,
|
|
599
661
|
get: signal.get,
|
|
600
662
|
computed,
|
|
@@ -608,14 +670,24 @@ const Lightview = {
|
|
|
608
670
|
hooks: {
|
|
609
671
|
onNonStandardHref: null,
|
|
610
672
|
processChild: null,
|
|
611
|
-
|
|
673
|
+
processAttribute: null,
|
|
674
|
+
validateUrl: null,
|
|
675
|
+
validate: (value, schema) => internals.hooks.validate(value, schema)
|
|
612
676
|
},
|
|
613
677
|
// Internals exposed for extensions
|
|
614
678
|
internals: {
|
|
615
679
|
core,
|
|
616
680
|
domToElement,
|
|
617
681
|
wrapDomElement,
|
|
618
|
-
setupChildren
|
|
682
|
+
setupChildren,
|
|
683
|
+
trackEffect,
|
|
684
|
+
saveScrolls,
|
|
685
|
+
restoreScrolls,
|
|
686
|
+
localRegistries: internals.localRegistries,
|
|
687
|
+
futureSignals: internals.futureSignals,
|
|
688
|
+
schemas: internals.schemas,
|
|
689
|
+
parents: internals.parents,
|
|
690
|
+
hooks: internals.hooks
|
|
619
691
|
}
|
|
620
692
|
};
|
|
621
693
|
|
package/src/reactivity/signal.js
CHANGED
|
@@ -6,33 +6,48 @@
|
|
|
6
6
|
// Global Handshake: Ensures all bundles share the same reactive engine
|
|
7
7
|
const _LV = (globalThis.__LIGHTVIEW_INTERNALS__ ||= {
|
|
8
8
|
currentEffect: null,
|
|
9
|
-
registry: new Map(),
|
|
10
|
-
|
|
9
|
+
registry: new Map(), // Global name -> Signal/Proxy
|
|
10
|
+
localRegistries: new WeakMap(), // Object/Element -> Map(name -> Signal/Proxy)
|
|
11
|
+
futureSignals: new Map(), // name -> Set of (signal) => void
|
|
12
|
+
schemas: new Map(), // name -> Schema (Draft 7+ or Shorthand)
|
|
13
|
+
parents: new WeakMap(), // Proxy -> Parent (Proxy/Element)
|
|
14
|
+
helpers: new Map(), // name -> function (used for transforms and expressions)
|
|
15
|
+
hooks: {
|
|
16
|
+
validate: (value, schema) => true // Hook for extensions (like JPRX) to provide full validation
|
|
17
|
+
}
|
|
11
18
|
});
|
|
12
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Resolves a named signal/state using up-tree search starting from scope.
|
|
22
|
+
*/
|
|
23
|
+
export const lookup = (name, scope) => {
|
|
24
|
+
let current = scope;
|
|
25
|
+
while (current && typeof current === 'object') {
|
|
26
|
+
const registry = _LV.localRegistries.get(current);
|
|
27
|
+
if (registry && registry.has(name)) return registry.get(name);
|
|
28
|
+
current = current.parentElement || _LV.parents.get(current);
|
|
29
|
+
}
|
|
30
|
+
return _LV.registry.get(name);
|
|
31
|
+
};
|
|
32
|
+
|
|
13
33
|
/**
|
|
14
34
|
* Creates a reactive signal.
|
|
15
35
|
*/
|
|
16
36
|
export const signal = (initialValue, optionsOrName) => {
|
|
17
|
-
|
|
37
|
+
const name = typeof optionsOrName === 'string' ? optionsOrName : optionsOrName?.name;
|
|
18
38
|
const storage = optionsOrName?.storage;
|
|
39
|
+
const scope = optionsOrName?.scope;
|
|
19
40
|
|
|
20
41
|
if (name && storage) {
|
|
21
42
|
try {
|
|
22
43
|
const stored = storage.getItem(name);
|
|
23
|
-
if (stored !== null)
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
} catch (e) { /* Ignore storage errors */ }
|
|
44
|
+
if (stored !== null) initialValue = JSON.parse(stored);
|
|
45
|
+
} catch (e) { /* Ignore */ }
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
let value = initialValue;
|
|
30
49
|
const subscribers = new Set();
|
|
31
|
-
|
|
32
|
-
const f = (...args) => {
|
|
33
|
-
if (args.length === 0) return f.value;
|
|
34
|
-
f.value = args[0];
|
|
35
|
-
};
|
|
50
|
+
const f = (...args) => args.length === 0 ? f.value : (f.value = args[0]);
|
|
36
51
|
|
|
37
52
|
Object.defineProperty(f, 'value', {
|
|
38
53
|
get() {
|
|
@@ -46,24 +61,24 @@ export const signal = (initialValue, optionsOrName) => {
|
|
|
46
61
|
if (value !== newValue) {
|
|
47
62
|
value = newValue;
|
|
48
63
|
if (name && storage) {
|
|
49
|
-
try {
|
|
50
|
-
storage.setItem(name, JSON.stringify(value));
|
|
51
|
-
} catch (e) { /* Ignore storage errors */ }
|
|
64
|
+
try { storage.setItem(name, JSON.stringify(value)); } catch (e) { /* Ignore */ }
|
|
52
65
|
}
|
|
53
|
-
// Copy subscribers to avoid infinite loop when effect re-subscribes during iteration
|
|
54
66
|
[...subscribers].forEach(effect => effect());
|
|
55
67
|
}
|
|
56
68
|
}
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
if (name) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
const registry = (scope && typeof scope === 'object') ? (_LV.localRegistries.get(scope) || _LV.localRegistries.set(scope, new Map()).get(scope)) : _LV.registry;
|
|
73
|
+
if (registry && registry.has(name) && registry.get(name) !== f) {
|
|
74
|
+
throw new Error(`Lightview: A signal or state with the name "${name}" is already registered.`);
|
|
75
|
+
}
|
|
76
|
+
if (registry) registry.set(name, f);
|
|
77
|
+
|
|
78
|
+
// Resolve future signal waiters
|
|
79
|
+
const futures = _LV.futureSignals.get(name);
|
|
80
|
+
if (futures) {
|
|
81
|
+
futures.forEach(resolve => resolve(f));
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -71,13 +86,37 @@ export const signal = (initialValue, optionsOrName) => {
|
|
|
71
86
|
};
|
|
72
87
|
|
|
73
88
|
/**
|
|
74
|
-
* Gets a named signal
|
|
89
|
+
* Gets a named signal, or a 'future' signal if not found.
|
|
75
90
|
*/
|
|
76
|
-
export const getSignal = (name,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
export const getSignal = (name, defaultValueOrOptions) => {
|
|
92
|
+
const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions !== null ? defaultValueOrOptions : { defaultValue: defaultValueOrOptions };
|
|
93
|
+
const { scope, defaultValue } = options;
|
|
94
|
+
|
|
95
|
+
const existing = lookup(name, scope);
|
|
96
|
+
if (existing) return existing;
|
|
97
|
+
|
|
98
|
+
if (defaultValue !== undefined) return signal(defaultValue, { name, scope });
|
|
99
|
+
|
|
100
|
+
// Return a "Future Signal" that will track the real one once registered
|
|
101
|
+
const future = signal(undefined);
|
|
102
|
+
const handler = (realSignal) => {
|
|
103
|
+
// When the real signal appears, sync the future one
|
|
104
|
+
// If it's a signal (has .value), track its value. If it's a state proxy or object, it IS the value.
|
|
105
|
+
const hasValue = realSignal && (typeof realSignal === 'object' || typeof realSignal === 'function') && 'value' in realSignal;
|
|
106
|
+
if (hasValue) {
|
|
107
|
+
future.value = realSignal.value;
|
|
108
|
+
effect(() => {
|
|
109
|
+
future.value = realSignal.value;
|
|
110
|
+
});
|
|
111
|
+
} else {
|
|
112
|
+
future.value = realSignal;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (!_LV.futureSignals.has(name)) _LV.futureSignals.set(name, new Set());
|
|
117
|
+
_LV.futureSignals.get(name).add(handler);
|
|
118
|
+
|
|
119
|
+
return future;
|
|
81
120
|
};
|
|
82
121
|
|
|
83
122
|
// Map .get to signal for backwards compatibility
|
|
@@ -131,3 +170,8 @@ export const computed = (fn) => {
|
|
|
131
170
|
* Returns the global registry.
|
|
132
171
|
*/
|
|
133
172
|
export const getRegistry = () => _LV.registry;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Returns the global internals (private use).
|
|
176
|
+
*/
|
|
177
|
+
export const internals = _LV;
|
package/src/reactivity/state.js
CHANGED
|
@@ -3,12 +3,53 @@
|
|
|
3
3
|
* Provides deeply reactive state by wrapping objects/arrays in Proxies.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { signal as signalFactory, effect, getRegistry } from './signal.js';
|
|
6
|
+
import { signal as signalFactory, effect, getRegistry, internals, lookup } from './signal.js';
|
|
7
7
|
|
|
8
|
-
// Internal
|
|
8
|
+
// Internal caches
|
|
9
9
|
const stateCache = new WeakMap();
|
|
10
10
|
const stateSignals = new WeakMap();
|
|
11
|
-
const
|
|
11
|
+
const stateSchemas = new WeakMap();
|
|
12
|
+
const { parents, schemas, hooks } = internals;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Core Validation & Coercion
|
|
16
|
+
*/
|
|
17
|
+
const validate = (target, prop, value, schema) => {
|
|
18
|
+
const current = target[prop];
|
|
19
|
+
const type = typeof current;
|
|
20
|
+
const isNew = !(prop in target);
|
|
21
|
+
|
|
22
|
+
// 1. Resolve schema behavior
|
|
23
|
+
let behavior = schema;
|
|
24
|
+
if (typeof schema === 'object' && schema !== null) behavior = schema.type;
|
|
25
|
+
|
|
26
|
+
if (behavior === 'auto' && isNew) throw new Error(`Lightview: Cannot add new property "${prop}" to fixed 'auto' state.`);
|
|
27
|
+
|
|
28
|
+
// 2. Perform Validation/Coercion
|
|
29
|
+
if (behavior === 'polymorphic' || (typeof behavior === 'object' && behavior?.coerce)) {
|
|
30
|
+
if (type === 'number') return Number(value);
|
|
31
|
+
if (type === 'boolean') return Boolean(value);
|
|
32
|
+
if (type === 'string') return String(value);
|
|
33
|
+
} else if (behavior === 'auto' || behavior === 'dynamic') {
|
|
34
|
+
if (!isNew && typeof value !== type) {
|
|
35
|
+
throw new Error(`Lightview: Type mismatch for "${prop}". Expected ${type}, got ${typeof value}.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Perform Transformations (Lightview Extension)
|
|
40
|
+
if (typeof schema === 'object' && schema !== null && schema.transform) {
|
|
41
|
+
const trans = schema.transform;
|
|
42
|
+
const transformFn = typeof trans === 'function' ? trans : (internals.helpers.get(trans) || globalThis.Lightview?.helpers?.[trans]);
|
|
43
|
+
if (transformFn) value = transformFn(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 4. Delegation to hooks (for full JSON Schema support in JPRX/Lightview-X)
|
|
47
|
+
if (hooks.validate(value, schema) === false) {
|
|
48
|
+
throw new Error(`Lightview: Validation failed for "${prop}".`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
12
53
|
|
|
13
54
|
// Build method lists dynamically from prototypes
|
|
14
55
|
const protoMethods = (proto, test) => Object.getOwnPropertyNames(proto).filter(k => typeof proto[k] === 'function' && test(k));
|
|
@@ -48,12 +89,15 @@ const proxyGet = (target, prop, receiver, signals) => {
|
|
|
48
89
|
};
|
|
49
90
|
|
|
50
91
|
const proxySet = (target, prop, value, receiver, signals) => {
|
|
92
|
+
const schema = stateSchemas.get(receiver);
|
|
93
|
+
const validatedValue = schema ? validate(target, prop, value, schema) : value;
|
|
94
|
+
|
|
51
95
|
if (!signals.has(prop)) {
|
|
52
96
|
signals.set(prop, signalFactory(Reflect.get(target, prop, receiver)));
|
|
53
97
|
}
|
|
54
|
-
const success = Reflect.set(target, prop,
|
|
98
|
+
const success = Reflect.set(target, prop, validatedValue, receiver);
|
|
55
99
|
const signal = signals.get(prop);
|
|
56
|
-
if (success && signal) signal.value =
|
|
100
|
+
if (success && signal) signal.value = validatedValue;
|
|
57
101
|
return success;
|
|
58
102
|
};
|
|
59
103
|
|
|
@@ -154,6 +198,8 @@ export const state = (obj, optionsOrName) => {
|
|
|
154
198
|
|
|
155
199
|
const name = typeof optionsOrName === 'string' ? optionsOrName : optionsOrName?.name;
|
|
156
200
|
const storage = optionsOrName?.storage;
|
|
201
|
+
const scope = optionsOrName?.scope;
|
|
202
|
+
const schema = optionsOrName?.schema;
|
|
157
203
|
|
|
158
204
|
if (name && storage) {
|
|
159
205
|
try {
|
|
@@ -162,7 +208,7 @@ export const state = (obj, optionsOrName) => {
|
|
|
162
208
|
const loaded = JSON.parse(item);
|
|
163
209
|
Array.isArray(obj) && Array.isArray(loaded) ? (obj.length = 0, obj.push(...loaded)) : Object.assign(obj, loaded);
|
|
164
210
|
}
|
|
165
|
-
} catch (e) { /*
|
|
211
|
+
} catch (e) { /* Ignore */ }
|
|
166
212
|
}
|
|
167
213
|
|
|
168
214
|
let proxy = stateCache.get(obj);
|
|
@@ -183,20 +229,25 @@ export const state = (obj, optionsOrName) => {
|
|
|
183
229
|
} else return obj;
|
|
184
230
|
}
|
|
185
231
|
|
|
232
|
+
if (schema) stateSchemas.set(proxy, schema);
|
|
233
|
+
|
|
186
234
|
if (name && storage) {
|
|
187
235
|
effect(() => {
|
|
188
|
-
try { storage.setItem(name, JSON.stringify(proxy)); } catch (e) { /*
|
|
236
|
+
try { storage.setItem(name, JSON.stringify(proxy)); } catch (e) { /* Ignore */ }
|
|
189
237
|
});
|
|
190
238
|
}
|
|
191
239
|
|
|
192
240
|
if (name) {
|
|
193
|
-
const registry = getRegistry();
|
|
194
|
-
if (registry.has(name)) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
241
|
+
const registry = (scope && typeof scope === 'object') ? (internals.localRegistries.get(scope) || internals.localRegistries.set(scope, new Map()).get(scope)) : getRegistry();
|
|
242
|
+
if (registry && registry.has(name) && registry.get(name) !== proxy) {
|
|
243
|
+
throw new Error(`Lightview: A signal or state with the name "${name}" is already registered.`);
|
|
244
|
+
}
|
|
245
|
+
if (registry) registry.set(name, proxy);
|
|
246
|
+
|
|
247
|
+
// Resolve future signal waiters
|
|
248
|
+
const futures = internals.futureSignals.get(name);
|
|
249
|
+
if (futures) {
|
|
250
|
+
futures.forEach(resolve => resolve(proxy));
|
|
200
251
|
}
|
|
201
252
|
}
|
|
202
253
|
|
|
@@ -204,14 +255,26 @@ export const state = (obj, optionsOrName) => {
|
|
|
204
255
|
};
|
|
205
256
|
|
|
206
257
|
/**
|
|
207
|
-
* Gets a named state from
|
|
258
|
+
* Gets a named state using up-tree search starting from scope.
|
|
208
259
|
*/
|
|
209
|
-
export const getState = (name,
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
260
|
+
export const getState = (name, defaultValueOrOptions) => {
|
|
261
|
+
const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions !== null ? defaultValueOrOptions : { defaultValue: defaultValueOrOptions };
|
|
262
|
+
const { scope, defaultValue } = options;
|
|
263
|
+
|
|
264
|
+
const existing = lookup(name, scope);
|
|
265
|
+
if (existing) return existing;
|
|
266
|
+
|
|
267
|
+
if (defaultValue !== undefined) return state(defaultValue, { name, scope });
|
|
268
|
+
|
|
269
|
+
// Future State Resolution (similar to getSignal)
|
|
270
|
+
const future = signalFactory(undefined);
|
|
271
|
+
const handler = (realState) => {
|
|
272
|
+
future.value = realState;
|
|
273
|
+
};
|
|
274
|
+
if (!internals.futureSignals.has(name)) internals.futureSignals.set(name, new Set());
|
|
275
|
+
internals.futureSignals.get(name).add(handler);
|
|
276
|
+
|
|
277
|
+
return future;
|
|
215
278
|
};
|
|
216
279
|
|
|
217
280
|
state.get = getState;
|