Archived
143137e2b4
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.
441 lines
15 KiB
JavaScript
Executable File
441 lines
15 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* elc-ui — el-ui component compiler CLI
|
|
*
|
|
* Usage:
|
|
* elc-ui App.el # compile to App.js (web target, default)
|
|
* elc-ui App.el -o out.js # explicit output path
|
|
* elc-ui App.el --target=server # compile SSR render script
|
|
* elc-ui App.el --target=server -o render.js
|
|
*
|
|
* Targets:
|
|
* web (default) — emits ES2022 JS module using el-ui runtime
|
|
* server — emits Node.js CJS module with renderToString(componentName, props)
|
|
*
|
|
* The web target emits the hand-compiled JS contract (class-per-component).
|
|
* The server target emits a render script that uses runtime/src/ssr.js to
|
|
* produce HTML strings from the .el source directly.
|
|
*
|
|
* Both outputs can be used together: web for client-side hydration,
|
|
* server for initial HTML generation.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
console.log(`
|
|
elc-ui — el-ui component compiler
|
|
|
|
Usage:
|
|
elc-ui <source.el> [options]
|
|
|
|
Options:
|
|
-o <path> output file path (default: <source>.js)
|
|
--target=<t> compilation target: web | server (default: web)
|
|
--runtime=<path> path to el-ui runtime (default: ./el-ui.js)
|
|
-h, --help show this help
|
|
|
|
Targets:
|
|
web ES2022 JS module for browsers (requires el-ui runtime)
|
|
server Node.js CJS module with renderToString() for SSR
|
|
|
|
Examples:
|
|
elc-ui Counter.el
|
|
elc-ui Counter.el --target=server -o render.js
|
|
elc-ui App.el --target=server --runtime=../../dist/el-ui.js
|
|
`.trim());
|
|
process.exit(0);
|
|
}
|
|
|
|
let sourceFile = null;
|
|
let outputFile = null;
|
|
let target = 'web';
|
|
let runtimePath = './el-ui.js';
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === '-o' && args[i + 1]) {
|
|
outputFile = args[++i];
|
|
} else if (arg.startsWith('--target=')) {
|
|
target = arg.slice('--target='.length);
|
|
} else if (arg.startsWith('--runtime=')) {
|
|
runtimePath = arg.slice('--runtime='.length);
|
|
} else if (!arg.startsWith('-')) {
|
|
sourceFile = arg;
|
|
}
|
|
}
|
|
|
|
if (!sourceFile) {
|
|
console.error('elc-ui: error: no source file specified');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!['web', 'server'].includes(target)) {
|
|
console.error(`elc-ui: error: unknown target "${target}". Valid targets: web, server`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!outputFile) {
|
|
const ext = path.extname(sourceFile);
|
|
outputFile = sourceFile.slice(0, -ext.length) + '.js';
|
|
}
|
|
|
|
// ── Source loading ────────────────────────────────────────────────────────────
|
|
|
|
let src;
|
|
try {
|
|
src = fs.readFileSync(sourceFile, 'utf8');
|
|
} catch (e) {
|
|
console.error(`elc-ui: error: cannot read "${sourceFile}": ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Codegen ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Emit a web (browser) JS module.
|
|
* Parses the .el source and emits class-per-component JS that matches
|
|
* the hand-compiled format used in examples/counter/app.js.
|
|
*
|
|
* @param {string} src
|
|
* @param {string} runtimePath
|
|
* @returns {string}
|
|
*/
|
|
function emitWebTarget(src, runtimePath) {
|
|
// We need the SSR parser to understand the component structure,
|
|
// then emit the equivalent JavaScript class.
|
|
const { parseComponents, escapeHtml } = require(
|
|
path.resolve(__dirname, '../../runtime/src/ssr.js'),
|
|
);
|
|
|
|
const components = parseComponents(src);
|
|
|
|
const lines = [];
|
|
lines.push(`import { Component, Graph, Renderer, Router, mount } from '${runtimePath}';\n`);
|
|
|
|
for (const [, comp] of components) {
|
|
lines.push(emitComponentClass(comp, components));
|
|
}
|
|
|
|
// Export all components
|
|
const names = [...components.keys()];
|
|
lines.push(`export { ${names.join(', ')} };`);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Emit a single Component class for the web target.
|
|
* @param {ComponentDef} comp
|
|
* @param {Map} components
|
|
* @returns {string}
|
|
*/
|
|
function emitComponentClass(comp, components) {
|
|
const lines = [];
|
|
lines.push(`class ${comp.name} extends Component {`);
|
|
lines.push(` constructor(props = {}) {`);
|
|
lines.push(` super();`);
|
|
lines.push(` this.props = props;`);
|
|
lines.push(` this._graph = new Graph();`);
|
|
lines.push(` this._stateNodes = {};`);
|
|
lines.push(` this._state = {};`);
|
|
|
|
for (const s of comp.state) {
|
|
const val = JSON.stringify(s.defaultValue);
|
|
lines.push(` this._stateNodes['${s.name}'] = this._graph.seed({ type: 'state', name: '${s.name}', content: ${val} });`);
|
|
lines.push(` this._state['${s.name}'] = ${val};`);
|
|
}
|
|
|
|
if (comp.state.length > 0) {
|
|
lines.push(` for (const [key, nodeId] of Object.entries(this._stateNodes)) {`);
|
|
lines.push(` this._graph.subscribe(nodeId, (node) => {`);
|
|
lines.push(` this._state[key] = node.content;`);
|
|
lines.push(` if (this._renderer) this._renderer.patch();`);
|
|
lines.push(` });`);
|
|
lines.push(` }`);
|
|
}
|
|
|
|
lines.push(` }\n`);
|
|
|
|
// setState
|
|
lines.push(` setState(name, value) {`);
|
|
lines.push(` if (this._stateNodes[name] !== undefined) {`);
|
|
lines.push(` this._graph.update(this._stateNodes[name], value);`);
|
|
lines.push(` }`);
|
|
lines.push(` }\n`);
|
|
|
|
// Methods
|
|
for (const method of comp.methods) {
|
|
lines.push(` ${method.name}() {`);
|
|
lines.push(` const __self = this;`);
|
|
// Expose state variables as locals
|
|
for (const s of comp.state) {
|
|
lines.push(` const ${s.name} = this._state['${s.name}'];`);
|
|
}
|
|
// Expose props as locals
|
|
for (const p of comp.props) {
|
|
lines.push(` const ${p.name} = this.props['${p.name}'] !== undefined ? this.props['${p.name}'] : ${JSON.stringify(p.defaultValue)};`);
|
|
}
|
|
// Transform method body: state assignments -> setState calls
|
|
const body = transformMethodBody(method.body, comp.state.map(s => s.name));
|
|
lines.push(body.split('\n').map(l => ` ${l}`).join('\n'));
|
|
lines.push(` }\n`);
|
|
}
|
|
|
|
// render()
|
|
lines.push(` render() {`);
|
|
lines.push(` const __self = this;`);
|
|
for (const s of comp.state) {
|
|
lines.push(` const ${s.name} = this._state['${s.name}'];`);
|
|
}
|
|
for (const p of comp.props) {
|
|
lines.push(` const ${p.name} = this.props['${p.name}'] !== undefined ? this.props['${p.name}'] : ${JSON.stringify(p.defaultValue)};`);
|
|
}
|
|
if (comp.template && comp.template.length > 0) {
|
|
const tpl = emitTemplateExpr(comp.template);
|
|
lines.push(` return \`${tpl}\`;`);
|
|
} else {
|
|
lines.push(` return '';`);
|
|
}
|
|
lines.push(` }\n`);
|
|
|
|
lines.push(`}\n`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Transform a method body: `varName = expr` → `__self.setState('varName', expr)`
|
|
* for known state variable names.
|
|
* @param {string} body
|
|
* @param {string[]} stateNames
|
|
* @returns {string}
|
|
*/
|
|
function transformMethodBody(body, stateNames) {
|
|
// 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');
|
|
}
|
|
|
|
/**
|
|
* Emit a template literal expression for the render() method.
|
|
* @param {TemplateNode[]} nodes
|
|
* @returns {string} template literal body (no backticks)
|
|
*/
|
|
function emitTemplateExpr(nodes) {
|
|
return nodes.map(n => emitNodeExpr(n)).join('');
|
|
}
|
|
|
|
function escapeTemplateLiteral(s) {
|
|
return s.replace(/`/g, '\\`').replace(/\$/g, '\\$').replace(/\\/g, '\\\\');
|
|
}
|
|
|
|
function emitNodeExpr(node) {
|
|
if (!node) return '';
|
|
switch (node.kind) {
|
|
case 'text':
|
|
return escapeTemplateLiteral(node.content);
|
|
|
|
case 'interpolation':
|
|
return `\${${node.expr} }`;
|
|
|
|
case 'raw_html':
|
|
return `\${${node.expr}}`;
|
|
|
|
case 'if': {
|
|
const thenExpr = emitTemplateExpr(node.then);
|
|
const elseExpr = node.else ? emitTemplateExpr(node.else) : '';
|
|
return `\${(${node.condition}) ? \`${thenExpr}\` : \`${elseExpr}\`}`;
|
|
}
|
|
|
|
case 'each': {
|
|
const bodyExpr = emitTemplateExpr(node.children);
|
|
return `\${(${node.items}).map((${node.itemName}) => \`${bodyExpr}\`).join('')}`;
|
|
}
|
|
|
|
case 'activate': {
|
|
const bodyExpr = emitTemplateExpr(node.children);
|
|
return `\${((this._graph.search("${node.query}")) || []).map((${node.resultName}) => \`${bodyExpr}\`).join('')}`;
|
|
}
|
|
|
|
case 'component': {
|
|
const propsExpr = node.props.map(a => {
|
|
const val = a.type === 'static'
|
|
? JSON.stringify(a.value)
|
|
: (a.type === 'dynamic' ? a.expr : 'true');
|
|
return `${a.name}: ${val}`;
|
|
}).join(', ');
|
|
return `\${__self._child(${node.name}, { ${propsExpr} })}`;
|
|
}
|
|
|
|
case 'element': {
|
|
const tag = node.tag;
|
|
const attrsStr = node.attrs.map(a => emitAttrStr(a)).join('');
|
|
// void elements
|
|
const VOID = new Set(['area','base','br','col','embed','hr','img','input',
|
|
'link','meta','param','source','track','wbr']);
|
|
if (VOID.has(tag.toLowerCase())) {
|
|
return `<${tag}${attrsStr}>`;
|
|
}
|
|
const childrenStr = emitTemplateExpr(node.children);
|
|
return `<${tag}${attrsStr} data-el-tag="${tag}">${childrenStr}</${tag}>`;
|
|
}
|
|
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function emitAttrStr(attr) {
|
|
const name = attr.name;
|
|
if (name.startsWith('on:')) {
|
|
const event = name.slice(3);
|
|
const expr = attr.type === 'dynamic' ? attr.expr : (attr.value || '');
|
|
// HTML-encode the expression for the attribute value
|
|
const encoded = expr
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
return ` data-el-${event}="${encoded}"`;
|
|
}
|
|
if (attr.type === 'static') {
|
|
return ` ${name}="${attr.value}"`;
|
|
}
|
|
if (attr.type === 'dynamic') {
|
|
return ` ${name}="\${${attr.expr}}"`;
|
|
}
|
|
if (attr.type === 'bool') {
|
|
return ` \${(${attr.expr}) ? '${name}' : ''}`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Emit a server-target render script.
|
|
* The output is a Node.js CJS module that exports:
|
|
* renderToString(componentName, props) -> string
|
|
* renderToDocument(componentName, props, shellOpts) -> string
|
|
* componentNames: string[]
|
|
*
|
|
* @param {string} src .el source
|
|
* @param {string} srcPath path to the source file (embedded in render script for reload)
|
|
* @returns {string}
|
|
*/
|
|
function emitServerTarget(src, srcPath) {
|
|
// Embed the source as a base64 string so the render script is self-contained
|
|
const srcB64 = Buffer.from(src, 'utf8').toString('base64');
|
|
|
|
// Compute path to ssr.js.
|
|
// Prefer a relative path when the output lives within the same project
|
|
// tree; use absolute otherwise. Generated render scripts are not portable
|
|
// across machines -- they reference the local installation of el-ui.
|
|
const ssrAbsPath = path.resolve(__dirname, '../../runtime/src/ssr.js');
|
|
const outAbsDir = fs.realpathSync(path.dirname(path.resolve(outputFile)));
|
|
const ssrRelPath = path.relative(outAbsDir, ssrAbsPath);
|
|
// Relative paths are cleaner; absolute paths are safer for output dirs
|
|
// that live outside the project (e.g., system /tmp).
|
|
const ssrRequirePath = ssrRelPath.startsWith('.') ? ssrRelPath : ssrAbsPath;
|
|
|
|
return `/**
|
|
* el-ui SSR render script — generated by elc-ui --target=server
|
|
* Source: ${path.basename(srcPath)}
|
|
*
|
|
* Exports:
|
|
* renderToString(componentName, props) -> HTML string
|
|
* renderToDocument(componentName, props, opts) -> full HTML document
|
|
* componentNames -> string[]
|
|
*/
|
|
'use strict';
|
|
|
|
const { renderToString: _rts, renderToDocument: _rtd, parseComponents } = require('${ssrRequirePath}');
|
|
|
|
// Component source embedded at compile time
|
|
const _src = Buffer.from('${srcB64}', '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));
|
|
}
|
|
`;
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
let output;
|
|
|
|
if (target === 'server') {
|
|
output = emitServerTarget(src, sourceFile);
|
|
} else {
|
|
output = emitWebTarget(src, runtimePath);
|
|
}
|
|
|
|
try {
|
|
fs.writeFileSync(outputFile, output, 'utf8');
|
|
console.log(`elc-ui: ${target} → ${outputFile}`);
|
|
} catch (e) {
|
|
console.error(`elc-ui: error: cannot write "${outputFile}": ${e.message}`);
|
|
process.exit(1);
|
|
}
|