diff --git a/examples/ssr-counter/Counter.el b/examples/ssr-counter/Counter.el new file mode 100644 index 0000000..5f70a30 --- /dev/null +++ b/examples/ssr-counter/Counter.el @@ -0,0 +1,88 @@ +// ssr-counter/Counter.el +// +// A counter component that demonstrates: +// - State with initial value +// - {#if} conditional rendering +// - {#each} list rendering +// - Interpolation +// - Event binding +// - SSR + client hydration +// +// Run `node render.js Counter` to see the server-rendered HTML. +// Open index.html in a browser to see the full SSR + hydration lifecycle. + +component Counter { + props { + initialValue: Int = 0 + label: String = "Counter" + } + + state { + count: Int = 0 + history: String = "" + } + + fn increment() -> Void { + count = count + 1 + history = history + "+" + count + " " + } + + fn decrement() -> Void { + count = count - 1 + history = history + "-" + count + " " + } + + fn reset() -> Void { + count = 0 + history = "" + } + + template { +
+

{label}

+
+ 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}> + {count} + +
+
+ + + +
+ {#if history != ""} +
+ history: + {history} +
+ {/if} + {#if count == 0} +

Press + or - to begin.

+ {/if} +
+ } +} + +component CounterPair { + template { +
+ + +
+ } +} + +component App { + template { +
+
+

el-ui SSR + Hydration

+

Server-rendered, client-hydrated with spreading activation

+
+
+ + +
+
+ } +} diff --git a/examples/ssr-counter/app.js b/examples/ssr-counter/app.js new file mode 100644 index 0000000..a1c8f8f --- /dev/null +++ b/examples/ssr-counter/app.js @@ -0,0 +1,119 @@ +import { Component, Graph, Renderer, Router, mount } from '../../dist/el-ui.js'; + +class Counter extends Component { + constructor(props = {}) { + super(); + this.props = props; + this._graph = new Graph(); + this._stateNodes = {}; + this._state = {}; + this._stateNodes['count'] = this._graph.seed({ type: 'state', name: 'count', content: 0 }); + this._state['count'] = 0; + this._stateNodes['history'] = this._graph.seed({ type: 'state', name: 'history', content: "" }); + this._state['history'] = ""; + for (const [key, nodeId] of Object.entries(this._stateNodes)) { + this._graph.subscribe(nodeId, (node) => { + this._state[key] = node.content; + if (this._renderer) this._renderer.patch(); + }); + } + } + + setState(name, value) { + if (this._stateNodes[name] !== undefined) { + this._graph.update(this._stateNodes[name], value); + } + } + + increment() { + const __self = this; + const count = this._state['count']; + const history = this._state['history']; + const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0; + const label = this.props['label'] !== undefined ? this.props['label'] : "Counter"; + + __self.setState('count', count + 1) + __self.setState('history', history + "+" + count + " ") + + } + + decrement() { + const __self = this; + const count = this._state['count']; + const history = this._state['history']; + const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0; + const label = this.props['label'] !== undefined ? this.props['label'] : "Counter"; + + __self.setState('count', count - 1) + __self.setState('history', history + "-" + count + " ") + + } + + reset() { + const __self = this; + const count = this._state['count']; + const history = this._state['history']; + const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0; + const label = this.props['label'] !== undefined ? this.props['label'] : "Counter"; + + __self.setState('count', 0) + __self.setState('history', "") + + } + + render() { + const __self = this; + const count = this._state['count']; + const history = this._state['history']; + const initialValue = this.props['initialValue'] !== undefined ? this.props['initialValue'] : 0; + const label = this.props['label'] !== undefined ? this.props['label'] : "Counter"; + return `

${label }

${count }
${(history != "") ? `
history:${history }
` : ``}${(count == 0) ? `

Press + or - to begin.

` : ``}
`; + } + +} + +class CounterPair extends Component { + constructor(props = {}) { + super(); + this.props = props; + this._graph = new Graph(); + this._stateNodes = {}; + this._state = {}; + } + + setState(name, value) { + if (this._stateNodes[name] !== undefined) { + this._graph.update(this._stateNodes[name], value); + } + } + + render() { + const __self = this; + return `
${__self._child(Counter, { label: "Left" })}${__self._child(Counter, { label: "Right" })}
`; + } + +} + +class App extends Component { + constructor(props = {}) { + super(); + this.props = props; + this._graph = new Graph(); + this._stateNodes = {}; + this._state = {}; + } + + setState(name, value) { + if (this._stateNodes[name] !== undefined) { + this._graph.update(this._stateNodes[name], value); + } + } + + render() { + const __self = this; + return `

el-ui SSR + Hydration

Server-rendered, client-hydrated with spreading activation

${__self._child(Counter, { label: "Main Counter" })}${__self._child(CounterPair, { })}
`; + } + +} + +export { Counter, CounterPair, App }; \ No newline at end of file diff --git a/examples/ssr-counter/hydrate.js b/examples/ssr-counter/hydrate.js new file mode 100644 index 0000000..648dae3 --- /dev/null +++ b/examples/ssr-counter/hydrate.js @@ -0,0 +1,49 @@ +/** + * hydrate.js — Client-side hydration entry point for ssr-counter. + * + * This script runs in the browser after the server has delivered the + * SSR-rendered HTML. It: + * 1. Finds each server-rendered component root (data-el-component attribute). + * 2. Reads back the initial state snapshot (data-el-state attribute). + * 3. Attaches the el-ui runtime WITHOUT replacing the existing DOM. + * 4. Binds all event handlers so the page becomes interactive. + * + * The page is immediately visible (the SSR HTML), and becomes interactive + * as soon as this script finishes executing. There is no content flash. + * + * SSR + hydration lifecycle: + * Server: renderToString(src, 'App', props) → HTML with markers + * Network: HTML delivered to browser, painted immediately + * Client: hydrate('[data-el-component="App"]', App) → events bound + * spreading activation activates on first setState() call + */ + +import { hydrate } from '../../dist/el-ui.js'; +import { Counter, CounterPair, App } from './app.js'; + +// Hydrate each component by its data-el-component selector. +// Order matters: hydrate leaf components before their parents so +// each instance gets its own renderer and state graph. + +// Hydrate all Counter instances +document.querySelectorAll('[data-el-component="Counter"]').forEach(el => { + // Read the stable ID to avoid double-hydration + if (el.dataset.elHydrated) return; + hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter); + el.dataset.elHydrated = 'true'; +}); + +// Hydrate all CounterPair instances +document.querySelectorAll('[data-el-component="CounterPair"]').forEach(el => { + if (el.dataset.elHydrated) return; + hydrate(`[data-el-id="${el.dataset.elId}"]`, CounterPair); + el.dataset.elHydrated = 'true'; +}); + +// The App component contains CounterPair and Counter children. +// If you want the App to own the full activation graph, hydrate it here. +// For this example we hydrate individual Counter components so each +// maintains its own independent state graph (the simpler model). + +// Uncomment to hydrate the whole App as one unit instead: +// hydrate('[data-el-component="App"]', App); diff --git a/examples/ssr-counter/index.html b/examples/ssr-counter/index.html new file mode 100644 index 0000000..ffbb93f --- /dev/null +++ b/examples/ssr-counter/index.html @@ -0,0 +1,297 @@ + + + + + + + ssr-counter — el-ui SSR + Hydration + + + + +
+ + + +
+ +

