6 Commits

Author SHA1 Message Date
Will Anderson 7e36983dd2 enforce source branch in CI: stage←dev, main←stage 2026-05-04 19:34:37 -05:00
Will Anderson 5fcb895dda Add release workflow with GCP prod publishing and ui-updated dispatch
Triggers on push to main and repository_dispatch el-sdk-updated.
Installs El SDK from foundation-prod Artifact Registry (Gitea fallback),
builds el-platform vessel, publishes to Gitea latest release and
foundation-prod Artifact Registry, then dispatches ui-updated to el-ide.
2026-05-04 19:32:14 -05:00
Will Anderson 0fddb7b5be update spec: mark ServerBackend SSR implemented, document hydration
Bumps spec version to 1.2.0.

Documents:
- SSR engine in runtime/src/ssr.js (Node.js, fully implemented)
- Hydration markers (data-el-component, data-el-id, data-el-state)
- hydrate() vs mount() and when to use each
- Full SSR + hydration lifecycle with code examples
- elc-ui CLI --target=server and --target=web flags
- Section 11: complete SSR + hydration walkthrough
- Section 12: versioning roadmap updated to reflect v0.1 actual state
- Section 9: clarifies that SSR is JS-implemented, Rust PlatformBackend is planned
2026-05-04 12:50:55 -05:00
Will Anderson 143137e2b4 add ssr-counter example demonstrating SSR + hydration end-to-end
Counter.el defines Counter, CounterPair, and App components with {#if}
conditionals, event handlers, and child component composition.

render.js (generated by elc-ui --target=server) is the Node.js render
script the server calls. app.js (generated by elc-ui --target=web) is the
compiled browser module. hydrate.js attaches the runtime to the
server-rendered DOM without replacing it.

index.html shows the integration point with instructions for injecting
server output and the two CLI commands needed to regenerate both targets.
2026-05-04 12:49:19 -05:00
Will Anderson f8e32e7719 add elc-ui CLI with --target=web and --target=server
elc-ui is a Node.js compiler CLI that reads .el component files and emits
either a browser ES2022 module (web target) or a Node.js CJS render script
(server target).

The server target embeds the component source at compile time and exports
renderToString(componentName, props) -> HTML string, which any El web server
can require() and call. CLI mode (node render.js ComponentName propsJson)
lets the web server invoke it via exec() as a subprocess.

The web target emits class-per-component JS matching the hand-compiled
format, so compiler output and hand-written components are interchangeable.
2026-05-04 12:47:34 -05:00
Will Anderson d24bb29817 implement el-ui SSR engine with renderToString and hydration markers
Adds runtime/src/ssr.js: a Node.js SSR engine that parses .el component
source and renders to HTML strings without a DOM. Covers the full template
AST -- elements, text, interpolations, {#if}, {#each}, {#activate}, and
child component recursion.

Every component root element receives hydration markers:
  data-el-component, data-el-id, data-el-state

Event bindings (on:click etc.) are preserved as data-el-* attributes so
the client hydration pass can bind them without re-rendering.

Updates Component base class with:
  - initialState constructor param for SSR state recovery
  - _hydrate(root): binds events on existing DOM, no re-render
  - onHydrate() lifecycle hook

Updates dist/el-ui.js bundle to match.
2026-05-04 12:45:27 -05:00
12 changed files with 2538 additions and 20 deletions
+10
View File
@@ -16,6 +16,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Enforce source branch (stage ← dev only)
if: github.event_name == 'pull_request'
run: |
SOURCE="${GITHUB_HEAD_REF}"
if [ "${SOURCE}" != "dev" ]; then
echo "ERROR: Stage branch only accepts PRs from 'dev'. Source was: '${SOURCE}'"
exit 1
fi
echo "Source branch check passed: ${SOURCE} → stage"
- name: Install build dependencies
run: |
apt-get update -qq
+179
View File
@@ -0,0 +1,179 @@
name: El UI Release
on:
push:
branches:
- main
repository_dispatch:
types:
- el-sdk-updated
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enforce source branch (main ← stage only)
if: github.event_name == 'pull_request'
run: |
SOURCE="${GITHUB_HEAD_REF}"
if [ "${SOURCE}" != "stage" ]; then
echo "ERROR: Main branch only accepts PRs from 'stage'. Source was: '${SOURCE}'"
exit 1
fi
echo "Source branch check passed: ${SOURCE} → main"
- name: Install build dependencies
run: |
apt-get update -qq
apt-get install -y gcc libcurl4-openssl-dev
# Install El SDK — try GCP Artifact Registry prod first, fall back to Gitea release
- name: Install El SDK
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
RELEASE_BASE: https://git.neuralplatform.ai/neuron-technologies/el/releases/download/latest
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
apt-get install -y -qq apt-transport-https ca-certificates gnupg curl
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update -qq && apt-get install -y google-cloud-cli
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
mkdir -p /usr/local/bin /usr/local/lib/el
LATEST_VERSION=$(gcloud artifacts versions list \
--repository=foundation-prod \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--format="value(name)" 2>/dev/null | sort | tail -1 | sed 's|.*/||' || true)
if [ -n "${LATEST_VERSION}" ]; then
gcloud artifacts generic download \
--repository=foundation-prod \
--location=us-central1 \
--project=neuron-785695 \
--package=el/elc \
--version="${LATEST_VERSION}" \
--destination=/usr/local/bin/elc
chmod +x /usr/local/bin/elc
else
echo "Falling back to Gitea latest release..."
curl -fsSL "${RELEASE_BASE}/elc" -o /usr/local/bin/elc
chmod +x /usr/local/bin/elc
fi
curl -fsSL "${RELEASE_BASE}/el_runtime.c" -o /usr/local/lib/el/el_runtime.c
curl -fsSL "${RELEASE_BASE}/el_runtime.h" -o /usr/local/lib/el/el_runtime.h
echo "El SDK installed:"; elc --version || true
rm -f /tmp/gcp-key.json
# Build el-platform vessel (foundation layer for all el-ui vessels)
- name: Build el-platform vessel
run: |
mkdir -p dist
elc vessels/el-platform/src/main.el > dist/el-platform.c
cc -std=c11 -O2 \
-I /usr/local/lib/el \
-o dist/el-platform \
dist/el-platform.c \
/usr/local/lib/el/el_runtime.c \
-lcurl -lpthread
chmod +x dist/el-platform
echo "Built dist/el-platform"
ls -lh dist/el-platform
- name: Run tests
run: |
if [ -f spec/run.sh ]; then
ELC=/usr/local/bin/elc bash spec/run.sh
elif [ -f tests/run.sh ]; then
ELC=/usr/local/bin/elc bash tests/run.sh
else
echo "No spec/run.sh or tests/run.sh — skipping"
fi
# Publish / update the `latest` release
- name: Publish latest release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.neuralplatform.ai/api/v1
REPO: neuron-technologies/el-ui
run: |
EXISTING_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/tags/latest" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing release id=${EXISTING_ID}"
curl -sf -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/${EXISTING_ID}"
fi
curl -sf -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/repos/${REPO}/tags/latest" || true
RELEASE_ID=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_API}/repos/${REPO}/releases" \
-d "{
\"tag_name\": \"latest\",
\"name\": \"El UI (latest)\",
\"body\": \"Latest El UI build from commit ${GITHUB_SHA}.\nBuilt $(date -u +%Y-%m-%dT%H:%M:%SZ).\",
\"draft\": false,
\"prerelease\": false
}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Created release id=${RELEASE_ID}"
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/el-platform;filename=el-platform" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets"
echo "Release published successfully"
# Publish artifact to GCP Artifact Registry (prod)
- name: Publish el-platform to Artifact Registry (prod)
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
VERSION="${GITEA_SHA:0:8}"
gcloud artifacts generic upload \
--repository=foundation-prod \
--location=us-central1 \
--project=neuron-785695 \
--package=el-ui/el-platform \
--version="${VERSION}" \
--source=dist/el-platform
echo "Published el-platform version=${VERSION} to foundation-prod/el-ui/el-platform"
rm -f /tmp/gcp-key.json
# Dispatch ui-updated to downstream dependents
- name: Dispatch ui-updated to el-ide
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.neuralplatform.ai/api/v1
run: |
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_API}/repos/neuron-technologies/el-ide/dispatches" \
-d "{\"type\":\"ui-updated\",\"inputs\":{\"ui_version\":\"latest\",\"commit\":\"${GITHUB_SHA}\"}}"
echo "Dispatched ui-updated to neuron-technologies/el-ide"
+35 -3
View File
@@ -351,15 +351,16 @@ export class Router {
}
}
// ── Component base class & mount() ───────────────────────────────────────────
// ── Component base class, mount(), and hydrate() ─────────────────────────────
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this.props = props;
this._renderer = null;
this._ssrState = initialState;
}
setState(name, value) {
@@ -382,6 +383,20 @@ export class Component {
render() { return ''; }
onMount() {}
onHydrate() {}
_hydrate(root) {
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') this.onHydrate();
}
}
export function mount(ComponentClass, selector, props = {}) {
@@ -394,4 +409,21 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer, Router };
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) throw new Error(`el-ui hydrate: element not found: ${selector}`);
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
const instance = new ComponentClass(props, initialState);
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer, Router };
+88
View File
@@ -0,0 +1,88 @@
// ssr-counter/Counter.el
//
// A counter component that demonstrates:
// - State with initial value
// - {#if} conditional rendering
// - {#each} list rendering
// - Interpolation
// - Event binding
// - SSR + client hydration
//
// Run `node render.js Counter` to see the server-rendered HTML.
// Open index.html in a browser to see the full SSR + hydration lifecycle.
component Counter {
props {
initialValue: Int = 0
label: String = "Counter"
}
state {
count: Int = 0
history: String = ""
}
fn increment() -> Void {
count = count + 1
history = history + "+" + count + " "
}
fn decrement() -> Void {
count = count - 1
history = history + "-" + count + " "
}
fn reset() -> Void {
count = 0
history = ""
}
template {
<div class="counter">
<h2 class="counter-label">{label}</h2>
<div class="count-display">
<span class={count > 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}>
{count}
</span>
</div>
<div class="buttons">
<button class="btn btn-decrement" on:click={() => decrement()}>-</button>
<button class="btn btn-reset" on:click={() => reset()}>reset</button>
<button class="btn btn-increment" on:click={() => increment()}>+</button>
</div>
{#if history != ""}
<div class="history">
<span class="history-label">history:</span>
<code>{history}</code>
</div>
{/if}
{#if count == 0}
<p class="hint">Press + or - to begin.</p>
{/if}
</div>
}
}
component CounterPair {
template {
<div class="counter-pair">
<Counter label="Left" />
<Counter label="Right" />
</div>
}
}
component App {
template {
<div class="app">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<Counter label="Main Counter" />
<CounterPair />
</main>
</div>
}
}
+119
View File
@@ -0,0 +1,119 @@
import { Component, Graph, Renderer, Router, mount } from '../../dist/el-ui.js';
class Counter extends Component {
constructor(props = {}) {
super();
this.props = props;
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this._stateNodes['count'] = this._graph.seed({ type: 'state', name: 'count', content: 0 });
this._state['count'] = 0;
this._stateNodes['history'] = this._graph.seed({ type: 'state', name: 'history', content: "" });
this._state['history'] = "";
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
}
}
increment() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
__self.setState('count', count + 1)
__self.setState('history', history + "+" + count + " ")
}
decrement() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
__self.setState('count', count - 1)
__self.setState('history', history + "-" + count + " ")
}
reset() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
__self.setState('count', 0)
__self.setState('history', "")
}
render() {
const __self = this;
const count = this._state['count'];
const history = this._state['history'];
const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0;
const label = this.props['label'] !== undefined ? this.props['label'] : "Counter";
return `<div class="counter" data-el-tag="div"><h2 class="counter-label" data-el-tag="h2">${label }</h2><div class="count-display" data-el-tag="div"><span class="${count > 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}" data-el-tag="span">${count }</span></div><div class="buttons" data-el-tag="div"><button class="btn btn-decrement" data-el-click="() => decrement()" data-el-tag="button">-</button><button class="btn btn-reset" data-el-click="() => reset()" data-el-tag="button">reset</button><button class="btn btn-increment" data-el-click="() => increment()" data-el-tag="button">+</button></div>${(history != "") ? `<div class="history" data-el-tag="div"><span class="history-label" data-el-tag="span">history:</span><code data-el-tag="code">${history }</code></div>` : ``}${(count == 0) ? `<p class="hint" data-el-tag="p">Press + or - to begin.</p>` : ``}</div>`;
}
}
class CounterPair extends Component {
constructor(props = {}) {
super();
this.props = props;
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
}
}
render() {
const __self = this;
return `<div class="counter-pair" data-el-tag="div">${__self._child(Counter, { label: "Left" })}${__self._child(Counter, { label: "Right" })}</div>`;
}
}
class App extends Component {
constructor(props = {}) {
super();
this.props = props;
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
}
setState(name, value) {
if (this._stateNodes[name] !== undefined) {
this._graph.update(this._stateNodes[name], value);
}
}
render() {
const __self = this;
return `<div class="app" data-el-tag="div"><header class="app-header" data-el-tag="header"><h1 data-el-tag="h1">el-ui SSR + Hydration</h1><p class="subtitle" data-el-tag="p">Server-rendered, client-hydrated with spreading activation</p></header><main class="app-main" data-el-tag="main">${__self._child(Counter, { label: "Main Counter" })}${__self._child(CounterPair, { })}</main></div>`;
}
}
export { Counter, CounterPair, App };
+49
View File
@@ -0,0 +1,49 @@
/**
* hydrate.js — Client-side hydration entry point for ssr-counter.
*
* This script runs in the browser after the server has delivered the
* SSR-rendered HTML. It:
* 1. Finds each server-rendered component root (data-el-component attribute).
* 2. Reads back the initial state snapshot (data-el-state attribute).
* 3. Attaches the el-ui runtime WITHOUT replacing the existing DOM.
* 4. Binds all event handlers so the page becomes interactive.
*
* The page is immediately visible (the SSR HTML), and becomes interactive
* as soon as this script finishes executing. There is no content flash.
*
* SSR + hydration lifecycle:
* Server: renderToString(src, 'App', props) → HTML with markers
* Network: HTML delivered to browser, painted immediately
* Client: hydrate('[data-el-component="App"]', App) → events bound
* spreading activation activates on first setState() call
*/
import { hydrate } from '../../dist/el-ui.js';
import { Counter, CounterPair, App } from './app.js';
// Hydrate each component by its data-el-component selector.
// Order matters: hydrate leaf components before their parents so
// each instance gets its own renderer and state graph.
// Hydrate all Counter instances
document.querySelectorAll('[data-el-component="Counter"]').forEach(el => {
// Read the stable ID to avoid double-hydration
if (el.dataset.elHydrated) return;
hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter);
el.dataset.elHydrated = 'true';
});
// Hydrate all CounterPair instances
document.querySelectorAll('[data-el-component="CounterPair"]').forEach(el => {
if (el.dataset.elHydrated) return;
hydrate(`[data-el-id="${el.dataset.elId}"]`, CounterPair);
el.dataset.elHydrated = 'true';
});
// The App component contains CounterPair and Counter children.
// If you want the App to own the full activation graph, hydrate it here.
// For this example we hydrate individual Counter components so each
// maintains its own independent state graph (the simpler model).
// Uncomment to hydrate the whole App as one unit instead:
// hydrate('[data-el-component="App"]', App);
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<!--
ssr-counter/index.html — el-ui SSR + Hydration Example
In production, the content of <div id="app"> is generated by the server
calling:
const { renderToString } = require('./render.js');
const html = renderToString('App', {});
This static file simulates that by inlining the rendered HTML directly.
The client hydration script (hydrate.js) attaches to the existing DOM
without replacing it.
To see server rendering live:
node render.js App > /tmp/rendered.html
# then inline that output into the #app div below
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ssr-counter — el-ui SSR + Hydration</title>
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--text: #e8edf3;
--text2: #7a8a9a;
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.4);
--green: #4ade80;
--red: #f87171;
--border: rgba(255,255,255,0.08);
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Mono', 'JetBrains Mono', 'Fira Code', monospace;
min-height: 100vh;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
}
.app-header {
text-align: center;
margin-bottom: 48px;
}
.app-header h1 {
font-size: 32px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.03em;
margin-bottom: 8px;
}
.subtitle {
color: var(--text2);
font-size: 14px;
}
.app-main {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
}
.counter {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px 40px;
min-width: 280px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.counter-label {
font-size: 14px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
.count-display {
display: flex;
align-items: center;
justify-content: center;
}
.count {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 120px;
text-align: center;
letter-spacing: -0.03em;
transition: color 0.15s;
}
.count.zero { color: var(--text2); }
.count.positive { color: var(--accent); text-shadow: 0 0 40px var(--accent-glow); }
.count.negative { color: var(--red); text-shadow: 0 0 40px rgba(248,113,113,0.4); }
.buttons {
display: flex;
gap: 12px;
}
.btn {
background: var(--accent-dim);
border: 1px solid var(--accent);
color: var(--accent);
font-size: 18px;
font-family: inherit;
padding: 0 20px;
height: 48px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
background: rgba(56,189,248,0.22);
box-shadow: 0 0 16px var(--accent-glow);
}
.btn:active { transform: scale(0.95); }
.btn-reset {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(255,255,255,0.04);
border-color: var(--border);
color: var(--text2);
}
.btn-reset:hover {
background: rgba(255,255,255,0.08);
box-shadow: none;
}
.btn-decrement {
color: var(--red);
border-color: var(--red);
background: rgba(248,113,113,0.08);
}
.btn-decrement:hover {
background: rgba(248,113,113,0.18);
box-shadow: 0 0 16px rgba(248,113,113,0.3);
}
.history {
font-size: 12px;
color: var(--text2);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.history-label {
color: var(--text2);
opacity: 0.6;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
code {
color: var(--accent);
font-family: inherit;
}
.hint {
font-size: 12px;
color: var(--text2);
opacity: 0.5;
}
.counter-pair {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.el-badge {
position: fixed;
bottom: 16px;
right: 16px;
font-size: 10px;
color: var(--text2);
opacity: 0.5;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* SSR indicator — shows before hydration completes */
.ssr-note {
text-align: center;
font-size: 11px;
color: var(--text2);
opacity: 0.4;
margin-bottom: 24px;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<!--
In a real server setup, this div's content is injected by the server:
const { renderToString } = require('./render.js');
res.send(`<div id="app">${renderToString('App', {})}</div>`);
To preview server output:
node render.js App
Below is the static simulation of that output.
The data-el-* attributes are the hydration markers written by the SSR engine.
-->
<div id="app">
<!-- SSR output placeholder. Run: node render.js App and paste here. -->
<!-- Or open this file with a server that injects the SSR output above. -->
<!--
Example of what the server renders (simplified for readability):
<div class="app"
data-el-component="App"
data-el-id="el-app-1"
data-el-state="{}">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<div class="counter"
data-el-component="Counter"
data-el-id="el-counter-1"
data-el-state='{"count":0,"history":""}'>
...
</div>
</main>
</div>
-->
</div>
<p class="ssr-note">el-ui SSR + hydration · no DOM replacement on hydrate</p>
<div class="el-badge">el-ui · spreading activation</div>
<!--
Hydration script.
- app.js: the compiled component classes (web target, for render() and setState())
- hydrate.js: attaches the runtime to the server-rendered DOM
Both are type="module" so they run after the document is parsed.
The SSR HTML is already painted before these scripts execute.
-->
<script type="module" src="./hydrate.js"></script>
<!--
Integration note for the El web server:
===========================================
The El web server (C binary) renders pages by calling:
node render.js App '{}'
and capturing stdout as the HTML to inject into the template above.
This is the simplest bridge: no Node.js server needed, just exec().
A tighter integration embeds Node.js (via libnode or napi) and calls
renderToString() directly without subprocess overhead — appropriate for
high-traffic pages. The render script API is identical either way.
-->
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
/**
* el-ui SSR render script — generated by elc-ui --target=server
* Source: Counter.el
*
* Exports:
* renderToString(componentName, props) -> HTML string
* renderToDocument(componentName, props, opts) -> full HTML document
* componentNames -> string[]
*/
'use strict';
const { renderToString: _rts, renderToDocument: _rtd, parseComponents } = require('../../runtime/src/ssr.js');
// Component source embedded at compile time
const _src = Buffer.from('Ly8gc3NyLWNvdW50ZXIvQ291bnRlci5lbAovLwovLyBBIGNvdW50ZXIgY29tcG9uZW50IHRoYXQgZGVtb25zdHJhdGVzOgovLyAgIC0gU3RhdGUgd2l0aCBpbml0aWFsIHZhbHVlCi8vICAgLSB7I2lmfSBjb25kaXRpb25hbCByZW5kZXJpbmcKLy8gICAtIHsjZWFjaH0gbGlzdCByZW5kZXJpbmcKLy8gICAtIEludGVycG9sYXRpb24KLy8gICAtIEV2ZW50IGJpbmRpbmcKLy8gICAtIFNTUiArIGNsaWVudCBoeWRyYXRpb24KLy8KLy8gUnVuIGBub2RlIHJlbmRlci5qcyBDb3VudGVyYCB0byBzZWUgdGhlIHNlcnZlci1yZW5kZXJlZCBIVE1MLgovLyBPcGVuIGluZGV4Lmh0bWwgaW4gYSBicm93c2VyIHRvIHNlZSB0aGUgZnVsbCBTU1IgKyBoeWRyYXRpb24gbGlmZWN5Y2xlLgoKY29tcG9uZW50IENvdW50ZXIgewogICAgcHJvcHMgewogICAgICAgIGluaXRpYWxWYWx1ZTogSW50ID0gMAogICAgICAgIGxhYmVsOiBTdHJpbmcgPSAiQ291bnRlciIKICAgIH0KCiAgICBzdGF0ZSB7CiAgICAgICAgY291bnQ6IEludCA9IDAKICAgICAgICBoaXN0b3J5OiBTdHJpbmcgPSAiIgogICAgfQoKICAgIGZuIGluY3JlbWVudCgpIC0+IFZvaWQgewogICAgICAgIGNvdW50ID0gY291bnQgKyAxCiAgICAgICAgaGlzdG9yeSA9IGhpc3RvcnkgKyAiKyIgKyBjb3VudCArICIgIgogICAgfQoKICAgIGZuIGRlY3JlbWVudCgpIC0+IFZvaWQgewogICAgICAgIGNvdW50ID0gY291bnQgLSAxCiAgICAgICAgaGlzdG9yeSA9IGhpc3RvcnkgKyAiLSIgKyBjb3VudCArICIgIgogICAgfQoKICAgIGZuIHJlc2V0KCkgLT4gVm9pZCB7CiAgICAgICAgY291bnQgPSAwCiAgICAgICAgaGlzdG9yeSA9ICIiCiAgICB9CgogICAgdGVtcGxhdGUgewogICAgICAgIDxkaXYgY2xhc3M9ImNvdW50ZXIiPgogICAgICAgICAgICA8aDIgY2xhc3M9ImNvdW50ZXItbGFiZWwiPntsYWJlbH08L2gyPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJjb3VudC1kaXNwbGF5Ij4KICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPXtjb3VudCA+IDAgPyAiY291bnQgcG9zaXRpdmUiIDogY291bnQgPCAwID8gImNvdW50IG5lZ2F0aXZlIiA6ICJjb3VudCB6ZXJvIn0+CiAgICAgICAgICAgICAgICAgICAge2NvdW50fQogICAgICAgICAgICAgICAgPC9zcGFuPgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPGRpdiBjbGFzcz0iYnV0dG9ucyI+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWRlY3JlbWVudCIgb246Y2xpY2s9eygpID0+IGRlY3JlbWVudCgpfT4tPC9idXR0b24+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLXJlc2V0IiBvbjpjbGljaz17KCkgPT4gcmVzZXQoKX0+cmVzZXQ8L2J1dHRvbj4KICAgICAgICAgICAgICAgIDxidXR0b24gY2xhc3M9ImJ0biBidG4taW5jcmVtZW50IiBvbjpjbGljaz17KCkgPT4gaW5jcmVtZW50KCl9Pis8L2J1dHRvbj4KICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgIHsjaWYgaGlzdG9yeSAhPSAiIn0KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9Imhpc3RvcnkiPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJoaXN0b3J5LWxhYmVsIj5oaXN0b3J5Ojwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8Y29kZT57aGlzdG9yeX08L2NvZGU+CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgey9pZn0KICAgICAgICAgICAgeyNpZiBjb3VudCA9PSAwfQogICAgICAgICAgICAgICAgPHAgY2xhc3M9ImhpbnQiPlByZXNzICsgb3IgLSB0byBiZWdpbi48L3A+CiAgICAgICAgICAgIHsvaWZ9CiAgICAgICAgPC9kaXY+CiAgICB9Cn0KCmNvbXBvbmVudCBDb3VudGVyUGFpciB7CiAgICB0ZW1wbGF0ZSB7CiAgICAgICAgPGRpdiBjbGFzcz0iY291bnRlci1wYWlyIj4KICAgICAgICAgICAgPENvdW50ZXIgbGFiZWw9IkxlZnQiIC8+CiAgICAgICAgICAgIDxDb3VudGVyIGxhYmVsPSJSaWdodCIgLz4KICAgICAgICA8L2Rpdj4KICAgIH0KfQoKY29tcG9uZW50IEFwcCB7CiAgICB0ZW1wbGF0ZSB7CiAgICAgICAgPGRpdiBjbGFzcz0iYXBwIj4KICAgICAgICAgICAgPGhlYWRlciBjbGFzcz0iYXBwLWhlYWRlciI+CiAgICAgICAgICAgICAgICA8aDE+ZWwtdWkgU1NSICsgSHlkcmF0aW9uPC9oMT4KICAgICAgICAgICAgICAgIDxwIGNsYXNzPSJzdWJ0aXRsZSI+U2VydmVyLXJlbmRlcmVkLCBjbGllbnQtaHlkcmF0ZWQgd2l0aCBzcHJlYWRpbmcgYWN0aXZhdGlvbjwvcD4KICAgICAgICAgICAgPC9oZWFkZXI+CiAgICAgICAgICAgIDxtYWluIGNsYXNzPSJhcHAtbWFpbiI+CiAgICAgICAgICAgICAgICA8Q291bnRlciBsYWJlbD0iTWFpbiBDb3VudGVyIiAvPgogICAgICAgICAgICAgICAgPENvdW50ZXJQYWlyIC8+CiAgICAgICAgICAgIDwvbWFpbj4KICAgICAgICA8L2Rpdj4KICAgIH0KfQo=', 'base64').toString('utf8');
// Parse component names at module load time
const _components = parseComponents(_src);
const componentNames = [..._components.keys()];
/**
* Render a component to an HTML string.
* @param {string} componentName
* @param {Object} [props={}]
* @returns {string}
*/
function renderToString(componentName, props = {}) {
return _rts(_src, componentName, props);
}
/**
* Render a component inside a full HTML document.
* @param {string} componentName
* @param {Object} [props={}]
* @param {Object} [shellOpts={}]
* @returns {string}
*/
function renderToDocument(componentName, props = {}, shellOpts = {}) {
return _rtd(_src, componentName, props, shellOpts);
}
module.exports = { renderToString, renderToDocument, componentNames };
// CLI mode: node render.js <ComponentName> [propsJson]
if (require.main === module) {
const compName = process.argv[2];
const propsJson = process.argv[3] || '{}';
if (!compName) {
console.error('Usage: node render.js <ComponentName> [propsJson]');
process.exit(1);
}
let props = {};
try { props = JSON.parse(propsJson); } catch (e) {
console.error('render.js: invalid props JSON:', e.message);
process.exit(1);
}
process.stdout.write(renderToString(compName, props));
}
+109 -4
View File
@@ -9,6 +9,7 @@
* Router — graph-based routing
* Component — base class for el-ui components
* mount() — attach a component to the DOM
* hydrate() — hydrate an SSR-rendered component without re-rendering
*
* @module el-ui
*/
@@ -30,14 +31,15 @@ import { Renderer } from './renderer.js';
* Base class for all el-ui components.
*
* Subclasses override:
* render() → returns HTML string
* onMount() → called after first mount (optional)
* render() → returns HTML string
* onMount() → called after first mount (optional)
* onHydrate() → called after hydration (optional)
*
* Subclasses call:
* setState(name, value) → update state, trigger activation, patch DOM
*/
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
/** @type {Graph} */
this._graph = new Graph();
@@ -52,6 +54,9 @@ export class Component {
/** @type {Renderer|null} */
this._renderer = null;
/** @type {Record<string, any>} initial state injected from SSR */
this._ssrState = initialState;
}
/**
@@ -105,6 +110,51 @@ export class Component {
* Override for side effects (timers, fetch calls, subscriptions).
*/
onMount() {}
/**
* Called once after client-side hydration completes.
* Override to run post-hydration side effects.
*/
onHydrate() {}
/**
* Hydrate this component instance onto an existing SSR-rendered DOM node.
*
* Does NOT call render() or replace innerHTML.
* Instead:
* 1. Seeds the Engram graph from the SSR initial state (already in _state
* because the constructor ran with the server's state values).
* 2. Sets up subscriptions so future setState() calls patch the DOM.
* 3. Binds all data-el-* event handlers via the renderer.
* 4. Calls onHydrate().
*
* This method is called by hydrate() after constructing the component
* instance. Subclasses rarely need to override it.
*
* @param {HTMLElement} root the SSR-rendered root element
*/
_hydrate(root) {
// Subscribe all state nodes so setState() → DOM patch works
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
// Create a renderer pointing at the SSR root.
// We record the current innerHTML as _currentHtml so the first
// real state change triggers a patch rather than a no-op comparison.
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
// Bind event handlers declared by the SSR output
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') {
this.onHydrate();
}
}
}
/**
@@ -133,4 +183,59 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer };
/**
* Hydrate a server-rendered el-ui component.
*
* Call this in the browser when the page was server-rendered and you want
* to attach the el-ui runtime to the existing DOM (rather than replacing it).
*
* The function:
* 1. Finds the SSR root element using `selector`.
* 2. Reads `data-el-state` from that element to recover the server's
* initial state snapshot.
* 3. Constructs the component with the recovered state pre-loaded.
* 4. Calls `instance._hydrate(root)` to bind events without re-rendering.
*
* @param {string} selector CSS selector for the SSR root element
* @param {typeof Component} ComponentClass the component class
* @param {Record<string, any>} [props={}] initial props (same as what server received)
* @returns {Component} the live, hydrated component instance
*
* @example
* import { hydrate } from './el-ui.js';
* import { Counter } from './counter.js';
*
* // Hydrate the SSR-rendered Counter at #app > [data-el-component="Counter"]
* hydrate('[data-el-component="Counter"]', Counter);
*/
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) {
throw new Error(`el-ui hydrate: element not found: ${selector}`);
}
// Recover initial state from the data-el-state attribute injected by SSR
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
// Construct the component; pass initialState so subclasses can pre-seed
// their state graph with the server values (avoids the flash of reset state)
const instance = new ComponentClass(props, initialState);
// Apply the recovered server state into the component's _state map.
// The compiled constructor already ran (seeding _stateNodes), so we
// update each state node's content to match the server snapshot.
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
// Directly update node content (no activation — just sync the value)
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer };
+951
View File
@@ -0,0 +1,951 @@
/**
* ssr.js — Server-side rendering engine for el-ui components.
*
* Parses .el component syntax and renders to HTML strings without a DOM.
* Runs in Node.js. Not a browser module.
*
* Architecture:
* ElParser — reads .el source, produces a component definition object
* SsrContext — holds props/state during a render pass
* renderNode — walks TemplateNode tree, emits HTML
* renderToString — top-level entry: component def + props -> HTML string
*
* Hydration markers emitted on every component root element:
* data-el-component="{ComponentName}"
* data-el-id="{stable_id}"
* data-el-state="{json}"
*
* These are consumed by hydrate() in the browser runtime.
*/
'use strict';
// ── HTML utilities ────────────────────────────────────────────────────────────
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
/**
* HTML-escape a string for text content or attribute values.
* @param {string} s
* @returns {string}
*/
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Generate a short stable ID for a component instance.
* In SSR we use a counter — the client uses the same position in the tree
* to match (position-based hydration).
* @param {string} name
* @param {number} idx
* @returns {string}
*/
function makeId(name, idx) {
return `el-${name.toLowerCase()}-${idx}`;
}
// ── Lexer ─────────────────────────────────────────────────────────────────────
/**
* Tokenize .el source into a flat list of tokens.
* This is a single-pass lexer that switches to template mode when it
* encounters `template {` and tracks brace depth for the block.
*
* Token shapes:
* { type: 'keyword', value: string }
* { type: 'ident', value: string }
* { type: 'string', value: string } -- unquoted value
* { type: 'number', value: number }
* { type: 'bool', value: boolean }
* { type: 'punct', value: string }
* { type: 'arrow', value: '->' }
* { type: 'op', value: string }
* { type: 'template_html', value: string } -- raw HTML content inside template {}
*
* @param {string} src
* @returns {Array<{type: string, value: any}>}
*/
function tokenize(src) {
const tokens = [];
let i = 0;
const KEYWORDS = new Set([
'component', 'props', 'state', 'fn', 'template',
'if', 'else', 'return', 'let',
]);
function peek(offset = 0) { return src[i + offset]; }
function advance() { return src[i++]; }
while (i < src.length) {
// Skip whitespace
if (/\s/.test(src[i])) { i++; continue; }
// Line comments
if (src[i] === '/' && src[i + 1] === '/') {
while (i < src.length && src[i] !== '\n') i++;
continue;
}
// String literals
if (src[i] === '"') {
i++;
let s = '';
while (i < src.length && src[i] !== '"') {
if (src[i] === '\\' && src[i + 1] === '"') { s += '"'; i += 2; }
else s += advance();
}
i++; // closing "
tokens.push({ type: 'string', value: s });
continue;
}
// Numbers
if (/[0-9]/.test(src[i]) || (src[i] === '-' && /[0-9]/.test(src[i + 1]))) {
let n = '';
if (src[i] === '-') n += advance();
while (i < src.length && /[0-9.]/.test(src[i])) n += advance();
tokens.push({ type: 'number', value: parseFloat(n) });
continue;
}
// Arrow operator ->
if (src[i] === '-' && src[i + 1] === '>') {
tokens.push({ type: 'arrow', value: '->' });
i += 2;
continue;
}
// Identifiers and keywords
if (/[a-zA-Z_]/.test(src[i])) {
let word = '';
while (i < src.length && /[a-zA-Z0-9_-]/.test(src[i])) word += advance();
if (word === 'true') tokens.push({ type: 'bool', value: true });
else if (word === 'false') tokens.push({ type: 'bool', value: false });
else if (KEYWORDS.has(word)) tokens.push({ type: 'keyword', value: word });
else tokens.push({ type: 'ident', value: word });
continue;
}
// Colon (used in type annotations)
if (src[i] === ':') {
tokens.push({ type: 'punct', value: ':' });
i++;
continue;
}
// Single-char operators and punctuation
const ch = src[i];
if ('{}()[].,=!<>+-*/|&'.includes(ch)) {
// Two-char: !=, ==, >=, <=
const two = src.slice(i, i + 2);
if (['!=', '==', '>=', '<=', '&&', '||'].includes(two)) {
tokens.push({ type: 'op', value: two });
i += 2;
} else {
tokens.push({ type: 'punct', value: ch });
i++;
}
continue;
}
// Skip unknown chars
i++;
}
return tokens;
}
// ── Parser ────────────────────────────────────────────────────────────────────
/**
* Parse .el source into an array of component definitions.
*
* ComponentDef {
* name: string
* props: Array<PropDef> -- { name, type, defaultValue }
* state: Array<StateDef> -- { name, type, defaultValue }
* methods: Array<MethodDef> -- { name, params, returnType, body: string }
* template: TemplateNode | null
* }
*
* TemplateNode variants:
* { kind: 'element', tag, attrs, children }
* { kind: 'component', name, props }
* { kind: 'text', content }
* { kind: 'interpolation', expr }
* { kind: 'if', condition, then, else: TemplateNode[] | null }
* { kind: 'each', items, itemName, children }
* { kind: 'activate', query, resultName, children }
* { kind: 'raw_html', expr }
*
* @param {string} src
* @returns {Array<ComponentDef>}
*/
function parseEl(src) {
const components = [];
let pos = 0;
// ── Template parser (operates on raw source substring) ──────────────────
/**
* Parse the HTML-like template block content.
* We parse the raw source directly (not tokens) for the template section,
* because the template syntax (angle brackets, interpolations) is not
* compatible with the El token stream.
*
* @param {string} tmplSrc raw source inside `template { ... }`
* @returns {TemplateNode[]}
*/
function parseTemplate(tmplSrc) {
let ti = 0;
function skipWs() {
while (ti < tmplSrc.length && /\s/.test(tmplSrc[ti])) ti++;
}
function parseNodes() {
const nodes = [];
while (ti < tmplSrc.length) {
skipWs();
if (ti >= tmplSrc.length) break;
// Closing block tag: {/if}, {/each}, {/activate}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') break;
// {:else}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === ':') break;
// Block constructs: {#if}, {#each}, {#activate}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '#') {
const block = parseBlock();
if (block) nodes.push(block);
continue;
}
// Interpolation: {expr}
if (tmplSrc[ti] === '{') {
const interp = parseInterpolation();
if (interp) nodes.push(interp);
continue;
}
// Element: <tag ...>
if (tmplSrc[ti] === '<') {
// Closing tag — stop parsing children
if (tmplSrc[ti + 1] === '/') break;
const el = parseElement();
if (el) nodes.push(el);
continue;
}
// Text content
const text = parseText();
if (text && text.content.trim()) nodes.push(text);
}
return nodes;
}
function parseInterpolation() {
ti++; // skip {
let expr = '';
let depth = 1;
while (ti < tmplSrc.length && depth > 0) {
if (tmplSrc[ti] === '{') depth++;
else if (tmplSrc[ti] === '}') { depth--; if (depth === 0) { ti++; break; } }
expr += tmplSrc[ti++];
}
return { kind: 'interpolation', expr: expr.trim() };
}
function parseBlock() {
ti += 2; // skip {#
let kw = '';
while (ti < tmplSrc.length && /[a-z]/.test(tmplSrc[ti])) kw += tmplSrc[ti++];
if (kw === 'if') {
// Read condition up to }
let cond = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') cond += tmplSrc[ti++];
ti++; // skip }
const thenBranch = parseNodes();
let elseBranch = null;
skipWs();
// {:else}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === ':') {
// skip {:else}
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
elseBranch = parseNodes();
}
skipWs();
// {/if}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'if', condition: cond.trim(), then: thenBranch, else: elseBranch };
}
if (kw === 'each') {
// {#each items as item}
let rest = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') rest += tmplSrc[ti++];
ti++;
// parse "items as item"
const asMatch = rest.trim().match(/^(.+?)\s+as\s+(\w+)$/);
const itemsExpr = asMatch ? asMatch[1].trim() : rest.trim();
const itemName = asMatch ? asMatch[2] : 'item';
const children = parseNodes();
skipWs();
// {/each}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'each', items: itemsExpr, itemName, children };
}
if (kw === 'activate') {
// {#activate "query" as result}
let rest = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') rest += tmplSrc[ti++];
ti++;
const actMatch = rest.trim().match(/^"(.+?)"\s+as\s+(\w+)$/);
const query = actMatch ? actMatch[1] : rest.trim();
const resultName = actMatch ? actMatch[2] : 'result';
const children = parseNodes();
skipWs();
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'activate', query, resultName, children };
}
// Unknown block — skip to closing
return null;
}
function parseElement() {
ti++; // skip <
// Read tag name
let tag = '';
while (ti < tmplSrc.length && /[a-zA-Z0-9_-]/.test(tmplSrc[ti])) tag += tmplSrc[ti++];
// Is this an uppercase component?
const isComponent = /^[A-Z]/.test(tag);
// Parse attributes
const attrs = parseAttrs();
skipWs();
// Self-closing: /> or void element
if (tmplSrc[ti] === '/' && tmplSrc[ti + 1] === '>') {
ti += 2;
if (isComponent) return { kind: 'component', name: tag, props: attrs };
return { kind: 'element', tag, attrs, children: [] };
}
if (tmplSrc[ti] === '>') {
ti++; // skip >
}
if (isComponent) {
// Components are always self-closing in practice; if not, skip content
return { kind: 'component', name: tag, props: attrs };
}
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
return { kind: 'element', tag, attrs, children: [] };
}
// Parse children
const children = parseNodes();
// Skip closing tag </tag>
skipWs();
if (tmplSrc[ti] === '<' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '>') ti++;
ti++; // skip >
}
return { kind: 'element', tag, attrs, children };
}
function parseAttrs() {
const attrs = [];
while (ti < tmplSrc.length) {
skipWs();
if (tmplSrc[ti] === '>' || (tmplSrc[ti] === '/' && tmplSrc[ti + 1] === '>')) break;
// Read attribute name (supports on:click etc.)
let attrName = '';
while (ti < tmplSrc.length && !/[\s=>\/]/.test(tmplSrc[ti])) {
attrName += tmplSrc[ti++];
}
if (!attrName) break;
skipWs();
if (tmplSrc[ti] !== '=') {
// Boolean attribute with no value
attrs.push({ name: attrName, type: 'bool', expr: 'true' });
continue;
}
ti++; // skip =
// Attribute value
if (tmplSrc[ti] === '"') {
// Static string value
ti++;
let val = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '"') val += tmplSrc[ti++];
ti++; // closing "
attrs.push({ name: attrName, type: 'static', value: val });
} else if (tmplSrc[ti] === '{') {
// Dynamic expression value
ti++;
let expr = '';
let depth = 1;
while (ti < tmplSrc.length && depth > 0) {
if (tmplSrc[ti] === '{') depth++;
else if (tmplSrc[ti] === '}') { depth--; if (depth === 0) { ti++; break; } }
expr += tmplSrc[ti++];
}
attrs.push({ name: attrName, type: 'dynamic', expr: expr.trim() });
}
}
return attrs;
}
function parseText() {
let text = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '<' && tmplSrc[ti] !== '{') {
text += tmplSrc[ti++];
}
return text ? { kind: 'text', content: text } : null;
}
return parseNodes();
}
// ── Component-level parser ───────────────────────────────────────────────
// Find all component blocks using brace tracking
const componentRegex = /component\s+(\w+)\s*\{/g;
let match;
while ((match = componentRegex.exec(src)) !== null) {
const name = match[1];
const bodyStart = match.index + match[0].length;
// Find the matching closing brace
let depth = 1;
let p = bodyStart;
while (p < src.length && depth > 0) {
if (src[p] === '{') depth++;
else if (src[p] === '}') depth--;
p++;
}
const bodyEnd = p - 1;
const body = src.slice(bodyStart, bodyEnd);
// Parse props block
const propsMatch = body.match(/props\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s);
const props = propsMatch ? parsePropOrStateBlock(propsMatch[1]) : [];
// Parse state block
const stateMatch = body.match(/state\s*\{([^}]*)\}/s);
const state = stateMatch ? parsePropOrStateBlock(stateMatch[1]) : [];
// Parse methods -- fn name(...) -> Type { body }
const methods = parseMethods(body);
// Parse template block -- capture raw content
const templateMatch = body.match(/template\s*\{/);
let templateNodes = null;
if (templateMatch) {
const tmplStart = templateMatch.index + templateMatch[0].length;
let tdepth = 1;
let tp = tmplStart;
while (tp < body.length && tdepth > 0) {
if (body[tp] === '{') tdepth++;
else if (body[tp] === '}') tdepth--;
tp++;
}
const tmplContent = body.slice(tmplStart, tp - 1);
templateNodes = parseTemplate(tmplContent);
}
components.push({ name, props, state, methods, template: templateNodes });
}
return components;
}
/**
* Parse a props or state block into field definitions.
* Each line: name: Type [ = defaultValue ]
* @param {string} blockSrc
* @returns {Array<{name: string, type: string, defaultValue: any}>}
*/
function parsePropOrStateBlock(blockSrc) {
const fields = [];
const lines = blockSrc.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
// name: Type = default OR name: Type
const m = trimmed.match(/^(\w+)\s*:\s*(\w+)(?:\s*=\s*(.+))?$/);
if (!m) continue;
const [, fieldName, typeName, defaultRaw] = m;
let defaultValue = undefined;
if (defaultRaw !== undefined) {
const dv = defaultRaw.trim();
if (dv === 'true') defaultValue = true;
else if (dv === 'false') defaultValue = false;
else if (dv.startsWith('"') && dv.endsWith('"')) defaultValue = dv.slice(1, -1);
else if (!isNaN(parseFloat(dv))) defaultValue = parseFloat(dv);
else defaultValue = dv;
} else {
// Default by type
if (typeName === 'Int' || typeName === 'Float') defaultValue = 0;
else if (typeName === 'Bool') defaultValue = false;
else if (typeName === 'String') defaultValue = '';
else defaultValue = null;
}
fields.push({ name: fieldName, type: typeName, defaultValue });
}
return fields;
}
/**
* Parse method definitions from a component body.
* @param {string} bodySrc
* @returns {Array<{name: string, body: string}>}
*/
function parseMethods(bodySrc) {
const methods = [];
const fnRegex = /fn\s+(\w+)\s*\([^)]*\)\s*->\s*\w+\s*\{/g;
let m;
while ((m = fnRegex.exec(bodySrc)) !== null) {
const name = m[1];
const bodyStart = m.index + m[0].length;
let depth = 1;
let p = bodyStart;
while (p < bodySrc.length && depth > 0) {
if (bodySrc[p] === '{') depth++;
else if (bodySrc[p] === '}') depth--;
p++;
}
const body = bodySrc.slice(bodyStart, p - 1);
methods.push({ name, body });
}
return methods;
}
// ── SSR Renderer ──────────────────────────────────────────────────────────────
/**
* Evaluation context for SSR rendering.
* Holds current component's state + props so expressions can be evaluated.
*/
class SsrContext {
/**
* @param {Object} state current state values
* @param {Object} props current prop values
* @param {Object} locals loop/block-scoped bindings
* @param {Map<string, ComponentDef>} components registry for child component lookup
* @param {Object} counters per-component-name instance counters (for IDs)
*/
constructor(state, props, locals, components, counters) {
this.state = state || {};
this.props = props || {};
this.locals = locals || {};
this.components = components || new Map();
this.counters = counters || {};
}
/**
* Resolve a name to a value: check locals first, then state, then props.
* @param {string} name
* @returns {any}
*/
resolve(name) {
if (name in this.locals) return this.locals[name];
if (name in this.state) return this.state[name];
if (name in this.props) return this.props[name];
return undefined;
}
/**
* Evaluate a JavaScript expression in the context of this component's
* state and props. Uses Function construction with named bindings.
* Returns undefined if evaluation fails.
* @param {string} expr
* @returns {any}
*/
evalExpr(expr) {
if (!expr || !expr.trim()) return undefined;
try {
// Build an environment object with all available bindings
const env = { ...this.props, ...this.state, ...this.locals };
const keys = Object.keys(env);
const vals = keys.map(k => env[k]);
// eslint-disable-next-line no-new-func
const fn = new Function(...keys, `return (${expr});`);
return fn(...vals);
} catch (e) {
// Expression may reference DOM globals or complex code not available in SSR;
// return empty string rather than crashing
return '';
}
}
/**
* Create a child context with additional local bindings.
* @param {Object} additionalLocals
* @returns {SsrContext}
*/
withLocals(additionalLocals) {
return new SsrContext(
this.state,
this.props,
{ ...this.locals, ...additionalLocals },
this.components,
this.counters,
);
}
}
/**
* Render a single TemplateNode to an HTML string.
* @param {TemplateNode} node
* @param {SsrContext} ctx
* @param {Object} [opts]
* @param {boolean} [opts.isRoot] if true, inject hydration attrs on the element
* @param {string} [opts.componentName]
* @param {string} [opts.componentId]
* @param {Object} [opts.initialState]
* @returns {string}
*/
function renderNode(node, ctx, opts = {}) {
if (!node) return '';
switch (node.kind) {
case 'text':
return escapeHtml(node.content);
case 'interpolation': {
const val = ctx.evalExpr(node.expr);
return escapeHtml(val === undefined || val === null ? '' : String(val));
}
case 'raw_html': {
const val = ctx.evalExpr(node.expr);
return val === undefined || val === null ? '' : String(val);
}
case 'if': {
const cond = ctx.evalExpr(node.condition);
const branch = cond ? node.then : (node.else || []);
return branch.map(n => renderNode(n, ctx)).join('');
}
case 'each': {
let items = ctx.evalExpr(node.items);
if (!Array.isArray(items)) {
// Try resolving as a simple name
items = ctx.resolve(node.items);
}
if (!Array.isArray(items)) return '';
return items.map(item => {
const childCtx = ctx.withLocals({ [node.itemName]: item });
return node.children.map(n => renderNode(n, childCtx)).join('');
}).join('');
}
case 'activate': {
// SSR: activate is a no-op — return empty (no Engram server available)
// The block still renders with an empty result set, which is correct:
// the client will re-run the semantic query once the graph is hydrated.
return '';
}
case 'component': {
const compDef = ctx.components.get(node.name);
if (!compDef) {
return `<!-- el-ui SSR: unknown component ${escapeHtml(node.name)} -->`;
}
// Resolve props for the child component
const childProps = {};
for (const attr of node.props) {
if (attr.type === 'static') {
childProps[attr.name] = attr.value;
} else if (attr.type === 'dynamic') {
childProps[attr.name] = ctx.evalExpr(attr.expr);
} else if (attr.type === 'bool') {
childProps[attr.name] = ctx.evalExpr(attr.expr);
}
}
// Build initial state for child
const childState = {};
for (const s of compDef.state) {
childState[s.name] = childProps[s.name] !== undefined ? childProps[s.name] : s.defaultValue;
}
// Apply prop defaults for child
const resolvedChildProps = {};
for (const p of compDef.props) {
resolvedChildProps[p.name] = childProps[p.name] !== undefined ? childProps[p.name] : p.defaultValue;
}
// Assign child instance ID
const cname = node.name;
ctx.counters[cname] = (ctx.counters[cname] || 0) + 1;
const cid = makeId(cname, ctx.counters[cname]);
return renderComponent(compDef, resolvedChildProps, childState, ctx.components, ctx.counters, cid);
}
case 'element': {
const tag = node.tag.toLowerCase();
const attrsHtml = renderAttrs(node.attrs, ctx, opts);
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrsHtml}>`;
}
const childrenHtml = node.children.map(n => renderNode(n, ctx)).join('');
return `<${tag}${attrsHtml}>${childrenHtml}</${tag}>`;
}
default:
return '';
}
}
/**
* Render an attribute list to a string fragment.
* For event binding attributes (on:click etc.), emits data-el-{event} for
* the client hydration pass to bind — does NOT execute the handler in SSR.
* For root element, injects hydration markers.
*
* @param {Array} attrs
* @param {SsrContext} ctx
* @param {Object} opts
* @returns {string}
*/
function renderAttrs(attrs, ctx, opts = {}) {
const BOOLEAN_ATTRS = new Set(['disabled', 'checked', 'readonly', 'required', 'multiple', 'selected']);
let out = '';
for (const attr of attrs) {
const name = attr.name;
// Event bindings: on:click -> data-el-click
if (name.startsWith('on:')) {
const event = name.slice(3);
const expr = attr.type === 'dynamic' ? attr.expr : attr.value || '';
out += ` data-el-${escapeHtml(event)}="${escapeHtml(expr)}"`;
continue;
}
if (attr.type === 'static') {
out += ` ${name}="${escapeHtml(attr.value)}"`;
} else if (attr.type === 'dynamic') {
const val = ctx.evalExpr(attr.expr);
if (val === null || val === false || val === undefined) {
// Omit the attribute
} else if (BOOLEAN_ATTRS.has(name)) {
if (val) out += ` ${name}`;
} else {
out += ` ${name}="${escapeHtml(String(val))}"`;
}
} else if (attr.type === 'bool') {
const val = ctx.evalExpr(attr.expr);
if (val) out += ` ${name}`;
}
}
// Inject hydration markers on the root element
if (opts.isRoot) {
out += ` data-el-component="${escapeHtml(opts.componentName)}"`;
out += ` data-el-id="${escapeHtml(opts.componentId)}"`;
if (opts.initialState !== undefined) {
out += ` data-el-state="${escapeHtml(JSON.stringify(opts.initialState))}"`;
}
}
return out;
}
/**
* Render a component definition to an HTML string.
* The first element in the template receives hydration marker attributes.
*
* @param {ComponentDef} compDef
* @param {Object} props
* @param {Object} state
* @param {Map<string, ComponentDef>} components
* @param {Object} counters
* @param {string} [instanceId]
* @returns {string}
*/
function renderComponent(compDef, props, state, components, counters, instanceId) {
if (!compDef.template || compDef.template.length === 0) return '';
const ctx = new SsrContext(state, props, {}, components, counters);
// Find the root element node (first non-text node)
const rootIdx = compDef.template.findIndex(n => n.kind === 'element' || n.kind === 'component');
const parts = compDef.template.map((node, i) => {
if (i === rootIdx) {
// Inject hydration markers on root element
return renderNodeWithHydration(node, ctx, {
componentName: compDef.name,
componentId: instanceId || makeId(compDef.name, 1),
initialState: state,
});
}
return renderNode(node, ctx);
});
return parts.join('');
}
/**
* Render a node with hydration attributes injected on the outermost element.
* @param {TemplateNode} node
* @param {SsrContext} ctx
* @param {Object} hydrationOpts
* @returns {string}
*/
function renderNodeWithHydration(node, ctx, hydrationOpts) {
if (!node) return '';
if (node.kind === 'element') {
const tag = node.tag.toLowerCase();
const attrsHtml = renderAttrs(node.attrs, ctx, { isRoot: true, ...hydrationOpts });
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrsHtml}>`;
}
const childrenHtml = node.children.map(n => renderNode(n, ctx)).join('');
return `<${tag}${attrsHtml}>${childrenHtml}</${tag}>`;
}
// If root is a component reference, render it (hydration attrs will be on its root)
return renderNode(node, ctx);
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Parse a .el source file and return a map of component definitions.
* @param {string} src
* @returns {Map<string, ComponentDef>}
*/
function parseComponents(src) {
const defs = parseEl(src);
const map = new Map();
for (const def of defs) {
map.set(def.name, def);
}
return map;
}
/**
* Render a named component from .el source to an HTML string.
*
* @param {string} src .el source containing the component definition
* @param {string} componentName name of the component to render
* @param {Object} [props={}] initial props
* @returns {string} server-rendered HTML
*
* @example
* const html = renderToString(src, 'Counter', { initialValue: 5 });
* // => '<div data-el-component="Counter" data-el-id="el-counter-1" data-el-state="{...}">...'
*/
function renderToString(src, componentName, props = {}) {
const components = parseComponents(src);
const compDef = components.get(componentName);
if (!compDef) {
throw new Error(`el-ui SSR: component "${componentName}" not found in source`);
}
// Build initial state from state declarations + prop overrides
const initialState = {};
for (const s of compDef.state) {
initialState[s.name] = props[s.name] !== undefined ? props[s.name] : s.defaultValue;
}
// Resolve prop values (apply defaults)
const resolvedProps = {};
for (const p of compDef.props) {
resolvedProps[p.name] = props[p.name] !== undefined ? props[p.name] : p.defaultValue;
}
const counters = {};
counters[componentName] = 1;
const id = makeId(componentName, 1);
return renderComponent(compDef, resolvedProps, initialState, components, counters, id);
}
/**
* Render a named component and wrap in a full HTML document shell.
* Useful for testing or single-page SSR without a template engine.
*
* @param {string} src
* @param {string} componentName
* @param {Object} [props={}]
* @param {Object} [shellOpts={}]
* @param {string} [shellOpts.title='el-ui app']
* @param {string} [shellOpts.runtimePath='./el-ui.js']
* @param {string} [shellOpts.appScriptPath='./app.js']
* @param {string} [shellOpts.styles='']
* @returns {string}
*/
function renderToDocument(src, componentName, props = {}, shellOpts = {}) {
const {
title = 'el-ui app',
runtimePath = './el-ui.js',
appScriptPath = './app.js',
styles = '',
} = shellOpts;
const body = renderToString(src, componentName, props);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>${styles ? `\n<style>${styles}</style>` : ''}
</head>
<body>
<div id="app">${body}</div>
<script type="module" src="${escapeHtml(appScriptPath)}"></script>
</body>
</html>`;
}
module.exports = {
renderToString,
renderToDocument,
parseComponents,
parseEl,
escapeHtml,
VOID_ELEMENTS,
};
+203 -13
View File
@@ -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 {
<div class="counter">
<h2>{label}</h2>
<span class={count > 0 ? "count positive" : "count zero"}>{count}</span>
<button on:click={() => increment()}>+</button>
{#if history != ""}
<code>{history}</code>
{/if}
</div>
}
}
```
### 11.2 Compile
```bash
# Server render script (embed source, export renderToString)
elc-ui Counter.el --target=server -o render.js
# Browser module (class-per-component, for hydration)
elc-ui Counter.el --target=web --runtime=../../dist/el-ui.js -o app.js
```
### 11.3 Server Render
```bash
# Render to HTML fragment
node render.js Counter
# => <div class="counter" data-el-component="Counter" data-el-id="el-counter-1"
# data-el-state='{"count":0,"history":""}'>
# <h2>Counter</h2>
# <span class="count zero">0</span>
# <button data-el-click="() => increment()">+</button>
# <p class="hint">Press + or - to begin.</p>
# </div>
```
Or from Node.js:
```javascript
const { renderToString } = require('./render.js');
const html = renderToString('Counter', { label: 'Score' });
```
### 11.4 Client Hydration
```javascript
// hydrate.js — runs in browser after SSR HTML is painted
import { hydrate } from '../../dist/el-ui.js';
import { Counter } from './app.js';
// Find each server-rendered Counter and attach the runtime
document.querySelectorAll('[data-el-component="Counter"]').forEach(el => {
if (el.dataset.elHydrated) return;
hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter);
el.dataset.elHydrated = 'true';
});
```
### 11.5 Full Lifecycle
```
1. Server calls: node render.js App
2. Server injects: rendered HTML into <div id="app">
3. Browser: HTML painted immediately (no JS needed for first paint)
4. Browser: <script type="module" src="./hydrate.js"> executes
5. hydrate.js: reads data-el-state, constructs Counter with server state
6. hydrate.js: binds data-el-click handlers without re-rendering DOM
7. User clicks +: count = count + 1 → setState → spreading activation → patch
```
The DOM is never blank. The page is interactive as soon as hydrate.js finishes executing. State is continuous across the SSR/hydration boundary because `data-el-state` carries the exact initial values the server used.
---
## 12. Versioning Roadmap
| Version | Key changes |
|---------|-------------|
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. JS (Web) target only via CLI. Native targets available via library API but `build_node_tree()` is stub. |
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. Web and Server targets via `elc-ui` CLI. SSR engine implemented (`runtime/src/ssr.js`). `hydrate()` function in client runtime. Native targets available via library API but `build_node_tree()` stub. |
| v0.2.x | Targeted DOM patching (only nodes in activation surface). `{#activate}` with embedding-based semantic search. CLI `--target` flag for native targets. Full AST→semantic→PlatformNode lowering. |
| v0.3.x | WASM-compiled Engram core replacing JavaScript graph. Sealed artifact support. `onDestroy()` lifecycle. |
| v1.0.0 | Stable API. Full production sealing. LSP integration with spreading activation autocomplete. |
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env node
/**
* elc-ui — el-ui component compiler CLI
*
* Usage:
* elc-ui App.el # compile to App.js (web target, default)
* elc-ui App.el -o out.js # explicit output path
* elc-ui App.el --target=server # compile SSR render script
* elc-ui App.el --target=server -o render.js
*
* Targets:
* web (default) — emits ES2022 JS module using el-ui runtime
* server — emits Node.js CJS module with renderToString(componentName, props)
*
* The web target emits the hand-compiled JS contract (class-per-component).
* The server target emits a render script that uses runtime/src/ssr.js to
* produce HTML strings from the .el source directly.
*
* Both outputs can be used together: web for client-side hydration,
* server for initial HTML generation.
*/
'use strict';
const fs = require('fs');
const path = require('path');
// ── Argument parsing ──────────────────────────────────────────────────────────
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`
elc-ui — el-ui component compiler
Usage:
elc-ui <source.el> [options]
Options:
-o <path> output file path (default: <source>.js)
--target=<t> compilation target: web | server (default: web)
--runtime=<path> path to el-ui runtime (default: ./el-ui.js)
-h, --help show this help
Targets:
web ES2022 JS module for browsers (requires el-ui runtime)
server Node.js CJS module with renderToString() for SSR
Examples:
elc-ui Counter.el
elc-ui Counter.el --target=server -o render.js
elc-ui App.el --target=server --runtime=../../dist/el-ui.js
`.trim());
process.exit(0);
}
let sourceFile = null;
let outputFile = null;
let target = 'web';
let runtimePath = './el-ui.js';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '-o' && args[i + 1]) {
outputFile = args[++i];
} else if (arg.startsWith('--target=')) {
target = arg.slice('--target='.length);
} else if (arg.startsWith('--runtime=')) {
runtimePath = arg.slice('--runtime='.length);
} else if (!arg.startsWith('-')) {
sourceFile = arg;
}
}
if (!sourceFile) {
console.error('elc-ui: error: no source file specified');
process.exit(1);
}
if (!['web', 'server'].includes(target)) {
console.error(`elc-ui: error: unknown target "${target}". Valid targets: web, server`);
process.exit(1);
}
if (!outputFile) {
const ext = path.extname(sourceFile);
outputFile = sourceFile.slice(0, -ext.length) + '.js';
}
// ── Source loading ────────────────────────────────────────────────────────────
let src;
try {
src = fs.readFileSync(sourceFile, 'utf8');
} catch (e) {
console.error(`elc-ui: error: cannot read "${sourceFile}": ${e.message}`);
process.exit(1);
}
// ── Codegen ───────────────────────────────────────────────────────────────────
/**
* Emit a web (browser) JS module.
* Parses the .el source and emits class-per-component JS that matches
* the hand-compiled format used in examples/counter/app.js.
*
* @param {string} src
* @param {string} runtimePath
* @returns {string}
*/
function emitWebTarget(src, runtimePath) {
// We need the SSR parser to understand the component structure,
// then emit the equivalent JavaScript class.
const { parseComponents, escapeHtml } = require(
path.resolve(__dirname, '../../runtime/src/ssr.js'),
);
const components = parseComponents(src);
const lines = [];
lines.push(`import { Component, Graph, Renderer, Router, mount } from '${runtimePath}';\n`);
for (const [, comp] of components) {
lines.push(emitComponentClass(comp, components));
}
// Export all components
const names = [...components.keys()];
lines.push(`export { ${names.join(', ')} };`);
return lines.join('\n');
}
/**
* Emit a single Component class for the web target.
* @param {ComponentDef} comp
* @param {Map} components
* @returns {string}
*/
function emitComponentClass(comp, components) {
const lines = [];
lines.push(`class ${comp.name} extends Component {`);
lines.push(` constructor(props = {}) {`);
lines.push(` super();`);
lines.push(` this.props = props;`);
lines.push(` this._graph = new Graph();`);
lines.push(` this._stateNodes = {};`);
lines.push(` this._state = {};`);
for (const s of comp.state) {
const val = JSON.stringify(s.defaultValue);
lines.push(` this._stateNodes['${s.name}'] = this._graph.seed({ type: 'state', name: '${s.name}', content: ${val} });`);
lines.push(` this._state['${s.name}'] = ${val};`);
}
if (comp.state.length > 0) {
lines.push(` for (const [key, nodeId] of Object.entries(this._stateNodes)) {`);
lines.push(` this._graph.subscribe(nodeId, (node) => {`);
lines.push(` this._state[key] = node.content;`);
lines.push(` if (this._renderer) this._renderer.patch();`);
lines.push(` });`);
lines.push(` }`);
}
lines.push(` }\n`);
// setState
lines.push(` setState(name, value) {`);
lines.push(` if (this._stateNodes[name] !== undefined) {`);
lines.push(` this._graph.update(this._stateNodes[name], value);`);
lines.push(` }`);
lines.push(` }\n`);
// Methods
for (const method of comp.methods) {
lines.push(` ${method.name}() {`);
lines.push(` const __self = this;`);
// Expose state variables as locals
for (const s of comp.state) {
lines.push(` const ${s.name} = this._state['${s.name}'];`);
}
// Expose props as locals
for (const p of comp.props) {
lines.push(` const ${p.name} = this.props['${p.name}'] !== undefined ? this.props['${p.name}'] : ${JSON.stringify(p.defaultValue)};`);
}
// Transform method body: state assignments -> setState calls
const body = transformMethodBody(method.body, comp.state.map(s => s.name));
lines.push(body.split('\n').map(l => ` ${l}`).join('\n'));
lines.push(` }\n`);
}
// render()
lines.push(` render() {`);
lines.push(` const __self = this;`);
for (const s of comp.state) {
lines.push(` const ${s.name} = this._state['${s.name}'];`);
}
for (const p of comp.props) {
lines.push(` const ${p.name} = this.props['${p.name}'] !== undefined ? this.props['${p.name}'] : ${JSON.stringify(p.defaultValue)};`);
}
if (comp.template && comp.template.length > 0) {
const tpl = emitTemplateExpr(comp.template);
lines.push(` return \`${tpl}\`;`);
} else {
lines.push(` return '';`);
}
lines.push(` }\n`);
lines.push(`}\n`);
return lines.join('\n');
}
/**
* Transform a method body: `varName = expr` → `__self.setState('varName', expr)`
* for known state variable names.
* @param {string} body
* @param {string[]} stateNames
* @returns {string}
*/
function transformMethodBody(body, stateNames) {
// Process line-by-line: for each line that contains a bare state assignment,
// rewrite: `name = expr` -> `__self.setState('name', expr)`
// This is a line-level transformation that works for the common case where
// each state assignment is on its own line (the el-ui style).
const lines = body.split('\n');
const transformed = lines.map(line => {
let changed = line;
for (const name of stateNames) {
// Match: optional whitespace + name + = (not ==, !=, <=, >=) + rest of line
const re = new RegExp(`^(\\s*)${name}\\s*=(?![=>])\\s*(.+)$`);
const m = changed.match(re);
if (m) {
// m[1] = leading whitespace, m[2] = RHS expression
// Strip trailing newline/whitespace from the RHS
const rhs = m[2].trimEnd();
changed = `${m[1]}__self.setState('${name}', ${rhs})`;
}
}
return changed;
});
return transformed.join('\n');
}
/**
* Emit a template literal expression for the render() method.
* @param {TemplateNode[]} nodes
* @returns {string} template literal body (no backticks)
*/
function emitTemplateExpr(nodes) {
return nodes.map(n => emitNodeExpr(n)).join('');
}
function escapeTemplateLiteral(s) {
return s.replace(/`/g, '\\`').replace(/\$/g, '\\$').replace(/\\/g, '\\\\');
}
function emitNodeExpr(node) {
if (!node) return '';
switch (node.kind) {
case 'text':
return escapeTemplateLiteral(node.content);
case 'interpolation':
return `\${${node.expr} }`;
case 'raw_html':
return `\${${node.expr}}`;
case 'if': {
const thenExpr = emitTemplateExpr(node.then);
const elseExpr = node.else ? emitTemplateExpr(node.else) : '';
return `\${(${node.condition}) ? \`${thenExpr}\` : \`${elseExpr}\`}`;
}
case 'each': {
const bodyExpr = emitTemplateExpr(node.children);
return `\${(${node.items}).map((${node.itemName}) => \`${bodyExpr}\`).join('')}`;
}
case 'activate': {
const bodyExpr = emitTemplateExpr(node.children);
return `\${((this._graph.search("${node.query}")) || []).map((${node.resultName}) => \`${bodyExpr}\`).join('')}`;
}
case 'component': {
const propsExpr = node.props.map(a => {
const val = a.type === 'static'
? JSON.stringify(a.value)
: (a.type === 'dynamic' ? a.expr : 'true');
return `${a.name}: ${val}`;
}).join(', ');
return `\${__self._child(${node.name}, { ${propsExpr} })}`;
}
case 'element': {
const tag = node.tag;
const attrsStr = node.attrs.map(a => emitAttrStr(a)).join('');
// void elements
const VOID = new Set(['area','base','br','col','embed','hr','img','input',
'link','meta','param','source','track','wbr']);
if (VOID.has(tag.toLowerCase())) {
return `<${tag}${attrsStr}>`;
}
const childrenStr = emitTemplateExpr(node.children);
return `<${tag}${attrsStr} data-el-tag="${tag}">${childrenStr}</${tag}>`;
}
default:
return '';
}
}
function emitAttrStr(attr) {
const name = attr.name;
if (name.startsWith('on:')) {
const event = name.slice(3);
const expr = attr.type === 'dynamic' ? attr.expr : (attr.value || '');
// HTML-encode the expression for the attribute value
const encoded = expr
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return ` data-el-${event}="${encoded}"`;
}
if (attr.type === 'static') {
return ` ${name}="${attr.value}"`;
}
if (attr.type === 'dynamic') {
return ` ${name}="\${${attr.expr}}"`;
}
if (attr.type === 'bool') {
return ` \${(${attr.expr}) ? '${name}' : ''}`;
}
return '';
}
/**
* Emit a server-target render script.
* The output is a Node.js CJS module that exports:
* renderToString(componentName, props) -> string
* renderToDocument(componentName, props, shellOpts) -> string
* componentNames: string[]
*
* @param {string} src .el source
* @param {string} srcPath path to the source file (embedded in render script for reload)
* @returns {string}
*/
function emitServerTarget(src, srcPath) {
// Embed the source as a base64 string so the render script is self-contained
const srcB64 = Buffer.from(src, 'utf8').toString('base64');
// Compute path to ssr.js.
// Prefer a relative path when the output lives within the same project
// tree; use absolute otherwise. Generated render scripts are not portable
// across machines -- they reference the local installation of el-ui.
const ssrAbsPath = path.resolve(__dirname, '../../runtime/src/ssr.js');
const outAbsDir = fs.realpathSync(path.dirname(path.resolve(outputFile)));
const ssrRelPath = path.relative(outAbsDir, ssrAbsPath);
// Relative paths are cleaner; absolute paths are safer for output dirs
// that live outside the project (e.g., system /tmp).
const ssrRequirePath = ssrRelPath.startsWith('.') ? ssrRelPath : ssrAbsPath;
return `/**
* el-ui SSR render script — generated by elc-ui --target=server
* Source: ${path.basename(srcPath)}
*
* Exports:
* renderToString(componentName, props) -> HTML string
* renderToDocument(componentName, props, opts) -> full HTML document
* componentNames -> string[]
*/
'use strict';
const { renderToString: _rts, renderToDocument: _rtd, parseComponents } = require('${ssrRequirePath}');
// Component source embedded at compile time
const _src = Buffer.from('${srcB64}', 'base64').toString('utf8');
// Parse component names at module load time
const _components = parseComponents(_src);
const componentNames = [..._components.keys()];
/**
* Render a component to an HTML string.
* @param {string} componentName
* @param {Object} [props={}]
* @returns {string}
*/
function renderToString(componentName, props = {}) {
return _rts(_src, componentName, props);
}
/**
* Render a component inside a full HTML document.
* @param {string} componentName
* @param {Object} [props={}]
* @param {Object} [shellOpts={}]
* @returns {string}
*/
function renderToDocument(componentName, props = {}, shellOpts = {}) {
return _rtd(_src, componentName, props, shellOpts);
}
module.exports = { renderToString, renderToDocument, componentNames };
// CLI mode: node render.js <ComponentName> [propsJson]
if (require.main === module) {
const compName = process.argv[2];
const propsJson = process.argv[3] || '{}';
if (!compName) {
console.error('Usage: node render.js <ComponentName> [propsJson]');
process.exit(1);
}
let props = {};
try { props = JSON.parse(propsJson); } catch (e) {
console.error('render.js: invalid props JSON:', e.message);
process.exit(1);
}
process.stdout.write(renderToString(compName, props));
}
`;
}
// ── Main ──────────────────────────────────────────────────────────────────────
let output;
if (target === 'server') {
output = emitServerTarget(src, sourceFile);
} else {
output = emitWebTarget(src, runtimePath);
}
try {
fs.writeFileSync(outputFile, output, 'utf8');
console.log(`elc-ui: ${target} → ${outputFile}`);
} catch (e) {
console.error(`elc-ui: error: cannot write "${outputFile}": ${e.message}`);
process.exit(1);
}