@thyn/vite-plugin 0.0.312 → 0.0.314
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/dist/dom.d.ts +35 -0
- package/dist/dom.js +142 -0
- package/dist/html-parser.d.ts +31 -0
- package/dist/html-parser.js +275 -0
- package/dist/index.js +72 -63
- package/package.json +2 -2
- package/src/html-parser.ts +332 -0
- package/src/index.ts +87 -79
- package/tsconfig.tsbuildinfo +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as acorn from "acorn";
|
|
2
2
|
import * as acornwalk from "acorn-walk";
|
|
3
3
|
import * as esbuild from "esbuild";
|
|
4
|
-
import {
|
|
4
|
+
import { parseHTML } from "./html-parser.js";
|
|
5
5
|
import MagicString from "magic-string";
|
|
6
6
|
import postcss from 'postcss';
|
|
7
7
|
import selectorParser from 'postcss-selector-parser';
|
|
@@ -224,6 +224,7 @@ function generateTextContentTemplate(text, parent, prevSibling) {
|
|
|
224
224
|
dynamic: "",
|
|
225
225
|
root: "",
|
|
226
226
|
staticRoot: root,
|
|
227
|
+
html: escapeHTML(finalText),
|
|
227
228
|
};
|
|
228
229
|
}
|
|
229
230
|
const interpolated = parts.map((part) => {
|
|
@@ -235,6 +236,19 @@ function generateTextContentTemplate(text, parent, prevSibling) {
|
|
|
235
236
|
const textNode = prevSibling
|
|
236
237
|
? `${prevSibling}.nextSibling`
|
|
237
238
|
: `${parent}.firstChild`;
|
|
239
|
+
// For templates with interpolations, build HTML with the static parts
|
|
240
|
+
// For reactive/dynamic content, we use the initial value
|
|
241
|
+
let htmlContent = "";
|
|
242
|
+
for (const part of parts) {
|
|
243
|
+
if (typeof part === "string") {
|
|
244
|
+
htmlContent += escapeHTML(part);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// For interpolation, we can't compute the value at compile time
|
|
248
|
+
// So we use an empty string as placeholder
|
|
249
|
+
htmlContent += "";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
238
252
|
if (hasReactive) {
|
|
239
253
|
let fn = `(() => \`${interpolated}\`)`;
|
|
240
254
|
if (parts.length === 1) {
|
|
@@ -254,6 +268,7 @@ function generateTextContentTemplate(text, parent, prevSibling) {
|
|
|
254
268
|
dynamic,
|
|
255
269
|
root: "",
|
|
256
270
|
staticRoot: root,
|
|
271
|
+
html: htmlContent || " ", // Use space to ensure text node is created
|
|
257
272
|
};
|
|
258
273
|
}
|
|
259
274
|
if (parts.length === 1) {
|
|
@@ -262,6 +277,7 @@ function generateTextContentTemplate(text, parent, prevSibling) {
|
|
|
262
277
|
static: `const ${root} = document.createTextNode("");\n`,
|
|
263
278
|
root: "",
|
|
264
279
|
staticRoot: root,
|
|
280
|
+
html: htmlContent || " ",
|
|
265
281
|
};
|
|
266
282
|
}
|
|
267
283
|
return {
|
|
@@ -269,6 +285,7 @@ function generateTextContentTemplate(text, parent, prevSibling) {
|
|
|
269
285
|
static: `const ${root} = document.createTextNode("");\n`,
|
|
270
286
|
root: "",
|
|
271
287
|
staticRoot: root,
|
|
288
|
+
html: htmlContent || " ",
|
|
272
289
|
};
|
|
273
290
|
}
|
|
274
291
|
const NAMESPACE = "__THYN__";
|
|
@@ -298,30 +315,63 @@ let varId = 0;
|
|
|
298
315
|
function makeVariable() {
|
|
299
316
|
return `${NAMESPACE}${varId++}`;
|
|
300
317
|
}
|
|
301
|
-
function
|
|
318
|
+
function escapeHTML(str) {
|
|
319
|
+
return str
|
|
320
|
+
.replace(/&/g, '&')
|
|
321
|
+
.replace(/</g, '<')
|
|
322
|
+
.replace(/>/g, '>')
|
|
323
|
+
.replace(/"/g, '"')
|
|
324
|
+
.replace(/'/g, ''');
|
|
325
|
+
}
|
|
326
|
+
function processTextForHTML(text) {
|
|
327
|
+
// Handle escaped braces by removing the backslashes
|
|
328
|
+
text = text.replace(/\\\{\{/g, '{{').replace(/\\\}\}/g, '}}');
|
|
329
|
+
return escapeHTML(text.trim());
|
|
330
|
+
}
|
|
331
|
+
function makeTemplate(node, parent, prevSibling, path = "") {
|
|
302
332
|
if (node.nodeType === 3) {
|
|
303
333
|
const text = node.textContent;
|
|
304
|
-
|
|
334
|
+
const result = generateTextContentTemplate(text, parent, prevSibling);
|
|
335
|
+
// For templates with interpolations, we need a placeholder
|
|
336
|
+
const html = result.html !== "<!-- -->" ? result.html : "<!-- -->";
|
|
337
|
+
return {
|
|
338
|
+
...result,
|
|
339
|
+
html,
|
|
340
|
+
};
|
|
305
341
|
}
|
|
306
342
|
const tag = node.tagName.toLowerCase();
|
|
307
343
|
const attrs = parseAttributes(node);
|
|
308
344
|
let statRoot = makeVariable();
|
|
309
|
-
let template = `const ${statRoot} = document.createElement("${tag}");\n`;
|
|
310
|
-
if (!parent) {
|
|
311
|
-
statRoot = "__THYN__template";
|
|
312
|
-
template = `${statRoot} = document.createElement("${tag}");\n`;
|
|
313
|
-
}
|
|
314
345
|
let code = "";
|
|
315
346
|
let dynRoot = makeVariable();
|
|
347
|
+
// Build HTML string for static template
|
|
348
|
+
let htmlAttrs = "";
|
|
349
|
+
const staticAttrs = {};
|
|
350
|
+
for (const [key, val] of Object.entries(attrs)) {
|
|
351
|
+
if (DIRECTIVES.includes(key))
|
|
352
|
+
continue;
|
|
353
|
+
if ("quoted" in val) {
|
|
354
|
+
staticAttrs[key] = val.quoted;
|
|
355
|
+
if (key === "class") {
|
|
356
|
+
htmlAttrs += ` class="${escapeHTML(val.quoted)}"`;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
htmlAttrs += ` ${key}="${escapeHTML(val.quoted)}"`;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
316
363
|
const childNodes = Array.from(node.childNodes).filter((n) => n.nodeType !== 3 || n.textContent.trim());
|
|
317
364
|
const children = [];
|
|
318
365
|
let ps = undefined;
|
|
366
|
+
let childHTML = "";
|
|
319
367
|
for (let i = 0; i < childNodes.length; i++) {
|
|
320
368
|
const cn = childNodes[i];
|
|
321
|
-
const ch = makeTemplate(cn, dynRoot, ps);
|
|
369
|
+
const ch = makeTemplate(cn, dynRoot, ps, `${path}.childNodes[${i}]`);
|
|
322
370
|
children.push(ch);
|
|
323
371
|
ps = ch.root || `${dynRoot}.childNodes[${i}]`;
|
|
372
|
+
childHTML += ch.html || "";
|
|
324
373
|
}
|
|
374
|
+
const fullHTML = `<${tag}${htmlAttrs}>${childHTML}</${tag}>`;
|
|
325
375
|
if (!parent) {
|
|
326
376
|
code = `const ${dynRoot} = __THYN__template_generate();\n`;
|
|
327
377
|
}
|
|
@@ -331,21 +381,7 @@ function makeTemplate(node, parent, prevSibling) {
|
|
|
331
381
|
else {
|
|
332
382
|
code = `const ${dynRoot} = ${prevSibling}.nextSibling;\n`;
|
|
333
383
|
}
|
|
334
|
-
|
|
335
|
-
if (DIRECTIVES.includes(key))
|
|
336
|
-
continue;
|
|
337
|
-
if ("quoted" in val) {
|
|
338
|
-
if (key === "class") {
|
|
339
|
-
template += `${statRoot}.className = "${val.quoted}";\n`;
|
|
340
|
-
}
|
|
341
|
-
else if (key.includes("-")) {
|
|
342
|
-
template += `${statRoot}.setAttribute("${key}", "${val.quoted}");\n`;
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
template += `${statRoot}["${key}"] = "${val.quoted}";\n`;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
384
|
+
// Generate dynamic code for reactive attributes and events
|
|
349
385
|
for (const [key, val] of Object.entries(attrs)) {
|
|
350
386
|
if (DIRECTIVES.includes(key))
|
|
351
387
|
continue;
|
|
@@ -410,24 +446,19 @@ function makeTemplate(node, parent, prevSibling) {
|
|
|
410
446
|
code += `${dynRoot}.${key} = ${val.raw};\n`;
|
|
411
447
|
}
|
|
412
448
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const childStaticRoot = ch.staticRoot || ch.root;
|
|
419
|
-
template += `${statRoot}.appendChild(${childStaticRoot});\n`;
|
|
420
|
-
}
|
|
421
|
-
if (ch.dynamic) {
|
|
422
|
-
code += ch.dynamic;
|
|
423
|
-
}
|
|
449
|
+
// Handle dynamic text content
|
|
450
|
+
for (let i = 0; i < children.length; i++) {
|
|
451
|
+
const ch = children[i];
|
|
452
|
+
if (ch.dynamic) {
|
|
453
|
+
code += ch.dynamic;
|
|
424
454
|
}
|
|
425
455
|
}
|
|
426
456
|
return {
|
|
427
457
|
root: dynRoot,
|
|
428
458
|
staticRoot: statRoot,
|
|
429
|
-
static: template
|
|
459
|
+
static: "", // No longer used - template is built from HTML
|
|
430
460
|
dynamic: code,
|
|
461
|
+
html: fullHTML,
|
|
431
462
|
};
|
|
432
463
|
}
|
|
433
464
|
function walkConditionChain(nodes, i) {
|
|
@@ -641,14 +672,6 @@ function hasComponentChildren(node) {
|
|
|
641
672
|
}
|
|
642
673
|
return Array.from(node.childNodes).some((n) => hasComponentChildren(n));
|
|
643
674
|
}
|
|
644
|
-
const forcedChildren = new Map([
|
|
645
|
-
["tbody", "tr"],
|
|
646
|
-
["thead", "tr"],
|
|
647
|
-
["tfoot", "tr"],
|
|
648
|
-
["ul", "li"],
|
|
649
|
-
["ol", "li"],
|
|
650
|
-
["select", "option"],
|
|
651
|
-
]);
|
|
652
675
|
const COMPONENT_TAG_REGEX = /<\/?([A-Z][a-zA-Z0-9]*)(\s(?:[^"'<>\/]|"[^"]*"|'[^']*')*)?(\/?)>/g;
|
|
653
676
|
function convertToColonBindings(html) {
|
|
654
677
|
let result = '';
|
|
@@ -742,20 +765,6 @@ function preserveCamelCaseAttributes(html) {
|
|
|
742
765
|
}
|
|
743
766
|
function addComponentAttributes(html) {
|
|
744
767
|
let processedHTML = html;
|
|
745
|
-
for (const [parentTag, childTag] of forcedChildren) {
|
|
746
|
-
const parentRegex = new RegExp(`<${parentTag}([^>]*)>([\\s\\S]*?)<\\/${parentTag}>`, "gis");
|
|
747
|
-
processedHTML = processedHTML.replace(parentRegex, (match, attributes, content) => {
|
|
748
|
-
const processedContent = content.replace(COMPONENT_TAG_REGEX, (componentMatch, componentName, attributes, selfClose) => {
|
|
749
|
-
const isClosing = componentMatch.startsWith("</");
|
|
750
|
-
return isClosing
|
|
751
|
-
? `</${childTag}>`
|
|
752
|
-
: selfClose
|
|
753
|
-
? `<${childTag}${attributes || ""} __thyn_component="${componentName}"/>`
|
|
754
|
-
: `<${childTag}${attributes || ""} __thyn_component="${componentName}">`;
|
|
755
|
-
});
|
|
756
|
-
return `<${parentTag}${attributes}>${processedContent}</${parentTag}>`;
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
768
|
processedHTML = processedHTML.replace(COMPONENT_TAG_REGEX, (componentMatch, componentName, attributes, selfClose) => {
|
|
760
769
|
const isClosing = componentMatch.startsWith("</");
|
|
761
770
|
return isClosing
|
|
@@ -833,10 +842,8 @@ function removeUnusedThynVars(code) {
|
|
|
833
842
|
}
|
|
834
843
|
async function transformHTMLtoJSX(html, style) {
|
|
835
844
|
const scopeId = `thyn-${(styleId++).toString(36)}`;
|
|
836
|
-
const div = new JSDOM("").window.document.createElement("div");
|
|
837
845
|
const processedHTML = preprocessHTML(html);
|
|
838
|
-
|
|
839
|
-
const template = div.firstElementChild;
|
|
846
|
+
const template = parseHTML("<template>" + processedHTML + "</template>");
|
|
840
847
|
const rootElement = template.content.firstElementChild;
|
|
841
848
|
let scopedStyle = null;
|
|
842
849
|
if (style) {
|
|
@@ -849,12 +856,14 @@ async function transformHTMLtoJSX(html, style) {
|
|
|
849
856
|
const root = makeVariable();
|
|
850
857
|
return [root, `const ${root} = ${code};`, hoist, scopedStyle];
|
|
851
858
|
}
|
|
852
|
-
const { root,
|
|
859
|
+
const { root, dynamic, html: templateHTML } = makeTemplate(rootElement);
|
|
853
860
|
const hoist = [`
|
|
854
861
|
let __THYN__template;
|
|
855
862
|
function __THYN__template_generate() {
|
|
856
863
|
if (!__THYN__template) {
|
|
857
|
-
|
|
864
|
+
const t = document.createElement('template');
|
|
865
|
+
t.innerHTML = \`${escapeTemplateLiteral(templateHTML)}\`;
|
|
866
|
+
__THYN__template = t.content.firstChild;
|
|
858
867
|
return __THYN__template;
|
|
859
868
|
}
|
|
860
869
|
return __THYN__template.cloneNode(true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thyn/vite-plugin",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.314",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"build": "tsc",
|
|
6
6
|
"pub": "tsc && npm version patch -f && npm -f publish --access=public",
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"acorn": "^8.15.0",
|
|
20
20
|
"acorn-walk": "^8.3.4",
|
|
21
21
|
"esbuild": "^0.27.2",
|
|
22
|
-
"jsdom": "^27.4.0",
|
|
23
22
|
"magic-string": "^0.30.21",
|
|
24
23
|
"postcss": "^8.5.6",
|
|
25
24
|
"postcss-selector-parser": "^7.1.1",
|
|
@@ -28,6 +27,7 @@
|
|
|
28
27
|
"devDependencies": {
|
|
29
28
|
"@thyn/core": "^0.0.298",
|
|
30
29
|
"@types/jsdom": "^27.0.0",
|
|
30
|
+
"jsdom": "^27.4.0",
|
|
31
31
|
"vite": "^7.3.1",
|
|
32
32
|
"vitest": "^4.0.16"
|
|
33
33
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
interface Node {
|
|
2
|
+
nodeType: number;
|
|
3
|
+
nodeName: string;
|
|
4
|
+
textContent: string;
|
|
5
|
+
childNodes: Node[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Element extends Node {
|
|
9
|
+
tagName: string;
|
|
10
|
+
attributes: Array<{ name: string; value: string }>;
|
|
11
|
+
children: Element[];
|
|
12
|
+
firstElementChild: Element | null;
|
|
13
|
+
hasAttribute(name: string): boolean;
|
|
14
|
+
getAttribute(name: string): string | null;
|
|
15
|
+
setAttribute(name: string, value: string): void;
|
|
16
|
+
removeAttribute(name: string): void;
|
|
17
|
+
classList: { add(className: string): void };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DocumentFragment {
|
|
21
|
+
childNodes: Node[];
|
|
22
|
+
firstElementChild: Element | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TemplateElement extends Element {
|
|
26
|
+
content: DocumentFragment;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseAttributes(attrStr: string): Array<{ name: string; value: string }> {
|
|
30
|
+
const attrs: Array<{ name: string; value: string }> = []
|
|
31
|
+
let i = 0;
|
|
32
|
+
|
|
33
|
+
while (i < attrStr.length) {
|
|
34
|
+
// Skip whitespace
|
|
35
|
+
while (i < attrStr.length && /\s/.test(attrStr[i])) i++;
|
|
36
|
+
if (i >= attrStr.length) break;
|
|
37
|
+
|
|
38
|
+
// Parse attribute name
|
|
39
|
+
let name = "";
|
|
40
|
+
while (i < attrStr.length && !/[\s=]/.test(attrStr[i])) {
|
|
41
|
+
name += attrStr[i];
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!name) break;
|
|
46
|
+
|
|
47
|
+
// Skip whitespace
|
|
48
|
+
while (i < attrStr.length && /\s/.test(attrStr[i])) i++;
|
|
49
|
+
|
|
50
|
+
let value = "";
|
|
51
|
+
if (i < attrStr.length && attrStr[i] === "=") {
|
|
52
|
+
i++; // skip '='
|
|
53
|
+
// Skip whitespace
|
|
54
|
+
while (i < attrStr.length && /\s/.test(attrStr[i])) i++;
|
|
55
|
+
|
|
56
|
+
if (i < attrStr.length) {
|
|
57
|
+
const quote = attrStr[i];
|
|
58
|
+
if (quote === '"' || quote === "'") {
|
|
59
|
+
i++; // skip opening quote
|
|
60
|
+
while (i < attrStr.length && attrStr[i] !== quote) {
|
|
61
|
+
value += attrStr[i];
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
if (i < attrStr.length) i++; // skip closing quote
|
|
65
|
+
} else {
|
|
66
|
+
// Unquoted value - take until whitespace
|
|
67
|
+
while (i < attrStr.length && !/\s/.test(attrStr[i])) {
|
|
68
|
+
value += attrStr[i];
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
attrs.push({ name, value });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return attrs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createTextNode(text: string): Node {
|
|
82
|
+
return {
|
|
83
|
+
nodeType: 3,
|
|
84
|
+
nodeName: "#text",
|
|
85
|
+
textContent: text,
|
|
86
|
+
childNodes: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createElement(tagName: string, attributes: Array<{ name: string; value: string }> = []): Element {
|
|
91
|
+
const children: Element[] = [];
|
|
92
|
+
const childNodes: Node[] = [];
|
|
93
|
+
|
|
94
|
+
const element: Element = {
|
|
95
|
+
nodeType: 1,
|
|
96
|
+
nodeName: tagName.toUpperCase(),
|
|
97
|
+
tagName: tagName.toUpperCase(),
|
|
98
|
+
textContent: "",
|
|
99
|
+
attributes: [...attributes],
|
|
100
|
+
children,
|
|
101
|
+
childNodes,
|
|
102
|
+
firstElementChild: null,
|
|
103
|
+
hasAttribute(name: string): boolean {
|
|
104
|
+
return this.attributes.some((attr) => attr.name === name);
|
|
105
|
+
},
|
|
106
|
+
getAttribute(name: string): string | null {
|
|
107
|
+
const attr = this.attributes.find((attr) => attr.name === name);
|
|
108
|
+
return attr ? attr.value : null;
|
|
109
|
+
},
|
|
110
|
+
setAttribute(name: string, value: string): void {
|
|
111
|
+
const existing = this.attributes.find((attr) => attr.name === name);
|
|
112
|
+
if (existing) {
|
|
113
|
+
existing.value = value;
|
|
114
|
+
} else {
|
|
115
|
+
this.attributes.push({ name, value });
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
removeAttribute(name: string): void {
|
|
119
|
+
this.attributes = this.attributes.filter((attr) => attr.name !== name);
|
|
120
|
+
},
|
|
121
|
+
classList: {
|
|
122
|
+
add: (className: string) => {
|
|
123
|
+
const existing = element.getAttribute("class");
|
|
124
|
+
const classes = existing ? existing.split(" ").filter(Boolean) : [];
|
|
125
|
+
if (!classes.includes(className)) {
|
|
126
|
+
classes.push(className);
|
|
127
|
+
element.setAttribute("class", classes.join(" "));
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return element;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Find next tag position, properly handling quoted strings
|
|
137
|
+
function findNextTag(html: string, startIndex: number): { index: number; endIndex: number; isClose: boolean; tagName: string; attrs: string; isSelfClose: boolean } | null {
|
|
138
|
+
let i = startIndex;
|
|
139
|
+
|
|
140
|
+
while (i < html.length) {
|
|
141
|
+
// Find the next '<'
|
|
142
|
+
while (i < html.length && html[i] !== '<') {
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (i >= html.length) return null;
|
|
147
|
+
|
|
148
|
+
const tagStart = i;
|
|
149
|
+
i++; // skip '<'
|
|
150
|
+
|
|
151
|
+
// Check if it's a closing tag
|
|
152
|
+
const isClose = i < html.length && html[i] === '/';
|
|
153
|
+
if (isClose) i++;
|
|
154
|
+
|
|
155
|
+
// Parse tag name
|
|
156
|
+
let tagName = '';
|
|
157
|
+
while (i < html.length && /[a-zA-Z0-9-]/.test(html[i])) {
|
|
158
|
+
tagName += html[i];
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!tagName) {
|
|
163
|
+
// Not a valid tag, continue searching
|
|
164
|
+
i = tagStart + 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Parse attributes, respecting quotes
|
|
169
|
+
let attrs = '';
|
|
170
|
+
let inQuote: string | null = null;
|
|
171
|
+
let tagEnd = -1;
|
|
172
|
+
|
|
173
|
+
while (i < html.length) {
|
|
174
|
+
const char = html[i];
|
|
175
|
+
|
|
176
|
+
if (inQuote) {
|
|
177
|
+
attrs += char;
|
|
178
|
+
if (char === inQuote) {
|
|
179
|
+
inQuote = null;
|
|
180
|
+
}
|
|
181
|
+
i++;
|
|
182
|
+
} else if (char === '"' || char === "'") {
|
|
183
|
+
attrs += char;
|
|
184
|
+
inQuote = char;
|
|
185
|
+
i++;
|
|
186
|
+
} else if (char === '>') {
|
|
187
|
+
tagEnd = i + 1; // Include the '>'
|
|
188
|
+
i++;
|
|
189
|
+
break;
|
|
190
|
+
} else {
|
|
191
|
+
attrs += char;
|
|
192
|
+
i++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (tagEnd === -1) {
|
|
197
|
+
// Malformed tag (no closing >), continue searching
|
|
198
|
+
i = tagStart + 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check for self-closing
|
|
203
|
+
const trimmedAttrs = attrs.trim();
|
|
204
|
+
const isSelfClose = trimmedAttrs.endsWith('/');
|
|
205
|
+
const finalAttrs = isSelfClose ? trimmedAttrs.slice(0, -1).trim() : trimmedAttrs;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
index: tagStart,
|
|
209
|
+
endIndex: tagEnd,
|
|
210
|
+
isClose,
|
|
211
|
+
tagName,
|
|
212
|
+
attrs: finalAttrs,
|
|
213
|
+
isSelfClose
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function parseHTML(html: string): TemplateElement {
|
|
221
|
+
const match = html.match(/<template([^>]*)>([\s\S]*)<\/template>/i);
|
|
222
|
+
|
|
223
|
+
if (!match) {
|
|
224
|
+
throw new Error("No <template> tag found in HTML");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const content = match[2].trim();
|
|
228
|
+
const stack: Element[] = [];
|
|
229
|
+
const textChunks: string[] = [];
|
|
230
|
+
const fragmentChildren: Node[] = [];
|
|
231
|
+
const fragmentElements: Element[] = [];
|
|
232
|
+
|
|
233
|
+
let pos = 0;
|
|
234
|
+
|
|
235
|
+
const flushText = () => {
|
|
236
|
+
if (textChunks.length > 0) {
|
|
237
|
+
const text = textChunks.join("");
|
|
238
|
+
textChunks.length = 0;
|
|
239
|
+
const textNode = createTextNode(text);
|
|
240
|
+
if (stack.length > 0) {
|
|
241
|
+
const parent = stack[stack.length - 1];
|
|
242
|
+
parent.childNodes.push(textNode);
|
|
243
|
+
} else {
|
|
244
|
+
fragmentChildren.push(textNode);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
while (pos < content.length) {
|
|
250
|
+
const tagInfo = findNextTag(content, pos);
|
|
251
|
+
|
|
252
|
+
if (!tagInfo) {
|
|
253
|
+
// No more tags, add remaining as text
|
|
254
|
+
if (pos < content.length) {
|
|
255
|
+
textChunks.push(content.slice(pos));
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Add text before this tag
|
|
261
|
+
if (tagInfo.index > pos) {
|
|
262
|
+
textChunks.push(content.slice(pos, tagInfo.index));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { isClose, tagName, attrs, isSelfClose, endIndex } = tagInfo;
|
|
266
|
+
|
|
267
|
+
if (isClose) {
|
|
268
|
+
flushText();
|
|
269
|
+
if (stack.length > 0) {
|
|
270
|
+
const closedElement = stack.pop()!;
|
|
271
|
+
if (closedElement.tagName.toLowerCase() !== tagName.toLowerCase()) {
|
|
272
|
+
throw new Error(`Mismatched tags: expected </${closedElement.tagName}>, got </${tagName}>`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Update parent's firstElementChild if needed
|
|
276
|
+
const parent = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
277
|
+
if (parent && !parent.firstElementChild) {
|
|
278
|
+
parent.firstElementChild = closedElement;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
flushText();
|
|
283
|
+
const attributes = parseAttributes(attrs);
|
|
284
|
+
const element = createElement(tagName, attributes);
|
|
285
|
+
|
|
286
|
+
if (stack.length === 0) {
|
|
287
|
+
// Top-level element
|
|
288
|
+
fragmentChildren.push(element);
|
|
289
|
+
fragmentElements.push(element);
|
|
290
|
+
} else {
|
|
291
|
+
const parent = stack[stack.length - 1];
|
|
292
|
+
parent.children.push(element);
|
|
293
|
+
parent.childNodes.push(element);
|
|
294
|
+
if (!parent.firstElementChild) {
|
|
295
|
+
parent.firstElementChild = element;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!isSelfClose) {
|
|
300
|
+
stack.push(element);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Move position past this tag
|
|
305
|
+
pos = endIndex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Flush any remaining text
|
|
309
|
+
if (stack.length === 0) {
|
|
310
|
+
flushText();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (stack.length > 0) {
|
|
314
|
+
throw new Error(`Unclosed tags remain: ${stack.map(e => e.tagName).join(', ')}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const fragment: DocumentFragment = {
|
|
318
|
+
childNodes: fragmentChildren,
|
|
319
|
+
firstElementChild: fragmentElements[0] || null,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const templateAttrs = parseAttributes(match[1].trim());
|
|
323
|
+
const templateElement: TemplateElement = {
|
|
324
|
+
...createElement("template", templateAttrs),
|
|
325
|
+
content: fragment,
|
|
326
|
+
};
|
|
327
|
+
templateElement.childNodes = [...fragmentChildren];
|
|
328
|
+
templateElement.children = [...fragmentElements];
|
|
329
|
+
templateElement.firstElementChild = fragmentElements[0] || null;
|
|
330
|
+
|
|
331
|
+
return templateElement;
|
|
332
|
+
}
|