tachyon-dom 0.1.0 → 0.1.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
@@ -1,268 +1,162 @@
1
1
  # Tachyon DOM
2
2
 
3
- Tachyon DOM is a small TypeScript UI runtime started from the hot paths in `js-framework-benchmark`. The first implementation focuses on keyed row workloads: bulk creation, replacement, partial text updates, row selection, swap, removal, append, and clear.
4
-
5
- The core idea is to keep a static DOM template and bind dynamic fields directly into reusable template chunks before cloning. That gives the benchmark path the same mechanical advantages as the fastest vanilla implementations while keeping the API reusable outside a single hand-written table.
6
-
7
- ## Current Target
8
-
9
- The initial runtime optimizes:
10
-
11
- - chunked row creation with detached `tbody` replacement
12
- - direct `Text.nodeValue` writes for dynamic fields
13
- - local selected-row class changes
14
- - `insertBefore` swaps for keyed row movement
15
- - `textContent = ""` clears
16
-
17
- Byte weight is intentionally secondary for now. The runtime is split so benchmark-specialized code can remain separate from future general-purpose modules.
18
-
19
- ## Compiler Direction
20
-
21
- The framework direction is HTML-first syntax with Solid-style fine-grained reactivity and Marko-style separated server/client compiler targets. The first compiler slice lives in `tachyon-dom/compiler` and can:
22
-
23
- - parse a single-root HTML-like template
24
- - extract `{expr}` text bindings
25
- - extract `class:name={expr}` class bindings
26
- - extract `on:event={handler}` event bindings
27
- - extract `<for each={items} key={item.id}>` keyed list boundaries
28
- - extract `<store count={initialCount}/>` store tags without adding DOM nodes
29
- - record `hydrate:id={islandId}` hydration boundaries for server/client handoff
30
- - record `<component name="Panel" prop={value}>` transparent component boundaries with props and local stores
31
- - generate `<await value={promise} then="name">` streaming fragments in the server stream target
32
- - render an escaped server string for the same template
33
- - generate a chunk-yielding streaming server target as a separate compiler output
34
- - generate client code that imports only the runtime helper modules it needs
35
- - optionally generate fine-grained client bindings through `tachyon-dom/runtime/signal`
36
-
37
- The fixed syntax surface is documented in [`docs/syntax-spec.md`](docs/syntax-spec.md).
38
-
39
- Routing is documented in [`docs/routing.md`](docs/routing.md), and runtime modules are summarized in [`docs/runtime.md`](docs/runtime.md).
40
-
41
- The compiler output is intentionally close to the current direct DOM runtime: static markup stays static, dynamic text/class/event slots are recorded as explicit paths, and generated client code can import subpath helpers such as `tachyon-dom/runtime/text`.
42
-
43
- The first server streaming adapter, `tachyon-dom/server/stream`, accepts sync or async HTML chunks and adapts them to `ReadableStream<Uint8Array>` or `Response` without building a single full HTML string first.
44
-
45
- The first list runtime path, `tachyon-dom/runtime/list`, preserves keyed row elements across updates, moves reused elements into order, patches text/class bindings, removes stale rows, and keeps event handlers current through a mutable row scope. Row events are delegated through the list container so reused rows do not need one listener per row.
46
-
47
- ## App Layer
48
-
49
- `tachyon-dom/app` provides a small app definition layer for examples and applications that should not need duplicated `main.ts`, `ssr.ts`, and HTML entry files. Define pages once, render SSR documents from the same definition, and let the Vite preset serve generated HTML in development and emit normalized HTML in production. With no option, development preserves tag formatting while production normalizes it. An explicit `htmlWhitespace` policy applies identically in both modes; legacy JavaScript values use the same runtime mapping, and invalid values fail during plugin creation before a server starts. The deprecated `minifyHtml` boolean also applies to both modes when explicitly set, while `htmlWhitespace` takes precedence:
50
-
51
- Create a starter app with:
52
-
53
- ```sh
54
- npm create tachyon-dom@latest my-app
55
- pnpm create tachyon-dom my-app
3
+ Tachyon DOM is an experimental HTML-first compiler that turns static templates into direct DOM updates, using a small fine-grained runtime shared with SSR and streaming targets.
4
+
5
+ > Experimental status: the compiler and runtime are ready for evaluation, examples, and incremental adoption, but public APIs may change before a stable release.
6
+
7
+ ## Why Tachyon DOM?
8
+
9
+ - HTML stays recognizable while dynamic fields become explicit DOM paths.
10
+ - Fine-grained updates write to cached text, attribute, class, and list targets without a virtual DOM.
11
+ - One parsed template feeds client, buffered SSR, and streaming server targets.
12
+ - Browser-safe runtime imports keep compiler and server dependencies out of client bundles.
13
+
14
+ ## Quick Example
15
+
16
+ This `.td` component combines a signal-backed counter with a keyed list:
17
+
18
+ ```html
19
+ <script setup lang="ts">
20
+ const count = createSignal(0);
21
+ const rows = createSignal([
22
+ { id: 1, label: "Alpha" },
23
+ { id: 2, label: "Beta" },
24
+ ]);
25
+ const increment = (): void => count.update((value) => value + 1);
26
+ </script>
27
+
28
+ <main>
29
+ <button on:click={increment}>{count}</button>
30
+ <ul>
31
+ <for each={rows} key={row.id}>
32
+ <li>{row.label}</li>
33
+ </for>
34
+ </ul>
35
+ </main>
56
36
  ```
57
37
 
58
- ```ts
59
- import { defineApp } from "tachyon-dom/app";
60
- import { tachyonApp, tachyonDom } from "tachyon-dom/vite";
61
-
62
- export const app = defineApp({
63
- pages: [
64
- {
65
- path: "/",
66
- fileName: "index.html",
67
- template: "<section><h1>{title}</h1></section>",
68
- scope: { title: "Home" },
69
- },
70
- ],
71
- });
72
-
73
- export default {
74
- plugins: [tachyonDom({ reactive: true }), tachyonApp(app)],
75
- };
76
- ```
77
-
78
- Route-local template conventions are available through `pagesFromRouteFiles()`: `src/routes/index/page.td` maps to `/`, `src/routes/counter/page.td` maps to `/counter/`, `[id]` maps to `:id`, and `[...slug]` maps to a named wildcard. Generated starters keep these pages in `src/routes.generated.ts`; `tachyon-dom add page settings/profile` updates that registry, so the new URL is available to the app, Vite, tests, and SSR without a manual import.
79
-
80
- `defineApp()` rejects every duplicate normalized route and output filename before compilation or serving. Path aliases such as `/`, `/index.html`, `/guide`, and `/guide/` share their normalized route keys. `renderAppDocument()` throws for an unknown path instead of falling back to home. SSR adapters should use `renderAppResponse()`, which returns `{ status, html }`; missing routes retain status 404 and render either the configured `notFound` page or the built-in not-found document.
81
-
82
- `examples/full-app` is a multi-page browser example with SSR initial HTML for every page, a persistent layout, client-side routing, counters, keyed lists, forms, settings, and compiler/stream diagnostics. Run it with `pnpm example:full-app`, then open the Vite dev server root. Source pages are route-local `.td` templates, and the example Vite config generates dev/build HTML entries instead of keeping `index.html` files in source. Production output is available with `pnpm example:full-app:build`; the example config builds every generated page entry and safely condenses HTML tag syntax during build.
38
+ The server targets escape interpolated text. The client target records direct paths to the button text and list container, then updates only those targets when their signals change.
83
39
 
84
- Use `tachyonDom({ templateWhitespace: "condense" })` when formatting newlines and indentation in directly imported `.td` templates should be reduced. Standard applications that compile raw route source must pass the same `templateWhitespace` value to `defineApp()` or `loadRouteApp()`. The compiler applies this opt-in policy to one shared template tree before client, buffered server, and streaming server generation. It preserves same-line spaces, non-ASCII whitespace, hydration markers, text-binding separators, RCDATA and raw-text-like elements including `title`, and inherited `xml:space="preserve"` content in SVG and MathML. A static `xml:space="default"` resets foreign-content preservation, and `foreignObject` returns to HTML whitespace rules. Formatting runs that contain line breaks become a single ASCII space, so applications whose CSS makes arbitrary whitespace significant should retain the default `"preserve"` policy.
40
+ ## What the Compiler Emits
85
41
 
86
- `normalizeHtmlTagWhitespace()` is a separate, parse5-validated helper that changes whitespace inside tag syntax only and copies every text node and comment byte-for-byte. The older `condenseHtmlWhitespace()`, `minifyHtml()`, `minify`, and `minifyHtml` names remain deprecated compatibility aliases. App `whitespace: "normalize-tags"`, Vite app `htmlWhitespace: "normalize-tags"`, and router `htmlWhitespace: "normalize-tags"` perform tag normalization only; they do not reduce inter-element indentation. `HtmlWhitespacePolicy` is deliberately distinct from compiler `TemplateWhitespacePolicy`, and tag-normalization options accept only `"preserve-tags" | "normalize-tags"`. Migrate the removed legacy option literal `"preserve"` to `"preserve-tags"` and `"condense"` to `"normalize-tags"`; deprecated boolean and helper aliases remain available separately. A route may provide a separate `stream` async iterable. Loaders and authoritative status and headers resolve before body iteration starts, and chunks are forwarded without collecting the completed body. Iteration failures terminate the body without injecting error HTML, and consumer cancellation closes the source iterator even if the body has not been pulled.
42
+ The real output uses modular runtime imports and explicit cleanup ownership. Abbreviated, the hot path looks like this:
87
43
 
88
- ### HTML whitespace policy migration
44
+ ```js
45
+ const countText = __tachyonTextAt(root, [0, 0]);
46
+ cleanups.push(__tachyonEffect(() => __tachyonSetText(countText, __tachyonRead(scope.count))));
89
47
 
