diff --git a/spec/framework.md b/spec/framework.md index 85db900..4d53ca5 100644 --- a/spec/framework.md +++ b/spec/framework.md @@ -1,6 +1,6 @@ # el-ui Framework Specification -Version 1.1.0 — April 30, 2026 +Version 1.2.0 — May 4, 2026 --- @@ -516,11 +516,13 @@ For each component: 7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind. 8. Emits `${__self._child(ComponentClass, props)}` for component references. -#### `CodegenTarget::Server` — Rust SSR +#### `CodegenTarget::Server` — Node.js SSR -Emits a Rust module where each component is a struct implementing `render_to_html(&self) -> String` via `el_platform::ServerBackend`. +Emits a Node.js CommonJS module (the "render script") that exports `renderToString(componentName, props)` and `renderToDocument(componentName, props, shellOpts)`. The render script uses `runtime/src/ssr.js` to parse the component source at module load time and render to HTML strings at call time. -**Note:** The `build_node_tree()` implementation in this target is a stub — it returns a placeholder `PlatformNode::element("div")`. Full template-to-PlatformNode codegen is not yet implemented for the server target. +The `build_node_tree()` / template-to-HTML pipeline is implemented in `runtime/src/ssr.js`. It covers all TemplateNode variants: `Element`, `Text`, `Interpolation`, `If`, `Each`, `Activate`, `Component`, and `RawHtml`. The `{#activate}` block renders empty on the server (no Engram server available at SSR time); the client runtime re-runs semantic queries after hydration. + +The `ServerBackend` target is the only target where hydration markers are injected: `data-el-component`, `data-el-id`, and `data-el-state` on each component root element. #### `CodegenTarget::Native(Platform)` — Rust Native / WASM @@ -578,12 +580,26 @@ Appearance is inferred from explicit `appearance="..."` attributes, CSS class na ### 7.6 CLI +The `elc-ui` Node.js script in `vessels/el-ui-compiler/elc-ui` exposes the compiler as a command-line tool: + ```bash -el-ui-compiler App.el # compiles to App.js (Web target, default) -el-ui-compiler App.el -o app.js # explicit output path +elc-ui App.el # compile to App.js (web target, default) +elc-ui App.el -o app.js # explicit output path +elc-ui App.el --target=server # emit Node.js render script +elc-ui App.el --target=server -o render.js +elc-ui App.el --runtime=../el-ui.js # custom runtime path for web target ``` -The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `--target ios`, etc.) is available as a `CodegenTarget` enum in the library API but is not exposed as a CLI flag in the current implementation. +**Web target** emits an ES2022 JavaScript module — the same class-per-component format as the hand-compiled examples in `examples/counter/app.js`. Requires the el-ui runtime (`el-ui.js` or `dist/el-ui.js`) to be available at the path specified by `--runtime`. + +**Server target** emits a Node.js CJS module that: +- Embeds the component source at compile time (base64-encoded) +- Exports `renderToString(componentName, props)` → HTML string +- Exports `renderToDocument(componentName, props, shellOpts)` → full HTML document +- Exports `componentNames` → array of component names found in the source +- Supports CLI invocation: `node render.js ComponentName '{"prop":"val"}'` + +The server render script is self-contained: it does not require re-reading the `.el` source file at render time. The El web server calls it via `exec()` and captures stdout as the rendered HTML string. --- @@ -597,15 +613,26 @@ The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, ` | `activation.js` | Standalone activation utilities: `spreadActivation`, `activationStrength`, `reachableNodes` | | `renderer.js` | DOM patching, event binding | | `router.js` | Graph-based routing | -| `index.js` | `Component` base class, `mount()`, re-exports | +| `index.js` | `Component` base class, `mount()`, `hydrate()`, re-exports | +| `ssr.js` | Server-side rendering engine (Node.js only, not a browser module) | ### 8.2 Component Lifecycle +**Client-side (mount path):** + | Lifecycle event | Description | |-----------------|-------------| -| `constructor(props)` | Seeds state graph, subscribes to activation | -| `onMount()` | Called after first render and DOM attachment | +| `constructor(props, initialState?)` | Seeds state graph, subscribes to activation. `initialState` is used by `hydrate()` to pre-load server state. | | `render()` | Returns HTML string; called on activation | +| `onMount()` | Called after first render and DOM attachment | + +**SSR + hydration path:** + +| Lifecycle event | Description | +|-----------------|-------------| +| `constructor(props, initialState)` | Called by `hydrate()` with recovered server state | +| `_hydrate(root)` | Subscribes state nodes, creates renderer pointing at existing DOM, binds events. Does NOT call `render()`. | +| `onHydrate()` | Called after hydration completes | **Note:** `onDestroy()` is documented in prior drafts but is not implemented in the base `Component` class or the `Renderer`. It will not be called by the runtime. @@ -621,6 +648,73 @@ const component = mount(App, '#app', { optionalProp: 'value' }); `mount` instantiates the root component, creates a `Renderer`, calls `renderer.mount()` which calls `render()`, sets `root.innerHTML`, binds events, then calls `onMount()`. +### 8.5 Hydration + +```javascript +import { hydrate } from './el-ui.js'; +import { Counter } from './app.js'; + +// Hydrate a server-rendered Counter without replacing the DOM +const component = hydrate('[data-el-component="Counter"]', Counter); +// or with a stable ID for multi-instance pages: +const component = hydrate('[data-el-id="el-counter-1"]', Counter); +``` + +`hydrate` finds the SSR root element, reads `data-el-state` to recover the server's initial state, constructs the component with that state pre-loaded, then calls `_hydrate(root)` which binds all event handlers without re-rendering. The existing server-rendered DOM is preserved; spreading activation begins on the first `setState()` call. + +**SSR lifecycle:** + +``` +Server: renderToString(src, 'App', props) → HTML with markers +Network: HTML delivered, painted immediately (zero JS latency) +Client: import { hydrate } from './el-ui.js' + hydrate('[data-el-component="App"]', App) + → events bound, spreading activation active + → subsequent setState() calls patch the DOM normally +``` + +### 8.6 Server-Side Rendering + +The SSR engine in `runtime/src/ssr.js` is a Node.js module that parses `.el` source files and renders components to HTML strings: + +```javascript +const { renderToString, renderToDocument, parseComponents } = require('./runtime/src/ssr.js'); +const fs = require('fs'); + +const src = fs.readFileSync('App.el', 'utf8'); + +// Render to HTML fragment +const html = renderToString(src, 'App', { title: 'Hello' }); + +// Render to full document +const doc = renderToDocument(src, 'App', { title: 'Hello' }, { + title: 'My App', + appScriptPath: './hydrate.js', + styles: 'body { background: #000; }', +}); +``` + +**Hydration markers** emitted on every component root element: + +| Attribute | Value | Purpose | +|-----------|-------|---------| +| `data-el-component` | component name | identifies component type for `hydrate()` | +| `data-el-id` | stable position-based ID | locates the exact instance when multiple components of the same type exist | +| `data-el-state` | JSON string | server's initial state snapshot; `hydrate()` reads this to initialize the Engram graph | + +Event bindings are preserved as `data-el-*` attributes (e.g., `data-el-click="() => increment()"`). The client hydration pass binds these handlers via `new Function('__self', ...)` exactly as the standard mount path does. + +**Server/client bridge:** + +The El web server calls the render script as a subprocess: + +```bash +node render.js ComponentName '{"prop":"value"}' +# stdout: the rendered HTML string +``` + +The render script is generated by `elc-ui --target=server`. It embeds the component source at compile time, so no `.el` file is needed at render time. + ### 8.4 DOM Patching Strategy (v0.1) The renderer uses full string re-render on every state change: `root.innerHTML = component.render()`. The activated node set argument to `patch()` is accepted but unused — targeted patching is planned for v0.2. @@ -653,7 +747,7 @@ pub trait PlatformBackend: Send + Sync { } ``` -The `ServerBackend` is the only backend where `supports_ssr()` returns `true` and `render_to_string()` produces real HTML output. All other backends are platform-native. +The `ServerBackend` is the only backend where `supports_ssr()` returns `true`. In v0.1, the server rendering implementation is in `runtime/src/ssr.js` (Node.js), not in a Rust crate. The `PlatformBackend` interface above describes the planned Rust abstraction; the current implementation fulfills the same contract via the JavaScript SSR engine, which is the authoritative server rendering path. All other backends are platform-native. Platform is selected via `manifest.el`: @@ -691,11 +785,107 @@ No prior framework uses Hebbian spreading activation as the re-render decision m --- -## 11. Versioning Roadmap +## 11. SSR + Hydration Example + +A complete SSR + hydration example lives in `examples/ssr-counter/`. + +### 11.1 Counter.el + +The component definition with state, methods, `{#if}` conditionals, and child composition: + +``` +component Counter { + props { + label: String = "Counter" + } + state { + count: Int = 0 + history: String = "" + } + fn increment() -> Void { + count = count + 1 + history = history + "+" + count + " " + } + template { +
{history}
+ {/if}
+ Press + or - to begin.
+#