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.
- package/README.md +37 -0
- package/cli/help.js +583 -0
- package/cli/index.js +24 -105
- package/package.json +3 -2
- package/runtime/async.js +39 -0
- package/runtime/dom-element.js +107 -0
- package/runtime/index.js +2 -0
- package/runtime/pulse.js +40 -0
- package/runtime/ssr-async.js +229 -0
- package/runtime/ssr-hydrator.js +310 -0
- package/runtime/ssr-serializer.js +266 -0
- package/runtime/ssr.js +463 -0
|
@@ -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, '&')
|
|
48
|
+
.replace(/</g, '<')
|
|
49
|
+
.replace(/>/g, '>');
|
|
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, '&')
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/</g, '<')
|
|
63
|
+
.replace(/>/g, '>');
|
|
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
|
+
};
|