90
- TypeScript consumers must replace the removed `LegacyHtmlWhitespacePolicy`, `HtmlWhitespacePolicyInput`, and `CompatibleHtmlWhitespacePolicy<T>` exports with `HtmlWhitespacePolicy`, remove the obsolete policy type parameter from app, Vite, router, Workers, Node, and Lambda option types, and use `"preserve-tags"` or `"normalize-tags"`. For example, migrate `WorkersHandlerOptions<Env, LegacyPolicy>` to `WorkersHandlerOptions<Env>` and `RouteRenderOptions<LegacyPolicy>` to `RouteRenderOptions`. Runtime compatibility remains for already-built JavaScript and JSON configuration: `"preserve"` maps to `"preserve-tags"`, and `"condense"` maps to `"normalize-tags"`. Unknown values never silently select preserve behavior, but the observable error depends on the public boundary:
91
-
92
- | Public boundary | Unknown runtime policy outcome |
93
- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
94
- | App document rendering | Throws the migration error directly. |
95
- | Vite app plugin creation | Throws the migration error directly before a development server or build starts. |
96
- | Buffered router | Resolves to the generic internal-error document with status 500 because route rendering owns an error boundary. |
97
- | Streaming router | Rejects with the migration error before returning stream metadata or chunks. |
98
- | Workers and Node buffered adapters | Return the buffered router's generic 500 response. |
99
- | Lambda proxy buffered adapter | Returns the generic Lambda proxy response with status 500. |
100
- | Workers, Node, and Lambda streaming adapters | Reject before committing a response when `streaming: true` selects the streaming router. |
101
-
102
- ## Recommended App Shape
103
-
104
- For applications, keep route markup in route-local `.td` files and let Vite run the Tachyon DOM plugins on the main development path:
105
-
106
- ```text
107
- src/
108
- routes/
109
- index/
110
- page.td
111
- page.td.d.ts
112
- routes.generated.ts
113
- client/
114
- main.ts
115
- app.ts
116
- vite.config.ts
48
+ cleanups.push(
49
+ __tachyonEffect(() =>
50
+ __tachyonMountKeyedList(listRoot, [], __tachyonRead(scope.rows), listOptions),
51
+ ),
52
+ );
117
53
  ```
118
54
 
119
- Use `src/routes/**/page.td` as the source of truth for page markup. Use `src/client/main.ts` for client-side runtime code that should be bundled by Vite. Do not put application code in `public/client/main.js`; reserve `public/` for static assets such as images, icons, manifests, and service workers.
55
+ Static markup remains in a reusable template. Events are attached once per created target, keyed rows retain their DOM nodes while moving, and the generated binding root disposes effects, resources, events, and controls together.
120
56
 
121
- Add Tachyon DOM template module types when TypeScript imports `.td` files directly:
57
+ See the [syntax specification](docs/syntax-spec.md) for supported expressions and exact target behavior.
122
58
 
123
- ```ts
124
- /// <reference types="tachyon-dom/td-modules" />
125
- ```
126
-
127
- For project-wide setup, add `"tachyon-dom/td-modules"` to `compilerOptions.types` alongside `"vite/client"`. The type entry covers `.td`, `.td?client`, `.td?server`, `.td?stream`, and `.td?raw` imports. During normal Vite development and builds, `tachyonDom()` writes an adjacent `.td.d.ts` for each transformed template. These declarations preserve exported SFC `scope()` types, require every referenced template field, and type event handlers. The generated starter and `add page` command create the initial declarations so a clean project typechecks before its first Vite run. `tachyon-dom typegen` remains available for non-Vite tooling but is not required by the standard workflow.
128
-
129
- ## Typed Templates and Environment Validation
59
+ ## Measured Size
130
60
 
131
- `tachyon-dom/typed` exposes the typed-template helpers used by generated template declarations:
132
-
133
- ```ts
134
- import { defineTemplate, templateScope, type TypedTemplate } from "tachyon-dom/typed";
61
+ `import { createSignal } from "tachyon-dom"` bundles to **600 bytes minified** in the current esbuild contract.
135
62
 
136
- type PageScope = {
137
- title: string;
138
- count: number;
139
- };
63
+ Quick example client bundle: 11519 bytes minified, 3838 bytes Brotli, including the counter, keyed-list binding code, and browser runtime. CI rejects TypeScript, parse5, compiler, server, app, and language-server modules from both browser metafiles.
140
64
 
141
- const page = defineTemplate<PageScope, "<h1>{title}</h1>">("<h1>{title}</h1>");
142
- const scoped = templateScope<PageScope>().define("<p>{count}</p>");
143
-
144
- const typed: TypedTemplate<PageScope> = page;
145
- void typed;
146
- void scoped;
65
+ ```sh
66
+ pnpm build
67
+ pnpm check:browser-entry
68
+ pnpm check:quick-example-size
147
69
  ```
148
70
 
149
- `tachyon-dom/env` provides a small runtime environment validator with a public/private split:
150
-
151
- ```ts
152
- import { defineEnvSchema, readEnv } from "tachyon-dom/env";
153
-
154
- const schema = defineEnvSchema({
155
- PUBLIC_APP_NAME: { default: "Tachyon App", public: true },
156
- SESSION_SECRET: { required: true },
157
- });
71
+ These numbers are produced from repository scripts, not estimated from source files. Subpath size budgets remain available through `pnpm check:size`.
158
72
 
159
- const result = readEnv(process.env, schema);
160
- if (!result.ok) {
161
- throw new Error(result.error.map((error) => error.message).join("\n"));
162
- }
73
+ ## Keyed List Benchmark
163
74
 
