@c9up/aurora 0.1.3

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/AuroraManager.d.ts +44 -0
  4. package/dist/AuroraManager.js +47 -0
  5. package/dist/AuroraProvider.d.ts +52 -0
  6. package/dist/AuroraProvider.js +145 -0
  7. package/dist/Pages.d.ts +78 -0
  8. package/dist/Pages.js +116 -0
  9. package/dist/component.d.ts +55 -0
  10. package/dist/component.js +97 -0
  11. package/dist/html.d.ts +30 -0
  12. package/dist/html.js +246 -0
  13. package/dist/hydrate.d.ts +29 -0
  14. package/dist/hydrate.js +379 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +12 -0
  17. package/dist/reactive.d.ts +83 -0
  18. package/dist/reactive.js +217 -0
  19. package/dist/relay.d.ts +43 -0
  20. package/dist/relay.js +144 -0
  21. package/dist/render.d.ts +25 -0
  22. package/dist/render.js +283 -0
  23. package/dist/route.d.ts +64 -0
  24. package/dist/route.js +49 -0
  25. package/dist/server/renderPage.d.ts +62 -0
  26. package/dist/server/renderPage.js +83 -0
  27. package/dist/server/serveAssets.d.ts +43 -0
  28. package/dist/server/serveAssets.js +89 -0
  29. package/dist/services/main.d.ts +18 -0
  30. package/dist/services/main.js +31 -0
  31. package/dist/ssr.d.ts +22 -0
  32. package/dist/ssr.js +179 -0
  33. package/dist/types.d.ts +78 -0
  34. package/dist/types.js +15 -0
  35. package/package.json +69 -0
  36. package/src/AuroraManager.ts +76 -0
  37. package/src/AuroraProvider.ts +187 -0
  38. package/src/Pages.ts +164 -0
  39. package/src/component.ts +138 -0
  40. package/src/html.ts +296 -0
  41. package/src/hydrate.ts +518 -0
  42. package/src/index.ts +43 -0
  43. package/src/reactive.ts +265 -0
  44. package/src/relay.ts +171 -0
  45. package/src/render.ts +378 -0
  46. package/src/route.ts +96 -0
  47. package/src/server/renderPage.ts +135 -0
  48. package/src/server/serveAssets.ts +135 -0
  49. package/src/services/main.ts +40 -0
  50. package/src/ssr.ts +179 -0
  51. package/src/types.ts +97 -0
