`;
+ }
+
+}
+
+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');
}
/**