pulse-js-framework 1.7.15 → 1.7.16

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,310 @@
1
+ /**
2
+ * Pulse SSR Hydrator - Client-side hydration utilities
3
+ *
4
+ * Provides utilities for hydrating server-rendered HTML by attaching
5
+ * event listeners and reactive bindings to existing DOM elements.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Hydration State
10
+ // ============================================================================
11
+
12
+ /** @type {boolean} */
13
+ let isHydrating = false;
14
+
15
+ /** @type {HydrationContext|null} */
16
+ let hydrationCtx = null;
17
+
18
+ // ============================================================================
19
+ // Hydration Context
20
+ // ============================================================================
21
+
22
+ /**
23
+ * @typedef {Object} HydrationContext
24
+ * @property {Element} root - Root container element
25
+ * @property {Node|null} cursor - Current position in DOM tree
26
+ * @property {Array<Function>} cleanups - Cleanup functions for disposal
27
+ * @property {Array<{element: Element, event: string, handler: Function}>} listeners - Attached event listeners
28
+ * @property {number} depth - Current nesting depth
29
+ * @property {boolean} mismatchWarned - Whether a mismatch warning was shown
30
+ */
31
+
32
+ /**
33
+ * Create a new hydration context for a container element.
34
+ * @param {Element} root - Root container element
35
+ * @returns {HydrationContext}
36
+ */
37
+ export function createHydrationContext(root) {
38
+ return {
39
+ root,
40
+ cursor: root.firstChild,
41
+ cleanups: [],
42
+ listeners: [],
43
+ depth: 0,
44
+ mismatchWarned: false
45
+ };
46
+ }
47
+
48
+ // ============================================================================
49
+ // Hydration Mode Control
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Enable or disable hydration mode.
54
+ * @param {boolean} enabled - Whether to enable hydration mode
55
+ * @param {HydrationContext|null} ctx - Hydration context (required when enabling)
56
+ */
57
+ export function setHydrationMode(enabled, ctx = null) {
58
+ isHydrating = enabled;
59
+ hydrationCtx = enabled ? ctx : null;
60
+ }
61
+
62
+ /**
63
+ * Check if currently in hydration mode.
64
+ * @returns {boolean}
65
+ */
66
+ export function isHydratingMode() {
67
+ return isHydrating;
68
+ }
69
+
70
+ /**
71
+ * Get the current hydration context.
72
+ * @returns {HydrationContext|null}
73
+ */
74
+ export function getHydrationContext() {
75
+ return hydrationCtx;
76
+ }
77
+
78
+ // ============================================================================
79
+ // DOM Cursor Navigation
80
+ // ============================================================================
81
+
82
+ /**
83
+ * Get the current node at the cursor position.
84
+ * @param {HydrationContext} ctx - Hydration context
85
+ * @returns {Node|null}
86
+ */
87
+ export function getCurrentNode(ctx) {
88
+ return ctx.cursor;
89
+ }
90
+
91
+ /**
92
+ * Advance the cursor to the next sibling.
93
+ * @param {HydrationContext} ctx - Hydration context
94
+ */
95
+ export function advanceCursor(ctx) {
96
+ if (ctx.cursor) {
97
+ ctx.cursor = ctx.cursor.nextSibling;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Enter a child scope (for nested elements).
103
+ * @param {HydrationContext} ctx - Hydration context
104
+ * @param {Element} element - Parent element to enter
105
+ */
106
+ export function enterChild(ctx, element) {
107
+ ctx.cursor = element.firstChild;
108
+ ctx.depth++;
109
+ }
110
+
111
+ /**
112
+ * Exit a child scope and restore cursor to parent level.
113
+ * @param {HydrationContext} ctx - Hydration context
114
+ * @param {Element} element - Element we're exiting
115
+ */
116
+ export function exitChild(ctx, element) {
117
+ ctx.cursor = element.nextSibling;
118
+ ctx.depth--;
119
+ }
120
+
121
+ /**
122
+ * Skip comment nodes (used as markers).
123
+ * @param {HydrationContext} ctx - Hydration context
124
+ */
125
+ export function skipComments(ctx) {
126
+ while (ctx.cursor && ctx.cursor.nodeType === 8) {
127
+ ctx.cursor = ctx.cursor.nextSibling;
128
+ }
129
+ }
130
+
131
+ // ============================================================================
132
+ // DOM Matching
133
+ // ============================================================================
134
+
135
+ /**
136
+ * Check if an element matches expected tag and basic attributes.
137
+ * @param {Node} node - Node to check
138
+ * @param {string} expectedTag - Expected tag name (lowercase)
139
+ * @param {string} [expectedId] - Expected ID (optional)
140
+ * @param {string} [expectedClass] - Expected class (optional)
141
+ * @returns {boolean}
142
+ */
143
+ export function matchesElement(node, expectedTag, expectedId, expectedClass) {
144
+ if (!node || node.nodeType !== 1) return false;
145
+
146
+ const tag = node.tagName?.toLowerCase();
147
+ if (tag !== expectedTag) return false;
148
+
149
+ if (expectedId && node.id !== expectedId) return false;
150
+ if (expectedClass && !node.classList?.contains(expectedClass)) return false;
151
+
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Log a hydration mismatch warning.
157
+ * @param {HydrationContext} ctx - Hydration context
158
+ * @param {string} expected - What was expected
159
+ * @param {Node|null} actual - What was found
160
+ */
161
+ export function warnMismatch(ctx, expected, actual) {
162
+ if (ctx.mismatchWarned) return;
163
+
164
+ const actualDesc = actual
165
+ ? `<${actual.tagName?.toLowerCase() || actual.nodeName}>`
166
+ : 'null';
167
+
168
+ console.warn(
169
+ `[Pulse Hydration] Mismatch at depth ${ctx.depth}: ` +
170
+ `expected ${expected}, found ${actualDesc}. ` +
171
+ `This may cause hydration errors.`
172
+ );
173
+
174
+ ctx.mismatchWarned = true;
175
+ }
176
+
177
+ // ============================================================================
178
+ // Event Listener Management
179
+ // ============================================================================
180
+
181
+ /**
182
+ * Register an event listener during hydration.
183
+ * @param {HydrationContext} ctx - Hydration context
184
+ * @param {Element} element - Target element
185
+ * @param {string} event - Event name
186
+ * @param {Function} handler - Event handler
187
+ * @param {Object} [options] - Event listener options
188
+ */
189
+ export function registerListener(ctx, element, event, handler, options) {
190
+ element.addEventListener(event, handler, options);
191
+ ctx.listeners.push({ element, event, handler, options });
192
+ }
193
+
194
+ /**
195
+ * Register a cleanup function.
196
+ * @param {HydrationContext} ctx - Hydration context
197
+ * @param {Function} cleanup - Cleanup function
198
+ */
199
+ export function registerCleanup(ctx, cleanup) {
200
+ ctx.cleanups.push(cleanup);
201
+ }
202
+
203
+ // ============================================================================
204
+ // Hydration Disposal
205
+ // ============================================================================
206
+
207
+ /**
208
+ * Dispose of all hydration resources.
209
+ * Removes event listeners and runs cleanup functions.
210
+ * @param {HydrationContext} ctx - Hydration context
211
+ */
212
+ export function disposeHydration(ctx) {
213
+ // Remove all event listeners
214
+ for (const { element, event, handler, options } of ctx.listeners) {
215
+ element.removeEventListener(event, handler, options);
216
+ }
217
+ ctx.listeners = [];
218
+
219
+ // Run cleanup functions
220
+ for (const cleanup of ctx.cleanups) {
221
+ try {
222
+ cleanup();
223
+ } catch (e) {
224
+ console.error('[Pulse Hydration] Cleanup error:', e);
225
+ }
226
+ }
227
+ ctx.cleanups = [];
228
+ }
229
+
230
+ // ============================================================================
231
+ // Hydration Helpers
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Find the next element matching a tag within the current scope.
236
+ * Useful for recovering from mismatches.
237
+ * @param {HydrationContext} ctx - Hydration context
238
+ * @param {string} tag - Tag name to find
239
+ * @returns {Element|null}
240
+ */
241
+ export function findNextElement(ctx, tag) {
242
+ let node = ctx.cursor;
243
+ while (node) {
244
+ if (node.nodeType === 1 && node.tagName?.toLowerCase() === tag) {
245
+ return node;
246
+ }
247
+ node = node.nextSibling;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Count remaining elements in the current scope.
254
+ * Useful for debugging hydration issues.
255
+ * @param {HydrationContext} ctx - Hydration context
256
+ * @returns {number}
257
+ */
258
+ export function countRemaining(ctx) {
259
+ let count = 0;
260
+ let node = ctx.cursor;
261
+ while (node) {
262
+ if (node.nodeType === 1) count++;
263
+ node = node.nextSibling;
264
+ }
265
+ return count;
266
+ }
267
+
268
+ /**
269
+ * Check if hydration is complete (no more nodes to process).
270
+ * @param {HydrationContext} ctx - Hydration context
271
+ * @returns {boolean}
272
+ */
273
+ export function isHydrationComplete(ctx) {
274
+ return ctx.cursor === null && ctx.depth === 0;
275
+ }
276
+
277
+ // ============================================================================
278
+ // Exports
279
+ // ============================================================================
280
+
281
+ export default {
282
+ // Mode control
283
+ setHydrationMode,
284
+ isHydratingMode,
285
+ getHydrationContext,
286
+
287
+ // Context
288
+ createHydrationContext,
289
+
290
+ // Navigation
291
+ getCurrentNode,
292
+ advanceCursor,
293
+ enterChild,
294
+ exitChild,
295
+ skipComments,
296
+
297
+ // Matching
298
+ matchesElement,
299
+ warnMismatch,
300
+ findNextElement,
301
+
302
+ // Resources
303
+ registerListener,
304
+ registerCleanup,
305
+ disposeHydration,
306
+
307
+ // Helpers
308
+ countRemaining,
309
+ isHydrationComplete
310
+ };
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Pulse SSR Serializer - HTML serialization for MockNode trees
3
+ *
4
+ * Converts MockDOMAdapter node trees into HTML strings for server-side rendering.
5
+ * Handles all node types: elements, text, comments, and fragments.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Constants
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Void elements that are self-closing in HTML (no closing tag).
14
+ */
15
+ const VOID_ELEMENTS = new Set([
16
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
17
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
18
+ ]);
19
+
20
+ /**
21
+ * Boolean attributes that are present or absent (no value needed).
22
+ */
23
+ const BOOLEAN_ATTRS = new Set([
24
+ 'disabled', 'checked', 'readonly', 'required', 'autofocus',
25
+ 'multiple', 'selected', 'hidden', 'open', 'inert', 'novalidate',
26
+ 'async', 'defer', 'formnovalidate', 'allowfullscreen', 'autoplay',
27
+ 'controls', 'loop', 'muted', 'playsinline', 'reversed', 'ismap'
28
+ ]);
29
+
30
+ /**
31
+ * Attributes that should be skipped during serialization.
32
+ */
33
+ const SKIP_ATTRS = new Set(['class', 'id', 'style']);
34
+
35
+ // ============================================================================
36
+ // HTML Escaping
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Escape HTML special characters in text content.
41
+ * @param {string} str - String to escape
42
+ * @returns {string} Escaped string safe for HTML text content
43
+ */
44
+ export function escapeHTML(str) {
45
+ if (str == null) return '';
46
+ return String(str)
47
+ .replace(/&/g, '&amp;')
48
+ .replace(/</g, '&lt;')
49
+ .replace(/>/g, '&gt;');
50
+ }
51
+
52
+ /**
53
+ * Escape special characters for use in HTML attributes.
54
+ * @param {string} str - String to escape
55
+ * @returns {string} Escaped string safe for attribute values
56
+ */
57
+ export function escapeAttr(str) {
58
+ if (str == null) return '';
59
+ return String(str)
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/"/g, '&quot;')
62
+ .replace(/</g, '&lt;')
63
+ .replace(/>/g, '&gt;');
64
+ }
65
+
66
+ // ============================================================================
67
+ // Style Serialization
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Convert camelCase to kebab-case for CSS properties.
72
+ * @param {string} str - camelCase string
73
+ * @returns {string} kebab-case string
74
+ */
75
+ function camelToKebab(str) {
76
+ return str.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
77
+ }
78
+
79
+ /**
80
+ * Serialize a style object to a CSS string.
81
+ * @param {Object} styleObj - Style object with camelCase properties
82
+ * @returns {string} CSS string like "color: red; font-size: 16px"
83
+ */
84
+ function serializeStyle(styleObj) {
85
+ if (!styleObj || typeof styleObj !== 'object') return '';
86
+
87
+ const styles = [];
88
+ for (const [key, value] of Object.entries(styleObj)) {
89
+ if (value != null && value !== '') {
90
+ styles.push(`${camelToKebab(key)}: ${value}`);
91
+ }
92
+ }
93
+ return styles.join('; ');
94
+ }
95
+
96
+ // ============================================================================
97
+ // Attribute Serialization
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Serialize element attributes to an HTML attribute string.
102
+ * @param {MockElement} element - Element to serialize attributes from
103
+ * @returns {string} Attribute string like ' id="foo" class="bar"'
104
+ */
105
+ function serializeAttributes(element) {
106
+ const parts = [];
107
+
108
+ // ID attribute
109
+ if (element.id) {
110
+ parts.push(`id="${escapeAttr(element.id)}"`);
111
+ }
112
+
113
+ // Class attribute
114
+ if (element.className) {
115
+ parts.push(`class="${escapeAttr(element.className)}"`);
116
+ }
117
+
118
+ // Other attributes from _attributes Map
119
+ if (element._attributes) {
120
+ for (const [name, value] of element._attributes) {
121
+ // Skip already handled attributes
122
+ if (SKIP_ATTRS.has(name)) continue;
123
+
124
+ if (BOOLEAN_ATTRS.has(name)) {
125
+ // Boolean attributes: present if truthy
126
+ if (value === 'true' || value === true || value === name || value === '') {
127
+ parts.push(name);
128
+ }
129
+ } else {
130
+ // Regular attributes
131
+ parts.push(`${name}="${escapeAttr(value)}"`);
132
+ }
133
+ }
134
+ }
135
+
136
+ // Style attribute
137
+ const styleStr = serializeStyle(element._style || element.style);
138
+ if (styleStr) {
139
+ parts.push(`style="${escapeAttr(styleStr)}"`);
140
+ }
141
+
142
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
143
+ }
144
+
145
+ // ============================================================================
146
+ // Node Serialization
147
+ // ============================================================================
148
+
149
+ /**
150
+ * Serialize a MockNode tree to an HTML string.
151
+ *
152
+ * Supports all node types:
153
+ * - Element nodes (nodeType 1)
154
+ * - Text nodes (nodeType 3)
155
+ * - Comment nodes (nodeType 8)
156
+ * - Document fragments (nodeType 11)
157
+ *
158
+ * @param {MockNode} node - Root node to serialize
159
+ * @param {Object} [options] - Serialization options
160
+ * @param {boolean} [options.pretty=false] - Pretty print with indentation
161
+ * @param {number} [options.indent=0] - Initial indentation level
162
+ * @param {string} [options.indentStr=' '] - Indentation string
163
+ * @returns {string} HTML string
164
+ *
165
+ * @example
166
+ * const adapter = new MockDOMAdapter();
167
+ * const div = adapter.createElement('div');
168
+ * div.id = 'app';
169
+ * div.className = 'container';
170
+ * adapter.appendChild(div, adapter.createTextNode('Hello'));
171
+ *
172
+ * serializeToHTML(div);
173
+ * // '<div id="app" class="container">Hello</div>'
174
+ */
175
+ export function serializeToHTML(node, options = {}) {
176
+ if (!node) return '';
177
+
178
+ const { pretty = false, indent = 0, indentStr = ' ' } = options;
179
+ const prefix = pretty ? indentStr.repeat(indent) : '';
180
+ const newline = pretty ? '\n' : '';
181
+
182
+ // Text node (nodeType 3)
183
+ if (node.nodeType === 3) {
184
+ const text = node.textContent ?? node.data ?? '';
185
+ return escapeHTML(text);
186
+ }
187
+
188
+ // Comment node (nodeType 8)
189
+ if (node.nodeType === 8) {
190
+ const data = node.data ?? node.textContent ?? '';
191
+ return `${prefix}<!--${data}-->`;
192
+ }
193
+
194
+ // Document fragment (nodeType 11)
195
+ if (node.nodeType === 11) {
196
+ return (node.childNodes || [])
197
+ .map(child => serializeToHTML(child, { ...options, indent }))
198
+ .join(newline);
199
+ }
200
+
201
+ // Element node (nodeType 1)
202
+ if (node.nodeType === 1) {
203
+ const tag = (node.tagName || 'div').toLowerCase();
204
+ const attrs = serializeAttributes(node);
205
+
206
+ // Void elements (self-closing)
207
+ if (VOID_ELEMENTS.has(tag)) {
208
+ return `${prefix}<${tag}${attrs}>`;
209
+ }
210
+
211
+ // Elements with children
212
+ const children = node.childNodes || [];
213
+ let childrenHTML = '';
214
+
215
+ if (children.length > 0) {
216
+ if (pretty) {
217
+ childrenHTML = newline +
218
+ children
219
+ .map(child => serializeToHTML(child, { ...options, indent: indent + 1 }))
220
+ .join(newline) +
221
+ newline + prefix;
222
+ } else {
223
+ childrenHTML = children
224
+ .map(child => serializeToHTML(child, options))
225
+ .join('');
226
+ }
227
+ }
228
+
229
+ return `${prefix}<${tag}${attrs}>${childrenHTML}</${tag}>`;
230
+ }
231
+
232
+ // Unknown node type
233
+ return '';
234
+ }
235
+
236
+ /**
237
+ * Serialize only the children of a node (excludes the node itself).
238
+ * Useful for serializing the body content without the body tag.
239
+ *
240
+ * @param {MockNode} node - Node whose children to serialize
241
+ * @param {Object} [options] - Serialization options
242
+ * @returns {string} HTML string of children
243
+ */
244
+ export function serializeChildren(node, options = {}) {
245
+ if (!node || !node.childNodes) return '';
246
+
247
+ const { pretty = false } = options;
248
+ const newline = pretty ? '\n' : '';
249
+
250
+ return node.childNodes
251
+ .map(child => serializeToHTML(child, options))
252
+ .join(newline);
253
+ }
254
+
255
+ // ============================================================================
256
+ // Exports
257
+ // ============================================================================
258
+
259
+ export default {
260
+ serializeToHTML,
261
+ serializeChildren,
262
+ escapeHTML,
263
+ escapeAttr,
264
+ VOID_ELEMENTS,
265
+ BOOLEAN_ATTRS
266
+ };