package/src/Pages.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Page registry — resolves a page NAME (e.g. `"ProjectPage"`) to its
3
+ * factory function, both server-side (dynamic import from disk) and
4
+ * client-side (URL on the asset mount).
5
+ *
6
+ * Convention: pages live in a configurable root directory, one file
7
+ * per page, default-exporting a function `(props) => TemplateResult`.
8
+ *
9
+ * resources/pages/
10
+ * ProjectPage.js → name `"ProjectPage"`
11
+ * dashboard/Home.js → name `"dashboard/Home"`
12
+ *
13
+ * Sub-paths are allowed; the last `/`-separated segment is the file
14
+ * stem (with or without the `.js` extension).
15
+ */
16
+
17
+ import { resolve as resolvePath, sep } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import type { TemplateResult } from "./types.js";
20
+
21
+ /** A page module's default export. Receives props, returns a template. */
22
+ export type PageFactory<P = unknown> = (
23
+ props: P,
24
+ ) => TemplateResult | Promise<TemplateResult>;
25
+
26
+ export interface PagesConfig {
27
+ /**
28
+ * Absolute filesystem path to the pages directory. The server
29
+ * imports `${root}/${name}.js` (or `${name}.ts` when transpiled at
30
+ * runtime by `@swc-node/register`).
31
+ */
32
+ root: string;
33
+
34
+ /**
35
+ * URL prefix the browser uses to fetch a page's compiled JS.
36
+ * Defaults to `/_assets/pages`. A name `"Foo"` maps to
37
+ * `${urlPrefix}/Foo.js`.
38
+ */
39
+ urlPrefix?: string;
40
+
41
+ /**
42
+ * File extension to append when neither the source nor the
43
+ * compiled module ships with one. Defaults to `.js` — Node ESM
44
+ * resolution requires the explicit extension, and `@swc-node`
45
+ * transparently handles `.ts` aliases that resolve back to `.js`.
46
+ */
47
+ extension?: string;
48
+ }
49
+
50
+ /**
51
+ * `Pages` is a tiny resolver — no caching, no glob, no magic. The
52
+ * server imports the module dynamically on every render so editors +
53
+ * `--watch` reloads pick up changes immediately. Apps that want a
54
+ * pre-registered map (e.g. when pages are bundled into one entry) can
55
+ * call `register()` to short-circuit the disk lookup.
56
+ */
57
+ export class Pages {
58
+ readonly root: string;
59
+ readonly urlPrefix: string;
60
+ readonly extension: string;
61
+
62
+ private readonly registry = new Map<string, PageFactory>();
63
+
64
+ constructor(config: PagesConfig) {
65
+ this.root = config.root;
66
+ this.urlPrefix = (config.urlPrefix ?? "/_assets/pages").replace(/\/$/, "");
67
+ this.extension = config.extension ?? ".js";
68
+ }
69
+
70
+ /**
71
+ * Pre-register a page factory under `name`, bypassing the disk
72
+ * lookup. Useful for bundled apps and tests.
73
+ *
74
+ * Generic on the props shape so callers can pass a tightly-typed
75
+ * factory (e.g. `PageFactory<{ name: string }>`) without TS rejecting
76
+ * the call due to function-parameter contravariance. The factory is
77
+ * stored as `PageFactory<unknown>` because the registry hands props
78
+ * back as `unknown` — the renderer JSON.stringifies them either way.
79
+ */
80
+ register<P>(name: string, factory: PageFactory<P>): void {
81
+ this.registry.set(name, factory as PageFactory);
82
+ }
83
+
84
+ /**
85
+ * Resolve a page name to its factory function. Throws when the
86
+ * page is neither registered nor importable from disk.
87
+ *
88
+ * Path safety: `name` is rejected if it contains `..` segments or
89
+ * absolute-path markers. The joined path is also checked to live
90
+ * under `root` — defense in depth against URL-decoding tricks.
91
+ */
92
+ async resolve(name: string): Promise<PageFactory> {
93
+ const preset = this.registry.get(name);
94
+ if (preset) return preset;
95
+
96
+ assertSafeName(name);
97
+
98
+ const absolute = resolvePath(this.root, `${name}${this.extension}`);
99
+ if (!absolute.startsWith(this.root + sep) && absolute !== this.root) {
100
+ throw new Error(
101
+ `[aurora] page path "${name}" resolves outside the pages root`,
102
+ );
103
+ }
104
+
105
+ // `pathToFileURL` so Windows + ESM stay happy. Node's ESM
106
+ // loader caches modules by URL, so a stable URL would freeze
107
+ // the first-imported version of the page for the whole process
108
+ // lifetime — pages edited on disk would NOT be picked up even
109
+ // when the app runs under a file watcher. In dev mode we bust
110
+ // the URL with the file's mtime so a real change yields a new
111
+ // cache key and triggers a re-import. In production we keep
112
+ // the stable URL — page sources don't change post-deploy and
113
+ // busting per-request would leak memory (each unique URL stays
114
+ // resident in the ESM loader for the process lifetime).
115
+ const isDev = process.env.NODE_ENV !== "production";
116
+ let urlHref = pathToFileURL(absolute).href;
117
+ if (isDev) {
118
+ try {
119
+ const { statSync } = await import("node:fs");
120
+ urlHref = `${urlHref}?v=${statSync(absolute).mtimeMs}`;
121
+ } catch {
122
+ // stat failed → fall back to stable URL; the import below
123
+ // will surface the underlying ENOENT.
124
+ }
125
+ }
126
+ let mod: { default?: unknown };
127
+ try {
128
+ mod = (await import(urlHref)) as { default?: unknown };
129
+ } catch (err) {
130
+ throw new Error(
131
+ `[aurora] page "${name}" not found at ${absolute} — ${
132
+ (err as Error).message
133
+ }`,
134
+ );
135
+ }
136
+ if (typeof mod.default !== "function") {
137
+ throw new Error(
138
+ `[aurora] page "${name}" must default-export a factory function`,
139
+ );
140
+ }
141
+ return mod.default as PageFactory;
142
+ }
143
+
144
+ /**
145
+ * Browser-side URL the importmap (or a `<script src="…">`) should
146
+ * point at to fetch the same page's compiled JS.
147
+ */
148
+ urlFor(name: string): string {
149
+ assertSafeName(name);
150
+ return `${this.urlPrefix}/${name}${this.extension}`;
151
+ }
152
+ }
153
+
154
+ function assertSafeName(name: string): void {
155
+ if (
156
+ name.length === 0 ||
157
+ name.startsWith("/") ||
158
+ name.startsWith("\\") ||
159
+ name.includes("..") ||
160
+ name.includes("\0")
161
+ ) {
162
+ throw new Error(`[aurora] illegal page name: ${JSON.stringify(name)}`);
163
+ }
164
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Component runtime.
3
+ *
4
+ * const Counter = component<{ initial?: number }>(({ initial = 0 }) => {
5
+ * const count = signal(initial)
6
+ * onMount(() => { document.title = `Count: ${count()}` })
7
+ * return html`<button @click="${() => count(count() + 1)}">${count}</button>`
8
+ * })
9
+ *
10
+ * Setup runs **once** when the component is created (mount time).
11
+ * `onMount` / `onUnmount` are bound to the per-component context active
12
+ * during setup, so they see the right cleanup queue when the component
13
+ * is unmounted later. State lives in plain `signal()` / `memo()` from
14
+ * `./reactive.js` — there's no separate hook layer, and signals work
15
+ * both inside and outside a component setup.
16
+ *
17
+ * Unlike React, there is no re-render — reactivity is push-based via the
18
+ * signals the setup function captures. The compiled template is what
19
+ * actually moves on screen.
20
+ */
21
+
22
+ import type { Disposer } from "./render.js";
23
+ import type { EffectCallback, TemplateResult } from "./types.js";
24
+
25
+ /**
26
+ * Active component context. `onMount` / `onUnmount` push into it so a
27
+ * surrounding `render()` can dispose everything when the component
28
+ * unmounts. The stack lets `component()` nest safely.
29
+ */
30
+ interface ComponentContext {
31
+ /** Cleanup functions to run at unmount. `onUnmount` pushes here. */
32
+ readonly cleanups: Disposer[];
33
+ /** Mount hooks queued via `onMount` — flushed after setup returns. */
34
+ readonly mountHooks: Array<EffectCallback>;
35
+ }
36
+
37
+ const contextStack: ComponentContext[] = [];
38
+
39
+ function activeContext(): ComponentContext {
40
+ const ctx = contextStack[contextStack.length - 1];
41
+ if (!ctx) {
42
+ throw new Error(
43
+ "[aurora] onMount / onUnmount called outside component() — only valid inside a component setup function.",
44
+ );
45
+ }
46
+ return ctx;
47
+ }
48
+
49
+ /**
50
+ * Build a component factory. The returned function takes props and
51
+ * produces a `TemplateResult` that can be rendered or nested inside
52
+ * another template.
53
+ *
54
+ * `component()` does NOT itself mount anything — it composes. The
55
+ * outermost `render(Component(props), container)` is what mounts.
56
+ */
57
+ export function component<P = Record<string, never>>(
58
+ setup: (props: P) => TemplateResult,
59
+ ): (props?: P) => TemplateResult {
60
+ return (props?: P) => {
61
+ const ctx: ComponentContext = {
62
+ cleanups: [],
63
+ mountHooks: [],
64
+ };
65
+ contextStack.push(ctx);
66
+ try {
67
+ const result = setup((props ?? ({} as P)) as P);
68
+ return wrapWithLifecycle(result, ctx);
69
+ } finally {
70
+ contextStack.pop();
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Stitch the component context onto the returned TemplateResult so the
77
+ * outer renderer can flush mount hooks + register unmount cleanups
78
+ * automatically when this slot is mounted / removed.
79
+ *
80
+ * The mechanism is a `Symbol`-keyed handoff: the renderer's text-slot
81
+ * path (which handles nested TemplateResults) checks for this property
82
+ * and forwards the lifecycle.
83
+ */
84
+ const COMPONENT_LIFECYCLE: unique symbol = Symbol.for("aurora:component");
85
+
86
+ interface ComponentLifecycle {
87
+ mountHooks: ReadonlyArray<EffectCallback>;
88
+ cleanups: Disposer[];
89
+ }
90
+
91
+ function wrapWithLifecycle(
92
+ result: TemplateResult,
93
+ ctx: ComponentContext,
94
+ ): TemplateResult {
95
+ (result as { [COMPONENT_LIFECYCLE]?: ComponentLifecycle })[
96
+ COMPONENT_LIFECYCLE
97
+ ] = {
98
+ mountHooks: ctx.mountHooks,
99
+ cleanups: ctx.cleanups,
100
+ };
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * Internal — extract the lifecycle attachment a `component()` left on a
106
+ * TemplateResult, if any. The renderer calls this after mounting the
107
+ * fragment so onMount fires once the DOM is live, and the returned
108
+ * cleanups bubble into the outer dispose chain.
109
+ */
110
+ export function readComponentLifecycle(
111
+ result: TemplateResult,
112
+ ): ComponentLifecycle | undefined {
113
+ return (result as { [COMPONENT_LIFECYCLE]?: ComponentLifecycle })[
114
+ COMPONENT_LIFECYCLE
115
+ ];
116
+ }
117
+
118
+ // ─── Lifecycle ────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Schedule a callback to run after the component is mounted into the
122
+ * live document. Returning a function from `onMount` registers it as an
123
+ * unmount cleanup.
124
+ */
125
+ export function onMount(fn: EffectCallback): void {
126
+ const ctx = activeContext();
127
+ ctx.mountHooks.push(fn);
128
+ }
129
+
130
+ /**
131
+ * Schedule a callback to run when the component unmounts. Equivalent
132
+ * to the cleanup return of `onMount` but available without a paired
133
+ * mount action.
134
+ */
135
+ export function onUnmount(fn: () => void): void {
136
+ const ctx = activeContext();
137
+ ctx.cleanups.push(fn);
138
+ }
package/src/html.ts ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Tagged-template HTML parser.
3
+ *
4
+ * html`<button @click="${onClick}">${count}</button>`
5
+ *
6
+ * returns a `TemplateResult`. The first call with a given `strings` array
7
+ * compiles a `Template` (parsed `<template>` element + slot descriptors)
8
+ * and caches it; subsequent calls reuse the compiled artefact and only
9
+ * pair it with fresh `values`.
10
+ *
11
+ * The parser is intentionally minimal — it supports text interpolation
12
+ * (`>${x}<`), attribute interpolation (`attr="${x}"` and multi-slot
13
+ * `class="a ${x} b ${y}"`), boolean attributes (`?disabled="${x}"`), DOM
14
+ * properties (`.value="${x}"`), and event listeners (`@click="${fn}"`).
15
+ * No custom directives, no fragments-in-attribute-name, no comment-only
16
+ * placeholders.
17
+ */
18
+
19
+ import {
20
+ type AttrSlot,
21
+ type BooleanAttrSlot,
22
+ type EventSlot,
23
+ isTemplateResult,
24
+ type PropSlot,
25
+ type Slot,
26
+ TEMPLATE_RESULT_BRAND,
27
+ type Template,
28
+ type TemplateResult,
29
+ type TextSlot,
30
+ } from "./types.js";
31
+
32
+ const TEMPLATE_CACHE = new WeakMap<TemplateStringsArray, Template>();
33
+
34
+ /** Sentinel inserted at every `${...}` site. Read back during the walk. */
35
+ const MARKER = "__aurora_slot_";
36
+
37
+ /** Marker comment that takes a child slot inside a text region. */
38
+ const TEXT_NODE_MARKER = `<!--${MARKER}-->`;
39
+
40
+ /** Markup-friendly placeholder for slots inside attribute values. */
41
+ function attrPlaceholder(i: number): string {
42
+ return `${MARKER}${i}__`;
43
+ }
44
+
45
+ /**
46
+ * Build the HTML string + remember which slots landed where. The classifier
47
+ * runs on the concatenated string; once we know each slot is either a
48
+ * text-region slot or an attribute-region slot, we emit the correct
49
+ * placeholder so the DOM parser doesn't choke (e.g. a `<!---->` comment
50
+ * cannot live inside an attribute value).
51
+ */
52
+ interface RawSlot {
53
+ region: "text" | "attribute";
54
+ }
55
+
56
+ function classifySlots(strings: readonly string[]): RawSlot[] {
57
+ const result: RawSlot[] = [];
58
+ let insideTag = false;
59
+ let insideComment = false;
60
+ for (let i = 0; i < strings.length - 1; i++) {
61
+ const segment = strings[i];
62
+ for (let j = 0; j < segment.length; j++) {
63
+ if (insideComment) {
64
+ // Comments swallow everything (including stray `<` / `>`) up
65
+ // to the literal `-->` terminator; without this guard a
66
+ // `<!-- > -->` literal would flip the tag scanner mid-stride.
67
+ if (
68
+ segment[j] === "-" &&
69
+ segment[j + 1] === "-" &&
70
+ segment[j + 2] === ">"
71
+ ) {
72
+ insideComment = false;
73
+ j += 2;
74
+ }
75
+ continue;
76
+ }
77
+ if (
78
+ !insideTag &&
79
+ segment[j] === "<" &&
80
+ segment[j + 1] === "!" &&
81
+ segment[j + 2] === "-" &&
82
+ segment[j + 3] === "-"
83
+ ) {
84
+ insideComment = true;
85
+ j += 3;
86
+ continue;
87
+ }
88
+ const ch = segment[j];
89
+ if (!insideTag && ch === "<") insideTag = true;
90
+ else if (insideTag && ch === ">") insideTag = false;
91
+ }
92
+ result.push({ region: insideTag ? "attribute" : "text" });
93
+ }
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * Build the HTML markup to feed the `<template>` element.
99
+ *
100
+ * Slots in text regions are replaced with a `<!--__aurora_slot_-->` comment
101
+ * that survives DOM parsing. Slots in attribute regions are replaced with a
102
+ * unique string token like `__aurora_slot_3__` that the post-parse walk
103
+ * detects when scanning attribute values.
104
+ */
105
+ function buildMarkup(
106
+ strings: readonly string[],
107
+ classification: readonly RawSlot[],
108
+ ): string {
109
+ let out = strings[0];
110
+ for (let i = 0; i < classification.length; i++) {
111
+ out +=
112
+ classification[i].region === "text"
113
+ ? TEXT_NODE_MARKER
114
+ : attrPlaceholder(i);
115
+ out += strings[i + 1];
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Walk the parsed fragment and collect a slot descriptor for every
122
+ * placeholder we emitted. The walk is depth-first, child-by-child, and
123
+ * records the integer path so `render()` can re-walk a clone without any
124
+ * string parsing.
125
+ */
126
+ function collectSlots(
127
+ root: HTMLTemplateElement,
128
+ classification: readonly RawSlot[],
129
+ ): Slot[] {
130
+ const slots: Slot[] = [];
131
+ let slotIndex = 0;
132
+
133
+ // Classify one marker-bearing attribute into slot(s): `@event`, `?boolean`,
134
+ // `.prop`, or a (possibly multi-slot) regular attribute. Pushes the slot(s)
135
+ // and queues the attribute for removal from the inert template.
136
+ function classifyAttribute(
137
+ attr: Attr,
138
+ path: number[],
139
+ toRemove: string[],
140
+ ): void {
141
+ const value = attr.value;
142
+ if (!value.includes(MARKER)) return;
143
+ const localPath = [...path];
144
+ if (attr.name.startsWith("@")) {
145
+ const slot: EventSlot = {
146
+ kind: "event",
147
+ path: localPath,
148
+ event: attr.name.slice(1),
149
+ };
150
+ slots.push(slot);
151
+ slotIndex++;
152
+ toRemove.push(attr.name);
153
+ return;
154
+ }
155
+ if (attr.name.startsWith("?")) {
156
+ const slot: BooleanAttrSlot = {
157
+ kind: "boolean-attr",
158
+ path: localPath,
159
+ name: attr.name.slice(1),
160
+ };
161
+ slots.push(slot);
162
+ slotIndex++;
163
+ toRemove.push(attr.name);
164
+ return;
165
+ }
166
+ if (attr.name.startsWith(".")) {
167
+ const slot: PropSlot = {
168
+ kind: "prop",
169
+ path: localPath,
170
+ name: attr.name.slice(1),
171
+ };
172
+ slots.push(slot);
173
+ slotIndex++;
174
+ toRemove.push(attr.name);
175
+ return;
176
+ }
177
+ // Regular attribute — may host one or more slots inline with static text.
178
+ // Strip the placeholder version (the renderer re-applies via setAttribute)
179
+ // so the inert template never carries the `__aurora_slot_0__` artefact.
180
+ const parts = value.split(/__aurora_slot_(\d+)__/);
181
+ // Even indices = static text, odd = slot indices. One slot with no
182
+ // surrounding static text → a "pure" attr slot; else carry static parts.
183
+ if (parts.length === 3 && parts[0] === "" && parts[2] === "") {
184
+ const slot: AttrSlot = { kind: "attr", path: localPath, name: attr.name };
185
+ slots.push(slot);
186
+ slotIndex++;
187
+ } else {
188
+ // Multi-slot attribute. Each slot points at the same `staticParts`
189
+ // array; consumers re-join on every update.
190
+ const staticParts: string[] = [];
191
+ const slotCountInThisAttr = (parts.length - 1) / 2;
192
+ for (let i = 0; i < parts.length; i += 2) {
193
+ staticParts.push(parts[i]);
194
+ }
195
+ for (let i = 0; i < slotCountInThisAttr; i++) {
196
+ const slot: AttrSlot = {
197
+ kind: "attr",
198
+ path: localPath,
199
+ name: attr.name,
200
+ staticParts,
201
+ staticPartIndex: i,
202
+ };
203
+ slots.push(slot);
204
+ slotIndex++;
205
+ }
206
+ }
207
+ toRemove.push(attr.name);
208
+ }
209
+
210
+ function visit(node: Node, path: number[]): void {
211
+ // Process children FIRST in reverse so a comment-marker we're about
212
+ // to remove never invalidates the index of a later sibling. But text
213
+ // slots also bring the node into existence — handle them with a
214
+ // stable path captured up front.
215
+ if (node.nodeType === 8 /* Comment */) {
216
+ const data = (node as Comment).data;
217
+ if (data === MARKER) {
218
+ if (slotIndex >= classification.length) return;
219
+ const cls = classification[slotIndex];
220
+ if (cls.region !== "text") {
221
+ throw new Error(
222
+ `[aurora] internal classification mismatch at slot ${slotIndex}`,
223
+ );
224
+ }
225
+ const slot: TextSlot = { kind: "text", path: [...path] };
226
+ slots.push(slot);
227
+ slotIndex++;
228
+ }
229
+ return;
230
+ }
231
+ if (node.nodeType === 1 /* Element */) {
232
+ const el = node as Element;
233
+ // Scan attributes — multiple slots can share one attribute.
234
+ // Collect attrs to remove AFTER iteration (mutating during it
235
+ // shifts indices on some DOM implementations).
236
+ const toRemove: string[] = [];
237
+ for (const attr of Array.from(el.attributes)) {
238
+ classifyAttribute(attr, path, toRemove);
239
+ }
240
+ for (const name of toRemove) el.removeAttribute(name);
241
+ }
242
+ // Recurse into children with their indices.
243
+ let childIndex = 0;
244
+ let child = node.firstChild;
245
+ while (child) {
246
+ const nextSibling: ChildNode | null = child.nextSibling;
247
+ visit(child, [...path, childIndex]);
248
+ child = nextSibling;
249
+ childIndex++;
250
+ }
251
+ }
252
+
253
+ visit(root.content, []);
254
+ return slots;
255
+ }
256
+
257
+ function compile(strings: TemplateStringsArray): Template {
258
+ const classification = classifySlots(strings);
259
+ const markup = buildMarkup(strings, classification);
260
+ const tpl = document.createElement("template");
261
+ tpl.innerHTML = markup;
262
+ const slots = collectSlots(tpl, classification);
263
+ return { element: tpl, slots };
264
+ }
265
+
266
+ /**
267
+ * Compile (and cache) the template for the given strings array. Exposed
268
+ * for the renderer + the SSR path; apps stick to `html`.
269
+ */
270
+ export function getTemplate(strings: TemplateStringsArray): Template {
271
+ let cached = TEMPLATE_CACHE.get(strings);
272
+ if (!cached) {
273
+ cached = compile(strings);
274
+ TEMPLATE_CACHE.set(strings, cached);
275
+ }
276
+ return cached;
277
+ }
278
+
279
+ /**
280
+ * Tagged-template entrypoint. The `strings` array is reference-stable per
281
+ * source location (TC39 guarantee), so caching by reference is safe and
282
+ * O(1) on the hot render path.
283
+ */
284
+ export function html(
285
+ strings: TemplateStringsArray,
286
+ ...values: unknown[]
287
+ ): TemplateResult {
288
+ const result: TemplateResult = {
289
+ strings,
290
+ values,
291
+ [TEMPLATE_RESULT_BRAND]: true,
292
+ };
293
+ return result;
294
+ }
295
+
296
+ export { isTemplateResult };