feat: extract JS browser runtime from feat/js-browser-runtime
- Update el-compiler/src/codegen-js.el to Phase 5 (1245 lines, up from 926) Adds: lambda literals, try/catch, extern fn, JS method call, Promise helpers, Object/Array utils, URL import declarations - Update el-compiler/runtime/el_runtime.js (1049 lines, up from 679) - Add examples/browser-counter.el, examples/browser-auth.el - Update spec/codegen-js.md to Phase 5 status - Update el-compiler/src/compiler.el: add --bundle, --minify, --obfuscate flags, bundled IIFE mode, terser/javascript-obfuscator post-processing pipeline - No lexer.el or parser.el taken from this branch
This commit is contained in:
+367
-48
@@ -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<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,20 +428,38 @@ 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" {
|
||||
// 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, Any>) -> 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, 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)
|
||||
}
|
||||
@@ -470,6 +562,13 @@ fn js_cg_expr(expr: Map<String, Any>) -> 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, Any>) -> 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, Any>) -> 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, Any>) -> 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<String, Any>, 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 <script>
|
||||
// tag or the module context). Emit a comment so the generated file is
|
||||
// self-documenting, but no JS function body — the implementation is external.
|
||||
if kind == "ExternFn" {
|
||||
let ename: String = stmt["name"]
|
||||
js_emit_line(indent + "// extern fn " + ename + " — provided by the JS environment")
|
||||
return declared
|
||||
}
|
||||
if kind == "CgiBlock" {
|
||||
// CGI blocks compile to a no-op + warning comment in JS target.
|
||||
// The runtime cgi identity is server-side; UI code is not a CGI
|
||||
@@ -785,16 +972,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()
|
||||
@@ -830,50 +1031,133 @@ fn js_is_top_level_decl(stmt: Map<String, Any>) -> Bool {
|
||||
if kind == "Import" { return true }
|
||||
if kind == "CgiBlock" { return true }
|
||||
if kind == "ServiceBlock" { return true }
|
||||
if kind == "ExternFn" { return true }
|
||||
false
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn codegen_js(stmts: [Map<String, Any>], source: String) -> String {
|
||||
codegen_js_inner(stmts, source, false, "")
|
||||
}
|
||||
|
||||
fn codegen_js_bundle(stmts: [Map<String, Any>], source: String, runtime_content: String) -> String {
|
||||
codegen_js_inner(stmts, source, true, runtime_content)
|
||||
}
|
||||
|
||||
fn codegen_js_inner(stmts: [Map<String, Any>], source: String, bundle_mode: Bool, runtime_content: String) -> String {
|
||||
// Reset per-compile state.
|
||||
state_set("__js_int_names", "")
|
||||
state_set("__js_match_counter", "")
|
||||
state_set("__js_async_fns", "")
|
||||
state_set("__js_lambda_counter", "")
|
||||
|
||||
// Preamble: inline the runtime via a single import that side-effects
|
||||
// globalThis. The runtime path is resolved relative to the generated
|
||||
// output; users running `elc --target=js` are responsible for ensuring
|
||||
// el_runtime.js is reachable. For self-contained output, the runtime
|
||||
// could be inlined; that is a follow-up.
|
||||
// Preamble: in bundle mode, inline the runtime and wrap in IIFE.
|
||||
// In module mode, emit a single import that side-effects globalThis.
|
||||
js_emit_line("// Generated by elc --target=js")
|
||||
js_emit_line("// Runtime: foundation/el/el-compiler/runtime/el_runtime.js")
|
||||
js_emit_line("import \"./el_runtime.js\";")
|
||||
js_emit_line("const {")
|
||||
js_emit_line(" println, print, el_str_concat, str_concat, str_eq, str_starts_with, str_ends_with,")
|
||||
js_emit_line(" str_len, int_to_str, str_to_int, str_slice, str_contains, str_replace,")
|
||||
js_emit_line(" str_to_upper, str_to_lower, str_trim, str_index_of, str_split, str_char_at,")
|
||||
js_emit_line(" str_char_code, str_lower, str_upper, el_abs, el_max, el_min,")
|
||||
js_emit_line(" el_list_new, el_list_len, el_list_get, el_list_append, el_list_empty, el_list_clone,")
|
||||
js_emit_line(" list_push, list_join, list_range,")
|
||||
js_emit_line(" el_map_new, el_get_field, el_map_get, el_map_set,")
|
||||
js_emit_line(" http_get, http_post, http_post_json,")
|
||||
js_emit_line(" fs_read, fs_write, fs_list,")
|
||||
js_emit_line(" json_parse, json_stringify, json_get, json_get_string, json_get_int,")
|
||||
js_emit_line(" time_now, time_now_utc, sleep_ms, bool_to_str, exit_program,")
|
||||
js_emit_line(" el_retain, el_release,")
|
||||
js_emit_line(" append, len, get, map_get, map_set,")
|
||||
js_emit_line(" native_list_get, native_list_len, native_list_append, native_list_empty,")
|
||||
js_emit_line(" native_list_clone, native_string_chars, native_int_to_str,")
|
||||
js_emit_line(" args, state_set, state_get, state_del, state_keys, env,")
|
||||
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("} = globalThis.__el;")
|
||||
if bundle_mode {
|
||||
js_emit_line("// Bundle mode: runtime inlined, no import statement needed.")
|
||||
js_emit_line("// Drop directly into a <script> tag.")
|
||||
js_emit_line(";(function() {")
|
||||
js_emit_line("\"use strict\";")
|
||||
// Inline the runtime content verbatim (already read from el_runtime.js).
|
||||
// Strip the final ES export lines -- they use `export` syntax which is
|
||||
// not valid inside an IIFE. The globalThis.__el assignment is what matters.
|
||||
js_emit_line(js_strip_es_exports(runtime_content))
|
||||
js_emit_line("")
|
||||
} else {
|
||||
js_emit_line("// Runtime: foundation/el/el-compiler/runtime/el_runtime.js")
|
||||
js_emit_line("import \"./el_runtime.js\";")
|
||||
}
|
||||
// In module mode: destructure all builtins off globalThis.__el so call
|
||||
// sites stay flat (println(x) not el.println(x)).
|
||||
// In bundle mode: function declarations from the inlined runtime are
|
||||
// already in scope within the IIFE -- no destructure needed.
|
||||
if !bundle_mode {
|
||||
js_emit_line("const {")
|
||||
js_emit_line(" println, print, el_str_concat, str_concat, str_eq, str_starts_with, str_ends_with,")
|
||||
js_emit_line(" str_len, int_to_str, str_to_int, str_slice, str_contains, str_replace,")
|
||||
js_emit_line(" str_to_upper, str_to_lower, str_trim, str_index_of, str_split, str_char_at,")
|
||||
js_emit_line(" str_char_code, str_lower, str_upper, el_abs, el_max, el_min,")
|
||||
js_emit_line(" el_list_new, el_list_len, el_list_get, el_list_append, el_list_empty, el_list_clone,")
|
||||
js_emit_line(" list_push, list_join, list_range,")
|
||||
js_emit_line(" el_map_new, el_get_field, el_map_get, el_map_set,")
|
||||
js_emit_line(" http_get, http_post, http_post_json,")
|
||||
js_emit_line(" fs_read, fs_write, fs_list,")
|
||||
js_emit_line(" json_parse, json_stringify, json_get, json_get_string, json_get_int,")
|
||||
js_emit_line(" time_now, time_now_utc, sleep_ms, bool_to_str, exit_program,")
|
||||
js_emit_line(" el_retain, el_release,")
|
||||
js_emit_line(" append, len, get, map_get, map_set,")
|
||||
js_emit_line(" native_list_get, native_list_len, native_list_append, native_list_empty,")
|
||||
js_emit_line(" native_list_clone, native_string_chars, native_int_to_str,")
|
||||
js_emit_line(" args, state_set, state_get, state_del, state_keys, env,")
|
||||
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(" dom_set_attr, dom_get_attr, dom_remove_attr, dom_set_html, dom_get_html,")
|
||||
js_emit_line(" dom_get_parent, dom_contains_class, dom_get_checked, dom_set_checked,")
|
||||
js_emit_line(" set_timeout, set_interval, clear_interval,")
|
||||
js_emit_line(" local_storage_get, local_storage_set, local_storage_remove,")
|
||||
js_emit_line(" window_location, window_redirect, window_on_load,")
|
||||
js_emit_line(" console_log,")
|
||||
js_emit_line(" window_set, window_get, native_js, native_js_call,")
|
||||
js_emit_line(" promise_then, promise_catch, promise_resolve, promise_reject,")
|
||||
js_emit_line(" object_assign, object_keys, object_values, json_deep_clone,")
|
||||
js_emit_line(" array_from, type_of, instanceof_check,")
|
||||
js_emit_line("} = globalThis.__el;")
|
||||
js_emit_blank()
|
||||
}
|
||||
|
||||
// URL import pass: emit `import "url"` (module mode) or a comment
|
||||
// (bundle mode) for any import whose path starts with http(s):// or
|
||||
// doesn't end in .el (i.e., it's a JS/CSS/CDN import, not an El source
|
||||
// import which was already inlined by resolve_imports).
|
||||
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, "Import") {
|
||||
let ipath: String = stmt["path"]
|
||||
let is_url = str_starts_with(ipath, "http://")
|
||||
let is_url = is_url || str_starts_with(ipath, "https://")
|
||||
let is_js = !str_ends_with(ipath, ".el")
|
||||
if is_url || is_js {
|
||||
if bundle_mode {
|
||||
js_emit_line("// external: " + ipath)
|
||||
} else {
|
||||
js_emit_line("import " + js_str_lit(ipath) + ";")
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
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) {
|
||||
@@ -921,6 +1205,41 @@ fn codegen_js(stmts: [Map<String, Any>], source: String) -> String {
|
||||
js_emit_line("main();")
|
||||
}
|
||||
|
||||
// Close IIFE in bundle mode.
|
||||
if bundle_mode {
|
||||
js_emit_line("")
|
||||
js_emit_line("})();")
|
||||
}
|
||||
|
||||
// Return empty string — output was streamed via println
|
||||
""
|
||||
}
|
||||
|
||||
// Strip ES module export statements from runtime content for IIFE embedding.
|
||||
// The runtime ends with `export { ... }` and `export { __el as default }` lines
|
||||
// that are invalid inside an IIFE. We strip everything from the first top-level
|
||||
// `export {` line onward.
|
||||
//
|
||||
// Also strips `import` statements at the top if any (though el_runtime.js has none).
|
||||
fn js_strip_es_exports(content: String) -> String {
|
||||
let lines: [String] = str_split(content, "\n")
|
||||
let n: Int = native_list_len(lines)
|
||||
let out: [String] = native_list_empty()
|
||||
let i = 0
|
||||
while i < n {
|
||||
let line: String = native_list_get(lines, i)
|
||||
let trimmed: String = str_trim(line)
|
||||
// Stop at top-level `export {` or `export default`
|
||||
if str_starts_with(trimmed, "export {") {
|
||||
let i = n
|
||||
} else {
|
||||
if str_starts_with(trimmed, "export default") {
|
||||
let i = n
|
||||
} else {
|
||||
let out = native_list_append(out, line)
|
||||
}
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
str_join(out, "\n")
|
||||
}
|
||||
|
||||
+251
-7
@@ -29,7 +29,7 @@ fn compile(source: String) -> String {
|
||||
codegen(stmts, source)
|
||||
}
|
||||
|
||||
// compile_js — full pipeline (JS target): source string -> JS source string
|
||||
// compile_js — full pipeline (JS target, module mode): source string -> JS source string
|
||||
fn compile_js(source: String) -> String {
|
||||
let tokens: [Map<String, Any>] = lex(source)
|
||||
let stmts: [Map<String, Any>] = parse(tokens)
|
||||
@@ -38,6 +38,20 @@ fn compile_js(source: String) -> String {
|
||||
codegen_js(stmts, source)
|
||||
}
|
||||
|
||||
// compile_js_with_bundle — JS target in bundle mode.
|
||||
// Reads el_runtime.js from runtime_path and inlines it inside an IIFE.
|
||||
fn compile_js_with_bundle(source: String, runtime_path: String) -> String {
|
||||
let tokens: [Map<String, Any>] = lex(source)
|
||||
let stmts: [Map<String, Any>] = parse(tokens)
|
||||
el_release(tokens)
|
||||
let runtime_content: String = fs_read(runtime_path)
|
||||
if str_eq(runtime_content, "") {
|
||||
println("el-compiler: warning: --bundle: could not read runtime at " + runtime_path)
|
||||
println("el-compiler: warning: bundle output will be incomplete")
|
||||
}
|
||||
codegen_js_bundle(stmts, source, runtime_content)
|
||||
}
|
||||
|
||||
// compile_dispatch — pick a backend based on the requested target.
|
||||
// tgt = "c" | "js"
|
||||
// (The parameter is named `tgt` because `target` is a reserved keyword
|
||||
@@ -48,6 +62,12 @@ fn compile_dispatch(tgt: String, source: String) -> String {
|
||||
compile(source)
|
||||
}
|
||||
|
||||
// compile_dispatch_bundle — like compile_dispatch but bundle mode for JS.
|
||||
fn compile_dispatch_bundle(tgt: String, source: String, runtime_path: String) -> String {
|
||||
if str_eq(tgt, "js") { return compile_js_with_bundle(source, runtime_path) }
|
||||
compile(source)
|
||||
}
|
||||
|
||||
// Detect a `--target=<lang>` flag in argv and return the target.
|
||||
// Returns "c" if none specified or unrecognized.
|
||||
fn detect_target(argv: [String]) -> String {
|
||||
@@ -91,6 +111,126 @@ fn detect_emit_header(argv: [String]) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Detect --bundle flag in argv.
|
||||
fn detect_bundle(argv: [String]) -> Bool {
|
||||
let n: Int = native_list_len(argv)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let a: String = native_list_get(argv, i)
|
||||
if str_eq(a, "--bundle") { return true }
|
||||
let i = i + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detect --minify flag in argv.
|
||||
fn detect_minify(argv: [String]) -> Bool {
|
||||
let n: Int = native_list_len(argv)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let a: String = native_list_get(argv, i)
|
||||
if str_eq(a, "--minify") { return true }
|
||||
let i = i + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detect --obfuscate flag in argv.
|
||||
fn detect_obfuscate(argv: [String]) -> Bool {
|
||||
let n: Int = native_list_len(argv)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let a: String = native_list_get(argv, i)
|
||||
if str_eq(a, "--obfuscate") { return true }
|
||||
let i = i + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Build a unique temp file path: /tmp/elc-<pid>-<timestamp>.<suffix>
|
||||
fn make_temp_path(suffix: String) -> String {
|
||||
let pid: Int = getpid_now()
|
||||
let ts: Int = time_now()
|
||||
"/tmp/elc-" + native_int_to_str(pid) + "-" + native_int_to_str(ts) + "." + suffix
|
||||
}
|
||||
|
||||
// Reserved globals that terser and javascript-obfuscator must not mangle.
|
||||
// These are referenced from HTML onclick= attributes and other direct window usage.
|
||||
fn js_reserved_names() -> String {
|
||||
"neuronDemoToggle,neuronDemoSend,neuronDemoReset,signInWith,signInWithEmail,signUpWithEmail,sendMagicLink,signOut,resetPassword,sendResetEmail,updatePassword,showSignIn,showSignUp,hideReset,setSort,addFamilyMember,removeFamilyMember,copyForPlatform,entHeadcountChange,NEURON_CFG"
|
||||
}
|
||||
|
||||
// Find a CLI tool by checking node_modules paths first, then falling back to npx.
|
||||
// src_dir is the directory of the source file being compiled.
|
||||
// Returns the command string to invoke the tool, or "" if not found.
|
||||
fn find_node_tool(tool_name: String, src_dir: String) -> String {
|
||||
// 1. Check ./node_modules/.bin/<tool> relative to source file
|
||||
let cand1: String = src_dir + "/node_modules/.bin/" + tool_name
|
||||
let check1: String = str_trim(exec_capture("test -x " + cand1 + " && echo yes 2>/dev/null"))
|
||||
if str_eq(check1, "yes") { return cand1 }
|
||||
// 2. Check ../node_modules/.bin/<tool> (monorepo layout)
|
||||
let parent_dir: String = dirname_of(src_dir)
|
||||
let cand2: String = parent_dir + "/node_modules/.bin/" + tool_name
|
||||
let check2: String = str_trim(exec_capture("test -x " + cand2 + " && echo yes 2>/dev/null"))
|
||||
if str_eq(check2, "yes") { return cand2 }
|
||||
// 3. Fall back to npx if it is on PATH. npx will use the globally cached
|
||||
// package or download on first use. Use --no to avoid auto-install if
|
||||
// the package is not already cached; if that fails, try with --yes.
|
||||
let npx_path: String = str_trim(exec_capture("which npx 2>/dev/null"))
|
||||
if !str_eq(npx_path, "") { return "npx --yes " + tool_name }
|
||||
return ""
|
||||
}
|
||||
|
||||
// apply_minify — run terser on js_path, write result to out_path.
|
||||
// Returns true on success, false on failure.
|
||||
fn apply_minify(js_path: String, out_path: String, src_dir: String) -> Bool {
|
||||
let terser: String = find_node_tool("terser", src_dir)
|
||||
if str_eq(terser, "") {
|
||||
println("el-compiler: error: terser not found. Run 'npm install terser' in your project directory.")
|
||||
return false
|
||||
}
|
||||
let names: String = js_reserved_names()
|
||||
// Single-quote the mangle reserved list so the shell does not glob-expand
|
||||
// the bracket expression. The compress options are safe without quoting.
|
||||
let compress_opts: String = "passes=2,drop_console=false,drop_debugger=true"
|
||||
let mangle_reserved: String = "'reserved=[" + names + "]'"
|
||||
let cmd: String = terser + " " + js_path + " --compress " + compress_opts + " --mangle " + mangle_reserved + " --output " + out_path
|
||||
let ret: Int = exec_command(cmd)
|
||||
if ret == 0 { return true }
|
||||
println("el-compiler: error: terser failed (exit " + native_int_to_str(ret) + ")")
|
||||
return false
|
||||
}
|
||||
|
||||
// apply_obfuscate — run javascript-obfuscator on js_path, write result to out_path.
|
||||
// Returns true on success, false on failure.
|
||||
fn apply_obfuscate(js_path: String, out_path: String, src_dir: String) -> Bool {
|
||||
let obfuscator: String = find_node_tool("javascript-obfuscator", src_dir)
|
||||
if str_eq(obfuscator, "") {
|
||||
println("el-compiler: error: javascript-obfuscator not found. Run 'npm install javascript-obfuscator' in your project directory.")
|
||||
return false
|
||||
}
|
||||
let names: String = js_reserved_names()
|
||||
let cmd: String = obfuscator + " " + js_path + " --output " + out_path + " --compact true --simplify true --string-array true --string-array-encoding base64 --string-array-threshold 0.75 --identifier-names-generator hexadecimal --rename-globals false --self-defending false --reserved-names " + names
|
||||
let ret: Int = exec_command(cmd)
|
||||
if ret == 0 { return true }
|
||||
println("el-compiler: error: javascript-obfuscator failed (exit " + native_int_to_str(ret) + ")")
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve the runtime path for --bundle mode.
|
||||
// Looks for el_runtime.js next to the source file first;
|
||||
// if not found there, looks next to the elc binary itself.
|
||||
// Returns "" if not found anywhere (caller emits a warning).
|
||||
fn resolve_runtime_path(src_path: String) -> String {
|
||||
let src_dir: String = dirname_of(src_path)
|
||||
let candidate: String = src_dir + "/el_runtime.js"
|
||||
let existing: String = fs_read(candidate)
|
||||
if !str_eq(existing, "") {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Reconstruct an El type annotation string from a parsed type node.
|
||||
fn type_node_to_el(t: Map<String, Any>) -> String {
|
||||
let k: String = t["kind"]
|
||||
@@ -249,12 +389,83 @@ fn resolve_imports(src_path: String) -> String {
|
||||
return str_join(prefix_chunks, "") + str_join(body_chunks, "")
|
||||
}
|
||||
|
||||
// run_with_postprocess — codegen + minify + optional obfuscate pipeline.
|
||||
//
|
||||
// Called from main() when --minify or --obfuscate is active. Redirects stdout
|
||||
// to a temp file during codegen so the output can be passed through the
|
||||
// external tools (terser, javascript-obfuscator) before final emission.
|
||||
//
|
||||
// Pipeline: codegen -> terser -> (javascript-obfuscator) -> stdout or file
|
||||
fn run_with_postprocess(tgt: String, source: String, src_path: String, do_bundle: Bool, do_obfuscate: Bool, argc: Int, positional: [String]) -> Void {
|
||||
let src_dir: String = dirname_of(src_path)
|
||||
let tmp_gen: String = make_temp_path("js")
|
||||
let tmp_min: String = make_temp_path("min.js")
|
||||
|
||||
// Redirect stdout to tmp_gen so codegen println output is captured.
|
||||
stdout_to_file(tmp_gen)
|
||||
if do_bundle {
|
||||
let runtime_path: String = resolve_runtime_path(src_path)
|
||||
compile_dispatch_bundle(tgt, source, runtime_path)
|
||||
} else {
|
||||
compile_dispatch(tgt, source)
|
||||
}
|
||||
stdout_restore()
|
||||
|
||||
// Run terser: tmp_gen -> tmp_min
|
||||
let ok_min: Bool = apply_minify(tmp_gen, tmp_min, src_dir)
|
||||
if !ok_min {
|
||||
exec_command("rm -f " + tmp_gen + " " + tmp_min)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Determine final result path (either tmp_min or post-obfuscation file).
|
||||
// Use state to pass the final path out of the optional obfuscation branch.
|
||||
state_set("__elc_final_js", tmp_min)
|
||||
|
||||
if do_obfuscate {
|
||||
let tmp_obf: String = make_temp_path("obf.js")
|
||||
let ok_obf: Bool = apply_obfuscate(tmp_min, tmp_obf, src_dir)
|
||||
if !ok_obf {
|
||||
exec_command("rm -f " + tmp_gen + " " + tmp_min + " " + tmp_obf)
|
||||
exit(1)
|
||||
}
|
||||
state_set("__elc_final_js", tmp_obf)
|
||||
}
|
||||
|
||||
let final_path: String = state_get("__elc_final_js")
|
||||
let final_js: String = fs_read(final_path)
|
||||
|
||||
// Clean up all temp files.
|
||||
exec_command("rm -f " + tmp_gen + " " + tmp_min)
|
||||
if do_obfuscate {
|
||||
exec_command("rm -f " + final_path)
|
||||
}
|
||||
|
||||
if argc >= 2 {
|
||||
let out_path: String = native_list_get(positional, 1)
|
||||
let ok: Bool = fs_write(out_path, final_js)
|
||||
if ok {
|
||||
return
|
||||
} else {
|
||||
println("el-compiler: failed to write output")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
// No output file: print final JS to stdout.
|
||||
print(final_js)
|
||||
}
|
||||
|
||||
// main — CLI entry point.
|
||||
//
|
||||
// elc <source.el> # emit C to stdout
|
||||
// elc --target=js <source.el> # emit JS to stdout
|
||||
// elc --target=c <source.el> <out.c> # write C to file
|
||||
// elc --target=js <source.el> <out.js> # write JS to file
|
||||
// elc <source.el> # emit C to stdout
|
||||
// elc --target=js <source.el> # emit JS (module) to stdout
|
||||
// elc --target=js --bundle <source.el> # emit self-contained JS (IIFE) to stdout
|
||||
// elc --target=js --bundle --minify <source.el> # emit minified IIFE to stdout
|
||||
// elc --target=js --bundle --obfuscate <source.el> # emit minified+obfuscated IIFE to stdout
|
||||
// elc --target=c <source.el> <out.c> # write C to file
|
||||
// elc --target=js <source.el> <out.js> # write JS to file
|
||||
// elc --target=js --bundle <source.el> <out.js> # write bundled JS to file
|
||||
// elc --target=js --bundle --minify <source.el> <out.min.js> # write minified JS to file
|
||||
fn main() -> Void {
|
||||
let argv: [String] = args()
|
||||
// Use `tgt` not `target`: `target` is a reserved keyword in the lexer
|
||||
@@ -262,12 +473,28 @@ fn main() -> Void {
|
||||
// because the function-name position has no token-class restriction.
|
||||
let tgt: String = detect_target(argv)
|
||||
let do_emit_header: Bool = detect_emit_header(argv)
|
||||
let do_bundle: Bool = detect_bundle(argv)
|
||||
let do_minify: Bool = detect_minify(argv)
|
||||
let do_obfuscate: Bool = detect_obfuscate(argv)
|
||||
// --obfuscate implies --minify: obfuscating unminified code is pointless.
|
||||
if do_obfuscate {
|
||||
let do_minify = true
|
||||
}
|
||||
let positional: [String] = strip_flags(argv)
|
||||
let argc: Int = native_list_len(positional)
|
||||
if argc < 1 {
|
||||
println("el-compiler: usage: elc [--target=c|js] [--emit-header] <source.el> [<output>]")
|
||||
println("el-compiler: usage: elc [--target=c|js] [--bundle] [--minify] [--obfuscate] [--emit-header] <source.el> [<output>]")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// --minify and --obfuscate require --target=js
|
||||
if do_minify {
|
||||
if !str_eq(tgt, "js") {
|
||||
println("el-compiler: error: --minify and --obfuscate require --target=js")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let src_path: String = native_list_get(positional, 0)
|
||||
|
||||
// When --emit-header is requested, parse the source file directly
|
||||
@@ -283,7 +510,24 @@ fn main() -> Void {
|
||||
}
|
||||
|
||||
let source: String = resolve_imports(src_path)
|
||||
let out: String = compile_dispatch(tgt, source)
|
||||
|
||||
// When post-processing (--minify or --obfuscate) is requested, redirect
|
||||
// stdout to a temp file so codegen output can be captured and piped through
|
||||
// the external tools. After codegen, restore stdout before emitting the
|
||||
// final result.
|
||||
if do_minify {
|
||||
run_with_postprocess(tgt, source, src_path, do_bundle, do_obfuscate, argc, positional)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// Standard path (no post-processing).
|
||||
let out: String = ""
|
||||
if do_bundle {
|
||||
let runtime_path: String = resolve_runtime_path(src_path)
|
||||
let out = compile_dispatch_bundle(tgt, source, runtime_path)
|
||||
} else {
|
||||
let out = compile_dispatch(tgt, source)
|
||||
}
|
||||
if argc >= 2 {
|
||||
let out_path: String = native_list_get(positional, 1)
|
||||
let ok: Bool = fs_write(out_path, out)
|
||||
|
||||
Reference in New Issue
Block a user