add ssr-counter example demonstrating SSR + hydration end-to-end

Counter.el defines Counter, CounterPair, and App components with {#if}
conditionals, event handlers, and child component composition.

render.js (generated by elc-ui --target=server) is the Node.js render
script the server calls. app.js (generated by elc-ui --target=web) is the
compiled browser module. hydrate.js attaches the runtime to the
server-rendered DOM without replacing it.

index.html shows the integration point with instructions for injecting
server output and the two CLI commands needed to regenerate both targets.
This commit is contained in:
Will Anderson
2026-05-04 12:49:19 -05:00
parent f8e32e7719
commit 143137e2b4
6 changed files with 632 additions and 10 deletions
+88
View File
@@ -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 {
<div class="counter">
<h2 class="counter-label">{label}</h2>
<div class="count-display">
<span class={count > 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}>
{count}
</span>
</div>
<div class="buttons">
<button class="btn btn-decrement" on:click={() => decrement()}>-</button>
<button class="btn btn-reset" on:click={() => reset()}>reset</button>
<button class="btn btn-increment" on:click={() => increment()}>+</button>
</div>
{#if history != ""}
<div class="history">
<span class="history-label">history:</span>
<code>{history}</code>
</div>
{/if}
{#if count == 0}
<p class="hint">Press + or - to begin.</p>
{/if}
</div>
}
}
component CounterPair {
template {
<div class="counter-pair">
<Counter label="Left" />
<Counter label="Right" />
</div>
}
}
component App {
template {
<div class="app">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<Counter label="Main Counter" />
<CounterPair />
</main>
</div>
}
}
+119
View File
@@ -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 `<div class="counter" data-el-tag="div"><h2 class="counter-label" data-el-tag="h2">${label }</h2><div class="count-display" data-el-tag="div"><span class="${count > 0 ? "count positive" : count < 0 ? "count negative" : "count zero"}" data-el-tag="span">${count }</span></div><div class="buttons" data-el-tag="div"><button class="btn btn-decrement" data-el-click="() => decrement()" data-el-tag="button">-</button><button class="btn btn-reset" data-el-click="() => reset()" data-el-tag="button">reset</button><button class="btn btn-increment" data-el-click="() => increment()" data-el-tag="button">+</button></div>${(history != "") ? `<div class="history" data-el-tag="div"><span class="history-label" data-el-tag="span">history:</span><code data-el-tag="code">${history }</code></div>` : ``}${(count == 0) ? `<p class="hint" data-el-tag="p">Press + or - to begin.</p>` : ``}</div>`;
}
}
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 `<div class="counter-pair" data-el-tag="div">${__self._child(Counter, { label: "Left" })}${__self._child(Counter, { label: "Right" })}</div>`;
}
}
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 `<div class="app" data-el-tag="div"><header class="app-header" data-el-tag="header"><h1 data-el-tag="h1">el-ui SSR + Hydration</h1><p class="subtitle" data-el-tag="p">Server-rendered, client-hydrated with spreading activation</p></header><main class="app-main" data-el-tag="main">${__self._child(Counter, { label: "Main Counter" })}${__self._child(CounterPair, { })}</main></div>`;
}
}
export { Counter, CounterPair, App };
+49
View File
@@ -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);
+297
View File
@@ -0,0 +1,297 @@
<!DOCTYPE html>
<!--
ssr-counter/index.html — el-ui SSR + Hydration Example
In production, the content of <div id="app"> is generated by the server
calling:
const { renderToString } = require('./render.js');
const html = renderToString('App', {});
This static file simulates that by inlining the rendered HTML directly.
The client hydration script (hydrate.js) attaches to the existing DOM
without replacing it.
To see server rendering live:
node render.js App > /tmp/rendered.html
# then inline that output into the #app div below
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ssr-counter — el-ui SSR + Hydration</title>
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--text: #e8edf3;
--text2: #7a8a9a;
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.4);
--green: #4ade80;
--red: #f87171;
--border: rgba(255,255,255,0.08);
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Mono', 'JetBrains Mono', 'Fira Code', monospace;
min-height: 100vh;
}
.app {
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
}
.app-header {
text-align: center;
margin-bottom: 48px;
}
.app-header h1 {
font-size: 32px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.03em;
margin-bottom: 8px;
}
.subtitle {
color: var(--text2);
font-size: 14px;
}
.app-main {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
}
.counter {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px 40px;
min-width: 280px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.counter-label {
font-size: 14px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
}
.count-display {
display: flex;
align-items: center;
justify-content: center;
}
.count {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 120px;
text-align: center;
letter-spacing: -0.03em;
transition: color 0.15s;
}
.count.zero { color: var(--text2); }
.count.positive { color: var(--accent); text-shadow: 0 0 40px var(--accent-glow); }
.count.negative { color: var(--red); text-shadow: 0 0 40px rgba(248,113,113,0.4); }
.buttons {
display: flex;
gap: 12px;
}
.btn {
background: var(--accent-dim);
border: 1px solid var(--accent);
color: var(--accent);
font-size: 18px;
font-family: inherit;
padding: 0 20px;
height: 48px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
background: rgba(56,189,248,0.22);
box-shadow: 0 0 16px var(--accent-glow);
}
.btn:active { transform: scale(0.95); }
.btn-reset {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(255,255,255,0.04);
border-color: var(--border);
color: var(--text2);
}
.btn-reset:hover {
background: rgba(255,255,255,0.08);
box-shadow: none;
}
.btn-decrement {
color: var(--red);
border-color: var(--red);
background: rgba(248,113,113,0.08);
}
.btn-decrement:hover {
background: rgba(248,113,113,0.18);
box-shadow: 0 0 16px rgba(248,113,113,0.3);
}
.history {
font-size: 12px;
color: var(--text2);
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.history-label {
color: var(--text2);
opacity: 0.6;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
code {
color: var(--accent);
font-family: inherit;
}
.hint {
font-size: 12px;
color: var(--text2);
opacity: 0.5;
}
.counter-pair {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.el-badge {
position: fixed;
bottom: 16px;
right: 16px;
font-size: 10px;
color: var(--text2);
opacity: 0.5;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* SSR indicator — shows before hydration completes */
.ssr-note {
text-align: center;
font-size: 11px;
color: var(--text2);
opacity: 0.4;
margin-bottom: 24px;
letter-spacing: 0.06em;
}
</style>
</head>
<body>
<!--
In a real server setup, this div's content is injected by the server:
const { renderToString } = require('./render.js');
res.send(`<div id="app">${renderToString('App', {})}</div>`);
To preview server output:
node render.js App
Below is the static simulation of that output.
The data-el-* attributes are the hydration markers written by the SSR engine.
-->
<div id="app">
<!-- SSR output placeholder. Run: node render.js App and paste here. -->
<!-- Or open this file with a server that injects the SSR output above. -->
<!--
Example of what the server renders (simplified for readability):
<div class="app"
data-el-component="App"
data-el-id="el-app-1"
data-el-state="{}">
<header class="app-header">
<h1>el-ui SSR + Hydration</h1>
<p class="subtitle">Server-rendered, client-hydrated with spreading activation</p>
</header>
<main class="app-main">
<div class="counter"
data-el-component="Counter"
data-el-id="el-counter-1"
data-el-state='{"count":0,"history":""}'>
...
</div>
</main>
</div>
-->
</div>
<p class="ssr-note">el-ui SSR + hydration · no DOM replacement on hydrate</p>
<div class="el-badge">el-ui · spreading activation</div>
<!--
Hydration script.
- app.js: the compiled component classes (web target, for render() and setState())
- hydrate.js: attaches the runtime to the server-rendered DOM
Both are type="module" so they run after the document is parsed.
The SSR HTML is already painted before these scripts execute.
-->
<script type="module" src="./hydrate.js"></script>
<!--
Integration note for the El web server:
===========================================
The El web server (C binary) renders pages by calling:
node render.js App '{}'
and capturing stdout as the HTML to inject into the template above.
This is the simplest bridge: no Node.js server needed, just exec().
A tighter integration embeds Node.js (via libnode or napi) and calls
renderToString() directly without subprocess overhead — appropriate for
high-traffic pages. The render script API is identical either way.
-->
</body>
</html>
+58
View File
@@ -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 <ComponentName> [propsJson]
if (require.main === module) {
const compName = process.argv[2];
const propsJson = process.argv[3] || '{}';
if (!compName) {
console.error('Usage: node render.js <ComponentName> [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));
}
+21 -10
View File
@@ -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');
}
/**