Rebuild elc binary from the feat/native-testing source to match the
full reporter implementation:
- Lexer tracks line numbers in every token via state (__lex_line)
- Parser propagates line numbers into TestDef and Assert AST nodes
- Text reporter: " FAIL <name> — <msg>" (em dash, stderr)
- JSON reporter (--reporter=json): newline-delimited JSON to stdout
with suite_start, test_start, test_pass, test_fail, suite_end events
- All fields: file (basename), line (test block), assert_line, message
- Fixed point verified: gen2 == gen3
elc-combined.el regenerated from source.
Add test { } and assert to the El language: the parser recognises TestDef
and Assert nodes; the C and JS codegens emit inert no-ops in normal mode
and a full test runner (with RUN/PASS/FAIL output and non-zero exit on
failure) when invoked with --test. compiler.el wires up compile_test /
compile_js_test and exposes --test / --reporter flags in the CLI.
Add two native test suites under tests/native/ (test_text.el,
test_codegen_js.el) covering string primitives, arithmetic, and list
operations. All 22 new native tests pass; the four existing run.sh
acceptance corpora are unaffected.
examples/html-page.el demonstrates HTML template syntax:
- <!doctype html> prefix handling
- Attribute values (static and interpolated)
- {#each list as item} iteration
- Auto-escaped interpolation via {expr}
- Self-closing void elements (meta, br, etc.)
Rebuilt dist/platform/elc from modified compiler source. The new
binary is self-hosted from the HTML-capable compiler source and
passes the standard identity check.
JS codegen (codegen-js.el):
- js_cg_html_template: emits an IIFE that builds HTML via += concat
- js_cg_html_element_str / js_cg_html_parts / js_cg_html_attrs_str:
mirror the C codegen structure using JS string accumulator
- js_cg_html_each: {#each} compiles to a JS for-loop
- Reuses existing js_str_lit / js_escape from the file header
Runtime (el_runtime.js):
- html_escape(s): replaces & < > " ' using regex chains
- html_raw(s): identity function
- Both exported from the runtime module exports object
C codegen (codegen.el):
- cg_html_template: emits a GCC/Clang statement-expression that
builds the HTML string via el_str_concat chains
- cg_html_element_str / cg_html_parts / cg_html_attrs_str: recursive
element and attribute emitters
- cg_html_each: {#each} compiles to a C for-loop with el_list_get
- __html_counter state tracks unique accumulator variable names
- Handles both 'static' (raw string) and 'dynamic' (expr node) attrs
matching the parser's attribute kind convention
Runtime (el_runtime.c / el_runtime.h):
- html_escape(s): escapes & < > " ' for safe interpolation
- html_raw(s): identity function for raw() bypass
- Both use the existing html_buf_t infrastructure from el_html_sanitize
Adds native HTML template literals to the El parser. Templates are
detected in value position when Lt is followed by a known HTML tag
name (is_html_tag_name) or by '!' for <!doctype html>.
New parser helpers:
- is_html_tag_name / is_void_element: classify tag names
- parse_html_text_tokens: collect intertoken text content
- parse_html_attrs: parse name, name="val", name={expr} attributes
- parse_html_children: recursive children with {expr}, {#each} support
- parse_html_element / parse_html_template: entry points
Adds Hash token kind ('#') to lexer for {#each} block syntax.
AST nodes: HtmlTemplate, html:Element, html:Text, html:Interp,
html:Raw, html:Each, html:Doctype. Doctype flag is stored on the
root element node rather than as a separate AST layer.
HTML templates parse correctly after 'return' and as the sole
expression in a function body. The 'return' keyword is required when
other let bindings precede the template, as El has no newline-as-
statement-terminator and '<' would otherwise be parsed as comparison.
No native_js or native_js_call anywhere. Full browser auth flow expressed
with proper El constructs:
- extern fn supabase_create_client(url, key) -> Any
Declares the Supabase CDN global without an El function body.
- client.auth.signInWithOtp(opts)
Direct method call chain on Any-typed value. The client is built by
calling the extern fn; .auth field access and .signInWithOtp(opts)
method call emit clean JS without any escape hatch.
- try { ... } catch (err: Any) { ... }
Wraps the auth call; unexpected runtime errors are caught and shown
to the user rather than crashing silently.
- fn(event: Any) -> Void { ... }
Inline anonymous function literals for DOM event listeners instead
of named forward-declared callbacks.
The rewrite is the proof: every browser JavaScript pattern used in a
real auth flow can now be expressed structurally in El.
import "https://cdn.example.com/lib.js" now emits:
- module mode: import "https://..." at the top of the generated file
- bundle/IIFE mode: // external: https://... comment
El source imports (.el files) are excluded -- they were already inlined
by resolve_imports before codegen. Any import path that doesn't end in
.el or starts with http(s):// is treated as an external JS dependency.
Add to el_runtime.js:
promise_then(p, cb) -- p.then(cb), works with any Promise-returning API
promise_catch(p, cb) -- p.catch(cb)
promise_resolve(val) -- Promise.resolve(val)
promise_reject(msg) -- Promise.reject(new Error(msg))
object_assign(t, s) -- Object.assign({}, t, s) (non-mutating)
object_keys(obj) -- Object.keys(obj)
object_values(obj) -- Object.values(obj)
json_deep_clone(obj) -- JSON.parse(JSON.stringify(obj))
array_from(iterable) -- Array.from(iterable)
type_of(val) -- typeof val
instanceof_check(v, n) -- val instanceof globalThis[name]
All new functions added to __el export object and ES named exports.
codegen-js preamble destructure updated to include all new names.
try { ... } catch (name: Type) { ... } is now a first-class El statement.
Lexer: `try` and `catch` are now keywords (Try, Catch token kinds).
Parser: TryCatch AST node with try_body, catch_name, catch_body.
codegen-js: emits try { ... } catch (name) { ... } directly -- correct
for all browser error handling patterns.
codegen.el (C backend): emits the try body with a comment; exception
handling is a no-op since C has no analogous mechanism. Programs using
try/catch should compile with --target=js.
The catch variable type annotation is parsed and skipped (same treatment
as all other type annotations in El).
fn(params) -> RetType { body } is now valid in expression position.
The parser produces a Lambda AST node. codegen-js emits a hoisted
JS function declaration with a generated name (__lambda_N) and returns
the name as the expression value, so inline callbacks compose cleanly:
dom_listen(btn, "click", fn(event: Any) -> Void { handle(event) })
emits:
function __lambda_1(event) { handle(event); }
dom_listen(btn, "click", __lambda_1);
The hoisted-declaration strategy is debuggable, has no closure-capture
issues, and requires no string-buffer mode in the codegen.
Any-typed receiver method calls now emit obj.method(args) directly
instead of requiring native_js_call. client.auth.signInWithOtp(p)
compiles to client["auth"].signInWithOtp(p) -- no escape hatch needed.
Field access emits obj["field"] (direct bracket notation) instead of
el_get_field, so prototype-inherited JS properties resolve correctly.
el_get_field's hasOwnProperty guard was silently returning null for
real JS objects with inherited fields (Supabase auth, DOM APIs, etc).
El runtime shortform methods (append, len, get, map_get, map_set)
still use the existing method(obj, args) convention for backward compat.
ExternFn statements emit a comment and are excluded from top-level
statement codegen -- the extern declaration tells the compiler the
function exists in the JS environment without emitting a body.
Adds two post-processing flags that produce production-ready browser JS in a
single elc invocation, replacing extract-js.py in the web product pipeline:
elc --target=js --bundle --minify source.el > output.min.js
elc --target=js --bundle --obfuscate source.el > output.obf.js
--minify shells out to terser (passes=2, no drop_console, drop_debugger).
--obfuscate shells out to javascript-obfuscator with the same options as the
old extract-js.py script. --obfuscate implies --minify.
Tool discovery: checks ./node_modules/.bin/, ../node_modules/.bin/ (monorepo),
then falls back to npx. Both flags require --target=js; passing either without
it exits 1 with a clear error.
Both tools receive a reserved-names list of globals referenced from HTML
onclick= attributes (neuronDemoToggle, signInWith, NEURON_CFG, etc.) so they
are not mangled.
Implementation adds stdout_to_file(path)/stdout_restore() builtins to the C
runtime so codegen's println-streamed output can be captured to a temp file
before being piped through the external tools. Temp files use
/tmp/elc-<pid>-<timestamp>.js naming and are cleaned up on success and failure.
Rebuilds dist/platform/elc and dist/platform/elc.c. Self-hosting verified.
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.
elc --target=js --bundle source.el > output.js produces a single file
with no import statement that can drop directly into a <script> tag.
How it works:
- detect_bundle() reads the --bundle flag from argv
- resolve_runtime_path() looks for el_runtime.js next to the source file
- compile_js_with_bundle() reads the runtime, calls codegen_js_bundle()
- codegen_js_inner(bundle_mode=true):
- emits ;(function() { "use strict"; at the top
- inlines the runtime content (stripping ES export statements which
are invalid inside an IIFE via js_strip_es_exports())
- skips the const {...} = globalThis.__el destructure -- the inlined
function declarations are already in scope within the IIFE
- closes with })(); after main()
Usage: elc --target=js --bundle app.el > app.js
Place el_runtime.js in the same directory as app.el.
Iteration 3: closes the browser API gap needed for real web pages.
New builtins in el_runtime.js:
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: window_location, window_redirect, window_on_load
Debug: console_log
All browser-only functions use _ensureBrowser guard. Timer functions
work in both Node and browser. All new names added to __el export
object, ES named exports, and codegen-js.el destructure preamble.
Spec table updated to document new categories.
type User = { name: String } was silently broken: the parser consumed
the type name then called expect(LBrace) while sitting on the = token.
expect() advances unconditionally on mismatch, so it consumed = and
treated { as the first field name, producing a corrupt TypeDef node.
The FnDef following the broken TypeDef was then parsed incorrectly or
lost entirely -- causing greet() and similar functions to vanish from
JS/C output with no error.
Fix: detect and skip the optional Eq token before expecting LBrace.
Both targets benefit; rebuild elc to pick up the fix.
Parser now handles `SomeEnum::Variant` in match arm patterns, emitting
a Variant pattern node with enum_name and variant fields. Previously
these fell through to Binding, producing broken codegen.
JS codegen: emit str_eq check against the variant name string (El enums
are plain strings at runtime). C codegen: same, via EL_STR + str_eq.
Rebuild elc to pick up the parser change.
- 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
Replace accumulate-by-concatenation loops with native_list_append + str_join.
Eliminates quadratic memory growth when processing large source files.
This is the v2 compiler state — what produced /tmp/elc-v2.
- .gitea/workflows/sdk-release.yaml: build elc from bootstrap, run tests,
publish latest release, dispatch el-sdk-updated to downstream repos
- install.sh: one-command El SDK install from Gitea release
elc-combined.el had drifted from el-compiler/src/ across three separate
commits that never synced the bundled flat file:
1. 13948f5 - fold fn main() body into C int main() + _argc/_argv rename
(codegen.el updated, elc-combined.el not updated)
2. 742bd0b - bare reassignment Assign AST node
(parser.el + codegen.el updated, elc-combined.el not updated)
3. ed564b6 - Calendar/CalendarTime/Rhythm/LocalDate/LocalTime types
(codegen.el updated, elc-combined.el not updated)
The drift meant that the elc binary (which embeds the correct logic) could
compile test programs correctly, but a fresh self-host pass using gen2 (built
from the stale elc-combined.el) would produce a gen3 that differed in 39
lines: no fn main body fold and broken bare-assignment codegen.
Fix: regenerate elc-combined.el as a flat concatenation of the current
lexer.el + parser.el + codegen.el + codegen-js.el + compiler.el source
files. Self-host fixed point verified: gen2 == gen3 byte-identical at
6450 lines.
Also rebuild dist/platform/elc and dist/platform/elc.c from the fixed
gen2 pass, and carry the pending http dual-stack change in el_runtime.c.
All tests pass: time (6/6), calendar (10/10), text (8/8), html_sanitizer (29/29).
24 new functions covering counting (str_count, str_count_chars,
str_count_bytes, str_count_lines, str_count_words, str_count_letters,
str_count_digits), finding (str_index_of_all, str_last_index_of,
str_find_chars), transforming (str_repeat, str_reverse,
str_strip_prefix/suffix/chars, str_lstrip, str_rstrip), character
classification (is_letter, is_digit, is_alphanumeric, is_whitespace,
is_punctuation, is_uppercase, is_lowercase), and splitting/joining
(str_split_lines, str_split_chars, str_split_n, str_join).
Phase 1 is byte-level + ASCII character classes. Unicode-grapheme
awareness, normalization, and regex are Phase 2 (filed separately).
Lexer-internal helpers is_digit, is_alpha, is_whitespace renamed to
lex_is_digit, lex_is_alpha, lex_is_whitespace to free the public names
for the runtime exports. The El compiler's lexer.el and the bundled
elc-combined.el both updated.
Codegen registrations: builtin_arity entries for all 24 functions,
is_int_call entries for the Int-returning ones (str_count*,
str_last_index_of, str_find_chars) so the + operator dispatches as
arithmetic when applicable.
Tests: tests/text/ corpus with 8 acceptance cases covering the surface
(count-substring, count-overlap-skip, count-lines-words-letters,
index-of-all, transform-suite, char-classes, split-lines, join). All
pass against a fold-fn-main-aware elc bootstrap (see ELC env var
override in run.sh).
Self-host fixed point: elc-combined.el's emit-main pass does not
currently fold the fn main body into C's main, a pre-existing
condition that surfaces as a 39-line gen2/gen3 diff with empty main
in gen3. The committed dist/platform/elc binary has the fold logic
so all tests pass against it. Filing the elc-combined fold-fn-main
fix separately. This commit does not introduce new self-host drift.
Phase 1.5 of time-system. Calendar is pluggable: EarthCalendar
(IANA zones, DST, Gregorian) is the default; MarsCalendar,
CycleCalendar(period), NoCycleCalendar handle non-Earth cases.
Rhythm abstracts recurrence from clock units - rhythm_cycle_phase(0.5)
means "midpoint of cycle" whether the cycle is 24 hours on Earth or
30 hours on a station or 300 years on a long-cycle world.
Phase 1 (Instant + Duration) unchanged. EarthCalendar(zone_local())
is the user-facing default; nobody who doesn't care about non-Earth
calendars sees the abstraction.
Self-host fixed point holds at 6339 lines.
Snapshot tagged at dist/platform/elc.20260502-1321-self-host.
Phase 2 (scheduling primitives every/after/at) lands next, now with
Calendar-aware grounding instead of Earth-time hardcoded.
Backlog: bl-297f66d8 (supersedes bl-b29b3e60)
Replaces the need for product-level denylist sanitizers. Small
state-machine parser; tag-and-attribute allowlist passed as JSON;
URL scheme validation on href/src attrs (http, https, mailto,
fragment, relative); whole-subtree drop for script/style/iframe/
object/embed/form (plus rarer media containers). No comment-
wrapping (was fragile to comment-injection bypass via a literal
--> inside an attacker-supplied attribute value).
Also picks up the codegen and parser changes for first-class
Instant/Duration types (postfix-literal time values, typed binop
dispatch) that were sitting in tree alongside this work.
Test corpus at tests/html_sanitizer/ covers the live attacker
probes (script, iframe, form, javascript:, about:, data:, img
onerror, onclick) plus structural attacks (comment-injection
bypass, tab-in-scheme bypass, encoded payloads, malformed input,
empty input, plain text). 29 cases, all green.
Self-host fixed point holds at 5720 lines via the canonical
el-compiler/src/compiler.el entry. Snapshot tagged at
dist/platform/elc.20260502-1249-self-host.
Backlog: bl-dc55ae07