Archived
Compare commits
6 Commits
main
...
feat/el-ui-ssr
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e36983dd2 | |||
| 5fcb895dda | |||
| 0fddb7b5be | |||
| 143137e2b4 | |||
| f8e32e7719 | |||
| d24bb29817 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Vendored
+35
-3
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -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. |
|
||||
|
||||
Executable
+440
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user