el-ui SSR + hydration · no DOM replacement on hydrate

+
el-ui · spreading activation
+ + + + + + + diff --git a/examples/ssr-counter/render.js b/examples/ssr-counter/render.js new file mode 100644 index 0000000..855136c --- /dev/null +++ b/examples/ssr-counter/render.js @@ -0,0 +1,58 @@ +/** + * el-ui SSR render script — generated by elc-ui --target=server + * Source: Counter.el + * + * Exports: + * renderToString(componentName, props) -> HTML string + * renderToDocument(componentName, props, opts) -> full HTML document + * componentNames -> string[] + */ +'use strict'; + +const { renderToString: _rts, renderToDocument: _rtd, parseComponents } = require('../../runtime/src/ssr.js'); + +// Component source embedded at compile time +const _src = Buffer.from('Ly8gc3NyLWNvdW50ZXIvQ291bnRlci5lbAovLwovLyBBIGNvdW50ZXIgY29tcG9uZW50IHRoYXQgZGVtb25zdHJhdGVzOgovLyAgIC0gU3RhdGUgd2l0aCBpbml0aWFsIHZhbHVlCi8vICAgLSB7I2lmfSBjb25kaXRpb25hbCByZW5kZXJpbmcKLy8gICAtIHsjZWFjaH0gbGlzdCByZW5kZXJpbmcKLy8gICAtIEludGVycG9sYXRpb24KLy8gICAtIEV2ZW50IGJpbmRpbmcKLy8gICAtIFNTUiArIGNsaWVudCBoeWRyYXRpb24KLy8KLy8gUnVuIGBub2RlIHJlbmRlci5qcyBDb3VudGVyYCB0byBzZWUgdGhlIHNlcnZlci1yZW5kZXJlZCBIVE1MLgovLyBPcGVuIGluZGV4Lmh0bWwgaW4gYSBicm93c2VyIHRvIHNlZSB0aGUgZnVsbCBTU1IgKyBoeWRyYXRpb24gbGlmZWN5Y2xlLgoKY29tcG9uZW50IENvdW50ZXIgewogICAgcHJvcHMgewogICAgICAgIGluaXRpYWxWYWx1ZTogSW50ID0gMAogICAgICAgIGxhYmVsOiBTdHJpbmcgPSAiQ291bnRlciIKICAgIH0KCiAgICBzdGF0ZSB7CiAgICAgICAgY291bnQ6IEludCA9IDAKICAgICAgICBoaXN0b3J5OiBTdHJpbmcgPSAiIgogICAgfQoKICAgIGZuIGluY3JlbWVudCgpIC0+IFZvaWQgewogICAgICAgIGNvdW50ID0gY291bnQgKyAxCiAgICAgICAgaGlzdG9yeSA9IGhpc3RvcnkgKyAiKyIgKyBjb3VudCArICIgIgogICAgfQoKICAgIGZuIGRlY3JlbWVudCgpIC0+IFZvaWQgewogICAgICAgIGNvdW50ID0gY291bnQgLSAxCiAgICAgICAgaGlzdG9yeSA9IGhpc3RvcnkgKyAiLSIgKyBjb3VudCArICIgIgogICAgfQoKICAgIGZuIHJlc2V0KCkgLT4gVm9pZCB7CiAgICAgICAgY291bnQgPSAwCiAgICAgICAgaGlzdG9yeSA9ICIiCiAgICB9CgogICAgdGVtcGxhdGUgewogICAgICAgIDxkaXYgY2xhc3M9ImNvdW50ZXIiPgogICAgICAgICAgICA8aDIgY2xhc3M9ImNvdW50ZXItbGFiZWwiPntsYWJlbH08L2gyPgogICAgICAgICAgICA8ZGl2IGNsYXNzPSJjb3VudC1kaXNwbGF5Ij4KICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPXtjb3VudCA+IDAgPyAiY291bnQgcG9zaXRpdmUiIDogY291bnQgPCAwID8gImNvdW50IG5lZ2F0aXZlIiA6ICJjb3VudCB6ZXJvIn0+CiAgICAgICAgICAgICAgICAgICAge2NvdW50fQogICAgICAgICAgICAgICAgPC9zcGFuPgogICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPGRpdiBjbGFzcz0iYnV0dG9ucyI+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLWRlY3JlbWVudCIgb246Y2xpY2s9eygpID0+IGRlY3JlbWVudCgpfT4tPC9idXR0b24+CiAgICAgICAgICAgICAgICA8YnV0dG9uIGNsYXNzPSJidG4gYnRuLXJlc2V0IiBvbjpjbGljaz17KCkgPT4gcmVzZXQoKX0+cmVzZXQ8L2J1dHRvbj4KICAgICAgICAgICAgICAgIDxidXR0b24gY2xhc3M9ImJ0biBidG4taW5jcmVtZW50IiBvbjpjbGljaz17KCkgPT4gaW5jcmVtZW50KCl9Pis8L2J1dHRvbj4KICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgIHsjaWYgaGlzdG9yeSAhPSAiIn0KICAgICAgICAgICAgICAgIDxkaXYgY2xhc3M9Imhpc3RvcnkiPgogICAgICAgICAgICAgICAgICAgIDxzcGFuIGNsYXNzPSJoaXN0b3J5LWxhYmVsIj5oaXN0b3J5Ojwvc3Bhbj4KICAgICAgICAgICAgICAgICAgICA8Y29kZT57aGlzdG9yeX08L2NvZGU+CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgey9pZn0KICAgICAgICAgICAgeyNpZiBjb3VudCA9PSAwfQogICAgICAgICAgICAgICAgPHAgY2xhc3M9ImhpbnQiPlByZXNzICsgb3IgLSB0byBiZWdpbi48L3A+CiAgICAgICAgICAgIHsvaWZ9CiAgICAgICAgPC9kaXY+CiAgICB9Cn0KCmNvbXBvbmVudCBDb3VudGVyUGFpciB7CiAgICB0ZW1wbGF0ZSB7CiAgICAgICAgPGRpdiBjbGFzcz0iY291bnRlci1wYWlyIj4KICAgICAgICAgICAgPENvdW50ZXIgbGFiZWw9IkxlZnQiIC8+CiAgICAgICAgICAgIDxDb3VudGVyIGxhYmVsPSJSaWdodCIgLz4KICAgICAgICA8L2Rpdj4KICAgIH0KfQoKY29tcG9uZW50IEFwcCB7CiAgICB0ZW1wbGF0ZSB7CiAgICAgICAgPGRpdiBjbGFzcz0iYXBwIj4KICAgICAgICAgICAgPGhlYWRlciBjbGFzcz0iYXBwLWhlYWRlciI+CiAgICAgICAgICAgICAgICA8aDE+ZWwtdWkgU1NSICsgSHlkcmF0aW9uPC9oMT4KICAgICAgICAgICAgICAgIDxwIGNsYXNzPSJzdWJ0aXRsZSI+U2VydmVyLXJlbmRlcmVkLCBjbGllbnQtaHlkcmF0ZWQgd2l0aCBzcHJlYWRpbmcgYWN0aXZhdGlvbjwvcD4KICAgICAgICAgICAgPC9oZWFkZXI+CiAgICAgICAgICAgIDxtYWluIGNsYXNzPSJhcHAtbWFpbiI+CiAgICAgICAgICAgICAgICA8Q291bnRlciBsYWJlbD0iTWFpbiBDb3VudGVyIiAvPgogICAgICAgICAgICAgICAgPENvdW50ZXJQYWlyIC8+CiAgICAgICAgICAgIDwvbWFpbj4KICAgICAgICA8L2Rpdj4KICAgIH0KfQo=', 'base64').toString('utf8'); + +// Parse component names at module load time +const _components = parseComponents(_src); +const componentNames = [..._components.keys()]; + +/** + * Render a component to an HTML string. + * @param {string} componentName + * @param {Object} [props={}] + * @returns {string} + */ +function renderToString(componentName, props = {}) { + return _rts(_src, componentName, props); +} + +/** + * Render a component inside a full HTML document. + * @param {string} componentName + * @param {Object} [props={}] + * @param {Object} [shellOpts={}] + * @returns {string} + */ +function renderToDocument(componentName, props = {}, shellOpts = {}) { + return _rtd(_src, componentName, props, shellOpts); +} + +module.exports = { renderToString, renderToDocument, componentNames }; + +// CLI mode: node render.js [propsJson] +if (require.main === module) { + const compName = process.argv[2]; + const propsJson = process.argv[3] || '{}'; + if (!compName) { + console.error('Usage: node render.js [propsJson]'); + process.exit(1); + } + let props = {}; + try { props = JSON.parse(propsJson); } catch (e) { + console.error('render.js: invalid props JSON:', e.message); + process.exit(1); + } + process.stdout.write(renderToString(compName, props)); +} diff --git a/vessels/el-ui-compiler/elc-ui b/vessels/el-ui-compiler/elc-ui index 9af35de..82fcdab 100755 --- a/vessels/el-ui-compiler/elc-ui +++ b/vessels/el-ui-compiler/elc-ui @@ -218,16 +218,27 @@ function emitComponentClass(comp, components) { * @returns {string} */ function transformMethodBody(body, stateNames) { - let result = body; - for (const name of stateNames) { - // Replace bare assignments: name = expr (not inside ==, !=, <=, >=) - const re = new RegExp(`(?])\\b${name}\\s*=(?!=)\\s*`, 'g'); - result = result.replace(re, `__self.setState('${name}', `); - // We need to close the paren — this is tricky without full parsing. - // For now, append ) at end of expression line (works for simple cases). - // Full statement-level transformation requires the parser. - } - return result; + // Process line-by-line: for each line that contains a bare state assignment, + // rewrite: `name = expr` -> `__self.setState('name', expr)` + // This is a line-level transformation that works for the common case where + // each state assignment is on its own line (the el-ui style). + const lines = body.split('\n'); + const transformed = lines.map(line => { + let changed = line; + for (const name of stateNames) { + // Match: optional whitespace + name + = (not ==, !=, <=, >=) + rest of line + const re = new RegExp(`^(\\s*)${name}\\s*=(?![=>])\\s*(.+)$`); + const m = changed.match(re); + if (m) { + // m[1] = leading whitespace, m[2] = RHS expression + // Strip trailing newline/whitespace from the RHS + const rhs = m[2].trimEnd(); + changed = `${m[1]}__self.setState('${name}', ${rhs})`; + } + } + return changed; + }); + return transformed.join('\n'); } /**