implement el-ui SSR engine with renderToString and hydration markers

Adds runtime/src/ssr.js: a Node.js SSR engine that parses .el component
source and renders to HTML strings without a DOM. Covers the full template
AST -- elements, text, interpolations, {#if}, {#each}, {#activate}, and
child component recursion.

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

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

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

Updates dist/el-ui.js bundle to match.
This commit is contained in:
Will Anderson
2026-05-04 12:45:27 -05:00
parent a9adea3b96
commit d24bb29817
3 changed files with 1095 additions and 7 deletions
+35 -3
View File
@@ -351,15 +351,16 @@ export class Router {
}
}
// ── Component base class & mount() ───────────────────────────────────────────
// ── Component base class, mount(), and hydrate() ─────────────────────────────
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
this._graph = new Graph();
this._stateNodes = {};
this._state = {};
this.props = props;
this._renderer = null;
this._ssrState = initialState;
}
setState(name, value) {
@@ -382,6 +383,20 @@ export class Component {
render() { return ''; }
onMount() {}
onHydrate() {}
_hydrate(root) {
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') this.onHydrate();
}
}
export function mount(ComponentClass, selector, props = {}) {
@@ -394,4 +409,21 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer, Router };
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) throw new Error(`el-ui hydrate: element not found: ${selector}`);
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
const instance = new ComponentClass(props, initialState);
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer, Router };
+109 -4
View File
@@ -9,6 +9,7 @@
* Router — graph-based routing
* Component — base class for el-ui components
* mount() — attach a component to the DOM
* hydrate() — hydrate an SSR-rendered component without re-rendering
*
* @module el-ui
*/
@@ -30,14 +31,15 @@ import { Renderer } from './renderer.js';
* Base class for all el-ui components.
*
* Subclasses override:
* render() → returns HTML string
* onMount() → called after first mount (optional)
* render() → returns HTML string
* onMount() → called after first mount (optional)
* onHydrate() → called after hydration (optional)
*
* Subclasses call:
* setState(name, value) → update state, trigger activation, patch DOM
*/
export class Component {
constructor(props = {}) {
constructor(props = {}, initialState = {}) {
/** @type {Graph} */
this._graph = new Graph();
@@ -52,6 +54,9 @@ export class Component {
/** @type {Renderer|null} */
this._renderer = null;
/** @type {Record<string, any>} initial state injected from SSR */
this._ssrState = initialState;
}
/**
@@ -105,6 +110,51 @@ export class Component {
* Override for side effects (timers, fetch calls, subscriptions).
*/
onMount() {}
/**
* Called once after client-side hydration completes.
* Override to run post-hydration side effects.
*/
onHydrate() {}
/**
* Hydrate this component instance onto an existing SSR-rendered DOM node.
*
* Does NOT call render() or replace innerHTML.
* Instead:
* 1. Seeds the Engram graph from the SSR initial state (already in _state
* because the constructor ran with the server's state values).
* 2. Sets up subscriptions so future setState() calls patch the DOM.
* 3. Binds all data-el-* event handlers via the renderer.
* 4. Calls onHydrate().
*
* This method is called by hydrate() after constructing the component
* instance. Subclasses rarely need to override it.
*
* @param {HTMLElement} root the SSR-rendered root element
*/
_hydrate(root) {
// Subscribe all state nodes so setState() → DOM patch works
for (const [key, nodeId] of Object.entries(this._stateNodes)) {
this._graph.subscribe(nodeId, (node) => {
this._state[key] = node.content;
if (this._renderer) this._renderer.patch();
});
}
// Create a renderer pointing at the SSR root.
// We record the current innerHTML as _currentHtml so the first
// real state change triggers a patch rather than a no-op comparison.
this._renderer = new Renderer(root, this);
this._renderer._currentHtml = root.innerHTML;
// Bind event handlers declared by the SSR output
this._renderer._bindEvents();
if (typeof this.onHydrate === 'function') {
this.onHydrate();
}
}
}
/**
@@ -133,4 +183,59 @@ export function mount(ComponentClass, selector, props = {}) {
return component;
}
export default { mount, Component, Graph, Renderer };
/**
* Hydrate a server-rendered el-ui component.
*
* Call this in the browser when the page was server-rendered and you want
* to attach the el-ui runtime to the existing DOM (rather than replacing it).
*
* The function:
* 1. Finds the SSR root element using `selector`.
* 2. Reads `data-el-state` from that element to recover the server's
* initial state snapshot.
* 3. Constructs the component with the recovered state pre-loaded.
* 4. Calls `instance._hydrate(root)` to bind events without re-rendering.
*
* @param {string} selector CSS selector for the SSR root element
* @param {typeof Component} ComponentClass the component class
* @param {Record<string, any>} [props={}] initial props (same as what server received)
* @returns {Component} the live, hydrated component instance
*
* @example
* import { hydrate } from './el-ui.js';
* import { Counter } from './counter.js';
*
* // Hydrate the SSR-rendered Counter at #app > [data-el-component="Counter"]
* hydrate('[data-el-component="Counter"]', Counter);
*/
export function hydrate(selector, ComponentClass, props = {}) {
const el = document.querySelector(selector);
if (!el) {
throw new Error(`el-ui hydrate: element not found: ${selector}`);
}
// Recover initial state from the data-el-state attribute injected by SSR
const stateJson = el.getAttribute('data-el-state');
const initialState = stateJson ? JSON.parse(stateJson) : {};
// Construct the component; pass initialState so subclasses can pre-seed
// their state graph with the server values (avoids the flash of reset state)
const instance = new ComponentClass(props, initialState);
// Apply the recovered server state into the component's _state map.
// The compiled constructor already ran (seeding _stateNodes), so we
// update each state node's content to match the server snapshot.
for (const [key, value] of Object.entries(initialState)) {
if (instance._stateNodes[key] !== undefined) {
// Directly update node content (no activation — just sync the value)
const node = instance._graph.get(instance._stateNodes[key]);
if (node) node.content = value;
instance._state[key] = value;
}
}
instance._hydrate(el);
return instance;
}
export default { mount, hydrate, Component, Graph, Renderer };
+951
View File
@@ -0,0 +1,951 @@
/**
* ssr.js — Server-side rendering engine for el-ui components.
*
* Parses .el component syntax and renders to HTML strings without a DOM.
* Runs in Node.js. Not a browser module.
*
* Architecture:
* ElParser — reads .el source, produces a component definition object
* SsrContext — holds props/state during a render pass
* renderNode — walks TemplateNode tree, emits HTML
* renderToString — top-level entry: component def + props -> HTML string
*
* Hydration markers emitted on every component root element:
* data-el-component="{ComponentName}"
* data-el-id="{stable_id}"
* data-el-state="{json}"
*
* These are consumed by hydrate() in the browser runtime.
*/
'use strict';
// ── HTML utilities ────────────────────────────────────────────────────────────
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
]);
/**
* HTML-escape a string for text content or attribute values.
* @param {string} s
* @returns {string}
*/
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Generate a short stable ID for a component instance.
* In SSR we use a counter — the client uses the same position in the tree
* to match (position-based hydration).
* @param {string} name
* @param {number} idx
* @returns {string}
*/
function makeId(name, idx) {
return `el-${name.toLowerCase()}-${idx}`;
}
// ── Lexer ─────────────────────────────────────────────────────────────────────
/**
* Tokenize .el source into a flat list of tokens.
* This is a single-pass lexer that switches to template mode when it
* encounters `template {` and tracks brace depth for the block.
*
* Token shapes:
* { type: 'keyword', value: string }
* { type: 'ident', value: string }
* { type: 'string', value: string } -- unquoted value
* { type: 'number', value: number }
* { type: 'bool', value: boolean }
* { type: 'punct', value: string }
* { type: 'arrow', value: '->' }
* { type: 'op', value: string }
* { type: 'template_html', value: string } -- raw HTML content inside template {}
*
* @param {string} src
* @returns {Array<{type: string, value: any}>}
*/
function tokenize(src) {
const tokens = [];
let i = 0;
const KEYWORDS = new Set([
'component', 'props', 'state', 'fn', 'template',
'if', 'else', 'return', 'let',
]);
function peek(offset = 0) { return src[i + offset]; }
function advance() { return src[i++]; }
while (i < src.length) {
// Skip whitespace
if (/\s/.test(src[i])) { i++; continue; }
// Line comments
if (src[i] === '/' && src[i + 1] === '/') {
while (i < src.length && src[i] !== '\n') i++;
continue;
}
// String literals
if (src[i] === '"') {
i++;
let s = '';
while (i < src.length && src[i] !== '"') {
if (src[i] === '\\' && src[i + 1] === '"') { s += '"'; i += 2; }
else s += advance();
}
i++; // closing "
tokens.push({ type: 'string', value: s });
continue;
}
// Numbers
if (/[0-9]/.test(src[i]) || (src[i] === '-' && /[0-9]/.test(src[i + 1]))) {
let n = '';
if (src[i] === '-') n += advance();
while (i < src.length && /[0-9.]/.test(src[i])) n += advance();
tokens.push({ type: 'number', value: parseFloat(n) });
continue;
}
// Arrow operator ->
if (src[i] === '-' && src[i + 1] === '>') {
tokens.push({ type: 'arrow', value: '->' });
i += 2;
continue;
}
// Identifiers and keywords
if (/[a-zA-Z_]/.test(src[i])) {
let word = '';
while (i < src.length && /[a-zA-Z0-9_-]/.test(src[i])) word += advance();
if (word === 'true') tokens.push({ type: 'bool', value: true });
else if (word === 'false') tokens.push({ type: 'bool', value: false });
else if (KEYWORDS.has(word)) tokens.push({ type: 'keyword', value: word });
else tokens.push({ type: 'ident', value: word });
continue;
}
// Colon (used in type annotations)
if (src[i] === ':') {
tokens.push({ type: 'punct', value: ':' });
i++;
continue;
}
// Single-char operators and punctuation
const ch = src[i];
if ('{}()[].,=!<>+-*/|&'.includes(ch)) {
// Two-char: !=, ==, >=, <=
const two = src.slice(i, i + 2);
if (['!=', '==', '>=', '<=', '&&', '||'].includes(two)) {
tokens.push({ type: 'op', value: two });
i += 2;
} else {
tokens.push({ type: 'punct', value: ch });
i++;
}
continue;
}
// Skip unknown chars
i++;
}
return tokens;
}
// ── Parser ────────────────────────────────────────────────────────────────────
/**
* Parse .el source into an array of component definitions.
*
* ComponentDef {
* name: string
* props: Array<PropDef> -- { name, type, defaultValue }
* state: Array<StateDef> -- { name, type, defaultValue }
* methods: Array<MethodDef> -- { name, params, returnType, body: string }
* template: TemplateNode | null
* }
*
* TemplateNode variants:
* { kind: 'element', tag, attrs, children }
* { kind: 'component', name, props }
* { kind: 'text', content }
* { kind: 'interpolation', expr }
* { kind: 'if', condition, then, else: TemplateNode[] | null }
* { kind: 'each', items, itemName, children }
* { kind: 'activate', query, resultName, children }
* { kind: 'raw_html', expr }
*
* @param {string} src
* @returns {Array<ComponentDef>}
*/
function parseEl(src) {
const components = [];
let pos = 0;
// ── Template parser (operates on raw source substring) ──────────────────
/**
* Parse the HTML-like template block content.
* We parse the raw source directly (not tokens) for the template section,
* because the template syntax (angle brackets, interpolations) is not
* compatible with the El token stream.
*
* @param {string} tmplSrc raw source inside `template { ... }`
* @returns {TemplateNode[]}
*/
function parseTemplate(tmplSrc) {
let ti = 0;
function skipWs() {
while (ti < tmplSrc.length && /\s/.test(tmplSrc[ti])) ti++;
}
function parseNodes() {
const nodes = [];
while (ti < tmplSrc.length) {
skipWs();
if (ti >= tmplSrc.length) break;
// Closing block tag: {/if}, {/each}, {/activate}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') break;
// {:else}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === ':') break;
// Block constructs: {#if}, {#each}, {#activate}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '#') {
const block = parseBlock();
if (block) nodes.push(block);
continue;
}
// Interpolation: {expr}
if (tmplSrc[ti] === '{') {
const interp = parseInterpolation();
if (interp) nodes.push(interp);
continue;
}
// Element: <tag ...>
if (tmplSrc[ti] === '<') {
// Closing tag — stop parsing children
if (tmplSrc[ti + 1] === '/') break;
const el = parseElement();
if (el) nodes.push(el);
continue;
}
// Text content
const text = parseText();
if (text && text.content.trim()) nodes.push(text);
}
return nodes;
}
function parseInterpolation() {
ti++; // skip {
let expr = '';
let depth = 1;
while (ti < tmplSrc.length && depth > 0) {
if (tmplSrc[ti] === '{') depth++;
else if (tmplSrc[ti] === '}') { depth--; if (depth === 0) { ti++; break; } }
expr += tmplSrc[ti++];
}
return { kind: 'interpolation', expr: expr.trim() };
}
function parseBlock() {
ti += 2; // skip {#
let kw = '';
while (ti < tmplSrc.length && /[a-z]/.test(tmplSrc[ti])) kw += tmplSrc[ti++];
if (kw === 'if') {
// Read condition up to }
let cond = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') cond += tmplSrc[ti++];
ti++; // skip }
const thenBranch = parseNodes();
let elseBranch = null;
skipWs();
// {:else}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === ':') {
// skip {:else}
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
elseBranch = parseNodes();
}
skipWs();
// {/if}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'if', condition: cond.trim(), then: thenBranch, else: elseBranch };
}
if (kw === 'each') {
// {#each items as item}
let rest = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') rest += tmplSrc[ti++];
ti++;
// parse "items as item"
const asMatch = rest.trim().match(/^(.+?)\s+as\s+(\w+)$/);
const itemsExpr = asMatch ? asMatch[1].trim() : rest.trim();
const itemName = asMatch ? asMatch[2] : 'item';
const children = parseNodes();
skipWs();
// {/each}
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'each', items: itemsExpr, itemName, children };
}
if (kw === 'activate') {
// {#activate "query" as result}
let rest = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') rest += tmplSrc[ti++];
ti++;
const actMatch = rest.trim().match(/^"(.+?)"\s+as\s+(\w+)$/);
const query = actMatch ? actMatch[1] : rest.trim();
const resultName = actMatch ? actMatch[2] : 'result';
const children = parseNodes();
skipWs();
if (tmplSrc[ti] === '{' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '}') ti++;
ti++;
}
return { kind: 'activate', query, resultName, children };
}
// Unknown block — skip to closing
return null;
}
function parseElement() {
ti++; // skip <
// Read tag name
let tag = '';
while (ti < tmplSrc.length && /[a-zA-Z0-9_-]/.test(tmplSrc[ti])) tag += tmplSrc[ti++];
// Is this an uppercase component?
const isComponent = /^[A-Z]/.test(tag);
// Parse attributes
const attrs = parseAttrs();
skipWs();
// Self-closing: /> or void element
if (tmplSrc[ti] === '/' && tmplSrc[ti + 1] === '>') {
ti += 2;
if (isComponent) return { kind: 'component', name: tag, props: attrs };
return { kind: 'element', tag, attrs, children: [] };
}
if (tmplSrc[ti] === '>') {
ti++; // skip >
}
if (isComponent) {
// Components are always self-closing in practice; if not, skip content
return { kind: 'component', name: tag, props: attrs };
}
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
return { kind: 'element', tag, attrs, children: [] };
}
// Parse children
const children = parseNodes();
// Skip closing tag </tag>
skipWs();
if (tmplSrc[ti] === '<' && tmplSrc[ti + 1] === '/') {
while (ti < tmplSrc.length && tmplSrc[ti] !== '>') ti++;
ti++; // skip >
}
return { kind: 'element', tag, attrs, children };
}
function parseAttrs() {
const attrs = [];
while (ti < tmplSrc.length) {
skipWs();
if (tmplSrc[ti] === '>' || (tmplSrc[ti] === '/' && tmplSrc[ti + 1] === '>')) break;
// Read attribute name (supports on:click etc.)
let attrName = '';
while (ti < tmplSrc.length && !/[\s=>\/]/.test(tmplSrc[ti])) {
attrName += tmplSrc[ti++];
}
if (!attrName) break;
skipWs();
if (tmplSrc[ti] !== '=') {
// Boolean attribute with no value
attrs.push({ name: attrName, type: 'bool', expr: 'true' });
continue;
}
ti++; // skip =
// Attribute value
if (tmplSrc[ti] === '"') {
// Static string value
ti++;
let val = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '"') val += tmplSrc[ti++];
ti++; // closing "
attrs.push({ name: attrName, type: 'static', value: val });
} else if (tmplSrc[ti] === '{') {
// Dynamic expression value
ti++;
let expr = '';
let depth = 1;
while (ti < tmplSrc.length && depth > 0) {
if (tmplSrc[ti] === '{') depth++;
else if (tmplSrc[ti] === '}') { depth--; if (depth === 0) { ti++; break; } }
expr += tmplSrc[ti++];
}
attrs.push({ name: attrName, type: 'dynamic', expr: expr.trim() });
}
}
return attrs;
}
function parseText() {
let text = '';
while (ti < tmplSrc.length && tmplSrc[ti] !== '<' && tmplSrc[ti] !== '{') {
text += tmplSrc[ti++];
}
return text ? { kind: 'text', content: text } : null;
}
return parseNodes();
}
// ── Component-level parser ───────────────────────────────────────────────
// Find all component blocks using brace tracking
const componentRegex = /component\s+(\w+)\s*\{/g;
let match;
while ((match = componentRegex.exec(src)) !== null) {
const name = match[1];
const bodyStart = match.index + match[0].length;
// Find the matching closing brace
let depth = 1;
let p = bodyStart;
while (p < src.length && depth > 0) {
if (src[p] === '{') depth++;
else if (src[p] === '}') depth--;
p++;
}
const bodyEnd = p - 1;
const body = src.slice(bodyStart, bodyEnd);
// Parse props block
const propsMatch = body.match(/props\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s);
const props = propsMatch ? parsePropOrStateBlock(propsMatch[1]) : [];
// Parse state block
const stateMatch = body.match(/state\s*\{([^}]*)\}/s);
const state = stateMatch ? parsePropOrStateBlock(stateMatch[1]) : [];
// Parse methods -- fn name(...) -> Type { body }
const methods = parseMethods(body);
// Parse template block -- capture raw content
const templateMatch = body.match(/template\s*\{/);
let templateNodes = null;
if (templateMatch) {
const tmplStart = templateMatch.index + templateMatch[0].length;
let tdepth = 1;
let tp = tmplStart;
while (tp < body.length && tdepth > 0) {
if (body[tp] === '{') tdepth++;
else if (body[tp] === '}') tdepth--;
tp++;
}
const tmplContent = body.slice(tmplStart, tp - 1);
templateNodes = parseTemplate(tmplContent);
}
components.push({ name, props, state, methods, template: templateNodes });
}
return components;
}
/**
* Parse a props or state block into field definitions.
* Each line: name: Type [ = defaultValue ]
* @param {string} blockSrc
* @returns {Array<{name: string, type: string, defaultValue: any}>}
*/
function parsePropOrStateBlock(blockSrc) {
const fields = [];
const lines = blockSrc.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
// name: Type = default OR name: Type
const m = trimmed.match(/^(\w+)\s*:\s*(\w+)(?:\s*=\s*(.+))?$/);
if (!m) continue;
const [, fieldName, typeName, defaultRaw] = m;
let defaultValue = undefined;
if (defaultRaw !== undefined) {
const dv = defaultRaw.trim();
if (dv === 'true') defaultValue = true;
else if (dv === 'false') defaultValue = false;
else if (dv.startsWith('"') && dv.endsWith('"')) defaultValue = dv.slice(1, -1);
else if (!isNaN(parseFloat(dv))) defaultValue = parseFloat(dv);
else defaultValue = dv;
} else {
// Default by type
if (typeName === 'Int' || typeName === 'Float') defaultValue = 0;
else if (typeName === 'Bool') defaultValue = false;
else if (typeName === 'String') defaultValue = '';
else defaultValue = null;
}
fields.push({ name: fieldName, type: typeName, defaultValue });
}
return fields;
}
/**
* Parse method definitions from a component body.
* @param {string} bodySrc
* @returns {Array<{name: string, body: string}>}
*/
function parseMethods(bodySrc) {
const methods = [];
const fnRegex = /fn\s+(\w+)\s*\([^)]*\)\s*->\s*\w+\s*\{/g;
let m;
while ((m = fnRegex.exec(bodySrc)) !== null) {
const name = m[1];
const bodyStart = m.index + m[0].length;
let depth = 1;
let p = bodyStart;
while (p < bodySrc.length && depth > 0) {
if (bodySrc[p] === '{') depth++;
else if (bodySrc[p] === '}') depth--;
p++;
}
const body = bodySrc.slice(bodyStart, p - 1);
methods.push({ name, body });
}
return methods;
}
// ── SSR Renderer ──────────────────────────────────────────────────────────────
/**
* Evaluation context for SSR rendering.
* Holds current component's state + props so expressions can be evaluated.
*/
class SsrContext {
/**
* @param {Object} state current state values
* @param {Object} props current prop values
* @param {Object} locals loop/block-scoped bindings
* @param {Map<string, ComponentDef>} components registry for child component lookup
* @param {Object} counters per-component-name instance counters (for IDs)
*/
constructor(state, props, locals, components, counters) {
this.state = state || {};
this.props = props || {};
this.locals = locals || {};
this.components = components || new Map();
this.counters = counters || {};
}
/**
* Resolve a name to a value: check locals first, then state, then props.
* @param {string} name
* @returns {any}
*/
resolve(name) {
if (name in this.locals) return this.locals[name];
if (name in this.state) return this.state[name];
if (name in this.props) return this.props[name];
return undefined;
}
/**
* Evaluate a JavaScript expression in the context of this component's
* state and props. Uses Function construction with named bindings.
* Returns undefined if evaluation fails.
* @param {string} expr
* @returns {any}
*/
evalExpr(expr) {
if (!expr || !expr.trim()) return undefined;
try {
// Build an environment object with all available bindings
const env = { ...this.props, ...this.state, ...this.locals };
const keys = Object.keys(env);
const vals = keys.map(k => env[k]);
// eslint-disable-next-line no-new-func
const fn = new Function(...keys, `return (${expr});`);
return fn(...vals);
} catch (e) {
// Expression may reference DOM globals or complex code not available in SSR;
// return empty string rather than crashing
return '';
}
}
/**
* Create a child context with additional local bindings.
* @param {Object} additionalLocals
* @returns {SsrContext}
*/
withLocals(additionalLocals) {
return new SsrContext(
this.state,
this.props,
{ ...this.locals, ...additionalLocals },
this.components,
this.counters,
);
}
}
/**
* Render a single TemplateNode to an HTML string.
* @param {TemplateNode} node
* @param {SsrContext} ctx
* @param {Object} [opts]
* @param {boolean} [opts.isRoot] if true, inject hydration attrs on the element
* @param {string} [opts.componentName]
* @param {string} [opts.componentId]
* @param {Object} [opts.initialState]
* @returns {string}
*/
function renderNode(node, ctx, opts = {}) {
if (!node) return '';
switch (node.kind) {
case 'text':
return escapeHtml(node.content);
case 'interpolation': {
const val = ctx.evalExpr(node.expr);
return escapeHtml(val === undefined || val === null ? '' : String(val));
}
case 'raw_html': {
const val = ctx.evalExpr(node.expr);
return val === undefined || val === null ? '' : String(val);
}
case 'if': {
const cond = ctx.evalExpr(node.condition);
const branch = cond ? node.then : (node.else || []);
return branch.map(n => renderNode(n, ctx)).join('');
}
case 'each': {
let items = ctx.evalExpr(node.items);
if (!Array.isArray(items)) {
// Try resolving as a simple name
items = ctx.resolve(node.items);
}
if (!Array.isArray(items)) return '';
return items.map(item => {
const childCtx = ctx.withLocals({ [node.itemName]: item });
return node.children.map(n => renderNode(n, childCtx)).join('');
}).join('');
}
case 'activate': {
// SSR: activate is a no-op — return empty (no Engram server available)
// The block still renders with an empty result set, which is correct:
// the client will re-run the semantic query once the graph is hydrated.
return '';
}
case 'component': {
const compDef = ctx.components.get(node.name);
if (!compDef) {
return `<!-- el-ui SSR: unknown component ${escapeHtml(node.name)} -->`;
}
// Resolve props for the child component
const childProps = {};
for (const attr of node.props) {
if (attr.type === 'static') {
childProps[attr.name] = attr.value;
} else if (attr.type === 'dynamic') {
childProps[attr.name] = ctx.evalExpr(attr.expr);
} else if (attr.type === 'bool') {
childProps[attr.name] = ctx.evalExpr(attr.expr);
}
}
// Build initial state for child
const childState = {};
for (const s of compDef.state) {
childState[s.name] = childProps[s.name] !== undefined ? childProps[s.name] : s.defaultValue;
}
// Apply prop defaults for child
const resolvedChildProps = {};
for (const p of compDef.props) {
resolvedChildProps[p.name] = childProps[p.name] !== undefined ? childProps[p.name] : p.defaultValue;
}
// Assign child instance ID
const cname = node.name;
ctx.counters[cname] = (ctx.counters[cname] || 0) + 1;
const cid = makeId(cname, ctx.counters[cname]);
return renderComponent(compDef, resolvedChildProps, childState, ctx.components, ctx.counters, cid);
}
case 'element': {
const tag = node.tag.toLowerCase();
const attrsHtml = renderAttrs(node.attrs, ctx, opts);
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrsHtml}>`;
}
const childrenHtml = node.children.map(n => renderNode(n, ctx)).join('');
return `<${tag}${attrsHtml}>${childrenHtml}</${tag}>`;
}
default:
return '';
}
}
/**
* Render an attribute list to a string fragment.
* For event binding attributes (on:click etc.), emits data-el-{event} for
* the client hydration pass to bind — does NOT execute the handler in SSR.
* For root element, injects hydration markers.
*
* @param {Array} attrs
* @param {SsrContext} ctx
* @param {Object} opts
* @returns {string}
*/
function renderAttrs(attrs, ctx, opts = {}) {
const BOOLEAN_ATTRS = new Set(['disabled', 'checked', 'readonly', 'required', 'multiple', 'selected']);
let out = '';
for (const attr of attrs) {
const name = attr.name;
// Event bindings: on:click -> data-el-click
if (name.startsWith('on:')) {
const event = name.slice(3);
const expr = attr.type === 'dynamic' ? attr.expr : attr.value || '';
out += ` data-el-${escapeHtml(event)}="${escapeHtml(expr)}"`;
continue;
}
if (attr.type === 'static') {
out += ` ${name}="${escapeHtml(attr.value)}"`;
} else if (attr.type === 'dynamic') {
const val = ctx.evalExpr(attr.expr);
if (val === null || val === false || val === undefined) {
// Omit the attribute
} else if (BOOLEAN_ATTRS.has(name)) {
if (val) out += ` ${name}`;
} else {
out += ` ${name}="${escapeHtml(String(val))}"`;
}
} else if (attr.type === 'bool') {
const val = ctx.evalExpr(attr.expr);
if (val) out += ` ${name}`;
}
}
// Inject hydration markers on the root element
if (opts.isRoot) {
out += ` data-el-component="${escapeHtml(opts.componentName)}"`;
out += ` data-el-id="${escapeHtml(opts.componentId)}"`;
if (opts.initialState !== undefined) {
out += ` data-el-state="${escapeHtml(JSON.stringify(opts.initialState))}"`;
}
}
return out;
}
/**
* Render a component definition to an HTML string.
* The first element in the template receives hydration marker attributes.
*
* @param {ComponentDef} compDef
* @param {Object} props
* @param {Object} state
* @param {Map<string, ComponentDef>} components
* @param {Object} counters
* @param {string} [instanceId]
* @returns {string}
*/
function renderComponent(compDef, props, state, components, counters, instanceId) {
if (!compDef.template || compDef.template.length === 0) return '';
const ctx = new SsrContext(state, props, {}, components, counters);
// Find the root element node (first non-text node)
const rootIdx = compDef.template.findIndex(n => n.kind === 'element' || n.kind === 'component');
const parts = compDef.template.map((node, i) => {
if (i === rootIdx) {
// Inject hydration markers on root element
return renderNodeWithHydration(node, ctx, {
componentName: compDef.name,
componentId: instanceId || makeId(compDef.name, 1),
initialState: state,
});
}
return renderNode(node, ctx);
});
return parts.join('');
}
/**
* Render a node with hydration attributes injected on the outermost element.
* @param {TemplateNode} node
* @param {SsrContext} ctx
* @param {Object} hydrationOpts
* @returns {string}
*/
function renderNodeWithHydration(node, ctx, hydrationOpts) {
if (!node) return '';
if (node.kind === 'element') {
const tag = node.tag.toLowerCase();
const attrsHtml = renderAttrs(node.attrs, ctx, { isRoot: true, ...hydrationOpts });
if (VOID_ELEMENTS.has(tag)) {
return `<${tag}${attrsHtml}>`;
}
const childrenHtml = node.children.map(n => renderNode(n, ctx)).join('');
return `<${tag}${attrsHtml}>${childrenHtml}</${tag}>`;
}
// If root is a component reference, render it (hydration attrs will be on its root)
return renderNode(node, ctx);
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Parse a .el source file and return a map of component definitions.
* @param {string} src
* @returns {Map<string, ComponentDef>}
*/
function parseComponents(src) {
const defs = parseEl(src);
const map = new Map();
for (const def of defs) {
map.set(def.name, def);
}
return map;
}
/**
* Render a named component from .el source to an HTML string.
*
* @param {string} src .el source containing the component definition
* @param {string} componentName name of the component to render
* @param {Object} [props={}] initial props
* @returns {string} server-rendered HTML
*
* @example
* const html = renderToString(src, 'Counter', { initialValue: 5 });
* // => '<div data-el-component="Counter" data-el-id="el-counter-1" data-el-state="{...}">...'
*/
function renderToString(src, componentName, props = {}) {
const components = parseComponents(src);
const compDef = components.get(componentName);
if (!compDef) {
throw new Error(`el-ui SSR: component "${componentName}" not found in source`);
}
// Build initial state from state declarations + prop overrides
const initialState = {};
for (const s of compDef.state) {
initialState[s.name] = props[s.name] !== undefined ? props[s.name] : s.defaultValue;
}
// Resolve prop values (apply defaults)
const resolvedProps = {};
for (const p of compDef.props) {
resolvedProps[p.name] = props[p.name] !== undefined ? props[p.name] : p.defaultValue;
}
const counters = {};
counters[componentName] = 1;
const id = makeId(componentName, 1);
return renderComponent(compDef, resolvedProps, initialState, components, counters, id);
}
/**
* Render a named component and wrap in a full HTML document shell.
* Useful for testing or single-page SSR without a template engine.
*
* @param {string} src
* @param {string} componentName
* @param {Object} [props={}]
* @param {Object} [shellOpts={}]
* @param {string} [shellOpts.title='el-ui app']
* @param {string} [shellOpts.runtimePath='./el-ui.js']
* @param {string} [shellOpts.appScriptPath='./app.js']
* @param {string} [shellOpts.styles='']
* @returns {string}
*/
function renderToDocument(src, componentName, props = {}, shellOpts = {}) {
const {
title = 'el-ui app',
runtimePath = './el-ui.js',
appScriptPath = './app.js',
styles = '',
} = shellOpts;
const body = renderToString(src, componentName, props);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>${styles ? `\n<style>${styles}</style>` : ''}
</head>
<body>
<div id="app">${body}</div>
<script type="module" src="${escapeHtml(appScriptPath)}"></script>
</body>
</html>`;
}
module.exports = {
renderToString,
renderToDocument,
parseComponents,
parseEl,
escapeHtml,
VOID_ELEMENTS,
};