164
- result.value.publicEnv.PUBLIC_APP_NAME;
165
- result.value.env.SESSION_SECRET;
166
- ```
75
+ On this machine, Tachyon DOM performed within 3% of the comparison implementations across nine keyed-row operations.
167
76
 
168
- Only keys marked `public: true` are exposed under `result.value.publicEnv`. By default public keys must use the `PUBLIC_` prefix; pass `publicPrefix` to `readEnv()` when an app uses a different convention.
77
+ | Compared with | Tachyon DOM result |
78
+ | --- | ---: |
79
+ | vanillajs-lite-keyed | 1.8% faster |
80
+ | solid-keyed | 0.9% faster |
81
+ | marko-keyed | 2.5% slower |
169
82
 
170
- ## Runtime APIs
83
+ Results are the geometric mean of nine operations: row creation, replacement, partial updates, selection, swapping, removal, append, clear, and creation of many rows. Lower execution time is better.
171
84
 
172
- The root entry exports the small reactive runtime: `createSignal()`, `createMemo()`, `effect()`, `batch()`, `untrack()`, `createResource()`, and `catchError()`. Use `createResource(source, loader)` for signal-driven async data with `data`, `error`, `loading`, and `refetch` accessors. Source changes abort superseded loads; the loader receives an `AbortSignal`, and `resource.dispose()` detaches tracking and aborts in-flight work. Use `untrack(fn)` to read signals without subscribing the active effect, and use `catchError(fn, onError)` when an effect should recover and keep tracking after a thrown error.
85
+ The recorded run used:
173
86
 
174
- `createErrorBoundary()` is available from the root entry and `tachyon-dom/runtime/error-boundary` for DOM-mounted fallback UI around client enhancements. `createI18n()` and `localeMiddleware()` are available from the root entry and `tachyon-dom/i18n` for dictionary lookup, interpolation, and request locale selection.
87
+ - AMD Ryzen 9 9950X
88
+ - Chromium 149.0.7827.55
89
+ - Production Vite builds
90
+ - 2 warmup runs and 7 measured runs per operation
91
+ - 20% trimmed means to reduce outlier influence
175
92
 
176
- Adapters are lower-level deployment APIs for Node, Workers, and Lambda composition. They are useful when composing Tachyon DOM with an existing Request-to-Response handler, but an adapter-only app with TypeScript string templates is not the standard framework shape. If you are migrating an existing SSR app, start by replacing hand-written enhancement registries with `tachyon-dom/runtime/enhancement`, then move one screen at a time into `.td` templates, and finally wire those screens through the app or route layer.
93
+ The [complete JSON artifact](benchmark/local-compare/results/2026-07-13-readme-baseline.json) includes raw samples, variability, dependency versions, and clean Git provenance.
177
94
 
178
- ## Routing
95
+ Reproduce the same benchmark contract locally:
179
96
 
180
- Routing is provided as a separate layer instead of being baked into the template compiler. The server router in `tachyon-dom/router` supports static routes, `:param` routes, wildcard routes, nested layouts through `outlet`, route loaders, form actions, auth guards, 404/error boundaries, head descriptor rendering, route hydration state scripts, trusted HTML responses, backend HTML sanitization helpers and adapters, CSRF guards, signed cookie sessions, middleware, observability hooks, deferred data helpers, CSP nonce propagation, resource hints, streaming finalization, build manifests, typed href builders, route preload plans, and route type generation. The client router in `tachyon-dom/runtime/router` supports same-origin link interception, History API navigation, `popstate`, abortable route loaders/actions, action-driven revalidation, loader cache/prefetch/invalidation, Vite route HMR revalidation, scroll-to-top hooks, focus restoration, history-entry `restoreScroll`, navigation announcements, title/head updates, nested layout outlets, optional `viewTransition`, and 404/error rendering.
97
+ ```sh
98
+ pnpm bench:local
99
+ ```
181
100
 
182
- The Vite integration also exposes `tachyonDomRoutes()` for a `virtual:tachyon-dom/routes` module. It emits a manifest plus lazy dynamic imports, which keeps route modules split into separate chunks.
101
+ This is a repository-local comparison based on the `js-framework-benchmark` row-operation model. It is not an official upstream result or a general ranking of framework performance. See the [benchmark methodology](benchmark/README.md) for details.
183
102
 
184
- During Vite dev server runs, `tachyonDom()` logs simple request lines such as `GET / 200 4ms` through Vite's logger. Query strings are omitted by default to avoid leaking tokens or other sensitive parameters. Disable request logs with `tachyonDom({ requestLog: false })`, pass `requestLog: { logger }` to route messages to a custom sink, or set `requestLog: { includeQuery: true }` when query strings are explicitly useful.
103
+ ## Install
185
104
 
186
- For request-scoped dynamic SSR, use `tachyonSsr()` in the Vite plugin list. It mounts a fetch-style `Request -> Response` handler in Vite dev, passes Vite module URLs such as `/@vite/client`, `/src/`, and `/node_modules/` through to Vite, and can serve public assets before the dynamic handler through the Node adapter static asset semantics:
105
+ Create a route-local starter:
187
106
 
188
- ```ts
189
- import { defineConfig } from "vite";
190
- import { tachyonDom, tachyonSsr } from "tachyon-dom/vite";
191
-
192
- const clientScript = (request: Request) =>
193
- new URL(request.url).searchParams.has("prod") ? "/client/main.js" : "/src/client/main.ts";
194
-
195
- export default defineConfig({
196
- plugins: [
197
- tachyonDom({ reactive: true }),
198
- tachyonSsr({
199
- clientScript,
200
- fetch: async (request, { clientScript }) => {
201
- const user = new URL(request.url).searchParams.get("user") ?? "Guest";
202
- return new Response(`<main>Hello ${user}</main><script type="module" src="${clientScript ?? ""}"></script>`, {
203
- headers: { "content-type": "text/html; charset=utf-8" },
204
- });
205
- },
206
- }),
207
- ],
208
- });
107
+ ```sh
108
+ npm create tachyon-dom@latest my-app
109
+ cd my-app
110
+ pnpm install
111
+ pnpm dev
209
112
  ```
210
113
 
211
- Server adapters live in `tachyon-dom/adapters` as compatibility exports, with runtime-specific entries at `tachyon-dom/adapters/node`, `tachyon-dom/adapters/workers`, and `tachyon-dom/adapters/lambda`. The Workers entry avoids Node built-ins and can serve Cloudflare Assets bindings before dynamic routes; the Node entry keeps file-system static asset serving; the Lambda entry supports Function URL and API Gateway HTTP API v2 style events, including AWS Lambda response streaming. When a Node app serves public files from a root `staticAssets.basePath`, set `staticAssets.fallthroughOnNotFound: true` to let missing files such as `/healthz`, `/`, or non-GET/HEAD app routes like `POST /login` continue to the app handler while still serving files that exist. `tachyon-dom/runtime/form` includes progressive form enhancement, `tachyon-dom/runtime/enhancement` adds small opt-in client behavior for SSR markup via `data-td-enhance`, and `tachyon-dom/runtime/hydrate` includes boundary mismatch diagnostics for SSR tests and development builds.
114
+ The equivalent pnpm command is `pnpm create tachyon-dom my-app`. For an installed package, use `tachyon-dom init --template basic --out my-app` or select the `ssr` template.
212
115
 
213
- For Cloudflare Pages, `tachyon-dom/vite` exposes `packageCloudflarePages()` to copy static assets and generate a Pages `_worker.js` that tries `env.ASSETS` before delegating to a Tachyon DOM SSR renderer.
116
+ The normal project shape keeps route markup in `src/routes/**/page.td`, client code in `src/client/main.ts`, and generated route registration in `src/routes.generated.ts`. Start with the [getting-started guide](docs/getting-started.md).
214
117
 
215
- Server-rendered string helpers live under `tachyon-dom/server/html` and `tachyon-dom/server/form-action`. The HTML helper escapes interpolation by default, provides explicit `attr()`, `booleanAttr()`, `classList()`, `join()`, and `rawHtml()` helpers, and does not require DOM globals. The form action helper keeps validation in userland while standardizing `FormData` parsing, safe value preservation, accessible field error attributes, and path-relative redirect responses for classic SSR forms.
118
+ ## Compiler and Runtime Boundaries
216
119
 
217
- ## Testing Templates
218
-
219
- `tachyon-dom/testing` includes helpers for fast server-side assertions without starting Vite. Use `renderTdForTest(filePath, scope, { locale })` in Vitest when you want to assert the SSR HTML for a `.td` template directly:
120
+ The package root contains browser-safe reactive and runtime APIs such as `createSignal()`, `createMemo()`, `createResource()`, `createRoot()`, `onCleanup()`, and the client router. Heavier tools use explicit subpaths:
220
121
 
