@esportsplus/template 0.16.15 → 0.17.1

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 CHANGED
@@ -9,6 +9,9 @@ High-performance, compiler-optimized HTML templating library for JavaScript/Type
9
9
  - **Reactive integration** - Works with `@esportsplus/reactivity` for dynamic updates
10
10
  - **Event delegation** - Efficient event handling with automatic delegation
11
11
  - **Lifecycle events** - `onconnect`, `ondisconnect`, `onrender`, `onresize`, `ontick`
12
+ - **Async slots** - Async function support with fallback content in `EffectSlot`
13
+ - **Non-destructive reordering** - Uses `moveBefore` DOM API for array sort/reverse when available
14
+ - **HMR support** - Fine-grained hot module replacement for templates in development
12
15
  - **Type-safe** - Full TypeScript support
13
16
 
14
17
  ## Installation
@@ -29,10 +32,12 @@ import { defineConfig } from 'vite';
29
32
  import template from '@esportsplus/template/compiler/vite';
30
33
 
31
34
  export default defineConfig({
32
- plugins: [template]
35
+ plugins: [template()]
33
36
  });
34
37
  ```
35
38
 
39
+ HMR is automatically enabled in development mode (`vite dev`). When a file containing `html` templates is saved, only the affected template factories are invalidated and re-created — no full page reload needed.
40
+
36
41
  ### TypeScript Compiler (tsc)
37
42
 
38
43
  For direct `tsc` compilation, use the transformer:
@@ -179,6 +184,31 @@ const todoList = (todos: string[]) =>
179
184
  html`<ul>${html.reactive(todos, todo => html`<li>${todo}</li>`)}</ul>`;
180
185
  ```
181
186
 
187
+ Array sort and reverse operations use the `moveBefore` DOM API when available, preserving element state (focus, animations, iframe content) during reordering. Falls back to `insertBefore` in older browsers.
188
+
189
+ ### Async Slots
190
+
191
+ Async functions are supported in effect slots, with an optional fallback callback for loading states:
192
+
193
+ ```typescript
194
+ import { html } from '@esportsplus/template';
195
+
196
+ // Async with fallback content while loading
197
+ const asyncContent = () =>
198
+ html`<div>${async (fallback: (content: any) => void) => {
199
+ fallback(html`<span>Loading...</span>`);
200
+ const data = await fetchData();
201
+ return html`<span>${data}</span>`;
202
+ }}</div>`;
203
+
204
+ // Simple async (no fallback)
205
+ const simpleAsync = () =>
206
+ html`<div>${async () => {
207
+ const data = await fetchData();
208
+ return html`<span>${data}</span>`;
209
+ }}</div>`;
210
+ ```
211
+
182
212
  ## Events
183
213
 
184
214
  ### Standard DOM Events
@@ -270,6 +300,8 @@ const circle = (fill: string) =>
270
300
  | `slot` | Slot rendering |
271
301
  | `ArraySlot` | Reactive array rendering |
272
302
  | `EffectSlot` | Reactive effect rendering |
303
+ | `accept` | HMR accept handler (dev only) |
304
+ | `createHotTemplate` | HMR template factory (dev only) |
273
305
 
274
306
  ### Types
275
307
 
