add DOM bridge, async/await, window export, and native_js to JS target

- el_runtime.js: add 19 dom_* builtins (browser-only, throw in Node),
  window_set/window_get for exposing El functions to the browser global
  scope, and native_js/native_js_call escape hatches for third-party libs
- codegen-js.el: destructure all new builtins in generated preamble; add
  @async decorator support that emits async function + await at call sites
  for known-async HTTP builtins and user-declared @async functions; pre-
  registration pass ensures forward calls to @async functions get await
- spec/codegen-js.md: mark Phase 3 (DOM bridge) implemented, document
  @async approach and its limitations, update builtin table and status
- examples/browser-counter.el: canonical example showing dom_get_element,
  dom_set_text, dom_is_null, window_set, and state_set/get
This commit is contained in:
Will Anderson
2026-05-04 10:29:43 -05:00
parent 1ed2dc3c11
commit a54b2bebf9
4 changed files with 335 additions and 17 deletions
+83 -8
View File
@@ -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<T> 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, Any>) -> 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<String, Any>) -> 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<String, Any>], 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<String, Any>], 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) {