#!/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 [options] Options: -o output file path (default: .js) --target= compilation target: web | server (default: web) --runtime= 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}`; } 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 [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)); } `; } // ── 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); }