@dinoreic/fez 0.4.0 → 0.5.2
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 +723 -198
- package/bin/fez +16 -6
- package/bin/fez-compile +347 -0
- package/bin/fez-debug +25 -0
- package/bin/fez-index +16 -4
- package/bin/refactor +699 -0
- package/dist/fez.js +142 -33
- package/dist/fez.js.map +4 -4
- package/fez.d.ts +533 -0
- package/package.json +25 -15
- package/src/fez/compile.js +396 -164
- package/src/fez/connect.js +250 -143
- package/src/fez/defaults.js +275 -84
- package/src/fez/instance.js +673 -514
- package/src/fez/lib/await-helper.js +64 -0
- package/src/fez/lib/global-state.js +22 -4
- package/src/fez/lib/index.js +140 -0
- package/src/fez/lib/localstorage.js +44 -0
- package/src/fez/lib/n.js +38 -23
- package/src/fez/lib/pubsub.js +208 -0
- package/src/fez/lib/svelte-template-lib.js +339 -0
- package/src/fez/lib/svelte-template.js +472 -0
- package/src/fez/lib/template.js +114 -119
- package/src/fez/morph.js +384 -0
- package/src/fez/root.js +284 -164
- package/src/fez/utility.js +319 -149
- package/src/fez/utils/dump.js +114 -84
- package/src/fez/utils/highlight_all.js +1 -1
- package/src/fez.js +65 -43
- package/src/rollup.js +1 -1
- package/src/svelte-cde-adapter.coffee +21 -12
- package/src/fez/vendor/idiomorph.js +0 -860
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async await helper for {#await} blocks in templates
|
|
3
|
+
*
|
|
4
|
+
* Manages promise state tracking and triggers re-renders when promises resolve/reject.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle promise state for {#await} blocks in templates
|
|
9
|
+
* Returns { status: 'pending'|'resolved'|'rejected', value, error }
|
|
10
|
+
*
|
|
11
|
+
* @param {FezBase} component - The component instance
|
|
12
|
+
* @param {number} awaitId - Unique ID for this await block
|
|
13
|
+
* @param {Promise|any} promiseOrValue - The promise or value to await
|
|
14
|
+
* @returns {Object} { status, value, error }
|
|
15
|
+
*/
|
|
16
|
+
export default function awaitHelper(component, awaitId, promiseOrValue) {
|
|
17
|
+
// Initialize await states map on the component
|
|
18
|
+
component._awaitStates ||= new Map()
|
|
19
|
+
|
|
20
|
+
// Check if we already have state for this await block
|
|
21
|
+
const existing = component._awaitStates.get(awaitId)
|
|
22
|
+
|
|
23
|
+
// If not a promise, return resolved immediately
|
|
24
|
+
if (!promiseOrValue || typeof promiseOrValue.then !== 'function') {
|
|
25
|
+
return { status: 'resolved', value: promiseOrValue, error: null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// If we have existing state for this exact promise, return it
|
|
29
|
+
if (existing && existing.promise === promiseOrValue) {
|
|
30
|
+
return existing
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// New promise - set pending state and start tracking
|
|
34
|
+
const state = { status: 'pending', value: null, error: null, promise: promiseOrValue }
|
|
35
|
+
component._awaitStates.set(awaitId, state)
|
|
36
|
+
|
|
37
|
+
// Handle promise resolution
|
|
38
|
+
promiseOrValue
|
|
39
|
+
.then(value => {
|
|
40
|
+
// Only update if this is still the current promise for this await block
|
|
41
|
+
const current = component._awaitStates.get(awaitId)
|
|
42
|
+
if (current && current.promise === promiseOrValue) {
|
|
43
|
+
current.status = 'resolved'
|
|
44
|
+
current.value = value
|
|
45
|
+
// Trigger re-render
|
|
46
|
+
if (component.isConnected) {
|
|
47
|
+
component.fezNextTick(component.fezRender, 'fezRender')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.catch(error => {
|
|
52
|
+
const current = component._awaitStates.get(awaitId)
|
|
53
|
+
if (current && current.promise === promiseOrValue) {
|
|
54
|
+
current.status = 'rejected'
|
|
55
|
+
current.error = error
|
|
56
|
+
// Trigger re-render
|
|
57
|
+
if (component.isConnected) {
|
|
58
|
+
component.fezNextTick(component.fezRender, 'fezRender')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return state
|
|
64
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Components access state via this.globalState proxy which automatically:
|
|
4
4
|
// - Registers component as listener when reading a value
|
|
5
5
|
// - Notifies component when that value changes
|
|
6
|
-
// - Calls onGlobalStateChange(key, value) if defined, then
|
|
6
|
+
// - Calls onGlobalStateChange(key, value) if defined, then fezRender()
|
|
7
7
|
//
|
|
8
8
|
// Example usage:
|
|
9
9
|
//
|
|
@@ -34,15 +34,19 @@ const GlobalState = {
|
|
|
34
34
|
globalSubscribers: new Set(), // Set of functions that listen to all changes
|
|
35
35
|
|
|
36
36
|
notify(key, value, oldValue) {
|
|
37
|
-
Fez.
|
|
37
|
+
Fez.consoleLog(`Global state change for ${key}: ${value} (from ${oldValue})`)
|
|
38
38
|
|
|
39
39
|
// Notify component listeners
|
|
40
40
|
const listeners = this.listeners.get(key)
|
|
41
41
|
if (listeners) {
|
|
42
42
|
listeners.forEach(comp => {
|
|
43
43
|
if (comp.isConnected) {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
try {
|
|
45
|
+
comp.onGlobalStateChange(key, value, oldValue)
|
|
46
|
+
comp.fezRender()
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`Error in component listener for key ${key}:`, error)
|
|
49
|
+
}
|
|
46
50
|
} else {
|
|
47
51
|
listeners.delete(comp)
|
|
48
52
|
}
|
|
@@ -72,8 +76,19 @@ const GlobalState = {
|
|
|
72
76
|
},
|
|
73
77
|
|
|
74
78
|
createProxy(component) {
|
|
79
|
+
// Register cleanup when component is destroyed
|
|
80
|
+
component.addOnDestroy(() => {
|
|
81
|
+
for (const [key, listeners] of this.listeners) {
|
|
82
|
+
listeners.delete(component)
|
|
83
|
+
}
|
|
84
|
+
component._globalStateKeys?.clear()
|
|
85
|
+
})
|
|
86
|
+
|
|
75
87
|
return new Proxy({}, {
|
|
76
88
|
get: (target, key) => {
|
|
89
|
+
// Skip symbol keys and prototype methods
|
|
90
|
+
if (typeof key === 'symbol') return undefined
|
|
91
|
+
|
|
77
92
|
// Skip if already listening to this key
|
|
78
93
|
component._globalStateKeys ||= new Set()
|
|
79
94
|
if (!component._globalStateKeys.has(key)) {
|
|
@@ -89,6 +104,9 @@ const GlobalState = {
|
|
|
89
104
|
},
|
|
90
105
|
|
|
91
106
|
set: (target, key, value) => {
|
|
107
|
+
// Skip symbol keys
|
|
108
|
+
if (typeof key === 'symbol') return true
|
|
109
|
+
|
|
92
110
|
const oldValue = this.data[key]
|
|
93
111
|
if (oldValue !== value) {
|
|
94
112
|
this.data[key] = value
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Component Index
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for all component data:
|
|
5
|
+
* Fez.index['ui-btn'].class - Component class
|
|
6
|
+
* Fez.index['ui-btn'].meta - Metadata from META = {...}
|
|
7
|
+
* Fez.index['ui-btn'].demo - Demo HTML string
|
|
8
|
+
* Fez.index['ui-btn'].info - Info HTML string
|
|
9
|
+
* Fez.index['ui-btn'].source - Raw .fez source
|
|
10
|
+
*
|
|
11
|
+
* Helper methods:
|
|
12
|
+
* Fez.index.get('ui-btn') - Get entry with DOM nodes for demo/info
|
|
13
|
+
* Fez.index.apply('ui-btn', el) - Render demo into element
|
|
14
|
+
* Fez.index.names() - Get all registered component names
|
|
15
|
+
* Fez.index.withDemo() - Get names of components with demos
|
|
16
|
+
* Fez.index.all() - Get all components as object
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function createDomNode(html) {
|
|
20
|
+
const node = document.createElement("div");
|
|
21
|
+
node.innerHTML = html;
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const index = {
|
|
26
|
+
// Component entries stored directly: index['ui-btn'] = { class, meta, ... }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get or create entry for component
|
|
30
|
+
* @param {string} name - Component name
|
|
31
|
+
* @returns {{ class: Function|null, meta: Object|null, demo: string|null, info: string|null, source: string|null }}
|
|
32
|
+
*/
|
|
33
|
+
ensure(name) {
|
|
34
|
+
if (
|
|
35
|
+
!this[name] ||
|
|
36
|
+
typeof this[name] !== "object" ||
|
|
37
|
+
!("class" in this[name])
|
|
38
|
+
) {
|
|
39
|
+
this[name] = {
|
|
40
|
+
class: null,
|
|
41
|
+
meta: null,
|
|
42
|
+
demo: null,
|
|
43
|
+
info: null,
|
|
44
|
+
source: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return this[name];
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get component data with DOM nodes for demo/info
|
|
52
|
+
* @param {string} name - Component name
|
|
53
|
+
* @returns {{ class: Function|null, meta: Object|null, demo: HTMLDivElement|null, info: HTMLDivElement|null, source: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
get(name) {
|
|
56
|
+
const entry = this[name];
|
|
57
|
+
if (!entry || typeof entry !== "object" || !("class" in entry)) {
|
|
58
|
+
return { class: null, meta: null, demo: null, info: null, source: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
class: entry.class,
|
|
63
|
+
meta: entry.meta,
|
|
64
|
+
source: entry.source,
|
|
65
|
+
demo: entry.demo ? createDomNode(entry.demo) : null,
|
|
66
|
+
info: entry.info ? createDomNode(entry.info) : null,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply demo to element and execute scripts
|
|
72
|
+
* Scripts are executed first to define data/variables, then DOM is injected
|
|
73
|
+
* @param {string} name - Component name
|
|
74
|
+
* @param {HTMLElement} target - Target element to render into
|
|
75
|
+
* @returns {boolean} - True if demo was found and applied
|
|
76
|
+
*/
|
|
77
|
+
apply(name, target) {
|
|
78
|
+
const entry = this[name];
|
|
79
|
+
if (!entry?.demo || !target) return false;
|
|
80
|
+
|
|
81
|
+
const tempDiv = document.createElement("div");
|
|
82
|
+
tempDiv.innerHTML = entry.demo;
|
|
83
|
+
|
|
84
|
+
// Execute top-level scripts first (before DOM parsing triggers components)
|
|
85
|
+
tempDiv.querySelectorAll(":scope > script").forEach((script) => {
|
|
86
|
+
const content = script.textContent;
|
|
87
|
+
if (content.trim()) {
|
|
88
|
+
try {
|
|
89
|
+
new Function(content)();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error(`Fez.index.apply("${name}") script error:`, e.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
script.remove();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
target.innerHTML = tempDiv.innerHTML;
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get all registered component names
|
|
103
|
+
* @returns {string[]}
|
|
104
|
+
*/
|
|
105
|
+
names() {
|
|
106
|
+
return Object.keys(this).filter(
|
|
107
|
+
(k) =>
|
|
108
|
+
typeof this[k] === "object" && this[k] !== null && "class" in this[k],
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get names of components that have demos
|
|
114
|
+
* @returns {string[]}
|
|
115
|
+
*/
|
|
116
|
+
withDemo() {
|
|
117
|
+
return this.names().filter((name) => this[name].demo);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get all components as object with DOM nodes
|
|
122
|
+
* @returns {Object} Object with component names as keys
|
|
123
|
+
*/
|
|
124
|
+
all() {
|
|
125
|
+
const result = {};
|
|
126
|
+
for (const name of this.names()) {
|
|
127
|
+
result[name] = this.get(name);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Print registered components to console
|
|
134
|
+
*/
|
|
135
|
+
info() {
|
|
136
|
+
console.log("Fez components:", this.names());
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export default index;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage wrapper with automatic JSON serialization
|
|
3
|
+
* Preserves types: integers, floats, strings, objects, arrays, booleans, null
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* localStorage.set('count', 42)
|
|
7
|
+
* localStorage.get('count') // 42 (number, not string)
|
|
8
|
+
*
|
|
9
|
+
* localStorage.set('user', { name: 'John', age: 30 })
|
|
10
|
+
* localStorage.get('user') // { name: 'John', age: 30 }
|
|
11
|
+
*
|
|
12
|
+
* localStorage.get('missing', 'default') // 'default'
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const storage = () => globalThis.localStorage || window.localStorage
|
|
16
|
+
|
|
17
|
+
function set(key, value) {
|
|
18
|
+
try {
|
|
19
|
+
storage().setItem(key, JSON.stringify(value))
|
|
20
|
+
} catch (e) {
|
|
21
|
+
console.error(`Fez localStorage: Failed to set "${key}"`, e)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function get(key, defaultValue = null) {
|
|
26
|
+
try {
|
|
27
|
+
const item = storage().getItem(key)
|
|
28
|
+
if (item === null) return defaultValue
|
|
29
|
+
return JSON.parse(item)
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error(`Fez localStorage: Failed to get "${key}"`, e)
|
|
32
|
+
return defaultValue
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function remove(key) {
|
|
37
|
+
storage().removeItem(key)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clear() {
|
|
41
|
+
storage().clear()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default { set, get, remove, clear }
|
package/src/fez/lib/n.js
CHANGED
|
@@ -7,59 +7,74 @@
|
|
|
7
7
|
// Licence MIT
|
|
8
8
|
|
|
9
9
|
export default function n(name, attrs = {}, data) {
|
|
10
|
-
if (typeof attrs ===
|
|
11
|
-
[attrs, data] = [data, attrs]
|
|
12
|
-
attrs ||= {}
|
|
10
|
+
if (typeof attrs === "string") {
|
|
11
|
+
[attrs, data] = [data, attrs];
|
|
12
|
+
attrs ||= {};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
if (attrs instanceof Node) {
|
|
16
|
-
data = attrs
|
|
17
|
-
attrs = {}
|
|
16
|
+
data = attrs;
|
|
17
|
+
attrs = {};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (Array.isArray(name)) {
|
|
21
|
-
data = name
|
|
22
|
-
name =
|
|
21
|
+
data = name;
|
|
22
|
+
name = "div";
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
if (typeof attrs !==
|
|
26
|
-
data = attrs
|
|
27
|
-
attrs = {}
|
|
25
|
+
if (typeof attrs !== "object" || Array.isArray(attrs)) {
|
|
26
|
+
data = attrs;
|
|
27
|
+
attrs = {};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
if (name.includes(
|
|
31
|
-
const parts = name.split(
|
|
32
|
-
name = parts.shift() ||
|
|
33
|
-
const c = parts.join(
|
|
30
|
+
if (name.includes(".")) {
|
|
31
|
+
const parts = name.split(".");
|
|
32
|
+
name = parts.shift() || "div";
|
|
33
|
+
const c = parts.join(" ");
|
|
34
34
|
if (attrs.class) {
|
|
35
35
|
attrs.class += ` ${c}`;
|
|
36
36
|
} else {
|
|
37
|
-
attrs.class = c
|
|
37
|
+
attrs.class = c;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const node = document.createElement(name);
|
|
42
42
|
|
|
43
|
+
const booleanAttrs = [
|
|
44
|
+
"checked",
|
|
45
|
+
"disabled",
|
|
46
|
+
"selected",
|
|
47
|
+
"readonly",
|
|
48
|
+
"required",
|
|
49
|
+
"hidden",
|
|
50
|
+
"multiple",
|
|
51
|
+
"autofocus",
|
|
52
|
+
];
|
|
53
|
+
|
|
43
54
|
for (const [k, v] of Object.entries(attrs)) {
|
|
44
|
-
if (typeof v ===
|
|
45
|
-
node[k] = v.bind(this)
|
|
55
|
+
if (typeof v === "function") {
|
|
56
|
+
node[k] = v.bind(this);
|
|
57
|
+
} else if (booleanAttrs.includes(k)) {
|
|
58
|
+
if (v) {
|
|
59
|
+
node.setAttribute(k, k);
|
|
60
|
+
}
|
|
46
61
|
} else {
|
|
47
|
-
const value = String(v).replaceAll(
|
|
48
|
-
node.setAttribute(k, value)
|
|
62
|
+
const value = String(v).replaceAll("fez.", this.fezHtmlRoot);
|
|
63
|
+
node.setAttribute(k, value);
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
if (data) {
|
|
53
68
|
if (Array.isArray(data)) {
|
|
54
69
|
for (const n of data) {
|
|
55
|
-
node.appendChild(n)
|
|
70
|
+
node.appendChild(n);
|
|
56
71
|
}
|
|
57
72
|
} else if (data instanceof Node) {
|
|
58
|
-
node.appendChild(data)
|
|
73
|
+
node.appendChild(data);
|
|
59
74
|
} else {
|
|
60
|
-
node.innerHTML = String(data)
|
|
75
|
+
node.innerHTML = String(data);
|
|
61
76
|
}
|
|
62
77
|
}
|
|
63
78
|
|
|
64
|
-
return node
|
|
79
|
+
return node;
|
|
65
80
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fez Pub/Sub System
|
|
3
|
+
*
|
|
4
|
+
* Global API:
|
|
5
|
+
* Fez.subscribe('event', callback) // Always fires
|
|
6
|
+
* Fez.subscribe('#selector', 'event', callback) // Fires if selector found at publish time
|
|
7
|
+
* Fez.subscribe(node, 'event', callback) // Fires if node.isConnected
|
|
8
|
+
* Fez.publish('event', ...args) // Broadcast to all
|
|
9
|
+
*
|
|
10
|
+
* Instance API (see instance.js):
|
|
11
|
+
* this.subscribe('event', callback) // Auto-cleanup on destroy
|
|
12
|
+
* this.publish('event', ...args) // Bubble to parent components
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// STORAGE
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
// Global subscriptions: channel -> Set of { selector, node, callback }
|
|
20
|
+
const globalSubs = new Map()
|
|
21
|
+
|
|
22
|
+
// Component subscriptions: channel -> [[component, callback], ...]
|
|
23
|
+
// Used for parent-child bubbling (this.publish)
|
|
24
|
+
const componentSubs = {}
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// GLOBAL PUB/SUB
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Subscribe to a channel (global)
|
|
32
|
+
*
|
|
33
|
+
* @param {string|Node} nodeOrSelector - Selector, node, or channel name
|
|
34
|
+
* @param {string|Function} channelOrCallback - Channel name or callback
|
|
35
|
+
* @param {Function} [callback] - Callback function
|
|
36
|
+
* @returns {Function} Unsubscribe function
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* subscribe('user-login', (user) => console.log(user))
|
|
40
|
+
* subscribe('#header', 'theme-change', (theme) => ...)
|
|
41
|
+
* subscribe(document.body, 'resize', () => ...)
|
|
42
|
+
*/
|
|
43
|
+
function subscribe(nodeOrSelector, channelOrCallback, callback) {
|
|
44
|
+
let selector = null
|
|
45
|
+
let node = null
|
|
46
|
+
let channel
|
|
47
|
+
|
|
48
|
+
// Normalize arguments
|
|
49
|
+
if (typeof channelOrCallback === 'function') {
|
|
50
|
+
// subscribe('event', callback)
|
|
51
|
+
channel = nodeOrSelector
|
|
52
|
+
callback = channelOrCallback
|
|
53
|
+
} else {
|
|
54
|
+
// subscribe(node/selector, 'event', callback)
|
|
55
|
+
channel = channelOrCallback
|
|
56
|
+
if (typeof nodeOrSelector === 'string') {
|
|
57
|
+
selector = nodeOrSelector // Store selector, resolve at publish time
|
|
58
|
+
} else {
|
|
59
|
+
node = nodeOrSelector // Store node reference
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!globalSubs.has(channel)) {
|
|
64
|
+
globalSubs.set(channel, new Set())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const channelSubs = globalSubs.get(channel)
|
|
68
|
+
|
|
69
|
+
// Remove duplicate (same selector/node + callback)
|
|
70
|
+
for (const sub of channelSubs) {
|
|
71
|
+
if (sub.callback === callback && sub.selector === selector && sub.node === node) {
|
|
72
|
+
channelSubs.delete(sub)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const subscription = { selector, node, callback }
|
|
77
|
+
channelSubs.add(subscription)
|
|
78
|
+
|
|
79
|
+
// Return unsubscribe function
|
|
80
|
+
return () => channelSubs.delete(subscription)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Publish to a channel (global broadcast)
|
|
85
|
+
*
|
|
86
|
+
* @param {string} channel - Event name
|
|
87
|
+
* @param {...any} args - Arguments to pass to callbacks
|
|
88
|
+
*/
|
|
89
|
+
function publish(channel, ...args) {
|
|
90
|
+
const channelSubs = globalSubs.get(channel)
|
|
91
|
+
if (channelSubs) {
|
|
92
|
+
for (const sub of channelSubs) {
|
|
93
|
+
let target = null
|
|
94
|
+
|
|
95
|
+
if (sub.selector) {
|
|
96
|
+
// Resolve selector at publish time
|
|
97
|
+
target = document.querySelector(sub.selector)
|
|
98
|
+
if (!target) continue // Skip if not found
|
|
99
|
+
} else if (sub.node) {
|
|
100
|
+
// Check node connection
|
|
101
|
+
if (!sub.node.isConnected) {
|
|
102
|
+
channelSubs.delete(sub) // Auto-cleanup disconnected
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
target = sub.node
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Call with target as context (or null for global)
|
|
109
|
+
try {
|
|
110
|
+
sub.callback.call(target, ...args)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`Fez pubsub error on "${channel}":`, e)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Also trigger component subscriptions (legacy compatibility)
|
|
118
|
+
if (componentSubs[channel]) {
|
|
119
|
+
componentSubs[channel].forEach(([comp, cb]) => {
|
|
120
|
+
if (comp.isConnected) {
|
|
121
|
+
cb.bind(comp)(...args)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// COMPONENT PUB/SUB (for this.subscribe / this.publish)
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Subscribe from a component (used by this.subscribe)
|
|
133
|
+
* Stores subscription for parent-child bubbling
|
|
134
|
+
*
|
|
135
|
+
* @param {FezBase} component - Component instance
|
|
136
|
+
* @param {string} channel - Event name
|
|
137
|
+
* @param {Function} callback - Handler function
|
|
138
|
+
* @returns {Function} Unsubscribe function
|
|
139
|
+
*/
|
|
140
|
+
function componentSubscribe(component, channel, callback) {
|
|
141
|
+
componentSubs[channel] ||= []
|
|
142
|
+
|
|
143
|
+
// Clean up disconnected components
|
|
144
|
+
componentSubs[channel] = componentSubs[channel].filter(([comp]) => comp.isConnected)
|
|
145
|
+
|
|
146
|
+
// Add subscription
|
|
147
|
+
componentSubs[channel].push([component, callback])
|
|
148
|
+
|
|
149
|
+
// Return unsubscribe function
|
|
150
|
+
return () => {
|
|
151
|
+
componentSubs[channel] = componentSubs[channel].filter(
|
|
152
|
+
([comp, cb]) => !(comp === component && cb === callback)
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Publish from a component (used by this.publish)
|
|
159
|
+
* Bubbles up through parent components
|
|
160
|
+
*
|
|
161
|
+
* @param {FezBase} component - Component instance
|
|
162
|
+
* @param {string} channel - Event name
|
|
163
|
+
* @param {...any} args - Arguments
|
|
164
|
+
* @returns {boolean} True if a parent handled the event
|
|
165
|
+
*/
|
|
166
|
+
function componentPublish(component, channel, ...args) {
|
|
167
|
+
const handlePublish = (comp) => {
|
|
168
|
+
if (componentSubs[channel]) {
|
|
169
|
+
const sub = componentSubs[channel].find(([c]) => c === comp)
|
|
170
|
+
if (sub) {
|
|
171
|
+
sub[1].bind(comp)(...args)
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check current component first
|
|
179
|
+
if (handlePublish(component)) {
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Bubble up to parent components
|
|
184
|
+
let parent = component.root?.parentElement
|
|
185
|
+
while (parent) {
|
|
186
|
+
if (parent.fez) {
|
|
187
|
+
if (handlePublish(parent.fez)) {
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
parent = parent.parentElement
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// =============================================================================
|
|
198
|
+
// EXPORTS
|
|
199
|
+
// =============================================================================
|
|
200
|
+
|
|
201
|
+
export {
|
|
202
|
+
subscribe,
|
|
203
|
+
publish,
|
|
204
|
+
componentSubscribe,
|
|
205
|
+
componentPublish,
|
|
206
|
+
globalSubs,
|
|
207
|
+
componentSubs
|
|
208
|
+
}
|