Archived
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:
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 };
|
||||
|
||||
+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,
|
||||
};
|
||||
Reference in New Issue
Block a user