221
122
  ```ts
222
- import { expect, it } from "vitest";
223
- import { renderTdForTest } from "tachyon-dom/testing";
224
-
225
- it("escapes user content in the account view", async () => {
226
- await expect(
227
- renderTdForTest("src/account-view.td", {
228
- userName: `<img src=x onerror=alert(1)>`,
229
- }),
230
- ).resolves.toContain("&lt;img src=x onerror=alert(1)&gt;");
231
- });
123
+ import { createSignal } from "tachyon-dom";
124
+ import { compileTemplate } from "tachyon-dom/compiler";
125
+ import { defineApp } from "tachyon-dom/app";
126
+ import { createRouter } from "tachyon-dom/router";
127
+ import { html } from "tachyon-dom/server/html";
128
+ import { tachyonDom } from "tachyon-dom/vite";
232
129
  ```
233
130
 
234
- The helper reads and compiles the template with Tachyon DOM diagnostics, then renders it through the server target. Compiler errors include the template file path, line, column, source line, and pointer so failures are suitable for normal unit-test output.
235
-
236
- ## Security Notes
131
+ This boundary keeps TypeScript, parse5, language-server, app, and server graphs away from a browser consumer that only needs reactivity.
237
132
 
238
- `sanitizeHtml(markup)` has a small built-in allowlist sanitizer for constrained, already-simple backend HTML. Do not rely on the default sanitizer for arbitrary untrusted HTML. For user-generated or third-party markup, pass a vetted adapter through `createHtmlSanitizer()`/`sanitizeHtml(..., { adapter })`, such as a DOMPurify-backed sanitizer in the target runtime.
133
+ ## Documentation
239
134
 
240
- Progressive route `stream()` strings are trusted raw HTML. Node, Workers, and Lambda adapters never escape or sanitize chunks. Escape untrusted text explicitly:
135
+ - [Getting started](docs/getting-started.md): starter creation, project shape, route files, template types, and testing.
136
+ - [Syntax specification](docs/syntax-spec.md): HTML-first syntax, expressions, directives, components, hydration, and streaming constructs.
137
+ - [Runtime](docs/runtime.md): signals, ownership, DOM helpers, keyed lists, forms, enhancements, portals, and hydration.
138
+ - [App and Vite](docs/app-vite.md): app definitions, file routes, plugins, request-scoped SSR, logging, and packaging.
139
+ - [Routing](docs/routing.md): server routes, layouts, loaders, actions, streaming, and client navigation.
140
+ - [Server adapters](docs/adapters.md): Workers, Node, Lambda, static assets, origins, and deployment behavior.
141
+ - [Security](docs/security.md): escaping, trusted HTML, sanitizers, redirects, CSRF, hosts, proxies, and origins.
142
+ - [Whitespace migration](docs/migrations/whitespace.md): compiler versus document policies, legacy mappings, and boundary outcomes.
143
+ - [Benchmarks](benchmark/README.md): provenance, contracts, reproduction, and interpretation.
144
+ - [Releasing](docs/releasing.md): package verification, Trusted Publishing, and release commands.
145
+ - [Changelog](CHANGELOG.md): release history, breaking changes, fixes, and security notes.
241
146
 
242
- ```ts
243
- import { escapeToHtml, trustedHtmlChunk, type RouteDefinition } from "tachyon-dom/router";
244
-
245
- const route: RouteDefinition = {
246
- path: "/search",
247
- loader: ({ url }) => url.searchParams.get("q") ?? "",
248
- render: () => "",
249
- stream: async function* ({ data }) {
250
- yield `<p>${trustedHtmlChunk(escapeToHtml(data))}</p>`;
251
- },
252
- };
253
- ```
147
+ ## Security
254
148
 
255
- `escapeToHtml()` is for HTML text content, not unquoted attributes, script/style source, URLs, or other parser contexts. For intentionally accepted markup, use a vetted sanitizer adapter and pass its factory-created `TrustedHtml` through `trustedHtmlChunk()`; forged structural objects are rejected.
149
+ Template interpolation and `tachyon-dom/server/html` escape text by default. `rawHtml()`, `trustedHtmlChunk()`, and route `stream()` chunks are explicit trust boundaries. Sanitize user-generated markup with a vetted runtime adapter, configure public origins and trusted proxy behavior, and apply method plus CSRF/Origin checks before direct form actions.
256
150
 
257
- `tachyon-dom/server/html` is an escaping helper, not a sanitizer. Use `rawHtml()` only for trusted framework or application output. Sanitize user-generated HTML before it reaches `rawHtml()`.
151
+ See the [security guide](docs/security.md) before deploying server-rendered or streamed applications.
258
152
 
259
- The built-in sanitizer rejects protocol-relative URLs and removes absolute `http:`/`https:` URLs unless their origin is explicitly listed in `allowedUrlOrigins`. `redirect()` similarly accepts path-relative targets by default; external redirects require `allowExternal: true` plus an `allowedOrigins` entry for the target origin.
153
+ ## Project Status
260
154
 
261
- The Node adapter derives request URLs from `Host` only when you pass `trustedHosts`, or from a fixed `origin` when configured. Without either option it falls back to `localhost` instead of trusting the incoming `Host` header. Only enable `trustProxy` behind a trusted proxy or edge that normalizes forwarded headers.
155
+ The current compiler supports text, class, attribute, style, ref, model, and event bindings; keyed lists and conditionals; local stores and components; hydration boundaries; buffered SSR; and streaming targets. The runtime uses fine-grained reactive ownership so component cleanup stops effects, aborts resources, and detaches DOM bindings.
262
156
 
263
- The Lambda adapter derives request URLs from `event.requestContext.domainName` by default and falls back to the `Host` header for minimal local events. Pass `origin` when the public origin differs from the Lambda event domain, for example behind CloudFront or a custom domain.
157
+ Tachyon DOM is still experimental. Evaluate compatibility, output, accessibility, security boundaries, and benchmark relevance for your application before production adoption.
264
158
 
265
- ## Commands
159
+ ## Development
266
160
 
267
161
  ```sh
268
162
  pnpm install
@@ -271,69 +165,18 @@ pnpm build
271
165
  pnpm lint
272
166
  pnpm check:exports
273
167
  pnpm check:size
274
- pnpm bench:local
275
- pnpm bench:local:dev
276
- pnpm bench:local:full
277
- pnpm bench:local:gate
278
- pnpm bench:local:smoke
168
+ pnpm check:browser-entry
169
+ pnpm check:quick-example-size
279
170
  ```
280
171
 
