web-manager 4.3.1 → 4.3.3

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.
@@ -0,0 +1,314 @@
1
+ class Bindings {
2
+ constructor(manager) {
3
+ this.manager = manager;
4
+ this._context = {};
5
+ }
6
+
7
+ // Update bindings with new data
8
+ update(data = {}) {
9
+ // Merge new data with existing context
10
+ // Whatever keys are provided will overwrite existing values
11
+ this._context = {
12
+ ...this._context,
13
+ ...data
14
+ };
15
+
16
+ // Get the top-level keys that were updating
17
+ const updatedKeys = Object.keys(data);
18
+
19
+ this._updateBindings(this._context, updatedKeys);
20
+ }
21
+
22
+ // Get current context
23
+ getContext() {
24
+ return this._context;
25
+ }
26
+
27
+ // Clear all context
28
+ clear() {
29
+ this._context = {};
30
+ this._updateBindings(this._context, null); // null = update all bindings
31
+ }
32
+
33
+ // Main binding update system
34
+ _updateBindings(context, updatedKeys = null) {
35
+ // Find all elements with data-wm-bind attribute
36
+ const bindElements = document.querySelectorAll('[data-wm-bind]');
37
+
38
+ /* @dev-only:start */
39
+ {
40
+ console.log('[Bindings] Updating bindings', context, updatedKeys);
41
+ }
42
+ /* @dev-only:end */
43
+
44
+ bindElements.forEach(element => {
45
+ const bindValue = element.getAttribute('data-wm-bind');
46
+
47
+ // Split by comma to support multiple actions
48
+ const bindings = this._parseBindings(bindValue);
49
+
50
+ // Execute each action, track if any were actually processed
51
+ let anyProcessed = false;
52
+ bindings.forEach(({ action, expression }) => {
53
+ if (this._executeAction(element, action, expression, context, updatedKeys)) {
54
+ anyProcessed = true;
55
+ }
56
+ });
57
+
58
+ // Only remove skeleton if at least one binding was actually processed
59
+ if (!anyProcessed) return;
60
+
61
+ // Add bound class to trigger fade out
62
+ element.classList.add('wm-bound');
63
+
64
+ // Remove skeleton class after fade completes
65
+ setTimeout(() => {
66
+ element.classList.remove('wm-binding-skeleton');
67
+ }, 300);
68
+ });
69
+ }
70
+
71
+ // Parse binding string into separate actions
72
+ _parseBindings(bindValue) {
73
+ const bindings = [];
74
+
75
+ // Split by comma, but be smart about it
76
+ // We need to handle cases where commas might be inside expressions
77
+ const parts = bindValue.split(',').map(p => p.trim());
78
+
79
+ parts.forEach(part => {
80
+ let action = '@text'; // Default action
81
+ let expression = part;
82
+
83
+ // Check if it starts with an action keyword
84
+ if (part.startsWith('@')) {
85
+ const spaceIndex = part.indexOf(' ');
86
+ if (spaceIndex > -1) {
87
+ action = part.slice(0, spaceIndex);
88
+ expression = part.slice(spaceIndex + 1).trim();
89
+ } else {
90
+ // No space means it's just an action with no expression (like @hide)
91
+ action = part;
92
+ expression = '';
93
+ }
94
+ }
95
+
96
+ bindings.push({ action, expression });
97
+ });
98
+
99
+ return bindings;
100
+ }
101
+
102
+ // Execute a single action on an element
103
+ // Returns true if the action was processed, false if skipped
104
+ _executeAction(element, action, expression, context, updatedKeys = null) {
105
+ switch (action) {
106
+ case '@show':
107
+ // Show element if condition is true (or always if no condition)
108
+
109
+ // Check if this path should be updated
110
+ if (!this._shouldUpdatePath(expression, updatedKeys)) {
111
+ return false;
112
+ }
113
+
114
+ const shouldShow = expression ? this._evaluateCondition(expression, context) : true;
115
+ if (shouldShow) {
116
+ element.removeAttribute('hidden');
117
+ } else {
118
+ element.setAttribute('hidden', '');
119
+ }
120
+ return true;
121
+
122
+ case '@hide':
123
+ // Hide element if condition is true (or always if no condition)
124
+
125
+ // Check if this path should be updated
126
+ if (!this._shouldUpdatePath(expression, updatedKeys)) {
127
+ return false;
128
+ }
129
+
130
+ const shouldHide = expression ? this._evaluateCondition(expression, context) : true;
131
+ if (shouldHide) {
132
+ element.setAttribute('hidden', '');
133
+ } else {
134
+ element.removeAttribute('hidden');
135
+ }
136
+ return true;
137
+
138
+ case '@attr':
139
+ // Set attribute value
140
+ // Format: @attr attributeName expression
141
+ const attrParts = expression.split(' ');
142
+ const attrName = attrParts[0];
143
+ const attrExpression = attrParts.slice(1).join(' ');
144
+
145
+ // Check if this path should be updated
146
+ if (!this._shouldUpdatePath(attrExpression, updatedKeys)) {
147
+ return false;
148
+ }
149
+
150
+ const attrValue = this._resolvePath(context, attrExpression) || '';
151
+
152
+ if (attrValue) {
153
+ // Block javascript: protocol on URL attributes to prevent XSS
154
+ const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
155
+ if (URL_ATTRS.includes(attrName.toLowerCase())
156
+ && /^\s*javascript\s*:/i.test(String(attrValue))) {
157
+ console.warn(`[Bindings] Blocked javascript: URL in @attr ${attrName}`);
158
+ return true;
159
+ }
160
+
161
+ element.setAttribute(attrName, attrValue);
162
+ } else {
163
+ element.removeAttribute(attrName);
164
+ }
165
+ return true;
166
+
167
+ case '@style':
168
+ // Set CSS custom property or style
169
+ // Format: @style propertyName expression
170
+ const styleParts = expression.split(' ');
171
+ const styleName = styleParts[0];
172
+ const styleExpression = styleParts.slice(1).join(' ');
173
+
174
+ // Check if this path should be updated
175
+ if (!this._shouldUpdatePath(styleExpression, updatedKeys)) {
176
+ return false;
177
+ }
178
+
179
+ const styleValue = this._resolvePath(context, styleExpression);
180
+
181
+ if (styleValue !== null && styleValue !== undefined && styleValue !== '') {
182
+ // If it starts with --, it's a CSS custom property
183
+ if (styleName.startsWith('--')) {
184
+ element.style.setProperty(styleName, styleValue);
185
+ } else {
186
+ // Regular style property
187
+ element.style[styleName] = styleValue;
188
+ }
189
+ } else {
190
+ // Remove the style if value is empty
191
+ if (styleName.startsWith('--')) {
192
+ element.style.removeProperty(styleName);
193
+ } else {
194
+ element.style[styleName] = '';
195
+ }
196
+ }
197
+ return true;
198
+
199
+ case '@value':
200
+ // Set input/textarea value explicitly
201
+ // Check if this path should be updated
202
+ if (!this._shouldUpdatePath(expression, updatedKeys)) {
203
+ return false;
204
+ }
205
+
206
+ const inputValue = this._resolvePath(context, expression) ?? '';
207
+ element.value = inputValue;
208
+ return true;
209
+
210
+ case '@text':
211
+ default:
212
+ // Set text content (default behavior)
213
+
214
+ // Check if this path should be updated
215
+ if (!this._shouldUpdatePath(expression, updatedKeys)) {
216
+ return false;
217
+ }
218
+
219
+ const textValue = this._resolvePath(context, expression) ?? '';
220
+ element.textContent = textValue;
221
+ return true;
222
+ }
223
+ }
224
+
225
+ // Check if a path should be updated based on updatedKeys
226
+ _shouldUpdatePath(path, updatedKeys) {
227
+ // If no updatedKeys filter, always update
228
+ if (updatedKeys === null || !path) {
229
+ return true;
230
+ }
231
+
232
+ // Strip negation operator if present before extracting root key
233
+ const cleanPath = path.trim().replace(/^!/, '');
234
+
235
+ // Extract the root key from the path
236
+ const rootKey = cleanPath.split('.')[0];
237
+
238
+ // Only update if the root key is in updatedKeys
239
+ return updatedKeys.includes(rootKey);
240
+ }
241
+
242
+ // Resolve nested object path
243
+ _resolvePath(obj, path) {
244
+ if (!obj || !path) return null;
245
+
246
+ return path.split('.').reduce((current, key) => {
247
+ return current?.[key];
248
+ }, obj);
249
+ }
250
+
251
+ // Safely evaluate simple conditions
252
+ _evaluateCondition(condition, context) {
253
+ try {
254
+ // Replace context references with actual values
255
+ // Support: auth.user.field, auth.account.field, simple comparisons
256
+
257
+ // Check for negation operator at the start
258
+ if (condition.trim().startsWith('!')) {
259
+ const expression = condition.trim().slice(1).trim();
260
+ const value = this._resolvePath(context, expression);
261
+ return !value;
262
+ }
263
+
264
+ // Parse the condition to extract left side, operator, and right side
265
+ const comparisonMatch = condition.match(/^(.+?)\s*(===|!==|==|!=|>|<|>=|<=)\s*(.+)$/);
266
+
267
+ if (comparisonMatch) {
268
+ const [, leftPath, operator, rightValue] = comparisonMatch;
269
+
270
+ // Get the left side value
271
+ const leftValue = this._resolvePath(context, leftPath.trim());
272
+
273
+ // Parse the right side (could be string, number, boolean)
274
+ let right = rightValue.trim();
275
+
276
+ // Remove quotes if it's a string
277
+ if ((right.startsWith("'") && right.endsWith("'")) ||
278
+ (right.startsWith('"') && right.endsWith('"'))) {
279
+ right = right.slice(1, -1);
280
+ } else if (right === 'true') {
281
+ right = true;
282
+ } else if (right === 'false') {
283
+ right = false;
284
+ } else if (right === 'null') {
285
+ right = null;
286
+ } else if (!isNaN(right)) {
287
+ right = Number(right);
288
+ }
289
+
290
+ // Evaluate based on operator
291
+ switch (operator) {
292
+ case '===': return leftValue === right;
293
+ case '!==': return leftValue !== right;
294
+ case '==': return leftValue == right;
295
+ case '!=': return leftValue != right;
296
+ case '>': return leftValue > right;
297
+ case '<': return leftValue < right;
298
+ case '>=': return leftValue >= right;
299
+ case '<=': return leftValue <= right;
300
+ default: return false;
301
+ }
302
+ } else {
303
+ // Simple truthy check (e.g., "auth.user.emailVerified" or "auth.account")
304
+ const value = this._resolvePath(context, condition.trim());
305
+ return !!value;
306
+ }
307
+ } catch (error) {
308
+ console.warn('Failed to evaluate condition:', condition, error);
309
+ return false;
310
+ }
311
+ }
312
+ }
313
+
314
+ export default Bindings;
@@ -0,0 +1,96 @@
1
+ // Load external script dynamically
2
+ export function loadScript(options) {
3
+ return new Promise((resolve, reject) => {
4
+ // Handle simple string parameter
5
+ if (typeof options === 'string') {
6
+ options = { src: options };
7
+ }
8
+
9
+ const {
10
+ src,
11
+ async = true,
12
+ defer = false,
13
+ crossorigin = false,
14
+ integrity = null,
15
+ attributes = {},
16
+ timeout = 60000,
17
+ retries = 0,
18
+ parent = null
19
+ } = options;
20
+
21
+ if (!src) {
22
+ return reject(new Error('Script source is required'));
23
+ }
24
+
25
+ let timeoutId;
26
+ let retryCount = 0;
27
+
28
+ function createAndLoadScript() {
29
+ const script = document.createElement('script');
30
+ script.src = src;
31
+ script.async = async;
32
+ script.defer = defer;
33
+
34
+ if (crossorigin) {
35
+ script.crossOrigin = typeof crossorigin === 'string' ? crossorigin : 'anonymous';
36
+ }
37
+
38
+ if (integrity) {
39
+ script.integrity = integrity;
40
+ }
41
+
42
+ // Add custom attributes
43
+ Object.keys(attributes).forEach(name => {
44
+ script.setAttribute(name, attributes[name]);
45
+ });
46
+
47
+ // Set up timeout
48
+ if (timeout > 0) {
49
+ timeoutId = setTimeout(() => {
50
+ script.remove();
51
+ handleError(new Error(`Script load timeout: ${src}`));
52
+ }, timeout);
53
+ }
54
+
55
+ // Event handlers
56
+ script.onload = () => {
57
+ clearTimeout(timeoutId);
58
+ resolve({ script, cached: false });
59
+ };
60
+
61
+ script.onerror = (error) => {
62
+ clearTimeout(timeoutId);
63
+ script.remove();
64
+ handleError(new Error(`Failed to load script ${src}`, { cause: error }));
65
+ };
66
+
67
+ // Append to document
68
+ const $targetParent = parent || document.head || document.documentElement;
69
+ $targetParent.appendChild(script);
70
+ }
71
+
72
+ function handleError(error) {
73
+ if (retryCount < retries) {
74
+ retryCount++;
75
+ setTimeout(createAndLoadScript, 1000 * retryCount);
76
+ } else {
77
+ reject(error);
78
+ }
79
+ }
80
+
81
+ createAndLoadScript();
82
+ });
83
+ }
84
+
85
+ // Return promise that resolves when DOM is ready
86
+ export function ready() {
87
+ return new Promise((resolve) => {
88
+ if (document.readyState === 'loading') {
89
+ // Wait for DOM if still loading
90
+ document.addEventListener('DOMContentLoaded', resolve);
91
+ } else {
92
+ // DOM is already ready
93
+ resolve();
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,306 @@
1
+ class Firestore {
2
+ constructor(manager) {
3
+ this.manager = manager;
4
+ this._db = null;
5
+ this._initialized = false;
6
+ this._initPromise = null;
7
+ }
8
+
9
+ async _ensureInitialized() {
10
+ if (this._initialized) {
11
+ return this._db;
12
+ }
13
+
14
+ if (!this._initPromise) {
15
+ this._initPromise = this._initializeFirestore();
16
+ }
17
+
18
+ return this._initPromise;
19
+ }
20
+
21
+ async _initializeFirestore() {
22
+ try {
23
+ // Check if Firebase app is initialized
24
+ if (!this.manager._firebaseApp) {
25
+ throw new Error('Firebase app not initialized. Please initialize Firebase first.');
26
+ }
27
+
28
+ // Dynamically import Firestore
29
+ const { getFirestore, doc: firestoreDoc, collection: firestoreCollection, getDoc, setDoc, updateDoc, deleteDoc, getDocs, query, where, orderBy, limit, startAt, endAt, onSnapshot } = await import('firebase/firestore');
30
+
31
+ // Store references for later use
32
+ this._firestoreMethods = {
33
+ doc: firestoreDoc,
34
+ collection: firestoreCollection,
35
+ getDoc,
36
+ setDoc,
37
+ updateDoc,
38
+ deleteDoc,
39
+ getDocs,
40
+ query,
41
+ where,
42
+ orderBy,
43
+ limit,
44
+ startAt,
45
+ endAt,
46
+ onSnapshot,
47
+ };
48
+
49
+ // Reuse the Firestore instance already initialized in index.js
50
+ this._db = getFirestore(this.manager._firebaseApp);
51
+
52
+ // Connect to Firestore emulator in development
53
+ if (this.manager.isDevelopment() && this.manager.config.env?.FIREBASE_EMULATOR_CONNECT) {
54
+ console.log('[Firestore] Connecting to emulator at localhost:8080');
55
+ const { connectFirestoreEmulator } = await import('firebase/firestore');
56
+ connectFirestoreEmulator(this._db, 'localhost', 8080);
57
+ console.log('[Firestore] Emulator connected');
58
+ }
59
+
60
+ this._initialized = true;
61
+
62
+ return this._db;
63
+ } catch (error) {
64
+ console.error('Failed to initialize Firestore:', error);
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ // Main doc() method - supports both 'path/to/doc' and ('collection', 'docId')
70
+ doc(...args) {
71
+ const self = this;
72
+ let docPath;
73
+
74
+ // Handle different argument patterns
75
+ if (args.length === 1 && typeof args[0] === 'string') {
76
+ // Single path string: doc('users/userId')
77
+ docPath = args[0];
78
+ } else if (args.length === 2 && typeof args[0] === 'string' && typeof args[1] === 'string') {
79
+ // Collection and doc ID: doc('users', 'userId')
80
+ docPath = `${args[0]}/${args[1]}`;
81
+ } else {
82
+ throw new Error('Invalid arguments for doc(). Use doc("path/to/doc") or doc("collection", "docId")');
83
+ }
84
+
85
+ return {
86
+ async get() {
87
+ await self._ensureInitialized();
88
+ const docRef = self._firestoreMethods.doc(self._db, docPath);
89
+ const docSnap = await self._firestoreMethods.getDoc(docRef);
90
+
91
+ return {
92
+ exists: () => docSnap.exists(),
93
+ data: () => docSnap.data(),
94
+ id: docSnap.id,
95
+ ref: docRef
96
+ };
97
+ },
98
+
99
+ async set(data, options = {}) {
100
+ await self._ensureInitialized();
101
+ const docRef = self._firestoreMethods.doc(self._db, docPath);
102
+ return await self._firestoreMethods.setDoc(docRef, data, options);
103
+ },
104
+
105
+ async update(data) {
106
+ await self._ensureInitialized();
107
+ const docRef = self._firestoreMethods.doc(self._db, docPath);
108
+ return await self._firestoreMethods.updateDoc(docRef, data);
109
+ },
110
+
111
+ async delete() {
112
+ await self._ensureInitialized();
113
+ const docRef = self._firestoreMethods.doc(self._db, docPath);
114
+ return await self._firestoreMethods.deleteDoc(docRef);
115
+ },
116
+
117
+ onSnapshot(callback, errorCallback) {
118
+ let unsubscribe = function () {};
119
+
120
+ self._ensureInitialized().then(function () {
121
+ const docRef = self._firestoreMethods.doc(self._db, docPath);
122
+
123
+ unsubscribe = self._firestoreMethods.onSnapshot(docRef, function (docSnap) {
124
+ callback({
125
+ exists: () => docSnap.exists(),
126
+ data: () => docSnap.data(),
127
+ id: docSnap.id,
128
+ ref: docRef,
129
+ });
130
+ }, errorCallback);
131
+ });
132
+
133
+ return function () { unsubscribe(); };
134
+ }
135
+ };
136
+ }
137
+
138
+ // Collection method for queries
139
+ collection(collectionPath) {
140
+ const self = this;
141
+
142
+ return {
143
+ async get() {
144
+ await self._ensureInitialized();
145
+ const collRef = self._firestoreMethods.collection(self._db, collectionPath);
146
+ const querySnapshot = await self._firestoreMethods.getDocs(collRef);
147
+
148
+ return {
149
+ docs: querySnapshot.docs.map(doc => ({
150
+ id: doc.id,
151
+ data: () => doc.data(),
152
+ exists: () => doc.exists(),
153
+ ref: doc.ref
154
+ })),
155
+ size: querySnapshot.size,
156
+ empty: querySnapshot.empty,
157
+ forEach: (callback) => querySnapshot.forEach(callback)
158
+ };
159
+ },
160
+
161
+ where(field, operator, value) {
162
+ return self._buildQuery(collectionPath, [{ type: 'where', field, operator, value }]);
163
+ },
164
+
165
+ orderBy(field, direction = 'asc') {
166
+ return self._buildQuery(collectionPath, [{ type: 'orderBy', field, direction }]);
167
+ },
168
+
169
+ limit(count) {
170
+ return self._buildQuery(collectionPath, [{ type: 'limit', count }]);
171
+ },
172
+
173
+ doc(docId) {
174
+ if (docId) {
175
+ return self.doc(`${collectionPath}/${docId}`);
176
+ }
177
+ // Auto-generate ID if not provided
178
+ return self.doc(`${collectionPath}/${self._generateId()}`);
179
+ }
180
+ };
181
+ }
182
+
183
+ // Build chainable query
184
+ _buildQuery(collectionPath, constraints = []) {
185
+ const self = this;
186
+
187
+ const queryBuilder = {
188
+ where(field, operator, value) {
189
+ constraints.push({ type: 'where', field, operator, value });
190
+ return queryBuilder;
191
+ },
192
+
193
+ orderBy(field, direction = 'asc') {
194
+ constraints.push({ type: 'orderBy', field, direction });
195
+ return queryBuilder;
196
+ },
197
+
198
+ limit(count) {
199
+ constraints.push({ type: 'limit', count });
200
+ return queryBuilder;
201
+ },
202
+
203
+ startAt(...values) {
204
+ constraints.push({ type: 'startAt', values });
205
+ return queryBuilder;
206
+ },
207
+
208
+ endAt(...values) {
209
+ constraints.push({ type: 'endAt', values });
210
+ return queryBuilder;
211
+ },
212
+
213
+ async get() {
214
+ return self._executeQuery(collectionPath, constraints);
215
+ },
216
+
217
+ onSnapshot(callback, errorCallback) {
218
+ let unsubscribe = function () {};
219
+
220
+ self._ensureInitialized().then(function () {
221
+ const collRef = self._firestoreMethods.collection(self._db, collectionPath);
222
+ const queryConstraints = self._buildConstraints(constraints);
223
+ const q = self._firestoreMethods.query(collRef, ...queryConstraints);
224
+
225
+ unsubscribe = self._firestoreMethods.onSnapshot(q, function (querySnapshot) {
226
+ callback({
227
+ docs: querySnapshot.docs.map(doc => ({
228
+ id: doc.id,
229
+ data: () => doc.data(),
230
+ exists: () => doc.exists(),
231
+ ref: doc.ref,
232
+ })),
233
+ size: querySnapshot.size,
234
+ empty: querySnapshot.empty,
235
+ forEach: (cb) => querySnapshot.forEach(cb),
236
+ });
237
+ }, errorCallback);
238
+ });
239
+
240
+ return function () { unsubscribe(); };
241
+ }
242
+ };
243
+
244
+ return queryBuilder;
245
+ }
246
+
247
+ // Build query constraint objects from constraint descriptors
248
+ _buildConstraints(constraints) {
249
+ const queryConstraints = [];
250
+
251
+ for (const constraint of constraints) {
252
+ switch (constraint.type) {
253
+ case 'where':
254
+ queryConstraints.push(this._firestoreMethods.where(constraint.field, constraint.operator, constraint.value));
255
+ break;
256
+ case 'orderBy':
257
+ queryConstraints.push(this._firestoreMethods.orderBy(constraint.field, constraint.direction));
258
+ break;
259
+ case 'limit':
260
+ queryConstraints.push(this._firestoreMethods.limit(constraint.count));
261
+ break;
262
+ case 'startAt':
263
+ queryConstraints.push(this._firestoreMethods.startAt(...constraint.values));
264
+ break;
265
+ case 'endAt':
266
+ queryConstraints.push(this._firestoreMethods.endAt(...constraint.values));
267
+ break;
268
+ }
269
+ }
270
+
271
+ return queryConstraints;
272
+ }
273
+
274
+ // Execute a query and return formatted results
275
+ async _executeQuery(collectionPath, constraints) {
276
+ await this._ensureInitialized();
277
+ const collRef = this._firestoreMethods.collection(this._db, collectionPath);
278
+ const queryConstraints = this._buildConstraints(constraints);
279
+ const q = this._firestoreMethods.query(collRef, ...queryConstraints);
280
+ const querySnapshot = await this._firestoreMethods.getDocs(q);
281
+
282
+ return {
283
+ docs: querySnapshot.docs.map(doc => ({
284
+ id: doc.id,
285
+ data: () => doc.data(),
286
+ exists: () => doc.exists(),
287
+ ref: doc.ref,
288
+ })),
289
+ size: querySnapshot.size,
290
+ empty: querySnapshot.empty,
291
+ forEach: (callback) => querySnapshot.forEach(callback),
292
+ };
293
+ }
294
+
295
+ // Helper to generate document IDs (similar to Firebase auto-generated IDs)
296
+ _generateId() {
297
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
298
+ let id = '';
299
+ for (let i = 0; i < 20; i++) {
300
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
301
+ }
302
+ return id;
303
+ }
304
+ }
305
+
306
+ export default Firestore;