@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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/AuroraManager.d.ts +44 -0
- package/dist/AuroraManager.js +47 -0
- package/dist/AuroraProvider.d.ts +52 -0
- package/dist/AuroraProvider.js +145 -0
- package/dist/Pages.d.ts +78 -0
- package/dist/Pages.js +116 -0
- package/dist/component.d.ts +55 -0
- package/dist/component.js +97 -0
- package/dist/html.d.ts +30 -0
- package/dist/html.js +246 -0
- package/dist/hydrate.d.ts +29 -0
- package/dist/hydrate.js +379 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/reactive.d.ts +83 -0
- package/dist/reactive.js +217 -0
- package/dist/relay.d.ts +43 -0
- package/dist/relay.js +144 -0
- package/dist/render.d.ts +25 -0
- package/dist/render.js +283 -0
- package/dist/route.d.ts +64 -0
- package/dist/route.js +49 -0
- package/dist/server/renderPage.d.ts +62 -0
- package/dist/server/renderPage.js +83 -0
- package/dist/server/serveAssets.d.ts +43 -0
- package/dist/server/serveAssets.js +89 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.js +31 -0
- package/dist/ssr.d.ts +22 -0
- package/dist/ssr.js +179 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +15 -0
- package/package.json +69 -0
- package/src/AuroraManager.ts +76 -0
- package/src/AuroraProvider.ts +187 -0
- package/src/Pages.ts +164 -0
- package/src/component.ts +138 -0
- package/src/html.ts +296 -0
- package/src/hydrate.ts +518 -0
- package/src/index.ts +43 -0
- package/src/reactive.ts +265 -0
- package/src/relay.ts +171 -0
- package/src/render.ts +378 -0
- package/src/route.ts +96 -0
- package/src/server/renderPage.ts +135 -0
- package/src/server/serveAssets.ts +135 -0
- package/src/services/main.ts +40 -0
- package/src/ssr.ts +179 -0
- 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
|
+
}
|
package/src/component.ts
ADDED
|
@@ -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 };
|