281
- The package CLI also exposes:
282
-
283
- ```sh
284
- tachyon-dom compile view.td --target client --out view.js
285
- tachyon-dom routes src/routes --out route-manifest.json
286
- tachyon-dom typegen src/routes/index/page.td --out src/routes/index/page.td.ts
287
- tachyon-dom add page settings/profile --routes-dir src/routes
288
- npm create tachyon-dom@latest my-app
289
- pnpm create tachyon-dom my-app
290
- tachyon-dom init --template basic --out my-app
291
- tachyon-dom dev --host 127.0.0.1 --port 5173
292
- tachyon-dom build
293
- tachyon-dom preview --host 127.0.0.1 --port 4173
294
- tachyon-dom language-server --stdio
295
- ```
296
-
297
- `npm create tachyon-dom@latest my-app` and `pnpm create tachyon-dom my-app` create a route-local starter with `src/routes/index/page.td`, `src/client/main.ts`, `src/app.ts`, `vite.config.ts`, `tsconfig.json`, `.gitignore`, a smoke test, CI workflow, `README.md`, and `package.json`. `tachyon-dom init --template basic --out my-app` is the equivalent installed-package command. Use `tachyon-dom init --template ssr --out my-app` when you also want a small `src/server.ts` SSR composition entry.
298
-
299
- `tachyon-dom init` and `tachyon-dom add page` preserve existing files by default. A starter conflict aborts before any file is written. Pass `--force` only when you deliberately want to overwrite the listed managed files; the command reports every overwritten path.
300
-
301
- `tachyon-dom add page` also reports the normalized route URL, adjacent declaration file, and generated registry it changed. For example, `users/[id]` reports `/users/:id/`, while `blog/[...slug]` reports `/blog/*slug/`.
302
-
303
- Template files use the short `.td` extension. The Vite plugin and file router also accept `.tachyon` and `.tachyon.html` for compatibility.
304
-
305
- `tachyon-dom language-server --stdio` starts a minimal Language Server Protocol server for editor integrations. The first version reports Tachyon template diagnostics for `.td`, `.tachyon`, and `.tachyon.html` documents; completion, hover, and semantic tokens are intentionally left to later editor-support slices.
306
-
307
- `pnpm bench:local` builds every implementation in production mode (bundled and minified, matching what applications ship), serves them from a temporary Vite preview server, measures Tachyon DOM against local copies of the keyed vanilla benchmark implementations in Playwright Chromium, prints ratio tables, and writes JSON results under `benchmark/local-compare/results/`. Production is the canonical mode because dev-server module loading adds overhead that does not exist in a shipped app.
308
-
309
- `pnpm bench:local:dev` runs the same comparison against the unbundled dev server for fast local iteration (no production build step).
310
-
311
- `pnpm bench:local:full` runs the canonical production comparison with extra warmup and measured iterations for noisier performance investigations.
312
-
313
- `pnpm bench:local:smoke` runs the same local comparison with one warmup and one measured iteration for manual benchmark smoke checks. The CI workflow exposes it through `workflow_dispatch`; normal push and pull request runs skip the benchmark.
314
-
315
- `pnpm bench:local:gate` runs the local comparison with operation and memory regression thresholds.
316
-
317
- The local benchmark prints row-operation timings plus auxiliary metrics for startup, JS heap usage, DOM node counts, and local source size. JSON output also keeps per-run values with mean, median, min, max, and p95.
318
-
319
- `pnpm check:exports` runs `publint --strict` and `attw --pack --no-emoji` against the package exports and declaration files. `pnpm check:size` runs per-subpath size budgets for the root entry, selected runtime helpers including `runtime/signal`, `runtime/list`, `runtime/error-boundary`, the client router, `i18n`, and the server HTML helper. CI runs both gates after `pnpm build` and `pnpm verify:package`.
320
-
321
- Version tags matching `v*` publish through GitHub Actions with `npm publish --access public` after the same build, package, exports, and size checks pass. npm provenance is intentionally omitted because this source repository is private.
322
-
323
- ## Example
172
+ Useful examples:
324
173
 
325
174
  ```sh
326
175
  pnpm example:web
327
- ```
328
-
329
- Open `http://127.0.0.1:5173/examples/web/` to try the browser example. It shows the compiled SSR preview, hydrate boundary markers, store updates, keyed list updates, stream chunks, and generated client module shape in one page.
330
-
331
- Open `http://127.0.0.1:5173/examples/auth-todo/` for the authenticated todo example. Browser example HTML entries are generated by the Vite dev plugin, so these examples do not need hand-written `index.html` files.
332
-
333
- ```sh
176
+ pnpm example:full-app
334
177
  pnpm example:stream
335
178
  ```
336
179
 
337
- The CLI example in `examples/store-hydrate-stream.ts` prints the streamed SSR HTML for the same store/hydrate/streaming slice.
180
+ ## License
338
181
 
339
- `examples/router.ts` shows nested route rendering with a loader, route head tags, and route hydration state.
182
+ MIT
package/dist/cli.js CHANGED
@@ -541,7 +541,7 @@ const starterPackageJsonSource = () => `${JSON.stringify({
541
541
  typecheck: "tsc --noEmit",
542
542
  },
543
543
  dependencies: {
544
- "tachyon-dom": "^0.1.0",
544
+ "tachyon-dom": "^0.1.1",
545
545
  },
546
546
  devDependencies: {
547
547
  "@types/node": "^24.0.3",
@@ -10,6 +10,10 @@ export type ExpressionNode = {
10
10
  } | {
11
11
  type: "literal";
12
12
  value: string | number | boolean | null;
13
+ } | {
14
+ type: "regex";
15
+ pattern: string;
16
+ flags: string;
13
17
  } | {
14
18
  type: "array";
15
19
  items: ExpressionNode[];
@@ -344,7 +344,7 @@ const validateExpressionNode = (node) => {
344
344
  }
345
345
  return ok(node);
346
346
  }
347
- if (node.type === "literal") {
347
+ if (node.type === "literal" || node.type === "regex") {
348
348
  return ok(node);
349
349
  }
350
350
  if (node.type === "array") {
@@ -454,6 +454,10 @@ const loadOxcParser = () => {
454
454
  };
455
455
  const oxcExpressionToNode = (node) => {
456
456
  if (node.type === "Literal") {
457
+ const regex = "regex" in node ? node.regex : undefined;
458
+ if (regex) {
459
+ return ok({ type: "regex", pattern: regex.pattern, flags: regex.flags });
460
+ }
457
461
  if (typeof node.value === "string" ||
458
462
  typeof node.value === "number" ||
459
463
  typeof node.value === "boolean" ||
@@ -657,6 +661,9 @@ export const evaluateExpressionNode = (node, scope) => {
657
661
  if (node.type === "literal") {
658
662
  return node.value;
659
663
  }
664
+ if (node.type === "regex") {
665
+ return new RegExp(node.pattern, node.flags);
666
+ }
660
667
  if (node.type === "array") {
661
668
  return node.items.map((item) => evaluateExpressionNode(item, scope));
662
669
  }
@@ -797,6 +804,9 @@ export const expressionNodeToJs = (node, locals = new Set(), scopeName = "scope"
797
804
  if (node.type === "literal") {
798
805
  return JSON.stringify(node.value);
799
806
  }
807
+ if (node.type === "regex") {
808
+ return `new RegExp(${JSON.stringify(node.pattern)}, ${JSON.stringify(node.flags)})`;
809
+ }
800
810
  if (node.type === "array") {
801
811
  return `[${node.items.map((item) => expressionNodeToJs(item, locals, scopeName)).join(", ")}]`;
802
812
  }
@@ -5,11 +5,20 @@ import { lowerClientTemplate } from "./targets/client.js";
5
5
  import { applyTemplateWhitespace } from "./whitespace.js";
6
6
  const compileCacheLimit = 128;
7
7
  const compileCache = new Map();
8
+ const deepFreeze = (value, seen = new WeakSet()) => {
9
+ if (value === null || typeof value !== "object" || seen.has(value))
10
+ return value;
11
+ seen.add(value);
12
+ for (const child of Object.values(value))
13
+ deepFreeze(child, seen);
14
+ return Object.freeze(value);
15
+ };
8
16
  const rememberCompiledTemplate = (cacheKey, result) => {
17
+ const frozenResult = deepFreeze(result);
9
18
  if (compileCache.has(cacheKey)) {
10
19
  compileCache.delete(cacheKey);
11
20
  }
12
- compileCache.set(cacheKey, result);
21
+ compileCache.set(cacheKey, frozenResult);
13
22
  while (compileCache.size > compileCacheLimit) {
14
23
  const oldest = compileCache.keys().next().value;
15
24
  if (oldest === undefined) {
@@ -17,7 +26,7 @@ const rememberCompiledTemplate = (cacheKey, result) => {
17
26
  }
18
27
  compileCache.delete(oldest);
19
28
  }
20
- return result;
29
+ return frozenResult;
21
30
  };
22
31
  export const compileTemplate = (source, options = {}) => {
23
32
  const whitespace = options.whitespace ?? "preserve";
@@ -37,12 +46,12 @@ export const compileTemplate = (source, options = {}) => {
37
46
  if (!irResult.ok) {
38
47
  return rememberCompiledTemplate(cacheKey, err(irResult.error));
39
48
  }
40
- return rememberCompiledTemplate(cacheKey, ok({
49
+ return rememberCompiledTemplate(cacheKey, ok(deepFreeze({
41
50
  source,
42
51
  ir: irResult.value,
43
52
  root: irResult.value.root,
44
53
  client: lowerClientTemplate(irResult.value.root),
45
- }));
54
+ })));
46
55
  };
47
56
  export * from "./types.js";
48
57
  export { generateClientModule } from "./targets/client.js";
@@ -46,34 +46,117 @@ const readQuotedValue = (parser) => {
46
46
  };
47
47
  const readBracedValue = (parser) => {
48
48
  let depth = 0;
49
- let quote;
49
+ let mode = "code";
50
+ let escaped = false;
51
+ let regexCharacterClass = false;
52
+ let canStartRegex = true;
53
+ const templateExpressionDepths = [];
50
54
  const start = parser.offset;
51
55
  while (parser.offset < parser.source.length) {
52
56
  const char = parser.source[parser.offset];
53
- const previous = parser.source[parser.offset - 1];
54
- if (quote) {
55
- if (char === quote && previous !== "\\") {
56
- quote = undefined;
57
+ const next = parser.source[parser.offset + 1];
58
+ if (mode === "line-comment") {
59
+ if (char === "\n" || char === "\r")
60
+ mode = "code";
61
+ parser.offset++;
62
+ continue;
63
+ }
64
+ if (mode === "block-comment") {
65
+ if (char === "*" && next === "/") {
66
+ mode = "code";
67
+ parser.offset += 2;
68
+ }
69
+ else {
70
+ parser.offset++;
71
+ }
72
+ continue;
73
+ }
74
+ if (mode === "single" || mode === "double") {
75
+ if (!escaped && char === (mode === "single" ? "'" : '"'))
76
+ mode = "code";
77
+ escaped = !escaped && char === "\\";
78
+ parser.offset++;
79
+ continue;
80
+ }
81
+ if (mode === "regex") {
82
+ if (!escaped) {
83
+ if (char === "[")
84
+ regexCharacterClass = true;
85
+ if (char === "]")
86
+ regexCharacterClass = false;
87
+ if (char === "/" && !regexCharacterClass) {
88
+ mode = "code";
89
+ canStartRegex = false;
90
+ }
91
+ }
92
+ escaped = !escaped && char === "\\";
93
+ parser.offset++;
94
+ continue;
95
+ }
96
+ if (mode === "template") {
97
+ if (!escaped && char === "`") {
98
+ mode = "code";
99
+ canStartRegex = false;
100
+ parser.offset++;
101
+ continue;
102
+ }
103
+ if (!escaped && char === "$" && next === "{") {
104
+ depth++;
105
+ templateExpressionDepths.push(depth);
106
+ mode = "code";
107
+ canStartRegex = true;
108
+ parser.offset += 2;
109
+ continue;
57
110
  }
