@thyn/core 0.0.344 → 0.0.347

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.
Files changed (105) hide show
  1. package/.github/workflows/static.yml +48 -0
  2. package/.github/workflows/test.yml +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +50 -0
  5. package/dist/{element.js → core/element.js} +14 -36
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/index.js +1 -0
  8. package/dist/{router.d.ts → core/router.d.ts} +1 -1
  9. package/dist/{router.js → core/router.js} +22 -5
  10. package/dist/index.d.ts +5 -2
  11. package/dist/index.js +5 -2
  12. package/dist/plugin/html-parser.d.ts +31 -0
  13. package/dist/plugin/html-parser.js +275 -0
  14. package/dist/plugin/index.d.ts +24 -0
  15. package/dist/plugin/index.js +1009 -0
  16. package/dist/plugin/utils.d.ts +12 -0
  17. package/dist/plugin/utils.js +194 -0
  18. package/docs/CNAME +1 -0
  19. package/docs/index.html +18 -0
  20. package/docs/package-lock.json +980 -0
  21. package/docs/package.json +15 -0
  22. package/docs/public/thyn.png +0 -0
  23. package/docs/public/thyn.svg +1 -0
  24. package/docs/src/App.thyn +10 -0
  25. package/docs/src/components/Button.thyn +3 -0
  26. package/docs/src/docs/GettingStarted.thyn +8 -0
  27. package/docs/src/main.css +17 -0
  28. package/docs/src/main.js +5 -0
  29. package/docs/src/pages/Home.thyn +147 -0
  30. package/docs/vite.config.js +7 -0
  31. package/package.json +18 -10
  32. package/src/{element.ts → core/element.ts} +14 -34
  33. package/src/core/index.ts +1 -0
  34. package/src/{router.ts → core/router.ts} +22 -6
  35. package/src/{signals.ts → core/signals.ts} +1 -1
  36. package/src/index.ts +5 -15
  37. package/src/plugin/html-parser.ts +332 -0
  38. package/src/plugin/index.ts +1127 -0
  39. package/src/plugin/utils.ts +213 -0
  40. package/tests/Bind.test.ts +14 -0
  41. package/tests/Bind.thyn +7 -0
  42. package/tests/ConsecInterps.test.ts +9 -0
  43. package/tests/ConsecInterps.thyn +9 -0
  44. package/tests/Counter.test.ts +12 -0
  45. package/tests/Counter.thyn +7 -0
  46. package/tests/DoubleQuotes.test.ts +9 -0
  47. package/tests/DoubleQuotes.thyn +3 -0
  48. package/tests/Escape.test.ts +9 -0
  49. package/tests/Escape.thyn +3 -0
  50. package/tests/EscapeDollar.test.ts +9 -0
  51. package/tests/EscapeDollar.thyn +5 -0
  52. package/tests/EventPipes.test.ts +13 -0
  53. package/tests/EventPipes.thyn +11 -0
  54. package/tests/List.test.ts +21 -0
  55. package/tests/List.thyn +15 -0
  56. package/tests/ListV2.test.ts +20 -0
  57. package/tests/ListV2.thyn +16 -0
  58. package/tests/MixElemAndText.test.ts +9 -0
  59. package/tests/MixElemAndText.thyn +12 -0
  60. package/tests/Show.test.ts +13 -0
  61. package/tests/Show.thyn +8 -0
  62. package/tests/Template.test.ts +9 -0
  63. package/tests/Template.thyn +8 -0
  64. package/tests/list/comprehensive.test.ts +659 -0
  65. package/tests/list/operations/ChildrenAppend.thyn +11 -0
  66. package/tests/list/operations/ChildrenFilter.thyn +11 -0
  67. package/tests/list/operations/ChildrenInsert.thyn +11 -0
  68. package/tests/list/operations/ChildrenNoneToSome.thyn +11 -0
  69. package/tests/list/operations/ChildrenPrepend.thyn +11 -0
  70. package/tests/list/operations/ChildrenRemove.thyn +11 -0
  71. package/tests/list/operations/ChildrenReplaceAll.thyn +11 -0
  72. package/tests/list/operations/ChildrenSomeToNone.thyn +11 -0
  73. package/tests/list/operations/ChildrenSort.thyn +11 -0
  74. package/tests/list/operations/IsolatedAppend.thyn +10 -0
  75. package/tests/list/operations/IsolatedFilter.thyn +16 -0
  76. package/tests/list/operations/IsolatedInsert.thyn +10 -0
  77. package/tests/list/operations/IsolatedMove.thyn +16 -0
  78. package/tests/list/operations/IsolatedNoneToSome.thyn +16 -0
  79. package/tests/list/operations/IsolatedPrepend.thyn +10 -0
  80. package/tests/list/operations/IsolatedRemove.thyn +17 -0
  81. package/tests/list/operations/IsolatedReplaceAll.thyn +10 -0
  82. package/tests/list/operations/IsolatedSomeToNone.thyn +10 -0
  83. package/tests/list/operations/IsolatedSort.thyn +16 -0
  84. package/tests/list/operations/TerminalAppend.thyn +12 -0
  85. package/tests/list/operations/TerminalFilter.thyn +12 -0
  86. package/tests/list/operations/TerminalInsert.thyn +12 -0
  87. package/tests/list/operations/TerminalNoneToSome.thyn +12 -0
  88. package/tests/list/operations/TerminalPrepend.thyn +12 -0
  89. package/tests/list/operations/TerminalRemove.thyn +12 -0
  90. package/tests/list/operations/TerminalReplaceAll.thyn +12 -0
  91. package/tests/list/operations/TerminalSomeToNone.thyn +12 -0
  92. package/tests/list/operations/TerminalSort.thyn +12 -0
  93. package/tests/tsconfig.json +14 -0
  94. package/tsconfig.json +11 -6
  95. package/types/thyn.d.ts +4 -0
  96. package/vitest.config.ts +7 -2
  97. package/tests/fx.test.ts +0 -31
  98. package/tests/lists.test.ts +0 -184
  99. package/tests/router.test.ts +0 -69
  100. package/tests/show.test.ts +0 -66
  101. package/tests/utils.ts +0 -3
  102. package/tsconfig.tsbuildinfo +0 -1
  103. /package/dist/{element.d.ts → core/element.d.ts} +0 -0
  104. /package/dist/{signals.d.ts → core/signals.d.ts} +0 -0
  105. /package/dist/{signals.js → core/signals.js} +0 -0
