Archived
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:
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user