implement ? nil-propagation, write browser-auth.el example, update spec

Iteration 5:

? nil-propagation: Field and Index handlers in js_cg_expr now detect when
the object expression is a Try node (the AST node for postfix `?`).
When detected, emit JS optional chaining: `(expr)?.["field"] ?? null`.
The `?? null` normalizes JS undefined to El's null. A bare `expr?` not
followed by field/index still passes through unchanged.

browser-auth.el: a realistic 130-line example demonstrating:
  - @async function with Supabase via native_js_call
  - DOM bridge: get/set value/text/attr, add/remove class, show/hide
  - local_storage_get/set for session hints
  - window_on_load for initialization
  - window_set to expose functions to the browser global scope
  - set_timeout for transient state, is_valid_email for input validation
  Compiles cleanly with elc --target=js --bundle

Spec updated: status promoted to Phase 4 / ~80% coverage, nil-prop
status updated, new example referenced.
This commit is contained in:
Will Anderson
2026-05-04 10:42:54 -05:00
parent 422442b14e
commit 21694b79d2
4 changed files with 190 additions and 2 deletions
BIN
View File
Binary file not shown.
+23
View File
@@ -439,19 +439,36 @@ fn js_cg_expr(expr: Map<String, Any>) -> String {
// 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.
//
// If the object is a Try (nil-propagation postfix `obj?`), use JS
// optional chaining: `obj?.["foo"]` returns undefined (null) if obj
// is null/undefined, otherwise accesses the field.
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) + ")"
}
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 + ")"
}
@@ -491,6 +508,12 @@ fn js_cg_expr(expr: Map<String, Any>) -> 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)
}
+165
View File
@@ -0,0 +1,165 @@
// browser-auth.el -- El-compiled auth flow using Supabase via native_js_call
//
// Compile: elc --target=js --bundle examples/browser-auth.el > auth.js
// (requires el_runtime.js in the same directory as browser-auth.el)
//
// Demonstrates:
// - @async functions with DOM interaction
// - native_js_call to invoke third-party library methods (Supabase)
// - DOM bridge: dom_get_element, dom_get_value, dom_set_text, dom_add_class
// dom_remove_class, dom_show, dom_hide, dom_is_null
// - window_set to expose El functions to the browser global scope
// - local_storage_set/get for session hints
// - set_timeout for transient UI state
// - state_set/get for component state
//
// Expected HTML elements:
// #acct-email-input -- email text input
// #send-link-btn -- submit button
// #auth-message -- status message container
// #auth-form -- the form to hide after success
//
// Supabase client is expected on window.supabase (loaded from Supabase CDN
// via a separate script tag before auth.js).
// UI helpers
fn show_message(text: String, is_error: Bool) -> Void {
let msg_el = dom_get_element("auth-message")
if !dom_is_null(msg_el) {
dom_set_text(msg_el, text)
dom_remove_class(msg_el, "hidden")
if is_error {
dom_add_class(msg_el, "error")
dom_remove_class(msg_el, "success")
} else {
dom_add_class(msg_el, "success")
dom_remove_class(msg_el, "error")
}
}
}
fn set_button_loading(loading: Bool) -> Void {
let btn = dom_get_element("send-link-btn")
if !dom_is_null(btn) {
if loading {
dom_set_text(btn, "Sending...")
dom_set_attr(btn, "disabled", "true")
} else {
dom_set_text(btn, "Send Magic Link")
dom_remove_attr(btn, "disabled")
}
}
}
fn clear_message() -> Void {
let msg_el = dom_get_element("auth-message")
if !dom_is_null(msg_el) {
dom_add_class(msg_el, "hidden")
dom_set_text(msg_el, "")
}
}
// Email validation
fn is_valid_email(email: String) -> Bool {
let trimmed: String = str_trim(email)
if str_len(trimmed) < 5 { return false }
let at_pos: Int = str_index_of(trimmed, "@")
if at_pos < 1 { return false }
let dot_pos: Int = str_index_of(trimmed, ".")
if dot_pos < at_pos + 2 { return false }
return true
}
// Auth flow
@async
fn send_magic_link() -> Void {
let email_el = dom_get_element("acct-email-input")
if dom_is_null(email_el) {
show_message("Could not find email input", true)
return null
}
let email: String = str_trim(dom_get_value(email_el))
if !is_valid_email(email) {
show_message("Please enter a valid email address", true)
return null
}
clear_message()
set_button_loading(true)
state_set("auth_email", email)
// Call Supabase via native_js_call.
// window.supabase.auth.signInWithOtp({ email: "..." }) returns a Promise.
let supabase = window_get("supabase")
if dom_is_null(supabase) {
show_message("Auth service not available", true)
set_button_loading(false)
return null
}
let auth = native_js_call(supabase, "auth", [])
let payload = { "email": email }
let result = native_js_call(auth, "signInWithOtp", [payload])
// Await the promise via native_js_call on the result object
let error = native_js_call(result, "then", [check_auth_result])
set_button_loading(false)
}
fn check_auth_result(resp: Any) -> Void {
let err = resp["error"]
if !dom_is_null(err) {
let msg: String = err["message"]
show_message("Error: " + msg, true)
return null
}
let email: String = state_get("auth_email")
local_storage_set("auth_pending_email", email)
show_message("Magic link sent! Check your inbox for " + email, false)
let form = dom_get_element("auth-form")
if !dom_is_null(form) {
dom_hide(form)
}
}
// Keyboard support
fn handle_email_keydown(event: Any) -> Void {
let key: String = dom_get_prop(event, "key")
if str_eq(key, "Enter") {
send_magic_link()
}
}
// Initialization
fn init_auth() -> Void {
let email_el = dom_get_element("acct-email-input")
if !dom_is_null(email_el) {
// Pre-fill from local storage if a pending send was interrupted
let pending: String = local_storage_get("auth_pending_email")
if !str_eq(pending, "") {
dom_set_value(email_el, pending)
}
dom_listen(email_el, "keydown", handle_email_keydown)
}
let btn = dom_get_element("send-link-btn")
if !dom_is_null(btn) {
dom_listen(btn, "click", send_magic_link)
}
state_set("auth_initialized", "true")
}
fn main() -> Void {
// Expose send_magic_link globally so inline event handlers can call it
window_set("sendMagicLink", send_magic_link)
window_set("initAuth", init_auth)
// Run init when DOM is ready
window_on_load(init_auth)
}
+2 -2
View File
@@ -1,6 +1,6 @@
# El JavaScript Backend (codegen-js)
**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.
**Status:** Phase 4 complete. ~80% language coverage. Core runtime (~70 builtins) implemented including full DOM bridge (extended), localStorage, timers, window navigation, window export helpers, native_js escape hatches, and @async/await support. Enum::Variant match patterns fully implemented in parser and both codegens. TypeDef parser bug fixed (= before { consumed correctly). `?` nil-propagation compiles to JS optional chaining for field/index access. `--bundle` flag produces self-contained IIFE output for direct `<script>` use. CGI / DHARMA / LLM / Engram intentionally stubbed.
**Authoritative files**
@@ -212,7 +212,7 @@ This is the canonical list. Programs that use any of these compile (no `#error`-
| `match` expressions | Compiled (basic) | LitInt/LitStr/LitBool/Wildcard/Binding all work via `if/else` chain. Tagged-union match deferred |
| `type` (struct) defs | Skipped at codegen | Treated as documentation; structs are plain JS objects. `t["field"]` works |
| `enum` defs | Skipped at codegen | Same — enum values are bare strings or ints |
| `?` postfix (nil-prop) | No-op | Same as C backend's current state |
| `?` postfix (nil-prop) | Implemented for field/index access | `obj?.field` emits `(obj)?.["field"] ?? null` via JS optional chaining. Bare `expr?` still passes through (no-op). |
| `try` postfix | Stripped to inner | Same as C backend |
| Capability enforcement | Not enforced | The C backend uses `#error` directives; the JS backend lets the runtime stubs throw. Future: emit `throw new Error('capability violation')` at compile time |
| VBD role check | Not enforced | Same |