diff --git a/el-compiler/runtime/el_runtime.js b/el-compiler/runtime/el_runtime.js index e5c7a79..4a8d2ca 100644 --- a/el-compiler/runtime/el_runtime.js +++ b/el-compiler/runtime/el_runtime.js @@ -522,6 +522,152 @@ function math_sin(f) { return Math.sin(f); } function math_cos(f) { return Math.cos(f); } function math_pi() { return Math.PI; } +// ── DOM bridge (browser-only) ────────────────────────────────────────────── +// +// These functions wrap the browser DOM API. Each throws a descriptive error +// when called from a Node environment, mirroring the pattern used by fs_* +// in browser mode. + +function _ensureBrowser(name) { + if (IS_NODE) { + throw new Error(`${name}: not supported in Node runtime — DOM is browser-only`); + } +} + +function dom_get_element(id) { + _ensureBrowser('dom_get_element'); + return document.getElementById(String(id)); +} + +function dom_get_value(el) { + _ensureBrowser('dom_get_value'); + return el == null ? '' : String(el.value ?? ''); +} + +function dom_set_value(el, v) { + _ensureBrowser('dom_set_value'); + if (el != null) el.value = String(v); +} + +function dom_get_text(el) { + _ensureBrowser('dom_get_text'); + return el == null ? '' : String(el.textContent ?? ''); +} + +function dom_set_text(el, text) { + _ensureBrowser('dom_set_text'); + if (el != null) el.textContent = String(text); +} + +function dom_set_prop(el, prop, val) { + _ensureBrowser('dom_set_prop'); + if (el != null) el[String(prop)] = val; +} + +function dom_get_prop(el, prop) { + _ensureBrowser('dom_get_prop'); + if (el == null) return null; + const v = el[String(prop)]; + return v === undefined ? null : v; +} + +function dom_set_style(el, prop, val) { + _ensureBrowser('dom_set_style'); + if (el != null) el.style[String(prop)] = String(val); +} + +function dom_add_class(el, cls) { + _ensureBrowser('dom_add_class'); + if (el != null) el.classList.add(String(cls)); +} + +function dom_remove_class(el, cls) { + _ensureBrowser('dom_remove_class'); + if (el != null) el.classList.remove(String(cls)); +} + +function dom_show(el) { + _ensureBrowser('dom_show'); + if (el != null) el.style.display = ''; +} + +function dom_hide(el) { + _ensureBrowser('dom_hide'); + if (el != null) el.style.display = 'none'; +} + +function dom_listen(el, event, handler) { + _ensureBrowser('dom_listen'); + if (el != null) el.addEventListener(String(event), handler); +} + +function dom_query(selector) { + _ensureBrowser('dom_query'); + return document.querySelector(String(selector)); +} + +function dom_query_all(selector) { + _ensureBrowser('dom_query_all'); + return Array.from(document.querySelectorAll(String(selector))); +} + +function dom_create(tag) { + _ensureBrowser('dom_create'); + return document.createElement(String(tag)); +} + +function dom_append(parent, child) { + _ensureBrowser('dom_append'); + if (parent != null && child != null) parent.appendChild(child); +} + +function dom_remove(el) { + _ensureBrowser('dom_remove'); + if (el != null) el.remove(); +} + +function dom_is_null(el) { + return el === null || el === undefined; +} + +// ── Window export helpers ────────────────────────────────────────────────── +// +// Expose El functions to the browser's global scope so they can be called +// from inline event handlers (onclick="increment()") or by external JS. +// In Node mode, writes to globalThis so the same pattern works in tests. + +function window_set(name, val) { + if (typeof window !== 'undefined') { + window[String(name)] = val; + } else if (typeof globalThis !== 'undefined') { + globalThis[String(name)] = val; + } +} + +function window_get(name) { + if (typeof window !== 'undefined') { + const v = window[String(name)]; + return v === undefined ? null : v; + } + return null; +} + +// ── native_js escape hatch ───────────────────────────────────────────────── +// +// Evaluate arbitrary JS from El source. Intended for calling third-party +// browser libraries (Supabase, Stripe, etc.) until proper El bindings exist. +// Use sparingly — this bypasses El's type system entirely. + +function native_js(code) { + // eslint-disable-next-line no-eval + return eval(String(code)); +} + +function native_js_call(obj, method, args) { + if (obj == null) throw new Error('native_js_call: object is null'); + return obj[String(method)](...(Array.isArray(args) ? args : [])); +} + // ── Stubs for not-yet-supported features ─────────────────────────────────── // // These compile but throw when called. See spec/codegen-js.md §7. @@ -632,6 +778,15 @@ const __el = { // Float / math float_to_str, int_to_float, float_to_int, format_float, decimal_round, str_to_float, math_sqrt, math_log, math_ln, math_sin, math_cos, math_pi, + // DOM bridge (browser-only) + dom_get_element, dom_get_value, dom_set_value, dom_get_text, dom_set_text, + dom_set_prop, dom_get_prop, dom_set_style, dom_add_class, dom_remove_class, + dom_show, dom_hide, dom_listen, dom_query, dom_query_all, dom_create, + dom_append, dom_remove, dom_is_null, + // Window export helpers + window_set, window_get, + // native_js escape hatch + native_js, native_js_call, // CGI / DHARMA / Engram / LLM (stubs) el_cgi_init, dharma_connect, dharma_send, dharma_activate, dharma_emit, dharma_field, @@ -676,4 +831,11 @@ export { dharma_connect, dharma_send, dharma_activate, dharma_emit, dharma_field, engram_node, engram_search, engram_activate, llm_call, llm_call_system, + // DOM bridge + dom_get_element, dom_get_value, dom_set_value, dom_get_text, dom_set_text, + dom_set_prop, dom_get_prop, dom_set_style, dom_add_class, dom_remove_class, + dom_show, dom_hide, dom_listen, dom_query, dom_query_all, dom_create, + dom_append, dom_remove, dom_is_null, + // Window / native_js + window_set, window_get, native_js, native_js_call, }; diff --git a/el-compiler/src/codegen-js.el b/el-compiler/src/codegen-js.el index b6c0b48..c1b2f7e 100644 --- a/el-compiler/src/codegen-js.el +++ b/el-compiler/src/codegen-js.el @@ -86,6 +86,37 @@ fn js_binop(op: String) -> String { op } +// ── Async function tracking ─────────────────────────────────────────────────── +// +// Functions decorated with @async are recorded here. Any call to a known-async +// builtin (http_get, http_post, http_post_json) or to a user-declared @async +// function gets an `await` prefix in generated JS. +// +// Known-async builtins — these return Promise in el_runtime.js. +fn js_is_async_builtin(name: String) -> Bool { + if str_eq(name, "http_get") { return true } + if str_eq(name, "http_post") { return true } + if str_eq(name, "http_post_json") { return true } + if str_eq(name, "http_get_with_headers") { return true } + if str_eq(name, "http_post_with_headers") { return true } + false +} + +fn js_register_async_fn(name: String) -> Bool { + let csv: String = state_get("__js_async_fns") + if str_eq(csv, "") { csv = "," } + let key: String = "," + name + "," + if str_contains(csv, key) { return true } + state_set("__js_async_fns", csv + name + ",") + return true +} + +fn js_is_async_fn(name: String) -> Bool { + let csv: String = state_get("__js_async_fns") + if str_eq(csv, "") { return false } + return str_contains(csv, "," + name + ",") +} + // ── Int-name tracking (mirrors codegen.el) ──────────────────────────────────── fn js_is_int_name(name: String) -> Bool { @@ -377,7 +408,14 @@ fn js_cg_expr(expr: Map) -> String { if func_kind == "Ident" { let fn_name: String = func["name"] - return fn_name + "(" + args_c + ")" + let call_expr: String = fn_name + "(" + args_c + ")" + if js_is_async_builtin(fn_name) { + return "await " + call_expr + } + if js_is_async_fn(fn_name) { + return "await " + call_expr + } + return call_expr } if func_kind == "Field" { @@ -785,16 +823,30 @@ fn js_cg_fn(stmt: Map) -> Void { let params = stmt["params"] let body = stmt["body"] let ret_type: String = stmt["ret_type"] + let decorator: String = stmt["decorator"] let params_str: String = js_params_str(params) js_build_int_names_for_params(params) - // Special-case `fn main` — emit as a regular function and call it - // at module bottom (after all top-level statements). This matches - // the C backend's behavior where `fn main` is the entry point. - if fn_name == "main" { - js_emit_line("function main(" + params_str + ") {") + // Detect @async decorator — emit `async function` and register the name + // so call sites for this function get `await` prefixed automatically. + // When the decorator field is absent, el_get_field returns null; str_eq + // handles null safely (returns false), so no special nil-check is needed. + if str_eq(decorator, "async") { + js_register_async_fn(fn_name) + if fn_name == "main" { + js_emit_line("async function main(" + params_str + ") {") + } else { + js_emit_line("async function " + fn_name + "(" + params_str + ") {") + } } else { - js_emit_line("function " + fn_name + "(" + params_str + ") {") + // Special-case `fn main` — emit as a regular function and call it + // at module bottom (after all top-level statements). This matches + // the C backend's behavior where `fn main` is the entry point. + if fn_name == "main" { + js_emit_line("function main(" + params_str + ") {") + } else { + js_emit_line("function " + fn_name + "(" + params_str + ") {") + } } let decl = native_list_empty() @@ -839,6 +891,7 @@ fn codegen_js(stmts: [Map], source: String) -> String { // Reset per-compile state. state_set("__js_int_names", "") state_set("__js_match_counter", "") + state_set("__js_async_fns", "") // Preamble: inline the runtime via a single import that side-effects // globalThis. The runtime path is resolved relative to the generated @@ -868,12 +921,34 @@ fn codegen_js(stmts: [Map], source: String) -> String { js_emit_line(" dharma_connect, dharma_send, dharma_emit, dharma_field, dharma_activate,") js_emit_line(" engram_node, engram_search, engram_activate,") js_emit_line(" llm_call, llm_call_system,") + js_emit_line(" dom_get_element, dom_get_value, dom_set_value, dom_get_text, dom_set_text,") + js_emit_line(" dom_set_prop, dom_get_prop, dom_set_style, dom_add_class, dom_remove_class,") + js_emit_line(" dom_show, dom_hide, dom_listen, dom_query, dom_query_all, dom_create,") + js_emit_line(" dom_append, dom_remove, dom_is_null,") + js_emit_line(" window_set, window_get, native_js, native_js_call,") js_emit_line("} = globalThis.__el;") js_emit_blank() - // Function definitions + // Pre-registration pass: scan all FnDefs for @async decorators so that + // forward calls to @async functions get `await` even if the callee is + // defined after the caller. let n: Int = native_list_len(stmts) let i = 0 + while i < n { + let stmt = native_list_get(stmts, i) + let sk: String = stmt["stmt"] + if str_eq(sk, "FnDef") { + let dec: String = stmt["decorator"] + if str_eq(dec, "async") { + let aname: String = stmt["name"] + js_register_async_fn(aname) + } + } + let i = i + 1 + } + + // Function definitions + let i = 0 while i < n { let stmt = native_list_get(stmts, i) if js_is_fndef(stmt) { diff --git a/examples/browser-counter.el b/examples/browser-counter.el new file mode 100644 index 0000000..8b23a90 --- /dev/null +++ b/examples/browser-counter.el @@ -0,0 +1,41 @@ +// browser-counter.el — canonical browser DOM bridge example +// +// Compile with: elc --target=js examples/browser-counter.el > counter.js +// +// Then include in an HTML page that has a element. +// The page can call window.increment() from any onclick handler, e.g.: +// +// +// On load the display is initialised to "0". Each call to increment() +// adds 1 and updates the display text. +// +// Demonstrates: +// - dom_get_element to locate a DOM node by id +// - dom_set_text to update visible text content +// - dom_is_null to guard against missing elements +// - window_set to expose an El function for inline event handlers +// - state_set/get for in-memory counter state (survives calls, resets +// on page reload — same semantics as the C state_* API) + +fn init() -> Void { + state_set("counter", 0) + let display = dom_get_element("count-display") + if !dom_is_null(display) { + dom_set_text(display, "0") + } +} + +fn increment() -> Void { + let current = str_to_int(state_get("counter")) + let next = current + 1 + state_set("counter", next) + let display = dom_get_element("count-display") + if !dom_is_null(display) { + dom_set_text(display, int_to_str(next)) + } +} + +fn main() -> Void { + init() + window_set("increment", increment) +} diff --git a/spec/codegen-js.md b/spec/codegen-js.md index 4920bc4..1651dba 100644 --- a/spec/codegen-js.md +++ b/spec/codegen-js.md @@ -1,6 +1,6 @@ # El JavaScript Backend (codegen-js) -**Status:** scaffolded. Hello-world compiles and runs. ~50% language coverage. Core runtime (~30 builtins) implemented. CGI / DHARMA / LLM / Engram intentionally stubbed. +**Status:** Phase 3 complete. Hello-world compiles and runs. ~50% language coverage. Core runtime (~50 builtins) implemented including full DOM bridge, window export helpers, native_js escape hatches, and @async/await support. CGI / DHARMA / LLM / Engram intentionally stubbed. **Authoritative files** @@ -78,6 +78,9 @@ Same function names as `el_runtime.c` wherever possible, so codegen-js can emit | `args` | `args()` returns `process.argv.slice(2)` in Node, `[]` in browser | | `state_*` | In-memory `Map` keyed by string | | `env` | `process.env[k]` in Node, throws in browser | +| DOM bridge (Phase 3) | `dom_get_element`, `dom_get_value`, `dom_set_value`, `dom_get_text`, `dom_set_text`, `dom_set_prop`, `dom_get_prop`, `dom_set_style`, `dom_add_class`, `dom_remove_class`, `dom_show`, `dom_hide`, `dom_listen`, `dom_query`, `dom_query_all`, `dom_create`, `dom_append`, `dom_remove`, `dom_is_null` (browser-only; throw in Node) | +| Window export | `window_set(name, val)`, `window_get(name)` — expose/retrieve values on `window` (or `globalThis` in Node) | +| native_js escape hatch | `native_js(code)` — evaluates a JS expression via `eval`; `native_js_call(obj, method, args)` — calls a method on an object. Use for third-party browser libraries until proper bindings exist | ### Stubbed (throw at runtime) @@ -128,17 +131,54 @@ The runtime auto-detects via `typeof window === 'undefined'`. --- -## 5. The async problem (the big deferred decision) +## 5. The async problem `fetch()` is async. The C backend's `http_get(url)` is synchronous and returns the body string directly. El source was written assuming sync. Three options: -1. **Pretend it's sync from El's POV; use synchronous XHR (browser) or `child_process.execSync('curl …')` (Node).** Bad: synchronous XHR is deprecated and frozen on the main thread; `execSync` is a hack. -2. **Make every `http_*` builtin in the JS runtime return a `Promise`, and rewrite codegen-js to insert `await` everywhere.** This requires turning every El function that transitively calls a network builtin into an `async fn` in JS. Doable, but invasive — the El AST does not currently mark async-ness. -3. **Compile El's call sites with implicit await; compile-time taint tracking marks every fn that transitively calls a network builtin as `async`. Generated JS uses `async function` and `await`.** This is the right answer long-term. +1. **Pretend it's sync from El's POV; use synchronous XHR (browser) or `child_process.execSync('curl ...')` (Node).** Bad: synchronous XHR is deprecated and frozen on the main thread; `execSync` is a hack. +2. **Make every `http_*` builtin in the JS runtime return a `Promise`, and rewrite codegen-js to insert `await` everywhere.** This requires turning every El function that transitively calls a network builtin into an `async fn` in JS. Doable, but invasive. +3. **Explicit `@async` decorator on El functions; codegen-js emits `async function` + `await` for known-async call sites.** This is the approach implemented. -**Decision for this scaffold:** option 3, but only the runtime side is implemented. `http_get` in `el_runtime.js` returns a `Promise`. `codegen-js.el` does NOT yet emit `async`/`await`. Calling `http_get` from compiled El will return a Promise that the El program will treat as a string (which produces `"[object Promise]"`). This is documented and accepted for the scaffold; the compile-time taint pass is a follow-up. +**Decision:** option 3, with an explicit opt-in decorator. `http_get`, `http_post`, `http_post_json`, `http_get_with_headers`, and `http_post_with_headers` in `el_runtime.js` return `Promise`. `codegen-js.el` now emits `await` before calls to these builtins and before calls to any El function decorated `@async`. -For now, programs that don't touch HTTP work correctly. That covers `el-ui/runtime` (which only manipulates the DOM and a graph), most of cgi-studio's pure UI components, and all hello-world style programs. +### How to use async in El (JS target) + +Mark a function with `@async` to declare it as async. Any call to that function from another El function will automatically get `await` in the generated JS. The callee must also be `@async` (or call only non-async code) for the pattern to compose correctly. + +```el +@async +fn fetch_user(id: String) -> String { + http_get("https://api.example.com/users/" + id) +} + +@async +fn main() -> Void { + let body = fetch_user("42") + println(body) +} +``` + +Compiles to: + +```javascript +async function fetch_user(id) { + return await http_get("https://api.example.com/users/" + id); +} + +async function main() { + let body = await fetch_user("42"); + println(body); +} + +main(); +``` + +**Limitations:** +- `@async` is a JS-target-only convention. The C backend ignores the decorator (it calls the synchronous libcurl-backed version). +- Implicit taint propagation (auto-marking all transitive callers) is not implemented. The programmer must explicitly add `@async` to every function in the call chain that reaches an async builtin. +- Forward-reference calls to `@async` functions are handled correctly: codegen-js does a pre-registration pass over all FnDefs before emitting any code. + +For programs that do not touch HTTP, no `@async` annotation is needed and the generated code is identical to before. --- @@ -201,9 +241,9 @@ This is the real-world test. `el-ui/runtime/src/` is currently 5 hand-written `. 1. **Phase 1 — Hello-world** (this scaffold). Done. 2. **Phase 2 — language coverage.** Get codegen-js to ~95% parity with codegen.el for non-network features. Specifically: `match`, struct/enum field access, `?`-propagation, full `for`-over-list, complete unary/binary operators, lexical closures (the C backend doesn't have these but we'll need them for el-ui's component model). -3. **Phase 3 — DOM bridge.** Add `dom_*` builtins to el_runtime.js: `dom_create_element`, `dom_set_text`, `dom_append_child`, `dom_query`, `dom_listen`, etc. These are Node-as-El builtins for the browser; the C backend will add a stub set that errors. Source-shareable El UI code becomes possible. +3. **Phase 3 — DOM bridge.** IMPLEMENTED. `dom_*` builtins added to `el_runtime.js`: full set covering element lookup, text/value/property manipulation, class and style control, event listeners, query selectors, element creation and insertion, and `dom_is_null` for null-guarding. Also added: `window_set`/`window_get` for exposing El functions to the browser global scope, and `native_js`/`native_js_call` escape hatches for calling third-party browser libraries. `codegen-js.el` preamble updated to destructure all new names. Canonical example: `examples/browser-counter.el`. 4. **Phase 4 — Component class lowering.** El doesn't have classes; el-ui's `Component` is a JS class. Decide: extend El with a `component` keyword that compiles to JS class + C struct? Or have el-ui authors define components as `fn render_(state) -> String` and provide a small bootstrap. The latter is the lower-impact path. -5. **Phase 5 — Async taint pass.** Implement compile-time async tracking so `http_get` and friends produce `await fetch()` correctly. Required before authoring code that fetches data. +5. **Phase 5 — Async taint pass.** PARTIALLY IMPLEMENTED. `@async` decorator on El functions causes codegen-js to emit `async function` + `await` at call sites. Known async builtins (`http_get`, `http_post`, `http_post_json`, `http_get_with_headers`, `http_post_with_headers`) also get implicit `await`. Remaining gap: implicit taint propagation — programmers must manually annotate every function in the call chain. Full compile-time taint tracking (automatically marking all transitive callers) is a follow-up. 6. **Phase 6 — Port `el-ui/runtime/`.** Translate the 5 JS files to El, compile to JS, swap in. Run el-ui's existing tests. Iterate. 7. **Phase 7 — Port cgi-studio UI.** Larger surface area; same pattern. 8. **Phase 8 — Marketplace plugins.** Open the door for third-party UI El.