@hashrock/ono 0.1.2 → 0.2.0

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/src/parser.js ADDED
@@ -0,0 +1,555 @@
1
+ // @ts-nocheck — parser combinators return anonymous tuples that TypeScript
2
+ // can't usefully infer; checkJs adds noise here without catching real bugs.
3
+ /**
4
+ * Parser combinators and an ES module-syntax parser.
5
+ *
6
+ * Parses only what the bundler needs — import/export declarations and
7
+ * top-level function names — using parser combinators instead of regular
8
+ * expressions. The rest of the source is skipped by a small scanner that
9
+ * understands strings, template literals, comments, and (heuristically)
10
+ * regex literals, so an "import" inside a string is never misparsed.
11
+ *
12
+ * Browser-compatible: no Node.js APIs.
13
+ */
14
+
15
+ // --- Combinator core -------------------------------------------------------
16
+ // A parser is a function (input, pos) => result.
17
+ // Success: { ok: true, value, pos } Failure: { ok: false, expected, pos }
18
+
19
+ const ok = (value, pos) => ({ ok: true, value, pos });
20
+ const fail = (expected, pos) => ({ ok: false, expected, pos });
21
+
22
+ /** Match an exact string */
23
+ export function str(expected) {
24
+ return (input, pos) =>
25
+ input.startsWith(expected, pos)
26
+ ? ok(expected, pos + expected.length)
27
+ : fail(expected, pos);
28
+ }
29
+
30
+ /** Run parsers in order, collecting their values */
31
+ export function seq(...parsers) {
32
+ return (input, pos) => {
33
+ const values = [];
34
+ let current = pos;
35
+ for (const parser of parsers) {
36
+ const result = parser(input, current);
37
+ if (!result.ok) return result;
38
+ values.push(result.value);
39
+ current = result.pos;
40
+ }
41
+ return ok(values, current);
42
+ };
43
+ }
44
+
45
+ /** Try parsers in order, returning the first success */
46
+ export function alt(...parsers) {
47
+ return (input, pos) => {
48
+ let furthest = null;
49
+ for (const parser of parsers) {
50
+ const result = parser(input, pos);
51
+ if (result.ok) return result;
52
+ if (!furthest || result.pos > furthest.pos) furthest = result;
53
+ }
54
+ return furthest || fail("no alternative matched", pos);
55
+ };
56
+ }
57
+
58
+ /** Match zero or more repetitions */
59
+ export function many(parser) {
60
+ return (input, pos) => {
61
+ const values = [];
62
+ let current = pos;
63
+ for (;;) {
64
+ const result = parser(input, current);
65
+ if (!result.ok || result.pos === current) return ok(values, current);
66
+ values.push(result.value);
67
+ current = result.pos;
68
+ }
69
+ };
70
+ }
71
+
72
+ /** Make a parser optional (yields null when it fails) */
73
+ export function opt(parser) {
74
+ return (input, pos) => {
75
+ const result = parser(input, pos);
76
+ return result.ok ? result : ok(null, pos);
77
+ };
78
+ }
79
+
80
+ /** Transform a parser's value */
81
+ export function map(parser, fn) {
82
+ return (input, pos) => {
83
+ const result = parser(input, pos);
84
+ return result.ok ? ok(fn(result.value), result.pos) : result;
85
+ };
86
+ }
87
+
88
+ /** Zero or more items separated by a separator */
89
+ export function sepBy(item, separator) {
90
+ return (input, pos) => {
91
+ const first = item(input, pos);
92
+ if (!first.ok) return ok([], pos);
93
+ const rest = many(map(seq(separator, item), ([, value]) => value));
94
+ const result = rest(input, first.pos);
95
+ return ok([first.value, ...result.value], result.pos);
96
+ };
97
+ }
98
+
99
+ // --- Lexical helpers -------------------------------------------------------
100
+
101
+ const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f" || c === "\v";
102
+ const isIdStart = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_" || c === "$";
103
+ const isIdChar = (c) => isIdStart(c) || (c >= "0" && c <= "9");
104
+
105
+ /** True when the whole string is a valid (ASCII) identifier name */
106
+ export function isIdentifierName(name) {
107
+ if (name.length === 0 || !isIdStart(name[0])) return false;
108
+ for (let i = 1; i < name.length; i++) {
109
+ if (!isIdChar(name[i])) return false;
110
+ }
111
+ return true;
112
+ }
113
+
114
+ /** Skip whitespace and comments, returning the new position */
115
+ export function skipTrivia(input, pos) {
116
+ for (;;) {
117
+ while (pos < input.length && isWs(input[pos])) pos++;
118
+ if (input.startsWith("//", pos)) {
119
+ while (pos < input.length && input[pos] !== "\n") pos++;
120
+ continue;
121
+ }
122
+ if (input.startsWith("/*", pos)) {
123
+ const end = input.indexOf("*/", pos + 2);
124
+ pos = end === -1 ? input.length : end + 2;
125
+ continue;
126
+ }
127
+ return pos;
128
+ }
129
+ }
130
+
131
+ /** Wrap a parser so it skips leading trivia */
132
+ function token(parser) {
133
+ return (input, pos) => parser(input, skipTrivia(input, pos));
134
+ }
135
+
136
+ /** Any identifier-like word (including reserved words) */
137
+ const wordRaw = (input, pos) => {
138
+ if (pos >= input.length || !isIdStart(input[pos])) return fail("identifier", pos);
139
+ let end = pos + 1;
140
+ while (end < input.length && isIdChar(input[end])) end++;
141
+ return ok(input.slice(pos, end), end);
142
+ };
143
+
144
+ const word = token(wordRaw);
145
+
146
+ /** A specific keyword (whole word) */
147
+ const kw = (expected) =>
148
+ token((input, pos) => {
149
+ const result = wordRaw(input, pos);
150
+ return result.ok && result.value === expected ? result : fail(`"${expected}"`, pos);
151
+ });
152
+
153
+ const punct = (s) => token(str(s));
154
+
155
+ /** A single- or double-quoted string literal, yielding its contents */
156
+ const stringLit = token((input, pos) => {
157
+ const quote = input[pos];
158
+ if (quote !== '"' && quote !== "'") return fail("string literal", pos);
159
+ let out = "";
160
+ let i = pos + 1;
161
+ while (i < input.length) {
162
+ const c = input[i];
163
+ if (c === "\\") {
164
+ out += input[i + 1] ?? "";
165
+ i += 2;
166
+ } else if (c === quote) {
167
+ return ok(out, i + 1);
168
+ } else if (c === "\n") {
169
+ break;
170
+ } else {
171
+ out += c;
172
+ i++;
173
+ }
174
+ }
175
+ return fail("closing quote", pos);
176
+ });
177
+
178
+ // --- Import declaration grammar -------------------------------------------
179
+
180
+ // { a, b as c, default as d }
181
+ const importSpecifier = map(
182
+ seq(word, opt(seq(kw("as"), word))),
183
+ ([imported, alias]) => ({ imported, local: alias ? alias[1] : imported }),
184
+ );
185
+
186
+ const namedList = (specifier) =>
187
+ map(
188
+ seq(punct("{"), sepBy(specifier, punct(",")), opt(punct(",")), punct("}")),
189
+ ([, specs]) => specs,
190
+ );
191
+
192
+ const namespaceImport = map(seq(punct("*"), kw("as"), word), ([, , name]) => name);
193
+
194
+ const importClause = alt(
195
+ map(namespaceImport, (namespace) => ({ namespace })),
196
+ map(namedList(importSpecifier), (named) => ({ named })),
197
+ map(
198
+ seq(
199
+ word,
200
+ opt(
201
+ seq(
202
+ punct(","),
203
+ alt(
204
+ map(namespaceImport, (namespace) => ({ namespace })),
205
+ map(namedList(importSpecifier), (named) => ({ named })),
206
+ ),
207
+ ),
208
+ ),
209
+ ),
210
+ ([defaultBinding, rest]) => ({ defaultBinding, ...(rest ? rest[1] : {}) }),
211
+ ),
212
+ );
213
+
214
+ const importDecl = map(
215
+ seq(
216
+ kw("import"),
217
+ alt(
218
+ map(stringLit, (specifier) => ({ specifier, sideEffect: true })),
219
+ map(seq(importClause, kw("from"), stringLit), ([clause, , specifier]) => ({
220
+ ...clause,
221
+ specifier,
222
+ })),
223
+ ),
224
+ opt(punct(";")),
225
+ ),
226
+ ([, decl]) => ({ type: "import", ...decl }),
227
+ );
228
+
229
+ // --- Export declaration grammar --------------------------------------------
230
+
231
+ // { a, b as c }
232
+ const exportSpecifier = map(
233
+ seq(word, opt(seq(kw("as"), word))),
234
+ ([local, alias]) => ({ local, exported: alias ? alias[1] : local }),
235
+ );
236
+
237
+ const exportStarFrom = map(
238
+ seq(punct("*"), opt(seq(kw("as"), word)), kw("from"), stringLit, opt(punct(";"))),
239
+ ([, alias, , specifier]) => ({
240
+ type: "exportStarFrom",
241
+ specifier,
242
+ alias: alias ? alias[1] : null,
243
+ }),
244
+ );
245
+
246
+ const exportNamedFrom = map(
247
+ seq(namedList(exportSpecifier), kw("from"), stringLit, opt(punct(";"))),
248
+ ([named, , specifier]) => ({ type: "exportNamedFrom", named, specifier }),
249
+ );
250
+
251
+ const exportNamed = map(
252
+ seq(namedList(exportSpecifier), opt(punct(";"))),
253
+ ([named]) => ({ type: "exportNamed", named }),
254
+ );
255
+
256
+ // function Foo / async function Foo / class Foo (name optional)
257
+ const functionOrClassHead = alt(
258
+ map(seq(opt(kw("async")), kw("function"), opt(punct("*")), opt(word)), ([, , , name]) => ({
259
+ kind: "function",
260
+ name,
261
+ })),
262
+ map(seq(kw("class"), opt(word)), ([, name]) => ({ kind: "class", name })),
263
+ );
264
+
265
+ /**
266
+ * Scan `const a = ..., b = ...;` collecting declared names.
267
+ * Initializers are skipped with depth/string tracking, not parsed.
268
+ */
269
+ function scanDeclaratorNames(input, pos) {
270
+ const names = [];
271
+ for (;;) {
272
+ pos = skipTrivia(input, pos);
273
+ const c = input[pos];
274
+ if (c === "{" || c === "[") {
275
+ throw new Error("Destructuring in an exported declaration is not supported by the Ono bundler");
276
+ }
277
+ const nameResult = wordRaw(input, pos);
278
+ if (!nameResult.ok) break;
279
+ names.push(nameResult.value);
280
+ pos = nameResult.pos;
281
+
282
+ // Skip to the next top-level "," (next declarator) or ";" (end)
283
+ let depth = 0;
284
+ let done = false;
285
+ while (pos < input.length) {
286
+ pos = skipTrivia(input, pos);
287
+ const ch = input[pos];
288
+ if (ch === undefined) break;
289
+ if (ch === '"' || ch === "'") {
290
+ pos = skipStringLiteral(input, pos);
291
+ } else if (ch === "`") {
292
+ pos = skipTemplateLiteral(input, pos);
293
+ } else if (ch === "(" || ch === "[" || ch === "{") {
294
+ depth++;
295
+ pos++;
296
+ } else if (ch === ")" || ch === "]" || ch === "}") {
297
+ depth--;
298
+ pos++;
299
+ } else if (depth === 0 && ch === ",") {
300
+ pos++;
301
+ break;
302
+ } else if (depth === 0 && ch === ";") {
303
+ done = true;
304
+ break;
305
+ } else {
306
+ pos++;
307
+ }
308
+ }
309
+ if (done || pos >= input.length) break;
310
+ }
311
+ return names;
312
+ }
313
+
314
+ /**
315
+ * Parse an export declaration starting at the `export` keyword.
316
+ * For `export <declaration>` forms only the header span is consumed
317
+ * (the declaration itself stays in place); `consumeTo` says where the
318
+ * scanner should continue.
319
+ */
320
+ function exportDecl(input, pos) {
321
+ const head = kw("export")(input, pos);
322
+ if (!head.ok) return head;
323
+ const afterExport = head.pos;
324
+
325
+ const fromForm = alt(exportStarFrom, exportNamedFrom, exportNamed)(input, afterExport);
326
+ if (fromForm.ok) {
327
+ return ok({ ...fromForm.value, consumeTo: fromForm.pos }, fromForm.pos);
328
+ }
329
+
330
+ const defaultKw = kw("default")(input, afterExport);
331
+ if (defaultKw.ok) {
332
+ const headerEnd = defaultKw.pos;
333
+ const decl = functionOrClassHead(input, headerEnd);
334
+ if (decl.ok && decl.value.name) {
335
+ // export default function Foo() {} — keep the declaration, strip keywords
336
+ return ok(
337
+ {
338
+ type: "exportDefaultDeclaration",
339
+ name: decl.value.name,
340
+ declarationKind: decl.value.kind,
341
+ headerEnd,
342
+ consumeTo: headerEnd,
343
+ },
344
+ headerEnd,
345
+ );
346
+ }
347
+ // export default <expression> (or anonymous function/class)
348
+ return ok({ type: "exportDefaultExpression", headerEnd, consumeTo: headerEnd }, headerEnd);
349
+ }
350
+
351
+ const fnHead = functionOrClassHead(input, afterExport);
352
+ if (fnHead.ok && fnHead.value.name) {
353
+ return ok(
354
+ {
355
+ type: "exportDeclaration",
356
+ names: [fnHead.value.name],
357
+ declarationKind: fnHead.value.kind,
358
+ headerEnd: afterExport,
359
+ consumeTo: afterExport,
360
+ },
361
+ afterExport,
362
+ );
363
+ }
364
+
365
+ const kind = alt(kw("const"), kw("let"), kw("var"))(input, afterExport);
366
+ if (kind.ok) {
367
+ const names = scanDeclaratorNames(input, kind.pos);
368
+ if (names.length > 0) {
369
+ return ok(
370
+ {
371
+ type: "exportDeclaration",
372
+ names,
373
+ declarationKind: "variable",
374
+ headerEnd: afterExport,
375
+ consumeTo: afterExport,
376
+ },
377
+ afterExport,
378
+ );
379
+ }
380
+ }
381
+
382
+ return fail("export declaration", afterExport);
383
+ }
384
+
385
+ // --- Source scanner ---------------------------------------------------------
386
+
387
+ function skipStringLiteral(input, pos) {
388
+ const quote = input[pos];
389
+ let i = pos + 1;
390
+ while (i < input.length) {
391
+ const c = input[i];
392
+ if (c === "\\") i += 2;
393
+ else if (c === quote) return i + 1;
394
+ else if (c === "\n") return i; // unterminated — bail out
395
+ else i++;
396
+ }
397
+ return i;
398
+ }
399
+
400
+ function skipTemplateLiteral(input, pos) {
401
+ let i = pos + 1;
402
+ while (i < input.length) {
403
+ const c = input[i];
404
+ if (c === "\\") {
405
+ i += 2;
406
+ } else if (c === "`") {
407
+ return i + 1;
408
+ } else if (c === "$" && input[i + 1] === "{") {
409
+ // Skip the embedded expression (may contain nested strings/templates)
410
+ i += 2;
411
+ let depth = 1;
412
+ while (i < input.length && depth > 0) {
413
+ i = skipTrivia(input, i);
414
+ const e = input[i];
415
+ if (e === undefined) break;
416
+ if (e === '"' || e === "'") i = skipStringLiteral(input, i);
417
+ else if (e === "`") i = skipTemplateLiteral(input, i);
418
+ else {
419
+ if (e === "{") depth++;
420
+ else if (e === "}") depth--;
421
+ i++;
422
+ }
423
+ }
424
+ } else {
425
+ i++;
426
+ }
427
+ }
428
+ return i;
429
+ }
430
+
431
+ function skipRegexLiteral(input, pos) {
432
+ let i = pos + 1;
433
+ let inClass = false;
434
+ while (i < input.length) {
435
+ const c = input[i];
436
+ if (c === "\\") i += 2;
437
+ else if (c === "[") { inClass = true; i++; }
438
+ else if (c === "]") { inClass = false; i++; }
439
+ else if (c === "/" && !inClass) {
440
+ i++;
441
+ while (i < input.length && isIdChar(input[i])) i++; // flags
442
+ return i;
443
+ } else if (c === "\n") {
444
+ return i; // not a regex after all — bail out
445
+ } else {
446
+ i++;
447
+ }
448
+ }
449
+ return i;
450
+ }
451
+
452
+ const REGEX_ALLOWED_AFTER_WORD = new Set([
453
+ "return", "typeof", "case", "in", "of", "new", "delete", "void",
454
+ "instanceof", "do", "else", "yield", "await",
455
+ ]);
456
+ const REGEX_ALLOWED_AFTER_CHAR = new Set([...("=([{,;:!&|?+-*%^~<>")]);
457
+ // Like the regex set, but without the statement-boundary chars `;` and `{`:
458
+ // after those, `function` opens a *declaration*, not an expression.
459
+ const EXPR_BEFORE_CHAR = new Set([...("=([,:!&|?+-*%^~<>")]);
460
+
461
+ /** True when the previous token puts us in expression position */
462
+ function isExpressionContext(lastToken) {
463
+ return (
464
+ EXPR_BEFORE_CHAR.has(lastToken) ||
465
+ REGEX_ALLOWED_AFTER_WORD.has(lastToken)
466
+ );
467
+ }
468
+
469
+ /**
470
+ * Parse the module syntax of a JavaScript source file.
471
+ * @param {string} source - JavaScript source (post JSX transform)
472
+ * @returns {{imports: object[], exports: object[], topLevelFunctions: string[]}}
473
+ * Each statement carries a `start`/`end` (or `headerEnd`) span into `source`.
474
+ */
475
+ export function parseModule(source) {
476
+ const imports = [];
477
+ const exportStatements = [];
478
+ const topLevelFunctions = [];
479
+
480
+ let pos = 0;
481
+ let braceDepth = 0;
482
+ let lastToken = ""; // previous significant token (word or single char)
483
+
484
+ while (pos < source.length) {
485
+ pos = skipTrivia(source, pos);
486
+ if (pos >= source.length) break;
487
+ const c = source[pos];
488
+
489
+ if (c === '"' || c === "'") {
490
+ pos = skipStringLiteral(source, pos);
491
+ lastToken = c;
492
+ continue;
493
+ }
494
+ if (c === "`") {
495
+ pos = skipTemplateLiteral(source, pos);
496
+ lastToken = c;
497
+ continue;
498
+ }
499
+ if (c === "/") {
500
+ // skipTrivia already handled comments, so this is division or a regex
501
+ if (lastToken === "" || REGEX_ALLOWED_AFTER_CHAR.has(lastToken) || REGEX_ALLOWED_AFTER_WORD.has(lastToken)) {
502
+ pos = skipRegexLiteral(source, pos);
503
+ } else {
504
+ pos++;
505
+ }
506
+ lastToken = "/";
507
+ continue;
508
+ }
509
+
510
+ if (isIdStart(c)) {
511
+ const start = pos;
512
+ let end = pos + 1;
513
+ while (end < source.length && isIdChar(source[end])) end++;
514
+ const wordText = source.slice(start, end);
515
+
516
+ if (wordText === "import") {
517
+ const result = importDecl(source, start);
518
+ if (result.ok) {
519
+ imports.push({ ...result.value, start, end: result.pos });
520
+ pos = result.pos;
521
+ lastToken = ";";
522
+ continue;
523
+ }
524
+ // dynamic import() or import.meta — fall through
525
+ } else if (wordText === "export" && braceDepth === 0) {
526
+ const result = exportDecl(source, start);
527
+ if (result.ok) {
528
+ const { consumeTo, ...statement } = result.value;
529
+ exportStatements.push({ ...statement, start, end: result.pos });
530
+ pos = consumeTo;
531
+ lastToken = ";";
532
+ continue;
533
+ }
534
+ } else if (wordText === "function" && braceDepth === 0 && lastToken !== "async" && !isExpressionContext(lastToken)) {
535
+ // A function *declaration* — `const f = function foo() {}` is skipped
536
+ const name = word(source, end);
537
+ if (name.ok) topLevelFunctions.push(name.value);
538
+ } else if (wordText === "async" && braceDepth === 0 && !isExpressionContext(lastToken)) {
539
+ const fn = seq(kw("function"), opt(punct("*")), word)(source, end);
540
+ if (fn.ok) topLevelFunctions.push(fn.value[2]);
541
+ }
542
+
543
+ pos = end;
544
+ lastToken = wordText;
545
+ continue;
546
+ }
547
+
548
+ if (c === "{") braceDepth++;
549
+ else if (c === "}") braceDepth = Math.max(0, braceDepth - 1);
550
+ lastToken = c;
551
+ pos++;
552
+ }
553
+
554
+ return { imports, exports: exportStatements, topLevelFunctions };
555
+ }
package/src/renderer.js CHANGED
@@ -1,17 +1,12 @@
1
1
  /**
2
2
  * Renderer - Convert VNodes to HTML strings
3
3
  */
