diff --git a/el-compiler/runtime/el_runtime.js b/el-compiler/runtime/el_runtime.js index e5c7a79..a223fdb 100644 --- a/el-compiler/runtime/el_runtime.js +++ b/el-compiler/runtime/el_runtime.js @@ -522,6 +522,328 @@ 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; +} + +// ── Extended DOM API (browser-only) ─────────────────────────────────────── + +function dom_set_attr(el, attr, val) { + _ensureBrowser('dom_set_attr'); + if (el != null) el.setAttribute(String(attr), String(val)); +} + +function dom_get_attr(el, attr) { + _ensureBrowser('dom_get_attr'); + if (el == null) return ''; + return el.getAttribute(String(attr)) ?? ''; +} + +function dom_remove_attr(el, attr) { + _ensureBrowser('dom_remove_attr'); + if (el != null) el.removeAttribute(String(attr)); +} + +function dom_set_html(el, html) { + _ensureBrowser('dom_set_html'); + if (el != null) el.innerHTML = String(html); +} + +function dom_get_html(el) { + _ensureBrowser('dom_get_html'); + return el == null ? '' : String(el.innerHTML ?? ''); +} + +function dom_get_parent(el) { + _ensureBrowser('dom_get_parent'); + return el == null ? null : (el.parentElement ?? null); +} + +function dom_contains_class(el, cls) { + _ensureBrowser('dom_contains_class'); + if (el == null) return false; + return el.classList.contains(String(cls)); +} + +function dom_get_checked(el) { + _ensureBrowser('dom_get_checked'); + return el == null ? false : Boolean(el.checked); +} + +function dom_set_checked(el, val) { + _ensureBrowser('dom_set_checked'); + if (el != null) el.checked = Boolean(val); +} + +// ── Timer API (browser + Node) ───────────────────────────────────────────── + +function set_timeout(ms, cb) { + if (typeof setTimeout === 'undefined') { + throw new Error('set_timeout: setTimeout not available in this environment'); + } + setTimeout(cb, ms | 0); +} + +function set_interval(ms, cb) { + if (typeof setInterval === 'undefined') { + throw new Error('set_interval: setInterval not available in this environment'); + } + return setInterval(cb, ms | 0); +} + +function clear_interval(handle) { + if (typeof clearInterval !== 'undefined') clearInterval(handle); +} + +// ── Local storage (browser-only) ─────────────────────────────────────────── + +function local_storage_get(key) { + _ensureBrowser('local_storage_get'); + return localStorage.getItem(String(key)) ?? ''; +} + +function local_storage_set(key, val) { + _ensureBrowser('local_storage_set'); + localStorage.setItem(String(key), String(val)); +} + +function local_storage_remove(key) { + _ensureBrowser('local_storage_remove'); + localStorage.removeItem(String(key)); +} + +// ── Window location / navigation (browser-only) ──────────────────────────── + +function window_location() { + _ensureBrowser('window_location'); + return window.location.href; +} + +function window_redirect(url) { + _ensureBrowser('window_redirect'); + window.location.href = String(url); +} + +function window_on_load(cb) { + if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', cb); + } else if (typeof window !== 'undefined') { + window.addEventListener('load', cb); + } + // In Node: no-op +} + +// ── console_log (explicit debug log, distinct from println) ──────────────── + +function console_log(msg) { + // eslint-disable-next-line no-console + console.log(String(msg)); +} + +// ── 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; +} + +// ── Promise helpers ──────────────────────────────────────────────────────── +// +// Third-party APIs often return Promises but are not El @async functions. +// These helpers let El programs chain .then / .catch without needing +// native_js, and without requiring the callee to be @async. + +function promise_then(p, cb) { + return Promise.resolve(p).then(cb); +} + +function promise_catch(p, cb) { + return Promise.resolve(p).catch(cb); +} + +function promise_resolve(val) { + return Promise.resolve(val); +} + +function promise_reject(msg) { + return Promise.reject(new Error(String(msg))); +} + +// ── Object / Array utilities ─────────────────────────────────────────────── +// +// Structural operations on Any-typed JS values. These complement the +// El map/list primitives for interop with third-party library objects. + +function object_assign(target, source) { + return Object.assign(Object.assign({}, target), source); +} + +function object_keys(obj) { + if (obj === null || obj === undefined) return []; + return Object.keys(obj); +} + +function object_values(obj) { + if (obj === null || obj === undefined) return []; + return Object.values(obj); +} + +function json_deep_clone(obj) { + if (obj === null || obj === undefined) return null; + return JSON.parse(JSON.stringify(obj)); +} + +function array_from(iterable) { + if (iterable === null || iterable === undefined) return []; + return Array.from(iterable); +} + +function type_of(val) { + return typeof val; +} + +function instanceof_check(val, constructor_name) { + if (typeof globalThis[constructor_name] === 'function') { + return val instanceof globalThis[constructor_name]; + } + return false; +} + +// ── 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 +954,31 @@ 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, + // Extended DOM + dom_set_attr, dom_get_attr, dom_remove_attr, dom_set_html, dom_get_html, + dom_get_parent, dom_contains_class, dom_get_checked, dom_set_checked, + // Timers + set_timeout, set_interval, clear_interval, + // Local storage + local_storage_get, local_storage_set, local_storage_remove, + // Window location + window_location, window_redirect, window_on_load, + // Debug + console_log, + // Window export helpers + window_set, window_get, + // Promise helpers + promise_then, promise_catch, promise_resolve, promise_reject, + // Object / Array utilities + object_assign, object_keys, object_values, json_deep_clone, + array_from, type_of, instanceof_check, + // 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 +1023,27 @@ 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, + // Extended DOM + dom_set_attr, dom_get_attr, dom_remove_attr, dom_set_html, dom_get_html, + dom_get_parent, dom_contains_class, dom_get_checked, dom_set_checked, + // Timers + set_timeout, set_interval, clear_interval, + // Local storage + local_storage_get, local_storage_set, local_storage_remove, + // Window location + window_location, window_redirect, window_on_load, + // Debug + console_log, + // Window / native_js + window_set, window_get, native_js, native_js_call, + // Promise helpers + promise_then, promise_catch, promise_resolve, promise_reject, + // Object / Array utilities + object_assign, object_keys, object_values, json_deep_clone, + array_from, type_of, instanceof_check, }; diff --git a/el-compiler/src/codegen-js.el b/el-compiler/src/codegen-js.el index b6c0b48..876bf9a 100644 --- a/el-compiler/src/codegen-js.el +++ b/el-compiler/src/codegen-js.el @@ -86,6 +86,57 @@ fn js_binop(op: String) -> String { op } +// ── Known El runtime method names ───────────────────────────────────────────── +// +// These are the method shortforms exported by el_runtime.js and used by the +// El C-backend convention of `obj.method(args)` -> `method(obj, args)`. +// Any method name NOT in this set is treated as a native JS method call on the +// receiver object, emitting `obj.method(args)` directly. +// +// This is the mechanism that makes `client.auth.signInWithOtp(payload)` work +// without `native_js_call`: the receiver is Any-typed, the method is unknown +// to El, so codegen emits the JS call directly. + +fn js_is_el_method(name: String) -> Bool { + if str_eq(name, "append") { return true } + if str_eq(name, "len") { return true } + if str_eq(name, "get") { return true } + if str_eq(name, "map_get") { return true } + if str_eq(name, "map_set") { return true } + false +} + +// ── 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,20 +428,38 @@ 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" { - // El's `obj.method(args)` becomes `method(obj, args)` — same - // convention as the C backend. The runtime exports method - // shortforms (append, len, get, map_get, map_set) that match. let obj = func["object"] let field: String = func["field"] let obj_c: String = js_cg_expr(obj) - if arity > 0 { - return field + "(" + obj_c + ", " + args_c + ")" + // If the method is a known El runtime shortform, keep the El + // convention: `method(obj, args)`. This preserves backward + // compatibility with list.append(x), map.map_get(k), etc. + if js_is_el_method(field) { + if arity > 0 { + return field + "(" + obj_c + ", " + args_c + ")" + } + return field + "(" + obj_c + ")" } - return field + "(" + obj_c + ")" + // Unknown method — emit as a native JS method call on the + // receiver. This handles Any-typed values (third-party library + // objects, DOM elements, Promises, etc.) without requiring + // native_js_call. Example: `client.auth.signInWithOtp(payload)` + // emits `client["auth"].signInWithOtp(args_c)`. + if arity > 0 { + return obj_c + "." + field + "(" + args_c + ")" + } + return obj_c + "." + field + "()" } let fn_c: String = js_cg_expr(func) @@ -398,22 +467,39 @@ fn js_cg_expr(expr: Map) -> String { } if kind == "Field" { - // El's `obj.foo` becomes JS `obj["foo"]` — works on plain objects - // (maps) and on JS objects with prototype. el_get_field is a - // runtime helper for callers that want EL_NULL on missing keys. + // El's `obj.foo` becomes JS `obj["foo"]` — direct bracket access. + // This works for plain El map objects AND for real JS objects with + // prototype-inherited properties (DOM elements, third-party library + // objects, Promises, etc.). el_get_field used hasOwnProperty which + // silently returned null for inherited props, breaking e.g. client.auth. + // + // Nil-propagation: `obj?.foo` emits `(obj)?.["foo"] ?? null`. let obj = expr["object"] let field: String = expr["field"] + let obj_kind: String = obj["expr"] + if str_eq(obj_kind, "Try") { + let inner = obj["inner"] + let inner_c: String = js_cg_expr(inner) + return "(" + inner_c + ")?.[" + js_str_lit(field) + "] ?? null" + } let obj_c: String = js_cg_expr(obj) - return "el_get_field(" + obj_c + ", " + js_str_lit(field) + ")" + return obj_c + "[" + js_str_lit(field) + "]" } if kind == "Index" { // Map vs list dispatch on the index expression kind, same as C. + // If the object is a Try (nil-propagation), use JS optional indexing. let obj = expr["object"] let idx = expr["index"] let obj_c: String = js_cg_expr(obj) let idx_c: String = js_cg_expr(idx) let idx_kind: String = idx["expr"] + let obj_kind: String = obj["expr"] + if str_eq(obj_kind, "Try") { + let inner = obj["inner"] + let inner_c: String = js_cg_expr(inner) + return "(" + inner_c + ")?.[" + idx_c + "] ?? null" + } if str_eq(idx_kind, "Str") { return "el_get_field(" + obj_c + ", " + idx_c + ")" } @@ -453,6 +539,12 @@ fn js_cg_expr(expr: Map) -> String { } if kind == "Try" { + // Postfix `?` — nil-propagation guard. + // When used as `expr?.field` the Field handler above intercepts and + // emits `(expr)?.["field"]`. Here, a bare `expr?` (not followed by + // field/index access) passes through to the inner expression unchanged + // (it acts as an identity but marks the value as "nil-propagating" for + // its caller). This matches the C backend's current behavior. let inner = expr["inner"] return js_cg_expr(inner) } @@ -470,6 +562,13 @@ fn js_cg_expr(expr: Map) -> String { return js_cg_match(expr) } + // Lambda (anonymous function literal): fn(params) -> RetType { body } + // Emitted as a JS arrow function expression: (params) => { body }. + // Used for inline callbacks: dom_listen(el, "click", fn(e: Any) -> Void { ... }) + if kind == "Lambda" { + return js_cg_lambda(expr) + } + "null" } @@ -528,8 +627,16 @@ fn js_cg_match(expr: Map) -> String { if str_eq(v, "true") { let bv = "true" } let parts = native_list_append(parts, "if (" + subj_var + " === " + bv + ") return (" + body_c + "); ") } else { - // unknown pattern → wildcard - let parts = native_list_append(parts, "return (" + body_c + "); ") + if str_eq(pkind, "Variant") { + // Enum::Variant patterns — El enums compile to plain + // strings (the variant name) or ints. Match the subject + // against the variant name string. + let variant: String = pat["variant"] + let parts = native_list_append(parts, "if (str_eq(" + subj_var + ", " + js_str_lit(variant) + ")) return (" + body_c + "); ") + } else { + // unknown pattern → wildcard + let parts = native_list_append(parts, "return (" + body_c + "); ") + } } } } @@ -541,6 +648,65 @@ fn js_cg_match(expr: Map) -> String { str_join(parts, "") } +// ── Lambda codegen ──────────────────────────────────────────────────────────── +// +// Anonymous function literals: fn(params) -> RetType { body } +// +// Strategy: emit the lambda as a hoisted JS function declaration with a +// generated name (__lambda_N), then return the name as the expression value. +// This works because JS function declarations are hoisted within their scope, +// so the generated name is valid at any use site within the same function or +// module. The emitted code looks like: +// +// function __lambda_1(event) { dom_hide(spinner); } +// ... +// dom_listen(btn, "click", __lambda_1); +// +// This approach is clean, debuggable, and avoids any need for a string-buffer +// mode in the codegen. + +fn js_next_lambda_id() -> String { + let csv: String = state_get("__js_lambda_counter") + let n = 0 + if !str_eq(csv, "") { + let n = str_to_int(csv) + } + let n = n + 1 + state_set("__js_lambda_counter", native_int_to_str(n)) + native_int_to_str(n) +} + +fn js_cg_lambda(expr: Map) -> String { + let params = expr["params"] + let body = expr["body"] + let ret_type: String = expr["ret_type"] + let id: String = js_next_lambda_id() + let lambda_name: String = "__lambda_" + id + let params_str: String = js_params_str(params) + // Emit the function definition immediately into the output stream. + // It will appear before the statement containing this expression. + js_emit_line("function " + lambda_name + "(" + params_str + ") {") + let decl = native_list_empty() + let np: Int = native_list_len(params) + let pi = 0 + while pi < np { + let param = native_list_get(params, pi) + let pname: String = param["name"] + let decl = native_list_append(decl, pname) + let pi = pi + 1 + } + let body_xformed = body + if !str_eq(ret_type, "Void") { + let body_xformed = js_transform_implicit_return(body) + } + js_build_int_names_for_params(params) + js_cg_stmts(body_xformed, " ", decl) + js_emit_line("}") + js_emit_blank() + // Return the function name as the expression value. + lambda_name +} + // ── Variable scope tracking ─────────────────────────────────────────────────── // // El allows `let x = ...` to redeclare in the same scope. JS would throw @@ -646,6 +812,27 @@ fn js_cg_stmt(stmt: Map, indent: String, declared: [String]) -> [St if kind == "TypeDef" { return declared } if kind == "EnumDef" { return declared } if kind == "Import" { return declared } + + if kind == "TryCatch" { + let try_body = stmt["try_body"] + let catch_name: String = stmt["catch_name"] + let catch_body = stmt["catch_body"] + js_emit_line(indent + "try {") + js_cg_stmts(try_body, indent + " ", native_list_clone(declared)) + js_emit_line(indent + "} catch (" + catch_name + ") {") + js_cg_stmts(catch_body, indent + " ", native_list_clone(declared)) + js_emit_line(indent + "}") + return declared + } + + // ExternFn: the function exists in the JS environment (loaded via