@@ -0,0 +1,48 @@
1
+ # Simple workflow for deploying static content to GitHub Pages
2
+ name: Deploy static content to Pages
3
+ on:
4
+ # Runs on pushes targeting the default branch
5
+ push:
6
+ branches: ["main"]
7
+ # Allows you to run this workflow manually from the Actions tab
8
+ workflow_dispatch:
9
+ # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
10
+ permissions:
11
+ contents: read
12
+ pages: write
13
+ id-token: write
14
+ # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
15
+ # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
16
+ concurrency:
17
+ group: "pages"
18
+ cancel-in-progress: false
19
+ jobs:
20
+ # Single deploy job since we're just deploying
21
+ deploy:
22
+ environment:
23
+ name: github-pages
24
+ url: ${{ steps.deployment.outputs.page_url }}
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - name: Checkout
28
+ uses: actions/checkout@v4
29
+ - name: Setup Node.js
30
+ uses: actions/setup-node@v4
31
+ with:
32
+ node-version: '20'
33
+ - name: Install dependencies
34
+ run: npm i
35
+ working-directory: ./docs
36
+ - name: Build project
37
+ run: npm run build
38
+ working-directory: ./docs
39
+ - name: Setup Pages
40
+ uses: actions/configure-pages@v5
41
+ - name: Upload artifact
42
+ uses: actions/upload-pages-artifact@v3
43
+ with:
44
+ # Upload entire repository
45
+ path: 'docs/dist'
46
+ - name: Deploy to GitHub Pages
47
+ id: deployment
48
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,39 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '*'
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+
12
+ strategy:
13
+ matrix:
14
+ node-version: [20.x]
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Use Node.js ${{ matrix.node-version }}
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: ${{ matrix.node-version }}
24
+
25
+ - name: Install core dependencies
26
+ working-directory: packages/core
27
+ run: npm install
28
+
29
+ - name: Run core tests
30
+ working-directory: packages/core
31
+ run: npm test
32
+
33
+ - name: Install vite-plugin dependencies
34
+ working-directory: packages/vite-plugin
35
+ run: npm install
36
+
37
+ - name: Run vite-plugin tests
38
+ working-directory: packages/vite-plugin
39
+ run: npm test
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thwarmon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Thyn
2
+
3
+ **Thyn** is an experimental JavaScript UI framework for building fast, memory-efficient web apps. It compiles `.thyn` single-file components into highly optimized DOM operations.
4
+
5
+ - Fast runtime performance (see [jsbenchmarks](https://jsbenchmarks.com/) and [js-framekworks-benchmark](https://krausest.github.io/js-framework-benchmark/2025/table_chrome_143.0.7499.41.html))
6
+ - Tiny bundles
7
+ - Minimal memory footprint
8
+ - Uses `.thyn` single-file components
9
+ - **Experimental** — limited tooling and ecosystem
10
+
11
+ ---
12
+
13
+ ## Getting Started
14
+ ```sh
15
+ npx degit thynjs/template my-app
16
+ cd my-app
17
+ npm install
18
+ npm run dev
19
+ ```
20
+
21
+
22
+ ## Example: Counter
23
+
24
+ ```vue
25
+ <script>
26
+ const count = $signal(0);
27
+
28
+ $effect(() => {
29
+ console.log(`Count is ${count()}`);
30
+ });
31
+ </script>
32
+
33
+ <button onclick={() => count(c => c + 1)}>
34
+ Count: {{ count() }}
35
+ </button>
36
+
37
+ <style>
38
+ /* Styles are scoped by default to prevent style bleeding 🎉 */
39
+ button {
40
+ background: #333;
41
+ border: 0;
42
+ color: #fff;
43
+ }
44
+ </style>
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Status
50
+ Thyn is in early development. Expect sharp edges and limited integrations. Feedback welcome!
@@ -300,8 +300,8 @@ export function list(props, terminal = false) {
300
300
  return;
301
301
  }
302
302
  if (start < 0) {
303
- for (let i = nextItems.length; i < oldLength; i++) {
304
- const e = childNodes[offset + --oldLength];
303
+ for (let i = oldLength - 1; i >= nextItems.length; i--) {
304
+ const e = childNodes[offset + i];
305
305
  teardownNode(e);
306
306
  remove(e);
307
307
  }
@@ -327,7 +327,7 @@ export function list(props, terminal = false) {
327
327
  for (const e of removalQueue) {
328
328
  remove(e);
329
329
  }
330
- if (oldLength - start === removalQueue.length) {
330
+ if (oldLength - start === removalQueue.length && newLength === oldLength) {
331
331
  prevItems = nextItems;
332
332
  nextItems = null;
333
333
  return;
@@ -337,10 +337,7 @@ export function list(props, terminal = false) {
337
337
  if (childNodes[i + offset] &&
338
338
  (!nextItems[i] ||
339
339
  prevItems[i] !== nextItems[i])) {
340
- keyMap.set(prevItems[i], {
341
- el: childNodes[i + offset],
342
- item: prevItems[i],
343
- });
340
+ keyMap.set(prevItems[i], childNodes[i + offset]);
344
341
  }
345
342
  }
346
343
  while (start <= newLength) {
@@ -351,22 +348,18 @@ export function list(props, terminal = false) {
351
348
  continue;
352
349
  }
353
350
  if (oldChd === undefined) {
354
- parent.insertBefore(render(newChd), endBookend);
351
+ parent.insertBefore(render(newChd), childNodeList[start + offset] ?? endBookend);
355
352
  start++;
356
353
  continue;
357
354
  }
358
355
  const mappedOld = keyMap.get(newChd);
359
356
  if (mappedOld) {
360
357
  const oldDom = childNodeList[start + offset];
361
- const { el, item } = mappedOld;
362
- if (oldDom !== el) {
363
- const tmp = el.nextSibling;
364
- parent.insertBefore(el, oldDom);
358
+ if (oldDom !== mappedOld) {
359
+ const tmp = mappedOld.nextSibling;
360
+ parent.insertBefore(mappedOld, oldDom);
365
361
  parent.insertBefore(oldDom, tmp);
366
362
  }
367
- else if (item !== newChd) {
368
- replaceWith(newChd, el, render);
369
- }
370
363
  keyMap.delete(newChd);
371
364
  }
372
365
  else if (oldChd !== newChd) {
@@ -374,10 +367,6 @@ export function list(props, terminal = false) {
374
367
  }
375
368
  start++;
376
369
  }
377
- for (const { el } of keyMap.values()) {
378
- teardownNode(el);
379
- remove(el);
380
- }
381
370
  keyMap = null;
382
371
  prevItems = nextItems;
383
372
  nextItems = null;
@@ -507,7 +496,7 @@ export function isolatedTerminalList(props) {
507
496
  ch.remove();
508
497
  childNodes[i] = null;
509
498
  }
510
- if (oldLength - start === removalQueueIndices.length) {
499
+ if (oldLength - start === removalQueueIndices.length && newLength === oldLength) {
511
500
  prevItems = nextItems;
512
501
  nextItems = null;
513
502
  childNodes = null;
@@ -518,10 +507,7 @@ export function isolatedTerminalList(props) {
518
507
  if (childNodes[i + 1] &&
519
508
  (!nextItems[i] ||
520
509
  prevItems[i] !== nextItems[i])) {
521
- keyMap.set(prevItems[i], {
522
- el: childNodes[i + 1],
523
- item: prevItems[i],
524
- });
510
+ keyMap.set(prevItems[i], childNodes[i + 1]);
525
511
  }
526
512
  }
527
513
  while (start <= newLength) {
@@ -532,24 +518,20 @@ export function isolatedTerminalList(props) {
532
518
  continue;
533
519
  }
534
520
  if (oldChd === undefined) {
535
- parent.insertBefore(render(newChd), endBookend);
521
+ parent.insertBefore(render(newChd), childNodeList[start + 1]);
536
522
  start++;
537
523
  continue;
538
524
  }
539
525
  const mappedOld = keyMap.get(newChd);
540
526
  if (mappedOld) {
541
527
  const oldDom = childNodeList[start + 1];
542
- const { el, item } = mappedOld;
543
- if (oldDom !== el) {
544
- const tmp = el.nextSibling;
545
- parent.insertBefore(el, oldDom);
528
+ if (oldDom !== mappedOld) {
529
+ const tmp = mappedOld.nextSibling;
530
+ parent.insertBefore(mappedOld, oldDom);
546
531
  if (oldDom !== tmp) {
547
532
  parent.insertBefore(oldDom, tmp);
548
533
  }
549
534
  }
550
- else if (item !== newChd) {
551
- replaceWith(newChd, el, render);
552
- }
553
535
  keyMap.delete(newChd);
554
536
  }
555
537
  else if (oldChd !== newChd) {
@@ -557,10 +539,6 @@ export function isolatedTerminalList(props) {
557
539
  }
558
540
  start++;
559
541
  }
560
- for (const { el } of keyMap.values()) {
561
- shallowTeardown(el);
562
- el.remove();
563
- }
564
542
  keyMap = null;
565
543
  prevItems = nextItems;
566
544
  nextItems = null;
@@ -0,0 +1 @@
1
+ export * from "./router.js";
@@ -0,0 +1 @@
1
+ export * from "./router.js";
@@ -1,5 +1,5 @@
1
1
  export declare const router: {
2
- path: import("./signals.js").Signal<string>;
2
+ readonly path: any;
3
3
  param: (name: string) => string | undefined;
4
4
  };
5
5
  interface Route {
@@ -1,11 +1,27 @@
1
1
  import { component, show } from "./element.js";
2
2
  import { $signal, staticEffect } from "./signals.js";
3
- const params = $signal({});
3
+ let params = null;
4
+ let routerPath = null;
5
+ let initialized = false;
6
+ function initRouter() {
7
+ if (initialized)
8
+ return;
9
+ params = $signal({});
10
+ routerPath = $signal(location.pathname);
11
+ initialized = true;
12
+ }
4
13
  export const router = {
5
- path: $signal(location.pathname),
6
- param: (name) => params()[name],
14
+ get path() {
15
+ initRouter();
16
+ return routerPath;
17
+ },
18
+ param: (name) => {
19
+ initRouter();
20
+ return params()[name];
21
+ },
7
22
  };
8
23
  export function Router({ routes }) {
24
+ initRouter();
9
25
  const current = $signal(null);
10
26
  const compiledRoutes = routes.map(route => {
11
27
  const compiledRoute = {
@@ -21,7 +37,7 @@ export function Router({ routes }) {
21
37
  return compiledRoute;
22
38
  });
23
39
  staticEffect(() => {
24
- const pn = router.path();
40
+ const pn = routerPath();
25
41
  if (pn !== location.pathname) {
26
42
  history.pushState({}, "", pn);
27
43
  }
@@ -48,6 +64,7 @@ export function Router({ routes }) {
48
64
  })));
49
65
  }
50
66
  export function Link({ slot, to }) {
67
+ initRouter();
51
68
  const a = document.createElement("a");
52
69
  a.href = to;
53
70
  for (const ch of slot) {
@@ -59,7 +76,7 @@ export function Link({ slot, to }) {
59
76
  !(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) {
60
77
  e.preventDefault();
61
78
  history.pushState({}, "", to);
62
- router.path(to);
79
+ routerPath(to);
63
80
  }
64
81
  };
65
82
  return a;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,5 @@
1
- export { addChildren, addEffect, component, createReactiveTextNode, fixedComponent, isolatedTerminalList, list, markAsReactive, mount, setAttribute, setProperty, setReactiveAttribute, setReactiveProperty, show, terminalList } from "./element.js";
2
- export { $effect, $signal, staticEffect } from "./signals.js";
1
+ export * from "./core/signals.js";
2
+ export * from "./core/element.js";
3
+ export * from "./core/router.js";
4
+ export { default } from "./plugin/index.js";
5
+ export { transformSFC, compileSFC } from "./plugin/index.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
- export { addChildren, addEffect, component, createReactiveTextNode, fixedComponent, isolatedTerminalList, list, markAsReactive, mount, setAttribute, setProperty, setReactiveAttribute, setReactiveProperty, show, terminalList } from "./element.js";
2
- export { $effect, $signal, staticEffect } from "./signals.js";
1
+ export * from "./core/signals.js";
2
+ export * from "./core/element.js";
3
+ export * from "./core/router.js";
4
+ export { default } from "./plugin/index.js";
5
+ export { transformSFC, compileSFC } from "./plugin/index.js";
@@ -0,0 +1,31 @@
1
+ interface Node {
2
+ nodeType: number;
3
+ nodeName: string;
4
+ textContent: string;
5
+ childNodes: Node[];
6
+ }
7
+ interface Element extends Node {
8
+ tagName: string;
9
+ attributes: Array<{
10
+ name: string;
11
+ value: string;
12
+ }>;
13
+ children: Element[];
14
+ firstElementChild: Element | null;
15
+ hasAttribute(name: string): boolean;
16
+ getAttribute(name: string): string | null;
17
+ setAttribute(name: string, value: string): void;
18
+ removeAttribute(name: string): void;
19
+ classList: {
20
+ add(className: string): void;
21
+ };
22
+ }
23
+ interface DocumentFragment {
24
+ childNodes: Node[];
25
+ firstElementChild: Element | null;
26
+ }
27
+ interface TemplateElement extends Element {
28
+ content: DocumentFragment;
29
+ }
30
+ export declare function parseHTML(html: string): TemplateElement;
31
+ export {};
@@ -0,0 +1,275 @@
1
+ function parseAttributes(attrStr) {
2
+ const attrs = [];
3
+ let i = 0;
4
+ while (i < attrStr.length) {
5
+ // Skip whitespace
6
+ while (i < attrStr.length && /\s/.test(attrStr[i]))
7
+ i++;
8
+ if (i >= attrStr.length)
9
+ break;
10
+ // Parse attribute name
11
+ let name = "";
12
+ while (i < attrStr.length && !/[\s=]/.test(attrStr[i])) {
13
+ name += attrStr[i];
14
+ i++;
15
+ }
16
+ if (!name)
17
+ break;
18
+ // Skip whitespace
19
+ while (i < attrStr.length && /\s/.test(attrStr[i]))
20
+ i++;
21
+ let value = "";
22
+ if (i < attrStr.length && attrStr[i] === "=") {
23
+ i++; // skip '='
24
+ // Skip whitespace
25
+ while (i < attrStr.length && /\s/.test(attrStr[i]))
26
+ i++;
27
+ if (i < attrStr.length) {
28
+ const quote = attrStr[i];
29
+ if (quote === '"' || quote === "'") {
30
+ i++; // skip opening quote
31
+ while (i < attrStr.length && attrStr[i] !== quote) {
32
+ value += attrStr[i];
33
+ i++;
34
+ }
35
+ if (i < attrStr.length)
36
+ i++; // skip closing quote
37
+ }
38
+ else {
39
+ // Unquoted value - take until whitespace
40
+ while (i < attrStr.length && !/\s/.test(attrStr[i])) {
41
+ value += attrStr[i];
42
+ i++;
43
+ }
44
+ }
45
+ }
46
+ }
47
+ attrs.push({ name, value });
48
+ }
49
+ return attrs;
50
+ }
51
+ function createTextNode(text) {
52
+ return {
53
+ nodeType: 3,
54
+ nodeName: "#text",
55
+ textContent: text,
56
+ childNodes: [],
57
+ };
58
+ }
59
+ function createElement(tagName, attributes = []) {
60
+ const children = [];
61
+ const childNodes = [];
62
+ const element = {
63
+ nodeType: 1,
64
+ nodeName: tagName.toUpperCase(),
65
+ tagName: tagName.toUpperCase(),
66
+ textContent: "",
67
+ attributes: [...attributes],
68
+ children,
69
+ childNodes,
70
+ firstElementChild: null,
71
+ hasAttribute(name) {
72
+ return this.attributes.some((attr) => attr.name === name);
73
+ },
74
+ getAttribute(name) {
75
+ const attr = this.attributes.find((attr) => attr.name === name);
76
+ return attr ? attr.value : null;
77
+ },
78
+ setAttribute(name, value) {
79
+ const existing = this.attributes.find((attr) => attr.name === name);
80
+ if (existing) {
81
+ existing.value = value;
82
+ }
83
+ else {
84
+ this.attributes.push({ name, value });
85
+ }
86
+ },
87
+ removeAttribute(name) {
88
+ this.attributes = this.attributes.filter((attr) => attr.name !== name);
89
+ },
90
+ classList: {
91
+ add: (className) => {
92
+ const existing = element.getAttribute("class");
93
+ const classes = existing ? existing.split(" ").filter(Boolean) : [];
94
+ if (!classes.includes(className)) {
95
+ classes.push(className);
96
+ element.setAttribute("class", classes.join(" "));
97
+ }
98
+ },
99
+ },
100
+ };
101
+ return element;
102
+ }
103
+ // Find next tag position, properly handling quoted strings
104
+ function findNextTag(html, startIndex) {
105
+ let i = startIndex;
106
+ while (i < html.length) {
107
+ // Find the next '<'
108
+ while (i < html.length && html[i] !== '<') {
109
+ i++;
110
+ }
111
+ if (i >= html.length)
112
+ return null;
113
+ const tagStart = i;
114
+ i++; // skip '<'
115
+ // Check if it's a closing tag
116
+ const isClose = i < html.length && html[i] === '/';
117
+ if (isClose)
118
+ i++;
119
+ // Parse tag name
120
+ let tagName = '';
121
+ while (i < html.length && /[a-zA-Z0-9-]/.test(html[i])) {
122
+ tagName += html[i];
123
+ i++;
124
+ }
125
+ if (!tagName) {
126
+ // Not a valid tag, continue searching
127
+ i = tagStart + 1;
128
+ continue;
129
+ }
130
+ // Parse attributes, respecting quotes
131
+ let attrs = '';
132
+ let inQuote = null;
133
+ let tagEnd = -1;
134
+ while (i < html.length) {
135
+ const char = html[i];
136
+ if (inQuote) {
137
+ attrs += char;
138
+ if (char === inQuote) {
139
+ inQuote = null;
140
+ }
141
+ i++;
142
+ }
143
+ else if (char === '"' || char === "'") {
144
+ attrs += char;
145
+ inQuote = char;
146
+ i++;
147
+ }
148
+ else if (char === '>') {
149
+ tagEnd = i + 1; // Include the '>'
150
+ i++;
151
+ break;
152
+ }
153
+ else {
154
+ attrs += char;
155
+ i++;
156
+ }
157
+ }
158
+ if (tagEnd === -1) {
159
+ // Malformed tag (no closing >), continue searching
160
+ i = tagStart + 1;
161
+ continue;
162
+ }
163
+ // Check for self-closing
164
+ const trimmedAttrs = attrs.trim();
165
+ const isSelfClose = trimmedAttrs.endsWith('/');
166
+ const finalAttrs = isSelfClose ? trimmedAttrs.slice(0, -1).trim() : trimmedAttrs;
167
+ return {
168
+ index: tagStart,
169
+ endIndex: tagEnd,
170
+ isClose,
171
+ tagName,
172
+ attrs: finalAttrs,
173
+ isSelfClose
174
+ };
175
+ }
176
+ return null;
177
+ }
178
+ export function parseHTML(html) {
179
+ const match = html.match(/<template([^>]*)>([\s\S]*)<\/template>/i);
180
+ if (!match) {
181
+ throw new Error("No <template> tag found in HTML");
182
+ }
183
+ const content = match[2].trim();
184
+ const stack = [];
185
+ const textChunks = [];
186
+ const fragmentChildren = [];
187
+ const fragmentElements = [];
188
+ let pos = 0;
189
+ const flushText = () => {
190
+ if (textChunks.length > 0) {
191
+ const text = textChunks.join("");
192
+ textChunks.length = 0;
193
+ const textNode = createTextNode(text);
194
+ if (stack.length > 0) {
195
+ const parent = stack[stack.length - 1];
196
+ parent.childNodes.push(textNode);
197
+ }
198
+ else {
199
+ fragmentChildren.push(textNode);
200
+ }
201
+ }
202
+ };
203
+ while (pos < content.length) {
204
+ const tagInfo = findNextTag(content, pos);
205
+ if (!tagInfo) {
206
+ // No more tags, add remaining as text
207
+ if (pos < content.length) {
208
+ textChunks.push(content.slice(pos));
209
+ }
210
+ break;
211
+ }
212
+ // Add text before this tag
213
+ if (tagInfo.index > pos) {
214
+ textChunks.push(content.slice(pos, tagInfo.index));
215
+ }
216
+ const { isClose, tagName, attrs, isSelfClose, endIndex } = tagInfo;
217
+ if (isClose) {
218
+ flushText();
219
+ if (stack.length > 0) {
220
+ const closedElement = stack.pop();
221
+ if (closedElement.tagName.toLowerCase() !== tagName.toLowerCase()) {
222
+ throw new Error(`Mismatched tags: expected </${closedElement.tagName}>, got </${tagName}>`);
223
+ }
224
+ // Update parent's firstElementChild if needed
225
+ const parent = stack.length > 0 ? stack[stack.length - 1] : null;
226
+ if (parent && !parent.firstElementChild) {
227
+ parent.firstElementChild = closedElement;
228
+ }
229
+ }
230
+ }
231
+ else {
232
+ flushText();
233
+ const attributes = parseAttributes(attrs);
234
+ const element = createElement(tagName, attributes);
235
+ if (stack.length === 0) {
236
+ // Top-level element
237
+ fragmentChildren.push(element);
238
+ fragmentElements.push(element);
239
+ }
240
+ else {
241
+ const parent = stack[stack.length - 1];
242
+ parent.children.push(element);
243
+ parent.childNodes.push(element);
244
+ if (!parent.firstElementChild) {
245
+ parent.firstElementChild = element;
246
+ }
247
+ }
248
+ if (!isSelfClose) {
249
+ stack.push(element);
250
+ }
251
+ }
252
+ // Move position past this tag
253
+ pos = endIndex;
254
+ }
255
+ // Flush any remaining text
256
+ if (stack.length === 0) {
257
+ flushText();
258
+ }
259
+ if (stack.length > 0) {
260
+ throw new Error(`Unclosed tags remain: ${stack.map(e => e.tagName).join(', ')}`);
261
+ }
262
+ const fragment = {
263
+ childNodes: fragmentChildren,
264
+ firstElementChild: fragmentElements[0] || null,
265
+ };
266
+ const templateAttrs = parseAttributes(match[1].trim());
267
+ const templateElement = {
268
+ ...createElement("template", templateAttrs),
269
+ content: fragment,
270
+ };
271
+ templateElement.childNodes = [...fragmentChildren];
272
+ templateElement.children = [...fragmentElements];
273
+ templateElement.firstElementChild = fragmentElements[0] || null;
274
+ return templateElement;
275
+ }