From d24bb298171352306d4c1261f5562ffe0c49fb2a Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 4 May 2026 12:45:27 -0500 Subject: [PATCH] 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. --- dist/el-ui.js | 38 +- runtime/src/index.js | 113 ++++- runtime/src/ssr.js | 951 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1095 insertions(+), 7 deletions(-) create mode 100644 runtime/src/ssr.js diff --git a/dist/el-ui.js b/dist/el-ui.js index 0dc2990..34c213e 100644 --- a/dist/el-ui.js +++ b/dist/el-ui.js @@ -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 }; diff --git a/runtime/src/index.js b/runtime/src/index.js index 5b33bc2..404c08b 100644 --- a/runtime/src/index.js +++ b/runtime/src/index.js @@ -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} 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} [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 }; diff --git a/runtime/src/ssr.js b/runtime/src/ssr.js new file mode 100644 index 0000000..3245640 --- /dev/null +++ b/runtime/src/ssr.js @@ -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, '''); +} + +/** + * 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 -- { name, type, defaultValue } + * state: Array -- { name, type, defaultValue } + * methods: Array -- { 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} + */ +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: + 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 + 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} 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 ``; + } + // 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}`; + } + + 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} 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}`; + } + + // 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} + */ +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 }); + * // => '
...' + */ +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 ` + + + + +${escapeHtml(title)}${styles ? `\n` : ''} + + +
${body}
+ + +`; +} + +module.exports = { + renderToString, + renderToDocument, + parseComponents, + parseEl, + escapeHtml, + VOID_ELEMENTS, +};