4
+ import { SELF_CLOSING_TAGS } from "./constants.js";
5
+ import { Fragment } from "./jsx-runtime.js";
4
6
 
5
- // Self-closing HTML tags
6
- const SELF_CLOSING_TAGS = new Set([
7
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
8
- 'link', 'meta', 'param', 'source', 'track', 'wbr'
9
- ]);
10
-
11
- /**
12
- * Escape HTML special characters to prevent XSS
13
- */
7
+ /** @param {unknown} text */
14
8
  function escapeHtml(text) {
9
+ /** @type {Record<string, string>} */
15
10
  const map = {
16
11
  '&': '&amp;',
17
12
  '<': '&lt;',
@@ -22,16 +17,12 @@ function escapeHtml(text) {
22
17
  return String(text).replace(/[&<>"']/g, (char) => map[char]);
23
18
  }
24
19
 
25
- /**
26
- * Convert camelCase to kebab-case
27
- */
20
+ /** @param {string} str */
28
21
  function camelToKebab(str) {
29
22
  return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
30
23
  }
31
24
 
32
- /**
33
- * Convert style object to CSS string
34
- */
25
+ /** @param {string | Record<string, unknown>} style */
35
26
  function styleToString(style) {
36
27
  if (typeof style === 'string') {
37
28
  return style;
@@ -42,40 +33,30 @@ function styleToString(style) {
42
33
  .join('; ');
43
34
  }
44
35
 
45
- /**
46
- * Render attributes to string
47
- */
36
+ /** @param {Record<string, any> | null | undefined} props */
48
37
  function renderAttributes(props) {
49
38
  if (!props) return '';
50
39
 
51
40
  const attributes = [];
52
41
 
53
42
  for (const [key, value] of Object.entries(props)) {
54
- // Skip special props
55
43
  if (key === 'children' || key === 'dangerouslySetInnerHTML') continue;
56
44
 
57
- // Handle className -> class conversion
58
45
  if (key === 'className') {
59
46
  attributes.push(`class="${escapeHtml(value)}"`);
60
47
  continue;
61
48
  }
62
49
 
63
- // Handle style object
64
50
  if (key === 'style') {
65
- const styleStr = styleToString(value);
66
- attributes.push(`style="${escapeHtml(styleStr)}"`);
51
+ attributes.push(`style="${escapeHtml(styleToString(value))}"`);
67
52
  continue;
68
53
  }
69
54
 
70
- // Handle boolean attributes
71
55
  if (typeof value === 'boolean') {
72
- if (value) {
73
- attributes.push(key);
74
- }
56
+ if (value) attributes.push(key);
75
57
  continue;
76
58
  }
77
59
 
78
- // Handle regular attributes
79
60
  if (value != null) {
80
61
  attributes.push(`${key}="${escapeHtml(value)}"`);
81
62
  }
@@ -85,10 +66,20 @@ function renderAttributes(props) {
85
66
  }
86
67
 
87
68
  /**
88
- * Render a VNode to HTML string
69
+ * @param {any[] | undefined} children
70
+ * @returns {string}
71
+ */
72
+ function renderChildren(children) {
73
+ return children && children.length > 0
74
+ ? children.map(renderToString).join('')
75
+ : '';
76
+ }
77
+
78
+ /**
79
+ * @param {any} vnode
80
+ * @returns {string}
89
81
  */
90
82
  export function renderToString(vnode) {
91
- // Handle primitive values
92
83
  if (vnode == null || typeof vnode === 'boolean') {
93
84
  return '';
94
85
  }
@@ -97,39 +88,29 @@ export function renderToString(vnode) {
97
88
  return escapeHtml(vnode);
98
89
  }
99
90
 
100
- // Handle VNode object
101
91
  const { tag, props, children } = vnode;
102
92
 
103
- // Handle component functions
93
+ if (tag === Fragment) {
94
+ return renderChildren(children);
95
+ }
96
+
104
97
  if (typeof tag === 'function') {
105
- // Pass props with children
106
98
  const componentProps = { ...props };
107
99
  if (children && children.length > 0) {
108
100
  componentProps.children = children;
109
101
  }
110
-
111
- // Call component function and render result
112
- const result = tag(componentProps);
113
- return renderToString(result);
102
+ return renderToString(tag(componentProps));
114
103
  }
115
104
 
116
- // Handle HTML elements
117
105
  const attrs = renderAttributes(props);
118
- const isSelfClosing = SELF_CLOSING_TAGS.has(tag);
119
106
 
120
- if (isSelfClosing) {
107
+ if (SELF_CLOSING_TAGS.has(tag)) {
121
108
  return `<${tag}${attrs} />`;
122
109
  }
123
110
 
124
- // Handle dangerouslySetInnerHTML
125
- if (props && props.dangerouslySetInnerHTML && props.dangerouslySetInnerHTML.__html) {
111
+ if (props?.dangerouslySetInnerHTML?.__html) {
126
112
  return `<${tag}${attrs}>${props.dangerouslySetInnerHTML.__html}</${tag}>`;
127
113
  }
128
114
 
129
- // Render children
130
- const childrenHtml = children && children.length > 0
131
- ? children.map(child => renderToString(child)).join('')
132
- : '';
133
-
134
- return `<${tag}${attrs}>${childrenHtml}</${tag}>`;
115
+ return `<${tag}${attrs}>${renderChildren(children)}</${tag}>`;
135
116
  }