111
+ escaped = !escaped && char === "\\";
112
+ parser.offset++;
113
+ continue;
114
+ }
115
+ if (char === "/" && next === "/") {
116
+ mode = "line-comment";
117
+ parser.offset += 2;
118
+ continue;
119
+ }
120
+ if (char === "/" && next === "*") {
121
+ mode = "block-comment";
122
+ parser.offset += 2;
123
+ continue;
124
+ }
125
+ if (char === "/" && canStartRegex) {
126
+ mode = "regex";
127
+ escaped = false;
128
+ regexCharacterClass = false;
58
129
  parser.offset++;
59
130
  continue;
60
131
  }
61
- if (char === `"` || char === `'`) {
62
- quote = char;
132
+ if (char === `"` || char === `'` || char === "`") {
133
+ mode = char === `"` ? "double" : char === `'` ? "single" : "template";
134
+ escaped = false;
63
135
  parser.offset++;
64
136
  continue;
65
137
  }
66
138
  if (char === "{") {
67
139
  depth++;
140
+ canStartRegex = true;
68
141
  }
69
142
  else if (char === "}") {
70
143
  depth--;
71
144
  parser.offset++;
145
+ if (templateExpressionDepths.at(-1) === depth + 1) {
146
+ templateExpressionDepths.pop();
147
+ mode = "template";
148
+ escaped = false;
149
+ continue;
150
+ }
72
151
  if (depth === 0) {
73
152
  return ok(parser.source.slice(start, parser.offset));
74
153
  }
154
+ canStartRegex = false;
75
155
  continue;
76
156
  }
157
+ else if (!isWhitespace(char)) {
158
+ canStartRegex = "([,:;!?=+-*%&|^~<>".includes(char);
159
+ }
77
160
  parser.offset++;
78
161
  }
79
162
  return parserError(parser, "Unclosed braced attribute value.");
@@ -7,20 +7,20 @@ export const sfcSetupScopeName = "__tachyonSfcSetupScope";
7
7
  const scriptOpenPattern = /<script\b([^>]*)>/gi;
8
8
  const autoImports = {
9
9
  batch: "tachyon-dom",
10
- compileTachyonSfc: "tachyon-dom",
11
- createClientRouter: "tachyon-dom",
10
+ compileTachyonSfc: "tachyon-dom/compiler",
11
+ createClientRouter: "tachyon-dom/runtime/router",
12
12
  createMemo: "tachyon-dom",
13
13
  createSignal: "tachyon-dom",
14
14
  createStore: "tachyon-dom",
15
15
  effect: "tachyon-dom",
16
16
  enhanceForm: "tachyon-dom",
17
17
  err: "tachyon-dom",
18
- generateClientModule: "tachyon-dom",
19
- generateServerStreamModule: "tachyon-dom",
18
+ generateClientModule: "tachyon-dom/compiler",
19
+ generateServerStreamModule: "tachyon-dom/compiler",
20
20
  ok: "tachyon-dom",
21
21
  readTextStreamChunks: "tachyon-dom",
22
- renderServerTemplate: "tachyon-dom",
23
- renderToReadableStream: "tachyon-dom",
22
+ renderServerTemplate: "tachyon-dom/compiler",
23
+ renderToReadableStream: "tachyon-dom/server/stream",
24
24
  validateFormData: "tachyon-dom",
25
25
  };
