pulse-js-framework 1.7.10 → 1.7.12
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 +3 -1
- package/compiler/lexer.js +23 -1
- package/compiler/parser.js +118 -17
- package/compiler/transformer/export.js +41 -2
- package/compiler/transformer/expressions.js +148 -5
- package/compiler/transformer/imports.js +1 -0
- package/compiler/transformer/view.js +219 -24
- package/loader/vite-plugin.js +27 -4
- package/package.json +15 -2
- package/runtime/context.js +374 -0
- package/runtime/dom-binding.js +32 -0
- package/runtime/graphql.js +1356 -0
- package/runtime/index.js +6 -0
- package/runtime/logger.js +2 -1
- package/runtime/websocket.js +874 -0
- package/types/context.d.ts +171 -0
- package/types/graphql.d.ts +490 -0
- package/types/index.d.ts +15 -0
- package/types/websocket.d.ts +347 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Context API - Dependency Injection and Prop Drilling Prevention
|
|
3
|
+
* @module pulse-js-framework/runtime/context
|
|
4
|
+
*
|
|
5
|
+
* Provides a React-like Context API for passing data through the component tree
|
|
6
|
+
* without explicit prop passing at every level.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { createContext, useContext, Provider } from './context.js';
|
|
10
|
+
* import { pulse } from './pulse.js';
|
|
11
|
+
*
|
|
12
|
+
* // Create a context with default value
|
|
13
|
+
* const ThemeContext = createContext('light');
|
|
14
|
+
*
|
|
15
|
+
* // Provide a value to the subtree
|
|
16
|
+
* const App = () => {
|
|
17
|
+
* const theme = pulse('dark');
|
|
18
|
+
* return Provider(ThemeContext, theme, () => [
|
|
19
|
+
* Header(),
|
|
20
|
+
* Content()
|
|
21
|
+
* ]);
|
|
22
|
+
* };
|
|
23
|
+
*
|
|
24
|
+
* // Consume the context anywhere in the subtree
|
|
25
|
+
* const Button = () => {
|
|
26
|
+
* const theme = useContext(ThemeContext);
|
|
27
|
+
* return el(`button.btn-${theme.get()}`);
|
|
28
|
+
* };
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { pulse, effect, computed, Pulse } from './pulse.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a value is a Pulse instance
|
|
35
|
+
* @param {any} value - Value to check
|
|
36
|
+
* @returns {boolean} True if value is a Pulse
|
|
37
|
+
*/
|
|
38
|
+
function isPulse(value) {
|
|
39
|
+
return value instanceof Pulse;
|
|
40
|
+
}
|
|
41
|
+
import { loggers } from './logger.js';
|
|
42
|
+
import { PulseError } from './errors.js';
|
|
43
|
+
|
|
44
|
+
const log = loggers.pulse;
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// CONTEXT REGISTRY
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Unique ID counter for contexts
|
|
52
|
+
* @type {number}
|
|
53
|
+
*/
|
|
54
|
+
let contextIdCounter = 0;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Context stack for provider nesting
|
|
58
|
+
* Each context has its own stack of provided values
|
|
59
|
+
* @type {Map<symbol, Array<any>>}
|
|
60
|
+
*/
|
|
61
|
+
const contextStacks = new Map();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Map of context IDs to their default values
|
|
65
|
+
* @type {Map<symbol, any>}
|
|
66
|
+
*/
|
|
67
|
+
const contextDefaults = new Map();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Map of context IDs to their display names (for debugging)
|
|
71
|
+
* @type {Map<symbol, string>}
|
|
72
|
+
*/
|
|
73
|
+
const contextNames = new Map();
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// CONTEXT CREATION
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {Object} Context
|
|
81
|
+
* @property {symbol} _id - Unique identifier for this context
|
|
82
|
+
* @property {string} displayName - Human-readable name for debugging
|
|
83
|
+
* @property {any} defaultValue - Default value when no provider is found
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a new context with a default value
|
|
88
|
+
*
|
|
89
|
+
* @template T
|
|
90
|
+
* @param {T} defaultValue - Value used when no Provider is found in the tree
|
|
91
|
+
* @param {Object} [options={}] - Context options
|
|
92
|
+
* @param {string} [options.displayName] - Name for debugging purposes
|
|
93
|
+
* @returns {Context<T>} Context object to pass to Provider and useContext
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* // Create a theme context
|
|
97
|
+
* const ThemeContext = createContext('light');
|
|
98
|
+
*
|
|
99
|
+
* // Create a user context with object default
|
|
100
|
+
* const UserContext = createContext({ name: 'Guest', role: 'anonymous' });
|
|
101
|
+
*
|
|
102
|
+
* // Create with display name for debugging
|
|
103
|
+
* const AuthContext = createContext(null, { displayName: 'AuthContext' });
|
|
104
|
+
*/
|
|
105
|
+
export function createContext(defaultValue, options = {}) {
|
|
106
|
+
const id = Symbol(`pulse.context.${++contextIdCounter}`);
|
|
107
|
+
const displayName = options.displayName || `Context${contextIdCounter}`;
|
|
108
|
+
|
|
109
|
+
// Initialize the context stack
|
|
110
|
+
contextStacks.set(id, []);
|
|
111
|
+
contextDefaults.set(id, defaultValue);
|
|
112
|
+
contextNames.set(id, displayName);
|
|
113
|
+
|
|
114
|
+
const context = {
|
|
115
|
+
_id: id,
|
|
116
|
+
displayName,
|
|
117
|
+
defaultValue,
|
|
118
|
+
|
|
119
|
+
// Provider shorthand method
|
|
120
|
+
Provider: (value, children) => Provider(context, value, children),
|
|
121
|
+
|
|
122
|
+
// Consumer shorthand method
|
|
123
|
+
Consumer: (render) => Consumer(context, render)
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
Object.freeze(context);
|
|
127
|
+
return context;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// CONTEXT PROVIDER
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Provide a value to a context for all descendants
|
|
136
|
+
*
|
|
137
|
+
* @template T
|
|
138
|
+
* @param {Context<T>} context - Context object from createContext
|
|
139
|
+
* @param {T|Pulse<T>} value - Value to provide (can be reactive pulse)
|
|
140
|
+
* @param {Function|Array} children - Child elements or render function
|
|
141
|
+
* @returns {Node|Array<Node>} The rendered children
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* // Provide a static value
|
|
145
|
+
* Provider(ThemeContext, 'dark', () => [
|
|
146
|
+
* Header(),
|
|
147
|
+
* Content()
|
|
148
|
+
* ]);
|
|
149
|
+
*
|
|
150
|
+
* // Provide a reactive value
|
|
151
|
+
* const theme = pulse('dark');
|
|
152
|
+
* Provider(ThemeContext, theme, () => [
|
|
153
|
+
* Header(),
|
|
154
|
+
* Content()
|
|
155
|
+
* ]);
|
|
156
|
+
*
|
|
157
|
+
* // Using context.Provider shorthand
|
|
158
|
+
* ThemeContext.Provider('dark', () => App());
|
|
159
|
+
*/
|
|
160
|
+
export function Provider(context, value, children) {
|
|
161
|
+
if (!context || !context._id) {
|
|
162
|
+
throw new PulseError('Provider requires a valid context created with createContext()', {
|
|
163
|
+
code: 'INVALID_CONTEXT'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stack = contextStacks.get(context._id);
|
|
168
|
+
if (!stack) {
|
|
169
|
+
throw new PulseError(`Context "${context.displayName}" has been disposed`, {
|
|
170
|
+
code: 'CONTEXT_DISPOSED'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Wrap non-pulse values in a pulse for consistent reactive API
|
|
175
|
+
const reactiveValue = isPulse(value) ? value : pulse(value);
|
|
176
|
+
|
|
177
|
+
// Push value onto context stack
|
|
178
|
+
stack.push(reactiveValue);
|
|
179
|
+
log.debug(`Provider: pushed value to ${context.displayName}, depth=${stack.length}`);
|
|
180
|
+
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
// Render children with the new context value available
|
|
184
|
+
result = typeof children === 'function' ? children() : children;
|
|
185
|
+
} finally {
|
|
186
|
+
// Pop value from stack when provider scope ends
|
|
187
|
+
stack.pop();
|
|
188
|
+
log.debug(`Provider: popped value from ${context.displayName}, depth=${stack.length}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// CONTEXT CONSUMER
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get the current value from a context
|
|
200
|
+
*
|
|
201
|
+
* Returns a reactive pulse that updates when the provided value changes.
|
|
202
|
+
* If no Provider is found, returns the context's default value.
|
|
203
|
+
*
|
|
204
|
+
* @template T
|
|
205
|
+
* @param {Context<T>} context - Context object from createContext
|
|
206
|
+
* @returns {Pulse<T>} Reactive pulse containing the context value
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* const theme = useContext(ThemeContext);
|
|
210
|
+
* console.log(theme.get()); // 'dark' (or default if no provider)
|
|
211
|
+
*
|
|
212
|
+
* // Reactive usage in effects
|
|
213
|
+
* effect(() => {
|
|
214
|
+
* document.body.className = theme.get();
|
|
215
|
+
* });
|
|
216
|
+
*/
|
|
217
|
+
export function useContext(context) {
|
|
218
|
+
if (!context || !context._id) {
|
|
219
|
+
throw new PulseError('useContext requires a valid context created with createContext()', {
|
|
220
|
+
code: 'INVALID_CONTEXT'
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const stack = contextStacks.get(context._id);
|
|
225
|
+
if (!stack) {
|
|
226
|
+
throw new PulseError(`Context "${context.displayName}" has been disposed`, {
|
|
227
|
+
code: 'CONTEXT_DISPOSED'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Get the current provider value (top of stack) or default
|
|
232
|
+
if (stack.length > 0) {
|
|
233
|
+
const value = stack[stack.length - 1];
|
|
234
|
+
log.debug(`useContext: got value from ${context.displayName}, depth=${stack.length}`);
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// No provider found, return default as reactive pulse
|
|
239
|
+
log.debug(`useContext: using default for ${context.displayName}`);
|
|
240
|
+
const defaultVal = contextDefaults.get(context._id);
|
|
241
|
+
|
|
242
|
+
// Wrap default in pulse for consistent API
|
|
243
|
+
return isPulse(defaultVal) ? defaultVal : pulse(defaultVal);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Consumer component pattern for context consumption
|
|
248
|
+
*
|
|
249
|
+
* @template T
|
|
250
|
+
* @param {Context<T>} context - Context object from createContext
|
|
251
|
+
* @param {Function} render - Render function receiving the context value
|
|
252
|
+
* @returns {any} Result of the render function
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* Consumer(ThemeContext, (theme) => {
|
|
256
|
+
* return el(`button.btn-${theme.get()}`, 'Click me');
|
|
257
|
+
* });
|
|
258
|
+
*
|
|
259
|
+
* // Using shorthand
|
|
260
|
+
* ThemeContext.Consumer((theme) => el('span', theme.get()));
|
|
261
|
+
*/
|
|
262
|
+
export function Consumer(context, render) {
|
|
263
|
+
const value = useContext(context);
|
|
264
|
+
return render(value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// =============================================================================
|
|
268
|
+
// CONTEXT UTILITIES
|
|
269
|
+
// =============================================================================
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Check if a value is a valid context object
|
|
273
|
+
*
|
|
274
|
+
* @param {any} value - Value to check
|
|
275
|
+
* @returns {boolean} True if value is a valid context
|
|
276
|
+
*/
|
|
277
|
+
export function isContext(value) {
|
|
278
|
+
return value !== null &&
|
|
279
|
+
typeof value === 'object' &&
|
|
280
|
+
typeof value._id === 'symbol' &&
|
|
281
|
+
contextStacks.has(value._id);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get the current provider depth for a context (useful for debugging)
|
|
286
|
+
*
|
|
287
|
+
* @param {Context} context - Context to check
|
|
288
|
+
* @returns {number} Current nesting depth of providers
|
|
289
|
+
*/
|
|
290
|
+
export function getContextDepth(context) {
|
|
291
|
+
if (!context || !context._id) return 0;
|
|
292
|
+
const stack = contextStacks.get(context._id);
|
|
293
|
+
return stack ? stack.length : 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Dispose a context and clean up its resources
|
|
298
|
+
* Should be called when a context is no longer needed (e.g., in tests)
|
|
299
|
+
*
|
|
300
|
+
* @param {Context} context - Context to dispose
|
|
301
|
+
*/
|
|
302
|
+
export function disposeContext(context) {
|
|
303
|
+
if (!context || !context._id) return;
|
|
304
|
+
|
|
305
|
+
contextStacks.delete(context._id);
|
|
306
|
+
contextDefaults.delete(context._id);
|
|
307
|
+
contextNames.delete(context._id);
|
|
308
|
+
|
|
309
|
+
log.debug(`Context disposed: ${context.displayName}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Create a derived context value from one or more contexts
|
|
314
|
+
*
|
|
315
|
+
* @template T
|
|
316
|
+
* @param {Function} selector - Function that receives context values and returns derived value
|
|
317
|
+
* @param {...Context} contexts - Contexts to derive from
|
|
318
|
+
* @returns {Pulse<T>} Computed pulse with derived value
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* const theme = useContextSelector(
|
|
322
|
+
* (settings, user) => settings.get().theme || user.get().preferredTheme,
|
|
323
|
+
* SettingsContext,
|
|
324
|
+
* UserContext
|
|
325
|
+
* );
|
|
326
|
+
*/
|
|
327
|
+
export function useContextSelector(selector, ...contexts) {
|
|
328
|
+
const values = contexts.map(ctx => useContext(ctx));
|
|
329
|
+
|
|
330
|
+
return computed(() => {
|
|
331
|
+
return selector(...values);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Provide multiple contexts at once
|
|
337
|
+
*
|
|
338
|
+
* @param {Array<[Context, any]>} providers - Array of [context, value] pairs
|
|
339
|
+
* @param {Function} children - Render function for children
|
|
340
|
+
* @returns {any} Rendered children
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* provideMany([
|
|
344
|
+
* [ThemeContext, 'dark'],
|
|
345
|
+
* [UserContext, currentUser],
|
|
346
|
+
* [LocaleContext, 'fr']
|
|
347
|
+
* ], () => App());
|
|
348
|
+
*/
|
|
349
|
+
export function provideMany(providers, children) {
|
|
350
|
+
if (providers.length === 0) {
|
|
351
|
+
return typeof children === 'function' ? children() : children;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const [first, ...rest] = providers;
|
|
355
|
+
const [context, value] = first;
|
|
356
|
+
|
|
357
|
+
return Provider(context, value, () => provideMany(rest, children));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// =============================================================================
|
|
361
|
+
// DEFAULT EXPORT
|
|
362
|
+
// =============================================================================
|
|
363
|
+
|
|
364
|
+
export default {
|
|
365
|
+
createContext,
|
|
366
|
+
useContext,
|
|
367
|
+
Provider,
|
|
368
|
+
Consumer,
|
|
369
|
+
isContext,
|
|
370
|
+
getContextDepth,
|
|
371
|
+
disposeContext,
|
|
372
|
+
useContextSelector,
|
|
373
|
+
provideMany
|
|
374
|
+
};
|
package/runtime/dom-binding.js
CHANGED
|
@@ -29,6 +29,19 @@ const BIND_URL_ATTRIBUTES = new Set([
|
|
|
29
29
|
// REACTIVE BINDINGS
|
|
30
30
|
// =============================================================================
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Attributes that should be set as properties (not attributes) on form elements
|
|
34
|
+
* because the attribute doesn't reflect the current value after user input
|
|
35
|
+
* @private
|
|
36
|
+
*/
|
|
37
|
+
const BIND_PROPERTY_ATTRIBUTES = new Set(['value', 'checked', 'selected']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tags where certain attributes should be set as properties
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
const FORM_ELEMENT_TAGS = new Set(['input', 'textarea', 'select', 'option']);
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Bind an attribute reactively with XSS protection
|
|
34
47
|
*
|
|
@@ -44,9 +57,22 @@ export function bind(element, attr, getValue) {
|
|
|
44
57
|
const lowerAttr = attr.toLowerCase();
|
|
45
58
|
const isUrlAttr = BIND_URL_ATTRIBUTES.has(lowerAttr);
|
|
46
59
|
|
|
60
|
+
// For form elements, certain attributes need to be set as properties
|
|
61
|
+
const tagName = dom.getTagName(element);
|
|
62
|
+
const useProperty = BIND_PROPERTY_ATTRIBUTES.has(lowerAttr) && FORM_ELEMENT_TAGS.has(tagName);
|
|
63
|
+
|
|
47
64
|
if (typeof getValue === 'function') {
|
|
48
65
|
effect(() => {
|
|
49
66
|
const value = getValue();
|
|
67
|
+
|
|
68
|
+
// For form element properties (value, checked, selected), use setProperty
|
|
69
|
+
if (useProperty) {
|
|
70
|
+
if (dom.getProperty(element, attr) !== value) {
|
|
71
|
+
dom.setProperty(element, attr, value ?? '');
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
if (value == null || value === false) {
|
|
51
77
|
dom.removeAttribute(element, attr);
|
|
52
78
|
} else if (value === true) {
|
|
@@ -69,6 +95,12 @@ export function bind(element, attr, getValue) {
|
|
|
69
95
|
}
|
|
70
96
|
});
|
|
71
97
|
} else {
|
|
98
|
+
// For form element properties, use setProperty
|
|
99
|
+
if (useProperty) {
|
|
100
|
+
dom.setProperty(element, attr, getValue ?? '');
|
|
101
|
+
return element;
|
|
102
|
+
}
|
|
103
|
+
|
|
72
104
|
// Sanitize URL attributes for static values too
|
|
73
105
|
if (isUrlAttr) {
|
|
74
106
|
const sanitized = sanitizeUrl(String(getValue));
|