@@ -1,13 +1,17 @@
1
1
  declare const _default: ({ root }?: {
2
2
  root?: string;
3
3
  }) => {
4
- configResolved: (config: unknown) => void;
5
- enforce: "pre";
6
- name: string;
7
- transform: (code: string, id: string) => {
4
+ configResolved(config: any): void;
5
+ handleHotUpdate(_ctx: {
6
+ file: string;
7
+ modules: any[];
8
+ }): void;
9
+ transform(code: string, id: string): {
8
10
  code: string;
9
11
  map: null;
10
12
  } | null;
13
+ enforce: "pre";
14
+ name: string;
11
15
  watchChange: (id: string) => void;
12
16
  };
13
17
  export default _default;
@@ -1,8 +1,43 @@
1
+ import { NAMESPACE, PACKAGE_NAME } from '../constants.js';
1
2
  import { plugin } from '@esportsplus/typescript/compiler';
2
- import { PACKAGE_NAME } from '../constants.js';
3
3
  import reactivity from '@esportsplus/reactivity/compiler';
4
4
  import template from '../index.js';
5
- export default plugin.vite({
5
+ const TEMPLATE_SEARCH = NAMESPACE + '.template(';
6
+ const TEMPLATE_CALL_REGEX = new RegExp('(const\\s+(\\w+)\\s*=\\s*' + NAMESPACE + '\\.template\\()(`)', 'g');
7
+ let base = plugin.vite({
6
8
  name: PACKAGE_NAME,
7
9
  plugins: [reactivity, template]
8
10
  });
11
+ function injectHMR(code, id) {
12
+ let hmrId = id.replace(/\\/g, '/'), hotReplace = NAMESPACE + '.createHotTemplate("' + hmrId + '", "', injected = code.replace(TEMPLATE_CALL_REGEX, function (_match, prefix, varName, backtick) {
13
+ return prefix.replace(TEMPLATE_SEARCH, hotReplace + varName + '", ') + backtick;
14
+ });
15
+ if (injected === code) {
16
+ return code;
17
+ }
18
+ injected += '\nif (import.meta.hot) { import.meta.hot.accept(() => { ' + NAMESPACE + '.accept("' + hmrId + '"); }); }';
19
+ return injected;
20
+ }
21
+ export default ({ root } = {}) => {
22
+ let isDev = false, vitePlugin = base({ root });
23
+ return {
24
+ ...vitePlugin,
25
+ configResolved(config) {
26
+ vitePlugin.configResolved(config);
27
+ isDev = config?.command === 'serve' || config?.mode === 'development';
28
+ },
29
+ handleHotUpdate(_ctx) {
30
+ },
31
+ transform(code, id) {
32
+ let result = vitePlugin.transform(code, id);
33
+ if (!result || !isDev) {
34
+ return result;
35
+ }
36
+ let injected = injectHMR(result.code, id);
37
+ if (injected === result.code) {
38
+ return result;
39
+ }
40
+ return { code: injected, map: null };
41
+ }
42
+ };
43
+ };
package/build/hmr.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ declare let modules: Map<string, Map<string, HotTemplate>>;
2
+ type HotTemplate = {
3
+ cached: DocumentFragment | undefined;
4
+ factory: () => DocumentFragment;
5
+ html: string;
6
+ };
7
+ declare const accept: (moduleId: string) => void;
8
+ declare const createHotTemplate: (moduleId: string, templateId: string, html: string) => () => DocumentFragment;
9
+ declare const hmrReset: () => void;
10
+ export { accept, createHotTemplate, hmrReset, modules };
package/build/hmr.js ADDED
@@ -0,0 +1,42 @@
1
+ let clone = (node, deep = true) => node.cloneNode(deep), modules = new Map(), tmpl = typeof document !== 'undefined' ? document.createElement('template') : null;
2
+ function invalidate(moduleId) {
3
+ let templates = modules.get(moduleId);
4
+ if (!templates) {
5
+ return;
6
+ }
7
+ for (let [, entry] of templates) {
8
+ entry.cached = undefined;
9
+ }
10
+ }
11
+ function register(moduleId, templateId, html) {
12
+ let entry = {
13
+ cached: undefined,
14
+ factory: () => {
15
+ if (!entry.cached) {
16
+ let element = tmpl.cloneNode();
17
+ element.innerHTML = entry.html;
18
+ entry.cached = element.content;
19
+ }
20
+ return clone(entry.cached, true);
21
+ },
22
+ html
23
+ };
24
+ (modules.get(moduleId) ?? (modules.set(moduleId, new Map()), modules.get(moduleId))).set(templateId, entry);
25
+ return entry.factory;
26
+ }
27
+ const accept = (moduleId) => {
28
+ invalidate(moduleId);
29
+ };
30
+ const createHotTemplate = (moduleId, templateId, html) => {
31
+ let existing = modules.get(moduleId)?.get(templateId);
32
+ if (existing) {
33
+ existing.cached = undefined;
34
+ existing.html = html;
35
+ return existing.factory;
36
+ }
37
+ return register(moduleId, templateId, html);
38
+ };
39
+ const hmrReset = () => {
40
+ modules.clear();
41
+ };
42
+ export { accept, createHotTemplate, hmrReset, modules };
package/build/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './attributes.js';
2
2
  export * from './event/index.js';
3
+ export * from './hmr.js';
3
4
  export * from './utilities.js';
4
5
  export { default as html } from './html.js';
5
6
  export { default as render } from './render.js';
package/build/index.js CHANGED
@@ -5,6 +5,7 @@ if (typeof Node !== 'undefined') {
5
5
  }
6
6
  export * from './attributes.js';
7
7
  export * from './event/index.js';
8
+ export * from './hmr.js';
8
9
  export * from './utilities.js';
9
10
  export { default as html } from './html.js';
10
11
  export { default as render } from './render.js';
@@ -190,7 +190,7 @@ class ArraySlot {
190
190
  if (!parent || keep.size === n) {
191
191
  return;
192
192
  }
193
- let ref = end;
193
+ let ref = end, useMoveBefore = 'moveBefore' in parent;
194
194
  for (let i = n - 1; i >= 0; i--) {
195
195
  let group = sorted[i];
196
196
  if (keep.has(i)) {
@@ -200,7 +200,12 @@ class ArraySlot {
200
200
  let node = group.tail;
201
201
  while (node) {
202
202
  let prev = node === group.head ? null : node.previousSibling;
203
- parent.insertBefore(node, ref);
203
+ if (useMoveBefore) {
204
+ parent.moveBefore(node, ref);
205
+ }
206
+ else {
207
+ parent.insertBefore(node, ref);
208
+ }
204
209
  ref = node;
205
210
  node = prev;
206
211
  }
@@ -219,6 +224,20 @@ class ArraySlot {
219
224
  if (!n) {
220
225
  return;
221
226
  }
227
+ let parent = this.marker.parentNode;
228
+ if (parent && 'moveBefore' in parent) {
229
+ let ref = nodes[0].tail.nextSibling;
230
+ for (let i = n - 1; i >= 0; i--) {
231
+ let group = nodes[i], node = group.tail;
232
+ while (node) {
233
+ let prev = node === group.head ? null : node.previousSibling;
234
+ parent.moveBefore(node, ref);
235
+ ref = node;
236
+ node = prev;
237
+ }
238
+ }
239
+ return;
240
+ }
222
241
  for (let i = 0; i < n; i++) {
223
242
  let group = nodes[i], next, node = group.head;
224
243
  while (node) {
@@ -1,11 +1,11 @@
1
- import { Element, Renderable, SlotGroup } from '../types.js';
1
+ import { Element, SlotGroup } from '../types.js';
2
2
  declare class EffectSlot {
3
3
  anchor: Element;
4
- disposer: VoidFunction;
4
+ disposer: VoidFunction | null;
5
5
  group: SlotGroup | null;
6
6
  scheduled: boolean;
7
7
  textnode: Node | null;
8
- constructor(anchor: Element, fn: (dispose?: VoidFunction) => Renderable<any>);
8
+ constructor(anchor: Element, fn: ((...args: any[]) => any));
9
9
  dispose(): void;
10
10
  update(value: unknown): void;
11
11
  }
@@ -1,4 +1,5 @@
1
1
  import { effect } from '@esportsplus/reactivity';
2
+ import { isAsyncFunction } from '@esportsplus/utilities';
2
3
  import { raf, text } from '../utilities.js';
3
4
  import { remove } from './cleanup.js';
4
5
  import render from './render.js';
@@ -18,37 +19,47 @@ class EffectSlot {
18
19
  scheduled = false;
19
20
  textnode = null;
20
21
  constructor(anchor, fn) {
21
- let dispose = fn.length ? () => this.dispose() : undefined, value;
22
22
  this.anchor = anchor;
23
- this.disposer = effect(() => {
24
- value = read(fn(dispose));
25
- if (!this.disposer) {
26
- this.update(value);
27
- }
28
- else if (!this.scheduled) {
29
- this.scheduled = true;
30
- raf(() => {
31
- this.scheduled = false;
23
+ this.disposer = null;
24
+ if (isAsyncFunction(fn)) {
25
+ fn((content) => this.update(content)).then((value) => this.update(value), () => { });
26
+ }
27
+ else {
28
+ let dispose = fn.length ? () => this.dispose() : undefined, value;
29
+ this.disposer = effect(() => {
30
+ value = read(fn(dispose));
31
+ if (!this.disposer) {
32
32
  this.update(value);
33
- });
34
- }
35
- });
33
+ }
34
+ else if (!this.scheduled) {
35
+ this.scheduled = true;
36
+ raf(() => {
37
+ this.scheduled = false;
38
+ this.update(value);
39
+ });
40
+ }
41
+ });
42
+ }
36
43
  }
37
44
  dispose() {
38
- let { anchor, group, textnode } = this;
45
+ let { anchor, disposer, group, textnode } = this;
46
+ if (!disposer) {
47
+ return;
48
+ }
39
49
  if (textnode) {
40
50
  group = { head: anchor, tail: textnode };
41
51
  }
42
52
  else if (group) {
43
53
  group.head = anchor;
44
54
  }
45
- this.disposer();
55
+ disposer();
46
56
  if (group) {
47
57
  remove(group);
48
58
  }
49
59
  }
50
60
  update(value) {
51
61
  let { anchor, group, textnode } = this;
62
+ value = read(value);
52
63
  if (group) {
53
64
  remove(group);
54
65
  this.group = null;
@@ -68,14 +79,22 @@ class EffectSlot {
68
79
  }
69
80
  }
70
81
  else {
71
- let fragment = render(anchor, value), head = fragment.firstChild;
82
+ let fragment = render(anchor, value), head, tail;
83
+ if (fragment.nodeType === 11) {
84
+ head = fragment.firstChild;
85
+ tail = fragment.lastChild;
86
+ }
87
+ else {
88
+ head = fragment;
89
+ tail = fragment;
90
+ }
72
91
  if (textnode?.isConnected) {
73
92
  remove({ head: textnode, tail: textnode });
74
93
  }
75
94
  if (head) {
76
95
  this.group = {
77
96
  head: head,
78
- tail: fragment.lastChild
97
+ tail: tail
79
98
  };
80
99
  anchor.after(fragment);
81
100
  }
package/llm.txt CHANGED
@@ -30,9 +30,16 @@ A compile-time template system that transforms `html` tagged template literals i
30
30
  │ RUNTIME │
31
31
  ├─────────────────────────────────────────────────────────────────┤
32
32
  │ utilities.ts → template(), clone(), fragment() │
33
+ │ hmr.ts → createHotTemplate(), accept() (dev only) │
33
34
  │ attributes.ts → setList(), setProperty(), reactive bindings │
34
35
  │ slot/ → ArraySlot, EffectSlot, static rendering │
35
36
  │ event/ → Event delegation, lifecycle hooks │
37
+ ├─────────────────────────────────────────────────────────────────┤
38
+ │ VITE PLUGIN (DEV) │
39
+ ├─────────────────────────────────────────────────────────────────┤
40
+ │ plugins/vite.ts → Wraps base transform + injects HMR code │
41
+ │ In dev: template() → createHotTemplate() │
42
+ │ Appends import.meta.hot.accept() │
36
43
  └─────────────────────────────────────────────────────────────────┘
37
44
  ```
38
45
 
@@ -182,11 +189,17 @@ Caches parsed HTML, returns cloned fragments on each call.
182
189
  - Wraps reactive function in `effect()`
183
190
  - Updates content on dependency changes
184
191
  - Batches updates via `requestAnimationFrame`
192
+ - **Async function support**: detects async functions via `isAsyncFunction()` from `@esportsplus/utilities`
193
+ - Async functions receive a `fallback` callback for loading state, resolved value replaces content
194
+ - Async signature: `async (fallback: (content: Renderable) => void) => Promise<Renderable>`
185
195
 
186
196
  3. **ArraySlot** (`slot/array.ts`)
187
197
  - Handles reactive arrays with fine-grained updates
188
- - Listens to array mutation events (push, pop, splice, etc.)
198
+ - Listens to array mutation events (push, pop, splice, sort, reverse, etc.)
189
199
  - Maintains DOM node groups per array item
200
+ - **`moveBefore` DOM API**: sort/reverse use `moveBefore` when available for non-destructive reordering (preserves focus, animations, iframe state)
201
+ - Falls back to `insertBefore` in browsers without `moveBefore`
202
+ - **LIS algorithm**: sort uses longest increasing subsequence to minimize DOM moves
190
203
 
191
204
  **SlotGroup Structure**:
192
205
 
@@ -291,6 +304,41 @@ const remove = (...groups) => {
291
304
  };
292
305
  ```
293
306
 
307
+ ### HMR Module: `hmr.ts`
308
+
309
+ **Purpose**: Dev-only runtime registry for hot module replacement of templates.
310
+
311
+ **Key Concepts**:
312
+ - In production, templates use `utilities.template()` which caches a parsed fragment and returns clones
313
+ - In development, the Vite plugin replaces `template()` calls with `createHotTemplate()` which registers factories in a module→template registry
314
+ - On file save, Vite triggers HMR accept which calls `accept(moduleId)` to invalidate cached fragments
315
+ - Next factory call re-parses the updated HTML, producing fresh DOM without page reload
316
+
317
+ **API**:
318
+
319
+ ```typescript
320
+ // Register or update a template factory for HMR tracking
321
+ createHotTemplate(moduleId: string, templateId: string, html: string): () => DocumentFragment
322
+
323
+ // Invalidate all cached templates for a module (called from import.meta.hot.accept)
324
+ accept(moduleId: string): void
325
+ ```
326
+
327
+ **Registry Structure**: `Map<moduleId, Map<templateId, HotTemplate>>` where HotTemplate holds the html string, cached fragment, and factory closure.
328
+
329
+ ### Vite Plugin: `compiler/plugins/vite.ts`
330
+
331
+ **Purpose**: Wraps the base `plugin.vite()` transform with HMR injection.
332
+
333
+ **How it works**:
334
+ 1. `configResolved` captures dev mode (`command === 'serve'` or `mode === 'development'`)
335
+ 2. In dev mode, after the base transform runs, `injectHMR()` post-processes the output:
336
+ - Replaces `NAMESPACE.template(\`...\`)` → `NAMESPACE.createHotTemplate(moduleId, templateId, \`...\`)`
337
+ - Appends `import.meta.hot.accept(() => { NAMESPACE.accept(moduleId); })`
338
+ 3. In production, no HMR code is injected — identical to previous behavior
339
+
340
+ **Regex**: `TEMPLATE_CALL_REGEX` matches `const varName = NAMESPACE.template(` to capture the variable name as the templateId.
341
+
294
342
  ## Constants Reference
295
343
 
296
344
  ### `constants.ts` (Runtime)
@@ -330,6 +378,11 @@ const remove = (...groups) => {
330
378
  - Add to `DIRECT_ATTACH_EVENTS` set
331
379
  - Uses existing `on()` function
332
380
 
381
+ 4. **Modifying HMR behavior**
382
+ - `hmr.ts` is standalone with no internal deps — safe to modify
383
+ - `injectHMR()` in `plugins/vite.ts` only runs in dev mode
384
+ - Production builds are unaffected by HMR changes
385
+
333
386
  ### Dangerous Changes
334
387
 
335
388
  1. **Modifying `parser.ts` level tracking**
@@ -345,6 +398,11 @@ const remove = (...groups) => {
345
398
  - Slots must be processed in document order
346
399
  - Expression indices must match slot order
347
400
 
401
+ 4. **Modifying `slot/array.ts` sort/sync**
402
+ - Uses `moveBefore` API with `insertBefore` fallback — both paths must stay in sync
403
+ - LIS algorithm in `lis()` determines which nodes to keep in place; incorrect LIS = unnecessary moves
404
+ - `sync()` and `sort()` both have moveBefore/insertBefore branches
405
+
348
406
  ### Testing Recommendations
349
407
 
350
408
  After modifying parser/codegen:
@@ -373,6 +431,7 @@ After modifying parser/codegen:
373
431
  index.ts (runtime entry)
374
432
  ├── constants.ts
375
433
  ├── attributes.ts ← constants, types, utilities, event
434
+ ├── hmr.ts (standalone, no internal deps)
376
435
  ├── html.ts ← types, slot (stub, replaced at compile)
377
436
  ├── render.ts ← types, utilities, slot
378
437
  ├── svg.ts (same as html.ts)
@@ -381,7 +440,7 @@ index.ts (runtime entry)
381
440
  ├── slot/
382
441
  │ ├── index.ts ← types, effect, render
383
442
  │ ├── array.ts ← reactivity, constants, types, utilities, cleanup, html
384
- │ ├── effect.ts ← reactivity, types, utilities, cleanup, render
443
+ │ ├── effect.ts ← reactivity, utilities (isAsyncFunction), types, utilities, cleanup, render
385
444
  │ ├── cleanup.ts ← constants, types
386
445
  │ └── render.ts ← utilities, constants, types, array
387
446
  └── event/
@@ -398,6 +457,6 @@ compiler/
398
457
  ├── ts-parser.ts ← typescript, typescript/compiler, constants
399
458
  ├── ts-analyzer.ts ← typescript, constants
400
459
  └── plugins/
401
- ├── vite.ts ← typescript/compiler, constants, reactivity/compiler, ..
402
- └── tsc.ts (similar)
460
+ ├── vite.ts ← typescript/compiler, constants, reactivity/compiler, .. (wraps base plugin, adds HMR injection)
461
+ └── tsc.ts (similar, no HMR)
403
462
  ```
package/package.json CHANGED
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "type": "module",
40
40
  "types": "./build/index.d.ts",
41
- "version": "0.16.15",
41
+ "version": "0.17.1",
42
42
  "scripts": {
43
43
  "bench:run": "vitest bench --config vitest.bench.config.ts",
44
44
  "build": "tsc",
@@ -1,10 +1,79 @@
1
+ import { NAMESPACE, PACKAGE_NAME } from '../constants';
1
2
  import { plugin } from '@esportsplus/typescript/compiler';
2
- import { PACKAGE_NAME } from '../constants';
3
+
3
4
  import reactivity from '@esportsplus/reactivity/compiler';
4
5
  import template from '..';
5
6
 
6
7
 
7
- export default plugin.vite({
8
- name: PACKAGE_NAME,
9
- plugins: [reactivity, template]
10
- });
8
+ type VitePlugin = {
9
+ configResolved: (config: any) => void;
10
+ enforce: 'pre';
11
+ handleHotUpdate?: (ctx: { file: string; modules: any[] }) => void;
12
+ name: string;
13
+ transform: (code: string, id: string) => { code: string; map: null } | null;
14
+ watchChange: (id: string) => void;
15
+ };
16
+
17
+
18
+ const TEMPLATE_SEARCH = NAMESPACE + '.template(';
19
+
20
+ const TEMPLATE_CALL_REGEX = new RegExp(
21
+ '(const\\s+(\\w+)\\s*=\\s*' + NAMESPACE + '\\.template\\()(`)',
22
+ 'g'
23
+ );
24
+
25
+
26
+ let base = plugin.vite({
27
+ name: PACKAGE_NAME,
28
+ plugins: [reactivity, template]
29
+ });
30
+
31
+
32
+ function injectHMR(code: string, id: string): string {
33
+ let hmrId = id.replace(/\\/g, '/'),
34
+ hotReplace = NAMESPACE + '.createHotTemplate("' + hmrId + '", "',
35
+ injected = code.replace(TEMPLATE_CALL_REGEX, function(_match: string, prefix: string, varName: string, backtick: string) {
36
+ return prefix.replace(TEMPLATE_SEARCH, hotReplace + varName + '", ') + backtick;
37
+ });
38
+
39
+ if (injected === code) {
40
+ return code;
41
+ }
42
+
43
+ injected += '\nif (import.meta.hot) { import.meta.hot.accept(() => { ' + NAMESPACE + '.accept("' + hmrId + '"); }); }';
44
+
45
+ return injected;
46
+ }
47
+
48
+
49
+ export default ({ root }: { root?: string } = {}) => {
50
+ let isDev = false,
51
+ vitePlugin = base({ root });
52
+
53
+ return {
54
+ ...vitePlugin,
55
+ configResolved(config: any) {
56
+ vitePlugin.configResolved(config);
57
+ isDev = config?.command === 'serve' || config?.mode === 'development';
58
+ },
59
+ handleHotUpdate(_ctx: { file: string; modules: any[] }) {
60
+ // Let Vite handle the default HMR flow
61
+ },
62
+ transform(code: string, id: string) {
63
+ let result = vitePlugin.transform(code, id);
64
+
65
+ if (!result || !isDev) {
66
+ return result;
67
+ }
68
+
69
+ let injected = injectHMR(result.code, id);
70
+
71
+ if (injected === result.code) {
72
+ return result;
73
+ }
74
+
75
+ return { code: injected, map: null };
76
+ }
77
+ } satisfies VitePlugin;
78
+ };
79
+
package/src/hmr.ts ADDED
@@ -0,0 +1,70 @@
1
+ let clone = <T extends DocumentFragment | Node>(node: T, deep: boolean = true) => node.cloneNode(deep) as T,
2
+ modules = new Map<string, Map<string, HotTemplate>>(),
3
+ tmpl = typeof document !== 'undefined' ? document.createElement('template') : null;
4
+
5
+
6
+ type HotTemplate = {
7
+ cached: DocumentFragment | undefined;
8
+ factory: () => DocumentFragment;
9
+ html: string;
10
+ };
11
+
12
+
13
+ function invalidate(moduleId: string): void {
14
+ let templates = modules.get(moduleId);
15
+
16
+ if (!templates) {
17
+ return;
18
+ }
19
+
20
+ for (let [, entry] of templates) {
21
+ entry.cached = undefined;
22
+ }
23
+ }
24
+
25
+ function register(moduleId: string, templateId: string, html: string): () => DocumentFragment {
26
+ let entry: HotTemplate = {
27
+ cached: undefined,
28
+ factory: () => {
29
+ if (!entry.cached) {
30
+ let element = tmpl!.cloneNode() as HTMLTemplateElement;
31
+
32
+ element.innerHTML = entry.html;
33
+ entry.cached = element.content;
34
+ }
35
+
36
+ return clone(entry.cached!, true) as DocumentFragment;
37
+ },
38
+ html
39
+ };
40
+
41
+ (modules.get(moduleId) ?? (modules.set(moduleId, new Map()), modules.get(moduleId)!)).set(templateId, entry);
42
+
43
+ return entry.factory;
44
+ }
45
+
46
+
47
+ const accept = (moduleId: string): void => {
48
+ invalidate(moduleId);
49
+ };
50
+
51
+ const createHotTemplate = (moduleId: string, templateId: string, html: string): () => DocumentFragment => {
52
+ let existing = modules.get(moduleId)?.get(templateId);
53
+
54
+ if (existing) {
55
+ existing.cached = undefined;
56
+ existing.html = html;
57
+
58
+ return existing.factory;
59
+ }
60
+
61
+ return register(moduleId, templateId, html);
62
+ };
63
+
64
+ // Test-only: reset state
65
+ const hmrReset = (): void => {
66
+ modules.clear();
67
+ };
68
+
69
+
70
+ export { accept, createHotTemplate, hmrReset, modules };
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ if (typeof Node !== 'undefined') {
10
10
 
11
11
  export * from './attributes';
12
12
  export * from './event';
13
+ export * from './hmr';
13
14
  export * from './utilities';
14
15
 
15
16
  export { default as html } from './html';