diff --git a/vessels/el-ui-compiler/elc-ui b/vessels/el-ui-compiler/elc-ui new file mode 100755 index 0000000..9af35de --- /dev/null +++ b/vessels/el-ui-compiler/elc-ui @@ -0,0 +1,429 @@ +#!/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) { + 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; +} + +/** + * 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); +}