26
26
  const autoImportPattern = new RegExp(`\\b(?:${Object.keys(autoImports)
@@ -257,6 +257,7 @@ const scopeName = (usesStore) => (usesStore ? "state" : "scope");
257
257
  const runtimeNames = {
258
258
  bindControl: "__tachyonBindControl",
259
259
  createStore: "__tachyonCreateStore",
260
+ createRoot: "__tachyonCreateRoot",
260
261
  delegate: "__tachyonDelegate",
261
262
  effect: "__tachyonEffect",
262
263
  elementAt: "__tachyonElementAt",
@@ -333,8 +334,8 @@ export const generateClientModule = (template, options = {}) => {
333
334
  ? `import { mountConditional as ${runtimeNames.mountConditional}, nodeAt as ${runtimeNames.nodeAt} } from "tachyon-dom/runtime/conditional";`
334
335
  : `import { mountConditional as ${runtimeNames.mountConditional} } from "tachyon-dom/runtime/conditional";`);
335
336
  }
336
- if (needsSignal) {
337
- lines.push(`import { effect as ${runtimeNames.effect}, read as ${runtimeNames.read} } from "tachyon-dom/runtime/signal";`);
337
+ if (needsSignal || hasDefaultScope) {
338
+ lines.push(`import { ${hasDefaultScope ? `createRoot as ${runtimeNames.createRoot}, ` : ""}${needsSignal ? `effect as ${runtimeNames.effect}, read as ${runtimeNames.read}` : ""} } from "tachyon-dom/runtime/signal";`);
338
339
  }
339
340
  if (needsStore) {
340
341
  lines.push(`import { createStore as ${runtimeNames.createStore} } from "tachyon-dom/runtime/store";`);
@@ -348,7 +349,9 @@ export const generateClientModule = (template, options = {}) => {
348
349
  lines.push(` return localScope && typeof localScope === "object" ? { ...localScope, ...inputScope } : inputScope;`);
349
350
  lines.push(`};`);
350
351
  }
351
- lines.push(hasDefaultScope ? `export const bind = (root, inputScope = {}) => {` : `export const bind = (root, scope) => {`);
352
+ lines.push(hasDefaultScope
353
+ ? `export const bind = (root, inputScope = {}) => ${runtimeNames.createRoot}((__tachyonDisposeRoot) => {`
354
+ : `export const bind = (root, scope) => {`);
352
355
  if (hasDefaultScope) {
353
356
  lines.push(` const scope = __tachyonCreateScope(inputScope);`);
354
357
  }
@@ -358,7 +361,7 @@ export const generateClientModule = (template, options = {}) => {
358
361
  .join(", ");
359
362
  lines.push(` const state = ${runtimeNames.createStore}({ ...scope, ${fields} });`);
360
363
  }
361
- if (reactive || needsEvent || needsModel) {
364
+ if (reactive || needsEvent || needsModel || hasDefaultScope) {
362
365
  lines.push(` const cleanups = [];`);
363
366
  }
364
367
  let listIndex = 0;
@@ -438,12 +441,26 @@ export const generateClientModule = (template, options = {}) => {
438
441
  lines.push(emitConditionalBinding(binding, reactive, sourceName, conditionalIndex++, targetName));
439
442
  }
440
443
  }
441
- if (reactive || needsEvent || needsModel) {
444
+ if (reactive || needsEvent || needsModel || hasDefaultScope) {
442
445
  lines.push(` return () => {`);
443
- lines.push(` for (const cleanup of cleanups) cleanup();`);
446
+ lines.push(` let __tachyonCleanupError;`);
447
+ lines.push(` let __tachyonCleanupFailed = false;`);
448
+ lines.push(` for (const cleanup of cleanups) {`);
449
+ lines.push(` try { cleanup(); } catch (error) {`);
450
+ lines.push(` if (!__tachyonCleanupFailed) __tachyonCleanupError = error;`);
451
+ lines.push(` __tachyonCleanupFailed = true;`);
452
+ lines.push(` }`);
453
+ lines.push(` }`);
454
+ if (hasDefaultScope) {
455
+ lines.push(` try { __tachyonDisposeRoot(); } catch (error) {`);
456
+ lines.push(` if (!__tachyonCleanupFailed) __tachyonCleanupError = error;`);
457
+ lines.push(` __tachyonCleanupFailed = true;`);
458
+ lines.push(` }`);
459
+ }
460
+ lines.push(` if (__tachyonCleanupFailed) throw __tachyonCleanupError;`);
444
461
  lines.push(` };`);
445
462
  }
446
- lines.push(`};`);
463
+ lines.push(hasDefaultScope ? `});` : `};`);
447
464
  const code = `${lines.join("\n")}\n`;
448
465
  const nextCache = cachedByOptions ?? new Map();
449
466
  nextCache.set(cacheKey, code);
@@ -1 +1,2 @@
1
1
  export * from "./compiler/index.js";
2
+ export { compileTachyonSfc } from "./compiler/sfc.js";
package/dist/compiler.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./compiler/index.js";
2
+ export { compileTachyonSfc } from "./compiler/sfc.js";
package/dist/index.d.ts CHANGED
@@ -1,19 +1,12 @@
1
1
  import { err, ok } from "./result.js";
2
- export * from "./app.js";
3
- export { compileTachyonSfc } from "./compiler/sfc.js";
4
- export { createI18n, localeMiddleware } from "./i18n.js";
5
- export { generateClientModule, generateServerStreamModule, renderServerTemplate } from "./compiler/index.js";
6
2
  export { enhanceForm, validateFormData } from "./runtime/form.js";
7
3
  export { cleanupEnhancements, createEnhancementRegistry, enhance, registerEnhancement } from "./runtime/enhancement.js";
8
4
  export { createClientRouter } from "./runtime/router.js";
9
5
  export { createErrorBoundary } from "./runtime/error-boundary.js";
10
6
  export { createKeyedRows } from "./runtime/keyed-rows.js";
11
- export { batch, catchError, createMemo, createResource, createSignal, effect, untrack } from "./runtime/signal.js";
7
+ export { batch, catchError, createMemo, createResource, createRoot, createSignal, effect, onCleanup, untrack, } from "./runtime/signal.js";
12
8
  export { createStore } from "./runtime/store.js";
13
9
  export { readTextStreamChunks } from "./runtime/stream-client.js";
14
- export { attr, booleanAttr, classList, html as serverHtml, join, rawHtml } from "./server/html.js";
15
- export { formAction, formField, formState, preserveFormValues, redirectResponse } from "./server/form-action.js";
16
- export { renderToReadableStream } from "./server/stream.js";
17
10
  export { err, ok };
18
11
  export { textAt } from "./runtime/text.js";
19
12
  export type { KeyedRows, KeyedRowsOptions } from "./runtime/keyed-rows.js";
package/dist/index.js CHANGED
@@ -1,18 +1,11 @@
1
1
  import { err, ok } from "./result.js";
2
- export * from "./app.js";
3
- export { compileTachyonSfc } from "./compiler/sfc.js";
4
- export { createI18n, localeMiddleware } from "./i18n.js";
5
- export { generateClientModule, generateServerStreamModule, renderServerTemplate } from "./compiler/index.js";
6
2
  export { enhanceForm, validateFormData } from "./runtime/form.js";
7
3
  export { cleanupEnhancements, createEnhancementRegistry, enhance, registerEnhancement } from "./runtime/enhancement.js";
8
4
  export { createClientRouter } from "./runtime/router.js";
9
5
  export { createErrorBoundary } from "./runtime/error-boundary.js";
10
6
  export { createKeyedRows } from "./runtime/keyed-rows.js";
11
- export { batch, catchError, createMemo, createResource, createSignal, effect, untrack } from "./runtime/signal.js";
7
+ export { batch, catchError, createMemo, createResource, createRoot, createSignal, effect, onCleanup, untrack, } from "./runtime/signal.js";
12
8
  export { createStore } from "./runtime/store.js";
13
9
  export { readTextStreamChunks } from "./runtime/stream-client.js";
14
- export { attr, booleanAttr, classList, html as serverHtml, join, rawHtml } from "./server/html.js";
15
- export { formAction, formField, formState, preserveFormValues, redirectResponse } from "./server/form-action.js";
16
- export { renderToReadableStream } from "./server/stream.js";
17
10
  export { err, ok };
18
11
  export { textAt } from "./runtime/text.js";
@@ -1,9 +1,9 @@
1
- import { elementAt, setClassPresence } from "./class.js";
1
+ import { setClassPresence } from "./class.js";
2
2
  import { setAttributeValue, setRef, setStyleValue } from "./attr.js";
3
3
  import { delegate } from "./event.js";
4
4
  import { bindControl, setControlValue } from "./form.js";
5
5
  import { mountKeyedList } from "./list.js";
6
- import { setText, textAt } from "./text.js";
6
+ import { setText } from "./text.js";
7
7
  const states = new WeakMap();
8
8
  export const nodeAt = (root, path) => {
9
9
  let current = root;
@@ -60,6 +60,12 @@ const createNodes = (templateHtml) => {
60
60
  template.innerHTML = templateHtml;
61
61
  return Array.from(template.content.childNodes).map((node) => node.cloneNode(true));
62
62
  };
63
+ const nodeAtState = (state, path) => {
64
+ if (state.nodes.length <= 1)
65
+ return nodeAt(state.nodes[0], path);
66
+ const [firstIndex, ...rest] = path;
67
+ return nodeAt(state.nodes[firstIndex ?? 0], rest);
68
+ };
63
69
  const bindNodes = (anchor, state, scope, options) => {
64
70
  state.scope = scope;
65
71
  const firstElement = state.nodes.find((node) => node instanceof Element);
@@ -68,43 +74,49 @@ const bindNodes = (anchor, state, scope, options) => {
68
74
  }
69
75
  for (const binding of options.bindings) {
70
76
  if (binding.kind === "text") {
71
- setText(textAt(firstElement, binding.path), readBinding(scope, binding));
77
+ setText(nodeAtState(state, binding.path), readBinding(scope, binding));
72
78
  }
73
79
  else if (binding.kind === "class") {
74
- setClassPresence(elementAt(firstElement, binding.path), binding.className, readBinding(scope, binding));
80
+ setClassPresence(nodeAtState(state, binding.path), binding.className, readBinding(scope, binding));
75
81
  }
76
82
  else if (binding.kind === "attr") {
77
- setAttributeValue(elementAt(firstElement, binding.path), binding.name, readBinding(scope, binding));
83
+ setAttributeValue(nodeAtState(state, binding.path), binding.name, readBinding(scope, binding));
78
84
  }
79
85
  else if (binding.kind === "style") {
80
- setStyleValue(elementAt(firstElement, binding.path), binding.name, readBinding(scope, binding));
86
+ setStyleValue(nodeAtState(state, binding.path), binding.name, readBinding(scope, binding));
81
87
  }
82
88
  else if (binding.kind === "ref") {
83
- setRef(scope, binding.expression, elementAt(firstElement, binding.path));
89
+ setRef(scope, binding.expression, nodeAtState(state, binding.path));
84
90
  }
85
91
  else if (binding.kind === "model") {
86
- setControlValue(elementAt(firstElement, binding.path), binding.property, readBinding(scope, binding));
92
+ setControlValue(nodeAtState(state, binding.path), binding.property, readBinding(scope, binding));
87
93
  }
88
94
  else if (binding.kind === "list") {
89
- mountKeyedList(firstElement, binding.path, readExpression(scope, binding.each, binding.read), { ...binding, scope });
95
+ const container = nodeAtState(state, binding.path);
96
+ if (!(container instanceof Element))
97
+ continue;
98
+ mountKeyedList(container, [], readExpression(scope, binding.each, binding.read), { ...binding, scope });
90
99
  }
91
100
  else if (binding.kind === "if") {
92
- mountConditional(firstElement, binding.path, readExpression(scope, binding.test, binding.read), scope, binding);
101
+ mountConditional(nodeAtState(state, binding.path), [], readExpression(scope, binding.test, binding.read), scope, binding);
93
102
  }
94
103
  }
95
104
  if (state.cleanups.length === 0) {
96
105
  for (const binding of options.bindings) {
97
106
  if (binding.kind === "event") {
107
+ const target = nodeAtState(state, binding.path);
108
+ if (!(target instanceof Element))
109
+ continue;
98
110
  const listener = (event) => {
99
111
  const handler = readEvent(state.scope, binding);
100
112
  if (typeof handler === "function") {
101
113
  handler(event);
102
114
  }
103
115
  };
104
- state.cleanups.push(delegate(firstElement, binding.eventName, binding.path, listener));
116
+ state.cleanups.push(delegate(target, binding.eventName, [], listener));
105
117
  }
106
118
  else if (binding.kind === "model") {
107
- const element = elementAt(firstElement, binding.path);
119
+ const element = nodeAtState(state, binding.path);
108
120
  state.cleanups.push(bindControl(element, binding.property, () => readBinding(scope, binding), (value) => writeBinding(scope, binding, value)));
109
121
  }
110
122
  }
@@ -171,12 +171,15 @@ const applyRowBinding = (record, scope, options, binding, index) => {
171
171
  else if (binding.kind === "list") {
172
172
  const eachBinding = binding.read ? { expression: binding.each, read: binding.read } : { expression: binding.each };
173
173
  const value = readBinding(scope, eachBinding);
174
- mountKeyedList(record.element, binding.path, value, { ...binding, scope });
174
+ const container = nodeAtRecord(record, binding.path);
175
+ if (container instanceof Element) {
176
+ mountKeyedList(container, [], value, { ...binding, scope });
177
+ }
175
178
  }
176
179
  else if (binding.kind === "if") {
177
180
  const testBinding = binding.read ? { expression: binding.test, read: binding.read } : { expression: binding.test };
178
181
  const value = readBinding(scope, testBinding);
179
- mountConditional(record.element, binding.path, value, scope, binding);
182
+ mountConditional(nodeAtRecord(record, binding.path), [], value, scope, binding);
180
183
  }
181
184
  };
182
185
  const bindRowBindings = (record, options) => {
@@ -1,4 +1,6 @@
1
1
  declare const signalBrand: unique symbol;
2
+ export declare const onCleanup: (cleanup: () => void) => void;
3
+ export declare const createRoot: <T>(fn: (dispose: () => void) => T) => T;
2
4
  export type Accessor<T> = (() => T) & {
3
5
  readonly [signalBrand]: true;
4
6
  };
@@ -1,9 +1,52 @@
1
1
  const signalBrand = Symbol("tachyon.signal");
2
2
  let activeEffect;
3
+ let currentOwner;
3
4
  let batchDepth = 0;
4
5
  let flushing = false;
5
6
  const pendingComputedEffects = new Set();
6
7
  const pendingEffects = new Set();
8
+ export const onCleanup = (cleanup) => {
9
+ if (currentOwner && !currentOwner.disposed) {
10
+ currentOwner.cleanups.push(cleanup);
11
+ }
12
+ };
13
+ export const createRoot = (fn) => {
14
+ const parent = currentOwner;
15
+ const owner = { disposed: false, cleanups: [] };
16
+ const dispose = () => {
17
+ if (owner.disposed)
18
+ return;
19
+ owner.disposed = true;
20
+ let firstError;
21
+ let failed = false;
22
+ for (let index = owner.cleanups.length - 1; index >= 0; index--) {
23
+ try {
24
+ owner.cleanups[index]?.();
25
+ }
26
+ catch (error) {
27
+ if (!failed)
28
+ firstError = error;
29
+ failed = true;
30
+ }
31
+ }
32
+ owner.cleanups.length = 0;
33
+ if (failed)
34
+ throw firstError;
35
+ };
36
+ if (parent && !parent.disposed)
37
+ parent.cleanups.push(dispose);
38
+ currentOwner = owner;
39
+ try {
40
+ return fn(dispose);
41
+ }
42
+ catch (error) {
43
+ dispose();
44
+ throw error;
45
+ }
46
+ finally {
47
+ currentOwner = parent;
48
+ }
49
+ };
7
50
  const cleanup = (runner) => {
8
51
  for (const child of Array.from(runner.children)) {
9
52
  disposeRunner(child);
@@ -182,7 +225,9 @@ const createEffect = (fn, computed) => {
182
225
  };
183
226
  parent?.children.add(runner);
184
227
  runner.run();
185
- return () => disposeRunner(runner);
228
+ const dispose = () => disposeRunner(runner);
229
+ onCleanup(dispose);
230
+ return dispose;
186
231
  };
187
232
  export const effect = (fn) => createEffect(fn, false);
188
233
  export const catchError = (fn, onError) => effect(() => {
@@ -255,7 +300,7 @@ export const createResource = (source, fetcher) => {
255
300
  else {
256
301
  void run();
257
302
  }
258
- return {
303
+ const resource = {
259
304
  data,
260
305
  error,
261
306
  loading,
@@ -272,4 +317,6 @@ export const createResource = (source, fetcher) => {
272
317
  loading.set(false);
273
318
  },
274
319
  };
320
+ onCleanup(resource.dispose);
321
+ return resource;
275
322
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tachyon-dom",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "description": "A small TypeScript UI runtime and HTML-first compiler.",
6
6
  "keywords": [
@@ -205,6 +205,7 @@
205
205
  "bench:html-minification": "tsx benchmark/html-minification.ts",
206
206
  "bench:local:stable": "tsx benchmark/local-compare/run-local-compare.ts --iterations 30 --warmup 5",
207
207
  "bench:local:trace": "tsx benchmark/local-compare/run-local-compare.ts --serve-mode dev --iterations 1 --warmup 1 --trace-implementation tachyon-dom --trace-scenario partialUpdate",
208
+ "bench:summary": "tsx benchmark/summary/cli.ts",
208
209
  "bench:web-framework": "tsx benchmark/web-framework/run-web-framework-benchmark.ts",
209
210
  "bench:web-framework:smoke": "tsx benchmark/web-framework/run-web-framework-benchmark.ts --smoke",
210
211
  "example:router": "tsx examples/router.ts",
@@ -219,6 +220,8 @@
219
220
  "build:create-package": "pnpm --dir packages/create-tachyon-dom build",
220
221
  "check:exports": "publint --strict && attw --pack --no-emoji --profile esm-only",
221
222
  "check:size": "size-limit",
223
+ "check:browser-entry": "node scripts/verify-browser-entry.mjs",
224
+ "check:quick-example-size": "node scripts/verify-quick-example-bundle.mjs",
222
225
  "verify:package": "node scripts/verify-package-artifacts.mjs",
223
226
  "verify:release": "node scripts/release-contract.mjs --verify-artifacts release-artifacts",
224
227
  "prepare:release": "node scripts/release-contract.mjs",