diff --git a/.agents/skills/livecodes/language-support/references/languages.md b/.agents/skills/livecodes/language-support/references/languages.md index 676a6a032..e6bc75ee1 100644 --- a/.agents/skills/livecodes/language-support/references/languages.md +++ b/.agents/skills/livecodes/language-support/references/languages.md @@ -156,6 +156,8 @@ Enabled via `processors` config: | Zig (Wasm) | `zig-wasm`, `zig` | Zig WASM | | V (Wasm) | `v-wasm`, `v`, `vlang`, `wasm.v`, `vsh` | V compiler in WASM | | Janet (Wasm) | `janet-wasm`, `janet`, `wasm.janet` | Janet interpreter (WASM) | +| Elixir (Wasm) | `elixir-wasm`, `ex`, `exs`, `elixir`, `wasm.ex` | Elixir compiler in WASM | +| Erlang (Wasm) | `erlang-wasm`, `erl`, `erlang`, `wasm.erl` | Erlang runtime in WASM (AtomVM) | | Tcl | `tcl` | Tcl interpreter | | WebAssembly | `wat`, `wast`, `wasm`, `webassembly` | WAT format | | Assembly (Wasm) | `assembly-wasm`, `assembly`, `asm`, `asm-wasm`, `x86`, `x86-64`, `nasm`, `wasm.asm` | Keystone + Unicorn (WASM), x86-64 assembly | diff --git a/docs/docs/languages/elixir-wasm.mdx b/docs/docs/languages/elixir-wasm.mdx new file mode 100644 index 000000000..9884dd083 --- /dev/null +++ b/docs/docs/languages/elixir-wasm.mdx @@ -0,0 +1,136 @@ +# Elixir (Wasm) + +Elixir is a dynamic, functional language for building scalable and maintainable applications, powered by the Erlang VM. + +In LiveCodes, Elixir runs in the browser using WebAssembly: the Elixir compiler and standard library execute on [AtomVM](https://atomvm.org/), a lightweight Erlang VM with a WebAssembly target. No server is involved. + +## Usage + +Demo: + +import LiveCodes from '../../src/components/LiveCodes.tsx'; + +export const elixirConfig = { + activeEditor: 'script', + script: { + language: 'elixir-wasm', + content: `IO.puts("Hello, Elixir!")`, + }, + mode: 'simple', + editor: 'auto', + tools: { + status: 'full', + }, +}; + + + +### Communication with JavaScript + +The program runs in a persistent worker owned by the result page, not in the result page's JavaScript +context. The browser global `livecodes.elixir` object is the result page's interface to that worker: + +- `livecodes.elixir.input`: the standard input passed to the program. +- `livecodes.elixir.loaded`: a promise that resolves after the initial program run has completed. + For a non-empty program, that includes downloading and booting the runtime; an empty initial + program resolves before the runtime is downloaded, and initialization starts on the first + non-empty run. It rejects if initialization fails. Other helpers should be used after it resolves. +- `livecodes.elixir.output`: the program's standard output. +- `livecodes.elixir.value`: the inspected return value of the program. +- `livecodes.elixir.warnings`: warnings emitted while compiling or running the program. +- `livecodes.elixir.error`: the syntax or runtime error, if the program failed. +- `livecodes.elixir.exitCode`: the exit code of the program. +- `livecodes.elixir.run`: runs the current code with new input, reusing the booted VM, and returns a + promise that resolves with `output`, `value`, `warnings`, `error` and `exitCode`. +- `livecodes.elixir.retry`: restarts the runtime after a failed initialization. A VM crash in a + normal run is recovered automatically on the next run. + +Example: + + + +```elixir +IO.puts("Hello, Elixir!") + +squares = Enum.map(1..5, &(&1 * &1)) +IO.inspect(squares, label: "squares") +``` + +### Runtime lifecycle + +The VM runs in a persistent worker owned by the result window. Updating the code +rewrites the result document but does not destroy the worker, so normal edits +reuse the booted VM instead of downloading and initializing it again. + +Each worker boot compiles a small Elixir prelude. That takes a few seconds on +the first run; subsequent runs only evaluate the user code. Evaluations time out +after 60 seconds. If a newer run is waiting and the active evaluation does not +finish within 10 seconds, the worker is terminated and recreated so the newer +run is not blocked. Pending edits are coalesced to the latest one. A VM crash is +reported in `error`; the next run starts a fresh worker automatically. Use +`retry()` when initialization itself failed. + +## Language Info + +### Name + +`elixir-wasm` + +### Aliases / Extensions + +`ex`, `exs`, `elixir`, `elixir-wasm`, `wasm.ex` + +### Editor + +`script` + +## Compiler + +The Elixir toolchain compiled to WebAssembly, running on +[AtomVM](https://github.com/atomvm/AtomVM) with the +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime and the +Elixir/Erlang bytecode bundle are built from source in +[`live-codes/browser-elixir`](https://github.com/live-codes/browser-elixir); this playground pins +commit `e9bd2faed8eb6e2bcacd4b42f8b57ea26a770831`. + +### Version + +Elixir 1.17.3 on OTP 27, with AtomVM 0.7.0-dev (32-bit). + +### Limitations + +AtomVM implements a subset of the Erlang VM and its standard library. Features that depend on a full +operating system (for example file I/O and OS processes) may be missing or behave differently. +Examples include `File.write!/2` (`not_implemented`), `Date.utc_today/0`, `Task.async/await`, +`Regex.match?/2`, and the `JSON` module. `System.halt/1` terminates the VM instead of returning an +exit status; the runtime reports the crash and the next run boots a fresh worker. Frameworks that +require a server, such as Phoenix and LiveView, are out of scope for the playground. + +The runtime is a single-threaded AtomVM build. It does not use WebAssembly threads, +`SharedArrayBuffer`, or `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` +headers, so it runs in normal (non-isolated) result pages and keeps popup-based OAuth working. + +## Code Formatting + +Not currently supported. + +## Live Reload + +By default, new code changes are sent to the result page for re-evaluation without a full page reload +or a VM restart. This behavior can be disabled by adding the code comment `# __livecodes_reload__` to +the Elixir code, which forces a full page reload. + +This comment can be added in the `hiddenContent` property of the editor for embedded playgrounds. + +## Starter Template + +https://livecodes.io/?template=elixir-wasm + +## Links + +- [Elixir official website](https://elixir-lang.org/) +- [Elixir documentation](https://hexdocs.pm/elixir/) +- [Learn X in Y minutes, where X=Elixir](https://learnxinyminutes.com/elixir/) +- [AtomVM](https://atomvm.org/) +- [browser-elixir](https://github.com/live-codes/browser-elixir) +- [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/docs/docs/languages/erlang-wasm.mdx b/docs/docs/languages/erlang-wasm.mdx new file mode 100644 index 000000000..135eb45c0 --- /dev/null +++ b/docs/docs/languages/erlang-wasm.mdx @@ -0,0 +1,150 @@ +# Erlang (Wasm) + +Erlang is a functional, concurrent language for building scalable and fault-tolerant systems. + +In LiveCodes, Erlang runs in the browser using WebAssembly on [AtomVM](https://atomvm.org/), the same lightweight Erlang VM used by [Elixir (Wasm)](./elixir-wasm.mdx). No server is involved. + +## Usage + +Demo: + +import LiveCodes from '../../src/components/LiveCodes.tsx'; + +export const erlangConfig = { + activeEditor: 'script', + script: { + language: 'erlang-wasm', + content: `io:format("Hello, Erlang!~n").`, + }, + mode: 'simple', + editor: 'auto', + tools: { + status: 'full', + }, +}; + + + +### Communication with JavaScript + +The program runs in a persistent worker owned by the result page, not in the result page's JavaScript +context. The browser global `livecodes.erlang` object is the result page's interface to that worker: + +- `livecodes.erlang.input`: the standard input passed to the program. +- `livecodes.erlang.loaded`: a promise that resolves after the runtime has downloaded and the first + program run has completed. It rejects if the environment fails to load. Other helpers should be + used after it resolves. +- `livecodes.erlang.output`: the program's standard output. +- `livecodes.erlang.value`: the inspected return value of the program. +- `livecodes.erlang.warnings`: warnings emitted while compiling or running the program. +- `livecodes.erlang.error`: the syntax or runtime error, if the program failed. +- `livecodes.erlang.exitCode`: the exit code of the program. +- `livecodes.erlang.run`: runs the current code with new input, reusing the booted VM, and returns a + promise that resolves with `output`, `value`, `warnings`, `error` and `exitCode`. +- `livecodes.erlang.retry`: restarts the runtime after a failed initialization. A VM crash in a + normal run is recovered automatically on the next run. + +Example: + + + +```erlang +io:format("Hello, Erlang!~n"), +[X * X || X <- lists:seq(1, 5)]. +``` + +### Modules and stdin + +Erlang expressions are comma-separated and must end with a dot. Code that starts with `-module(` +is compiled and loaded into the running VM, so it stays callable in later runs: + +```erlang +-module(m). +-export([double/1]). + +double(X) -> X * 2. +``` + +Then run `m:double(21).` in a later run. `io:get_line("")` reads the standard input, and +`erlang:halt(0)` (or a bare `halt(0)`) sets the exit code. + +The shared stdin device returns binaries. Use `binary_to_list/1` when an API needs a charlist, +and remove the trailing newline with `binary:replace(Value, <<"\n">>, <<>>, [global])` +(`string:trim/1` is not reliable on AtomVM). + +### Runtime lifecycle + +The VM runs in a persistent worker owned by the result window. Updating the code +rewrites the result document but does not destroy the worker, so normal edits +reuse the booted VM instead of downloading and initializing it again. + +Each worker boot compiles a small Elixir prelude that provides the shared I/O +device for Erlang and Elixir. That takes a few seconds on the first run; +subsequent runs only evaluate the user code. Evaluations time out after 60 +seconds. If a newer run is waiting and the active evaluation does not finish +within 10 seconds, the worker is terminated and recreated so the newer run is +not blocked. Pending edits are coalesced to the latest one. A VM crash is +reported in `error`; the next run starts a fresh worker automatically. Use +`retry()` when initialization itself failed. + +## Language Info + +### Name + +`erlang-wasm` + +### Aliases / Extensions + +`erl`, `erlang`, `erlang-wasm`, `wasm.erl` + +### Editor + +`script` + +## Compiler + +The Erlang runtime compiled to WebAssembly, running on +[AtomVM](https://github.com/atomvm/AtomVM) with the +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime and the +shared Elixir/Erlang bytecode bundle are built from source in +[`live-codes/browser-elixir`](https://github.com/live-codes/browser-elixir); this playground pins +commit `e9bd2faed8eb6e2bcacd4b42f8b57ea26a770831`. + +### Version + +AtomVM 0.7.0-dev (32-bit), with the Erlang runtime bundled in the shared Elixir/Erlang Wasm bundle. + +### Limitations + +AtomVM implements a subset of the Erlang VM and its standard library. Features that depend on a full +operating system (for example file I/O and OS processes) may be missing or behave differently. +Frameworks and applications that require a server are out of scope for the playground. + +The runtime is a single-threaded AtomVM build. It does not use WebAssembly threads, +`SharedArrayBuffer`, or `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` +headers, so it runs in normal (non-isolated) result pages and keeps popup-based OAuth working. + +## Code Formatting + +Not currently supported. + +## Live Reload + +By default, new code changes are sent to the result page for re-evaluation without a full page reload +or a VM restart. This behavior can be disabled by adding the code comment `% __livecodes_reload__` to +the Erlang code, which forces a full page reload. + +This comment can be added in the `hiddenContent` property of the editor for embedded playgrounds. + +## Starter Template + +https://livecodes.io/?template=erlang-wasm + +## Links + +- [Erlang official website](https://www.erlang.org/) +- [Erlang documentation](https://www.erlang.org/docs) +- [Learn X in Y minutes, where X=Erlang](https://learnxinyminutes.com/erlang/) +- [AtomVM](https://atomvm.org/) +- [browser-elixir](https://github.com/live-codes/browser-elixir) +- [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/docs/src/components/LanguageSliders.tsx b/docs/src/components/LanguageSliders.tsx index de8d9efd1..92edaa78d 100644 --- a/docs/src/components/LanguageSliders.tsx +++ b/docs/src/components/LanguageSliders.tsx @@ -104,6 +104,8 @@ export default function Sliders() { { name: 'dart', title: 'Dart' }, { name: 'flutter', title: 'Flutter' }, { name: 'v-wasm', title: 'V (Wasm)' }, + { name: 'elixir-wasm', title: 'Elixir (Wasm)' }, + { name: 'erlang-wasm', title: 'Erlang (Wasm)' }, { name: 'rust-wasm', title: 'Rust (Wasm)' }, { name: 'scala-wasm', title: 'Scala (Wasm)' }, { name: 'swift-wasm', title: 'Swift (Wasm)' }, diff --git a/docs/src/components/TemplateList.tsx b/docs/src/components/TemplateList.tsx index 7a7e90b67..0b40b77ec 100644 --- a/docs/src/components/TemplateList.tsx +++ b/docs/src/components/TemplateList.tsx @@ -58,6 +58,8 @@ const templates = [ { name: 'crystal-wasm', title: 'Crystal Starter', thumbnail: 'crystal.svg' }, { name: 'odin-wasm', title: 'Odin Starter', thumbnail: 'odin.svg' }, { name: 'v-wasm', title: 'V Starter', thumbnail: 'v.svg' }, + { name: 'elixir-wasm', title: 'Elixir Starter', thumbnail: 'elixir.png' }, + { name: 'erlang-wasm', title: 'Erlang Starter', thumbnail: 'erlang.svg' }, { name: 'cpp-wasm', title: 'C++ (Wasm) Starter', thumbnail: 'cpp.svg' }, { name: 'objc-wasm', title: 'Objective-C (Wasm) Starter', thumbnail: 'objective-c.svg' }, { name: 'rust-wasm', title: 'Rust (Wasm) Starter', thumbnail: 'rust.svg' }, diff --git a/e2e/specs/elixir-wasm.spec.ts b/e2e/specs/elixir-wasm.spec.ts new file mode 100644 index 000000000..637f83815 --- /dev/null +++ b/e2e/specs/elixir-wasm.spec.ts @@ -0,0 +1,419 @@ +import { expect, type Frame, type Page } from '@playwright/test'; +import { test } from '../test-fixtures'; + +const APP_URL = `${process.env.TEST_URL || 'http://localhost:8080'}?template=elixir-wasm`; + +interface RunEnd { + runId?: number; + exitCode?: number; + latest?: boolean; + hasError?: boolean; + stdoutBytes?: number; + stderrBytes?: number; + empty?: boolean; +} + +interface ElixirState { + evals?: number; + runId?: number; + ready?: boolean; + failed?: boolean; + output?: string | null; + value?: string | null; + warnings?: string | null; + error?: string | null; + exitCode?: number | null; + popcornReady?: boolean; + timings: Array<{ name: string; detail?: Record }>; +} + +const getResultFrame = (page: Page): Frame | null => page.frame('result'); + +const requireResultFrame = (page: Page): Frame => { + const frame = getResultFrame(page); + if (frame == null) throw new Error('Result frame is not available'); + return frame; +}; + +const readState = async (page: Page): Promise => { + const frame = getResultFrame(page); + if (frame == null) return null; + try { + return await frame.evaluate(() => { + const elixir = (window as any).livecodes?.elixir; + if (elixir == null) return null; + return { + evals: elixir.evals, + runId: elixir.runId, + ready: elixir.ready, + failed: elixir.failed, + output: elixir.output, + value: elixir.value, + warnings: elixir.warnings, + error: elixir.error, + exitCode: elixir.exitCode, + popcornReady: elixir.popcorn?.isReady, + timings: elixir.timings ?? [], + }; + }); + } catch { + // LiveReload may be replacing the document; retry from the caller. + return null; + } +}; + +const latestRunEnd = (state: ElixirState, runId: number): RunEnd | undefined => + [...state.timings] + .reverse() + .find((timing) => timing.name === 'run-end' && timing.detail?.runId === runId)?.detail; + +const waitForState = async ( + page: Page, + predicate: (state: ElixirState) => boolean, + timeout = 120_000, +): Promise => { + const start = Date.now(); + let last: ElixirState | null = null; + while (Date.now() - start < timeout) { + last = await readState(page); + if (last != null && predicate(last)) return last; + await page.waitForTimeout(25); + } + throw new Error(`Timed out waiting for Elixir state: ${JSON.stringify(last)}`); +}; + +const waitForRun = async (page: Page, runId: number, timeout = 120_000) => + waitForState( + page, + (state) => + latestRunEnd(state, runId)?.latest === true || + state.timings.some( + (timing) => + timing.name === 'run-error' && + timing.detail?.runId === runId && + timing.detail?.latest === true, + ), + timeout, + ); + +const installApiHook = async (page: Page) => { + await page.addInitScript(() => { + window.addEventListener('livecodes-app-loaded', (event) => { + (window as any).livecodesApi = (event as CustomEvent).detail; + }); + }); +}; + +const waitForApi = async (page: Page) => { + await page.waitForFunction(() => (window as any).livecodesApi != null, undefined, { + timeout: 30_000, + }); +}; + +const setSource = async (frame: Frame, source: string) => { + await frame.evaluate((content) => { + const scripts = document.querySelectorAll('script[type="text/elixir-wasm"]'); + if (scripts.length === 0) throw new Error('No Elixir script tag found'); + scripts[scripts.length - 1].textContent = content; + }, source); +}; + +const runDirect = async (page: Page, source: string, input?: string) => { + const frame = requireResultFrame(page); + await setSource(frame, source); + const runId = await frame.evaluate((value) => { + const elixir = (window as any).livecodes.elixir; + if (value === undefined) { + void elixir.run(); + } else { + void elixir.run(value); + } + return elixir.runId; + }, input); + return waitForRun(page, runId); +}; + +const setConfigAndTrigger = async (page: Page, source: string) => { + const before = await readState(page); + await page.evaluate(async (content) => { + const api = (window as any).livecodesApi; + await api.setConfig({ script: { content } }); + await api.run(); + }, source); + return before; +}; + +const setConfigAndRun = async (page: Page, source: string) => { + const before = await setConfigAndTrigger(page, source); + return waitForState( + page, + (state) => + state.evals != null && + before?.evals != null && + state.evals > before.evals && + state.runId != null && + before?.runId != null && + state.runId > before.runId && + latestRunEnd(state, state.runId)?.latest === true, + 30_000, + ); +}; + +const boot = async (page: Page) => { + await installApiHook(page); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await waitForApi(page); + return waitForRun(page, 1, 180_000); +}; + +test.describe('Elixir (Wasm)', () => { + test.describe.configure({ timeout: 120_000 }); + + test('runs, keeps the VM alive across edits, and reports IO/errors', async ({ page }) => { + const initial = await boot(page); + expect(initial.ready).toBe(true); + expect(initial.failed).toBe(false); + expect(initial.output).toBe('Elixir\n0'); + expect(initial.value).toBe(':ok'); + expect(initial.error).toBeNull(); + + const isolation = await requireResultFrame(page).evaluate(() => ({ + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + })); + expect(isolation).toEqual({ isolated: false, sharedArrayBuffer: false }); + + const warm = await requireResultFrame(page).evaluate(async () => { + const elixir = (window as any).livecodes.elixir; + const start = performance.now(); + const result = await elixir.run(); + return { ms: performance.now() - start, ...result }; + }); + expect(warm.output).toBe('Elixir\n0'); + expect(warm.ms).toBeLessThan(5_000); + + const stdout = await runDirect(page, 'IO.puts("hello")'); + expect(stdout.output).toBe('hello'); + expect(stdout.value).toBe(':ok'); + + const returnValue = await runDirect(page, '1 + 1'); + expect(returnValue.output).toBeNull(); + expect(returnValue.value).toBe('2'); + expect(returnValue.exitCode).toBe(0); + + const stdin = await runDirect(page, 'IO.puts(String.trim(IO.gets("")))', 'hello'); + expect(stdin.output).toBe('hello'); + const stdinReused = await runDirect(page, 'IO.puts(String.trim(IO.gets("")))'); + expect(stdinReused.output).toBe('hello'); + + const write = await runDirect(page, 'IO.write("abc")'); + expect(write.output).toBe('abc'); + const next = await runDirect(page, 'IO.puts("next")'); + expect(next.output).toBe('next'); + + const syntax = await runDirect(page, 'defmodule broken do'); + expect(syntax.error).toContain('SyntaxError'); + expect(syntax.exitCode).toBe(1); + expect(syntax.failed).toBe(false); + + const runtime = await runDirect(page, 'raise "boom"'); + expect(runtime.error).toBe('RuntimeError: boom'); + expect(runtime.exitCode).toBe(1); + expect(runtime.failed).toBe(false); + + const warning = await runDirect(page, 'IO.warn("careful")'); + expect(warning.error).toBeNull(); + expect(warning.exitCode).toBe(0); + expect(warning.warnings).toContain('careful'); + + const empty = await runDirect(page, ''); + expect(empty.output).toBeNull(); + expect(empty.error).toBeNull(); + expect(empty.exitCode).toBe(0); + expect(empty.ready).toBe(true); + }); + + test('keeps the VM alive across config edits', async ({ page }) => { + const initial = await boot(page); + const workerCreatesBefore = initial.timings.filter( + (timing) => timing.name === 'worker-create-start', + ).length; + + const edited = await setConfigAndRun(page, 'IO.puts("edited")\n:edited_value'); + expect(edited.output).toBe('edited'); + expect(edited.value).toBe(':edited_value'); + expect(edited.error).toBeNull(); + expect(edited.timings.filter((timing) => timing.name === 'worker-create-start').length).toBe( + workerCreatesBefore, + ); + }); + + test('coalesces rapid edits and recovers from a VM crash', async ({ page }) => { + const initial = await boot(page); + const workerCreates = initial.timings.filter( + (timing) => timing.name === 'worker-create-start', + ).length; + + const beforeSlow = await readState(page); + await page.evaluate(async () => { + const api = (window as any).livecodesApi; + await api.setConfig({ + script: { content: 'Process.sleep(3000)\nIO.puts("slow")\n:slow' }, + }); + await api.run(); + }); + const slow = await waitForState( + page, + (state) => + state.runId != null && + beforeSlow?.runId != null && + state.runId > beforeSlow.runId && + state.timings.some( + (timing) => timing.name === 'run-start' && timing.detail?.runId === state.runId, + ), + 30_000, + ); + const slowRunId = slow.runId!; + // The slow evaluation is synchronous inside the worker; give it a moment + // to enter the call before issuing edits. + await page.waitForTimeout(200); + + // Trigger three edits while the slow run is still active. Only the newest + // pending run should execute after it. + const rapidStart = Date.now(); + await page.evaluate(async () => { + const api = (window as any).livecodesApi; + for (const content of [ + 'IO.puts("one")\n:one', + 'IO.puts("two")\n:two', + 'IO.puts("three")\n:three', + ]) { + await api.setConfig({ script: { content } }); + await api.run(); + } + }); + const rapid = await waitForState( + page, + (state) => + state.output === 'three' && + state.runId != null && + state.runId > slowRunId && + latestRunEnd(state, state.runId)?.latest === true, + 30_000, + ); + expect(rapid.value).toBe(':three'); + expect(rapid.error).toBeNull(); + expect(Date.now() - rapidStart).toBeLessThan(30_000); + expect(rapid.timings.filter((timing) => timing.name === 'worker-create-start').length).toBe( + workerCreates, + ); + expect(rapid.timings.some((timing) => timing.name === 'run-superseded')).toBe(true); + + const crash = await runDirect(page, 'System.halt(1)'); + expect(crash.failed).toBe(true); + expect(crash.error).toBeTruthy(); + + const recovered = await runDirect(page, 'IO.puts("recovered")\n:recovered'); + expect(recovered.output).toBe('recovered'); + expect(recovered.value).toBe(':recovered'); + expect(recovered.error).toBeNull(); + expect(recovered.failed).toBe(false); + expect( + recovered.timings.filter((timing) => timing.name === 'worker-create-start').length, + ).toBeGreaterThan(workerCreates); + }); + + test('supersedes a hung evaluation after the timeout', async ({ page }) => { + await boot(page); + const beforeHung = await setConfigAndTrigger(page, 'receive do :never -> :ok end'); + const hung = await waitForState( + page, + (state) => + state.runId != null && + beforeHung?.runId != null && + state.runId > beforeHung.runId && + state.timings.some( + (timing) => timing.name === 'eval-start' && timing.detail?.runId === state.runId, + ), + 30_000, + ); + // The evaluation is synchronous inside the worker; let it enter the call. + await page.waitForTimeout(250); + + const start = Date.now(); + await setConfigAndTrigger(page, 'IO.puts("newer")\n:newer'); + const newer = await waitForState( + page, + (state) => + state.output === 'newer' && + state.runId != null && + state.runId > hung.runId! && + latestRunEnd(state, state.runId)?.latest === true, + 30_000, + ); + expect(newer.value).toBe(':newer'); + expect(newer.error).toBeNull(); + expect(newer.timings.some((timing) => timing.name === 'run-supersede-timeout')).toBe(true); + expect(Date.now() - start).toBeGreaterThanOrEqual(9_500); + }); + + test('reports a failed boot and retries without reloading the page', async ({ page }) => { + await page.context().route('**/AtomVM.mjs', (route) => route.abort()); + await installApiHook(page); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + + const failed = await waitForState(page, (state) => state.failed === true, 30_000); + expect(failed.error).toContain('Failed to fetch'); + + await page.context().unroute('**/AtomVM.mjs'); + await requireResultFrame(page).evaluate(async () => { + await (window as any).livecodes.elixir.retry(); + }); + const retried = await waitForState( + page, + (state) => state.failed === false && state.popcornReady === true, + 120_000, + ); + expect(retried.popcornReady).toBe(true); + + const rerun = await runDirect(page, 'IO.puts("after retry")\n:ok'); + expect(rerun.output).toBe('after retry'); + expect(rerun.error).toBeNull(); + }); + + test('runs the result frame in the dev sandbox without cross-origin isolation', async ({ + page, + }) => { + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const frame = await (async () => { + const start = Date.now(); + while (Date.now() - start < 30_000) { + const result = getResultFrame(page); + if (result != null && result.url().startsWith('http')) return result; + await page.waitForTimeout(100); + } + throw new Error('Result frame did not load'); + })(); + + const response = await page.request.get(frame.url()); + const headers = response.headers(); + expect(headers['cross-origin-opener-policy']).toBeUndefined(); + expect(headers['cross-origin-embedder-policy']).toBeUndefined(); + + const isolated = await frame.evaluate(() => window.crossOriginIsolated); + expect(isolated).toBe(false); + }); + + test('does not declare isolation headers in the static deploy configs', async () => { + const { readFileSync } = await import('fs'); + const productionHeaders = readFileSync('src/_headers', 'utf8'); + expect(productionHeaders).not.toContain('Cross-Origin-Opener-Policy'); + expect(productionHeaders).not.toContain('Cross-Origin-Embedder-Policy'); + expect(productionHeaders).not.toContain('Cross-Origin-Resource-Policy'); + + const netlifyConfig = readFileSync('src/netlify.toml', 'utf8'); + expect(netlifyConfig).not.toContain('cross-origin-opener-policy'); + expect(netlifyConfig).not.toContain('cross-origin-embedder-policy'); + expect(netlifyConfig).not.toContain('cross-origin-resource-policy'); + }); +}); diff --git a/e2e/specs/erlang-wasm.spec.ts b/e2e/specs/erlang-wasm.spec.ts new file mode 100644 index 000000000..7c4fba304 --- /dev/null +++ b/e2e/specs/erlang-wasm.spec.ts @@ -0,0 +1,200 @@ +import { expect, type Frame, type Page } from '@playwright/test'; +import { test } from '../test-fixtures'; + +const APP_URL = `${process.env.TEST_URL || 'http://localhost:8080'}?template=erlang-wasm`; + +interface RunEnd { + runId?: number; + exitCode?: number; + latest?: boolean; + hasError?: boolean; + empty?: boolean; +} + +interface ErlangState { + evals?: number; + runId?: number; + ready?: boolean; + failed?: boolean; + output?: string | null; + value?: string | null; + warnings?: string | null; + error?: string | null; + exitCode?: number | null; + popcornReady?: boolean; + timings: Array<{ name: string; detail?: Record }>; +} + +const getResultFrame = (page: Page): Frame | null => page.frame('result'); + +const requireResultFrame = (page: Page): Frame => { + const frame = getResultFrame(page); + if (frame == null) throw new Error('Result frame is not available'); + return frame; +}; + +const readState = async (page: Page): Promise => { + const frame = getResultFrame(page); + if (frame == null) return null; + try { + return await frame.evaluate(() => { + const erlang = (window as any).livecodes?.erlang; + if (erlang == null) return null; + return { + evals: erlang.evals, + runId: erlang.runId, + ready: erlang.ready, + failed: erlang.failed, + output: erlang.output, + value: erlang.value, + warnings: erlang.warnings, + error: erlang.error, + exitCode: erlang.exitCode, + popcornReady: erlang.popcorn?.isReady, + timings: erlang.timings ?? [], + }; + }); + } catch { + // LiveReload may be replacing the document; retry from the caller. + return null; + } +}; + +const latestRunEnd = (state: ErlangState, runId: number): RunEnd | undefined => + [...state.timings] + .reverse() + .find((timing) => timing.name === 'run-end' && timing.detail?.runId === runId)?.detail; + +const waitForState = async ( + page: Page, + predicate: (state: ErlangState) => boolean, + timeout = 120_000, +): Promise => { + const start = Date.now(); + let last: ErlangState | null = null; + while (Date.now() - start < timeout) { + last = await readState(page); + if (last != null && predicate(last)) return last; + await page.waitForTimeout(25); + } + throw new Error(`Timed out waiting for Erlang state: ${JSON.stringify(last)}`); +}; + +const waitForRun = async (page: Page, runId: number, timeout = 120_000) => + waitForState( + page, + (state) => + latestRunEnd(state, runId)?.latest === true || + state.timings.some( + (timing) => + timing.name === 'run-error' && + timing.detail?.runId === runId && + timing.detail?.latest === true, + ), + timeout, + ); + +const installApiHook = async (page: Page) => { + await page.addInitScript(() => { + window.addEventListener('livecodes-app-loaded', (event) => { + (window as any).livecodesApi = (event as CustomEvent).detail; + }); + }); +}; + +const waitForApi = async (page: Page) => { + await page.waitForFunction(() => (window as any).livecodesApi != null, undefined, { + timeout: 30_000, + }); +}; + +const setSource = async (frame: Frame, source: string) => { + await frame.evaluate((content) => { + const scripts = document.querySelectorAll('script[type="text/erlang-wasm"]'); + if (scripts.length === 0) throw new Error('No Erlang script tag found'); + scripts[scripts.length - 1].textContent = content; + }, source); +}; + +const runDirect = async (page: Page, source: string, input?: string) => { + const frame = requireResultFrame(page); + await setSource(frame, source); + const runId = await frame.evaluate((value) => { + const erlang = (window as any).livecodes.erlang; + if (value === undefined) { + void erlang.run(); + } else { + void erlang.run(value); + } + return erlang.runId; + }, input); + return waitForRun(page, runId); +}; + +test.describe('Erlang (Wasm)', () => { + test.describe.configure({ timeout: 120_000 }); + + test('runs expressions, modules, stdin, errors, and exits', async ({ page }) => { + await installApiHook(page); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await waitForApi(page); + const initial = await waitForRun(page, 1, 180_000); + + expect(initial.ready).toBe(true); + expect(initial.failed).toBe(false); + expect(initial.output).toBe('Erlang\n0'); + expect(initial.error).toBeNull(); + + const isolation = await requireResultFrame(page).evaluate(() => ({ + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + })); + expect(isolation).toEqual({ isolated: false, sharedArrayBuffer: false }); + + const warm = await requireResultFrame(page).evaluate(async () => { + const erlang = (window as any).livecodes.erlang; + const start = performance.now(); + const result = await erlang.run(); + return { ms: performance.now() - start, ...result }; + }); + expect(warm.output).toBe('Erlang\n0'); + expect(warm.ms).toBeLessThan(5_000); + + const exit = await runDirect(page, 'erlang:halt(3).'); + expect(exit.exitCode).toBe(3); + expect(exit.error).toBeNull(); + expect(exit.failed).toBe(false); + + const hello = await runDirect(page, 'io:format("hello~n").'); + expect(hello.output).toBe('hello'); + expect(hello.error).toBeNull(); + + const value = await runDirect(page, '1 + 1.'); + expect(value.output).toBeNull(); + expect(value.value).toBe('2'); + expect(value.exitCode).toBe(0); + + const stdin = await runDirect(page, 'io:format("~s", [io:get_line("")]).', 'hello'); + expect(stdin.output).toBe('hello'); + expect(stdin.error).toBeNull(); + + const moduleRun = await runDirect( + page, + '-module(m).\n-export([double/1]).\n\ndouble(X) -> X * 2.\n', + ); + expect(moduleRun.error).toBeNull(); + const moduleCall = await runDirect(page, 'm:double(21).'); + expect(moduleCall.value).toBe('42'); + expect(moduleCall.error).toBeNull(); + + const runtime = await runDirect(page, '1/0.'); + expect(runtime.error).toContain('badarith'); + expect(runtime.exitCode).toBe(1); + expect(runtime.failed).toBe(false); + + const empty = await runDirect(page, ''); + expect(empty.output).toBeNull(); + expect(empty.error).toBeNull(); + expect(empty.exitCode).toBe(0); + }); +}); diff --git a/functions/vendors/templates.js b/functions/vendors/templates.js index 35f0fa5e9..330b50392 100644 --- a/functions/vendors/templates.js +++ b/functions/vendors/templates.js @@ -59,6 +59,8 @@ export const starterTemplates = { "cobol-wasm": "COBOL Starter", "fortran-wasm": "Fortran Starter", "d-wasm": "D Starter", + "elixir-wasm": "Elixir Starter", + "erlang-wasm": "Erlang Starter", "v-wasm": "V Starter", "rust-wasm": "Rust (Wasm) Starter", "scala": "Scala Starter", diff --git a/scripts/build.js b/scripts/build.js index d4a65d8bb..b325e25e8 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -240,6 +240,8 @@ const iifeBuild = () => 'languages/odin-wasm/lang-odin-wasm-script.ts', 'languages/cobol-wasm/lang-cobol-wasm-script.ts', 'languages/v-wasm/lang-v-wasm-script.ts', + 'languages/elixir-wasm/lang-elixir-wasm-script.ts', + 'languages/erlang-wasm/lang-erlang-wasm-script.ts', 'languages/go-wasm/lang-go-wasm-script.ts', 'languages/csharp-wasm/lang-csharp-wasm-script.ts', 'languages/fanak/lang-fanak-script.ts', diff --git a/server/php/inc/starter-templates.json b/server/php/inc/starter-templates.json index b728d44dc..61b39ad26 100644 --- a/server/php/inc/starter-templates.json +++ b/server/php/inc/starter-templates.json @@ -59,6 +59,8 @@ "cobol-wasm": "COBOL Starter", "fortran-wasm": "Fortran Starter", "d-wasm": "D Starter", + "elixir-wasm": "Elixir Starter", + "erlang-wasm": "Erlang Starter", "v-wasm": "V Starter", "rust-wasm": "Rust (Wasm) Starter", "scala": "Scala Starter", diff --git a/src/livecodes/UI/command-menu-actions.ts b/src/livecodes/UI/command-menu-actions.ts index 7550d34f5..2fd925bb9 100644 --- a/src/livecodes/UI/command-menu-actions.ts +++ b/src/livecodes/UI/command-menu-actions.ts @@ -315,6 +315,8 @@ export const getCommandMenuActions = ({ 'd-wasm', 'crystal-wasm', 'odin-wasm', + 'elixir-wasm', + 'erlang-wasm', 'v-wasm', 'rust-wasm', 'scala', diff --git a/src/livecodes/assets/templates/elixir.png b/src/livecodes/assets/templates/elixir.png new file mode 100644 index 000000000..5d49f9974 Binary files /dev/null and b/src/livecodes/assets/templates/elixir.png differ diff --git a/src/livecodes/assets/templates/erlang.svg b/src/livecodes/assets/templates/erlang.svg new file mode 100644 index 000000000..53761bd4c --- /dev/null +++ b/src/livecodes/assets/templates/erlang.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/livecodes/html/language-info.html b/src/livecodes/html/language-info.html index 66ffcdf04..028150f09 100644 --- a/src/livecodes/html/language-info.html +++ b/src/livecodes/html/language-info.html @@ -3115,6 +3115,66 @@

TypeScript

+
+

Elixir (Wasm)

+
+ Elixir compiler running on WebAssembly, using + AtomVM +
+ +
+
+

Erlang (Wasm)

+
+ Erlang runtime running on WebAssembly, using + AtomVM +
+ +

V (Wasm)

diff --git a/src/livecodes/i18n/locales/ar/language-info.lokalise.json b/src/livecodes/i18n/locales/ar/language-info.lokalise.json index 473773e91..c1db78abf 100644 --- a/src/livecodes/i18n/locales/ar/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ar/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "مترجم Elixir يعمل على WebAssembly، باستخدام AtomVM" + }, + "elixirWasm.link": { + "translation": " موقع Elixir وثائق لغة Elixir تعلم X في Y دقيقة، حيث X = Elixir وثائق LiveCodes تحميل قالب البداية " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "يتم ترجمة لغة Elm إلى JavaScript في المتصفح بواسطة إصدار WebAssembly من مُترجم Elm." }, diff --git a/src/livecodes/i18n/locales/ar/language-info.ts b/src/livecodes/i18n/locales/ar/language-info.ts index b5d6df9d7..a1ab2054d 100644 --- a/src/livecodes/i18n/locales/ar/language-info.ts +++ b/src/livecodes/i18n/locales/ar/language-info.ts @@ -144,6 +144,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>الموقع الرسمي <3> <4>وثائق LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'مترجم Elixir يعمل على WebAssembly، باستخدام <1>AtomVM', + link: '<1> <2>موقع Elixir <3> <4>وثائق لغة Elixir <5> <6>تعلم X في Y دقيقة، حيث X = Elixir <7> <8>وثائق LiveCodes <9> <10>تحميل قالب البداية ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'يتم ترجمة لغة Elm إلى JavaScript في المتصفح بواسطة إصدار WebAssembly من مُترجم Elm.', link: '<1><2>الموقع الرسمي لـ Elm <3><4>دليل Elm <5> <6>وثائق LiveCodes <7> <8>تحميل قالب البداية ', diff --git a/src/livecodes/i18n/locales/ar/translation.lokalise.json b/src/livecodes/i18n/locales/ar/translation.lokalise.json index cebdf92f2..cb6cf1ed5 100644 --- a/src/livecodes/i18n/locales/ar/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ar/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "قالب DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "قالب Elixir" + }, "templates.starter.elm": { "translation": "قالب Elm" }, diff --git a/src/livecodes/i18n/locales/ar/translation.ts b/src/livecodes/i18n/locales/ar/translation.ts index 51e88ad44..5061b2c5a 100644 --- a/src/livecodes/i18n/locales/ar/translation.ts +++ b/src/livecodes/i18n/locales/ar/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'قالب daisyUI', dart: 'قالب Dart', diagrams: 'قالب الرسوم البيانية', + 'elixir-wasm': 'قالب Elixir', elm: 'قالب Elm', fanak: 'قالب Fanak', fennel: 'قالب Fennel', diff --git a/src/livecodes/i18n/locales/bn/language-info.lokalise.json b/src/livecodes/i18n/locales/bn/language-info.lokalise.json index dd346eb35..486601d0e 100644 --- a/src/livecodes/i18n/locales/bn/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/bn/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Elixir কম্পাইলার ওয়েবঅ্যাসেম্বলিতে চলছে, AtomVM ব্যবহার করে" + }, + "elixirWasm.link": { + "translation": " Elixir ওয়েবসাইট Elixir ল্যাঙ্গুয়েজ ডকুমেন্টেশন Y মিনিটে X শিখুন, যেখানে X=Elixir LiveCodes ডকুমেন্টেশন স্টার্টার টেমপ্লেট লোড করুন " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm কম্পাইলারের একটি WebAssembly বিল্ড দ্বারা ব্রাউজারে JavaScript-এ কম্পাইল করা হয়।" }, diff --git a/src/livecodes/i18n/locales/bn/language-info.ts b/src/livecodes/i18n/locales/bn/language-info.ts index 80c8a2cef..a56b76f02 100644 --- a/src/livecodes/i18n/locales/bn/language-info.ts +++ b/src/livecodes/i18n/locales/bn/language-info.ts @@ -144,6 +144,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>অফিসিয়াল ওয়েবসাইট <3> <4>LiveCodes ডকুমেন্টেশন ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir কম্পাইলার ওয়েবঅ্যাসেম্বলিতে চলছে, <1>AtomVM ব্যবহার করে', + link: '<1> <2>Elixir ওয়েবসাইট <3> <4>Elixir ল্যাঙ্গুয়েজ ডকুমেন্টেশন <5> <6>Y মিনিটে X শিখুন, যেখানে X=Elixir <7> <8>LiveCodes ডকুমেন্টেশন <9> <10>স্টার্টার টেমপ্লেট লোড করুন ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm কম্পাইলারের একটি WebAssembly বিল্ড দ্বারা ব্রাউজারে JavaScript-এ কম্পাইল করা হয়।', link: '<1><2>Elm অফিসিয়াল ওয়েবসাইট <3><4>Elm গাইড <5> <6>LiveCodes ডকুমেন্টেশন <7> <8>স্টার্টার টেমপ্লেট লোড করুন ', diff --git a/src/livecodes/i18n/locales/bn/translation.lokalise.json b/src/livecodes/i18n/locales/bn/translation.lokalise.json index 89e60aa0b..002d820cb 100644 --- a/src/livecodes/i18n/locales/bn/translation.lokalise.json +++ b/src/livecodes/i18n/locales/bn/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB স্টার্টার" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir স্টার্টার" + }, "templates.starter.elm": { "translation": "Elm স্টার্টার" }, diff --git a/src/livecodes/i18n/locales/bn/translation.ts b/src/livecodes/i18n/locales/bn/translation.ts index 8bdb306f9..b3e228a02 100644 --- a/src/livecodes/i18n/locales/bn/translation.ts +++ b/src/livecodes/i18n/locales/bn/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI স্টার্টার', dart: 'Dart স্টার্টার', diagrams: 'Diagrams স্টার্টার', + 'elixir-wasm': 'Elixir স্টার্টার', elm: 'Elm স্টার্টার', fanak: 'Fanak স্টার্টার', fennel: 'Fennel স্টার্টার', diff --git a/src/livecodes/i18n/locales/de/language-info.lokalise.json b/src/livecodes/i18n/locales/de/language-info.lokalise.json index 2a9e4436e..b1479742b 100644 --- a/src/livecodes/i18n/locales/de/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/de/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Elixir-Compiler, der auf WebAssembly läuft, unter Verwendung von AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir-Website Elixir-Sprachdokumentation Lerne X in Y Minuten, wobei X = Elixir LiveCodes-Dokumentation Starter-Elixirorlage laden " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm wird im Browser durch eine WebAssembly-Version des Elm-Compilers in JavaScript kompiliert." }, diff --git a/src/livecodes/i18n/locales/de/language-info.ts b/src/livecodes/i18n/locales/de/language-info.ts index a97383fb5..9fb1b6f07 100644 --- a/src/livecodes/i18n/locales/de/language-info.ts +++ b/src/livecodes/i18n/locales/de/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Offizielle Website <3> <4>LiveCodes Dokumentation ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir-Compiler, der auf WebAssembly läuft, unter Verwendung von <1>AtomVM', + link: '<1> <2>Elixir-Website <3> <4>Elixir-Sprachdokumentation <5> <6>Lerne X in Y Minuten, wobei X = Elixir <7> <8>LiveCodes-Dokumentation <9> <10>Starter-Elixirorlage laden ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm wird im Browser durch eine WebAssembly-Version des Elm-Compilers in JavaScript kompiliert.', link: '<1><2>Offizielle Website von Elm <3><4>Elm-Handbuch <5> <6>LiveCodes-Dokumentation <7> <8>Starter-Vorlage laden ', diff --git a/src/livecodes/i18n/locales/de/translation.lokalise.json b/src/livecodes/i18n/locales/de/translation.lokalise.json index 8bfbf0f9b..3cfc13a4a 100644 --- a/src/livecodes/i18n/locales/de/translation.lokalise.json +++ b/src/livecodes/i18n/locales/de/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB-Starter" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir-Starter" + }, "templates.starter.elm": { "translation": "Elm-Starter" }, diff --git a/src/livecodes/i18n/locales/de/translation.ts b/src/livecodes/i18n/locales/de/translation.ts index 12bdfaafb..340a08fd6 100644 --- a/src/livecodes/i18n/locales/de/translation.ts +++ b/src/livecodes/i18n/locales/de/translation.ts @@ -1004,6 +1004,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI-Starter', dart: 'Dart-Starter', diagrams: 'Diagramm-Starter', + 'elixir-wasm': 'Elixir-Starter', elm: 'Elm-Starter', fanak: 'Fanak-Starter', fennel: 'Fennel-Starter', diff --git a/src/livecodes/i18n/locales/en/language-info.lokalise.json b/src/livecodes/i18n/locales/en/language-info.lokalise.json index 19e518dab..19e56bfca 100644 --- a/src/livecodes/i18n/locales/en/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/en/language-info.lokalise.json @@ -348,6 +348,18 @@ "notes": "", "translation": "EJS" }, + "elixirWasm.desc": { + "notes": "### ###\n\n\n", + "translation": "Elixir compiler running on WebAssembly, using AtomVM" + }, + "elixirWasm.link": { + "notes": "### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n", + "translation": " Elixir official website Elixir documentation Learn X in Y minutes, where X=Elixir LiveCodes Documentation Load starter template " + }, + "elixirWasm.name": { + "notes": "", + "translation": "Elixir (Wasm)" + }, "elm.desc": { "notes": "", "translation": "Elm is compiled to JavaScript in the browser by a WebAssembly build of the Elm compiler." @@ -360,6 +372,18 @@ "notes": "", "translation": "Elm" }, + "erlangWasm.desc": { + "notes": "### ###\n\n\n", + "translation": "Erlang runtime running on WebAssembly, using AtomVM" + }, + "erlangWasm.link": { + "notes": "### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n", + "translation": " Erlang official website Erlang documentation Learn X in Y minutes, where X=Erlang LiveCodes Documentation Load starter template " + }, + "erlangWasm.name": { + "notes": "", + "translation": "Erlang (Wasm)" + }, "eta.desc": { "notes": "", "translation": "Embedded JS template engine for Node, Deno, and the browser. Lightweight, fast, and pluggable. Written in TypeScript." diff --git a/src/livecodes/i18n/locales/en/language-info.ts b/src/livecodes/i18n/locales/en/language-info.ts index 95f5bca6c..dcff774f2 100644 --- a/src/livecodes/i18n/locales/en/language-info.ts +++ b/src/livecodes/i18n/locales/en/language-info.ts @@ -157,11 +157,21 @@ const languageInfo = { link: '<1><2>Official website <3> <4>LiveCodes Documentations ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir compiler running on WebAssembly, using <1>AtomVM', + link: '<1> <2>Elixir official website <3> <4>Elixir documentation <5> <6>Learn X in Y minutes, where X=Elixir <7> <8>LiveCodes Documentation <9> <10>Load starter template ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm is compiled to JavaScript in the browser by a WebAssembly build of the Elm compiler.', link: '<1><2>Elm official website <3><4>Elm guide <5> <6>LiveCodes Documentation <7> <8>Load starter template ', name: 'Elm', }, + erlangWasm: { + desc: 'Erlang runtime running on WebAssembly, using <1>AtomVM', + link: '<1> <2>Erlang official website <3> <4>Erlang documentation <5> <6>Learn X in Y minutes, where X=Erlang <7> <8>LiveCodes Documentation <9> <10>Load starter template ', + name: 'Erlang (Wasm)', + }, eta: { desc: 'Embedded JS template engine for Node, Deno, and the browser. Lightweight, fast, and pluggable. Written in TypeScript.', link: '<1><2>Official website <3> <4>Documentation <5> <6>LiveCodes Documentations ', diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index e0beaf8f2..2c92bfdec 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -2656,10 +2656,18 @@ "notes": "", "translation": "DuckDB Starter" }, + "templates.starter.elixir-wasm": { + "notes": "", + "translation": "Elixir Starter" + }, "templates.starter.elm": { "notes": "", "translation": "Elm Starter" }, + "templates.starter.erlang-wasm": { + "notes": "", + "translation": "Erlang Starter" + }, "templates.starter.fanak": { "notes": "", "translation": "Fanak Starter" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index 83e37810f..dd3ef7cd2 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -1010,7 +1010,9 @@ const translation = { dart: 'Dart Starter', diagrams: 'Diagrams Starter', duckdb: 'DuckDB Starter', + 'elixir-wasm': 'Elixir Starter', elm: 'Elm Starter', + 'erlang-wasm': 'Erlang Starter', fanak: 'Fanak Starter', fennel: 'Fennel Starter', flutter: 'Flutter Starter', diff --git a/src/livecodes/i18n/locales/es/language-info.lokalise.json b/src/livecodes/i18n/locales/es/language-info.lokalise.json index a335af022..b93152d00 100644 --- a/src/livecodes/i18n/locales/es/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/es/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilador Elixir que se ejecuta en WebAssembly, utilizando AtomVM" + }, + "elixirWasm.link": { + "translation": " Página web de Elixir Documentación del lenguaje Elixir Aprende X en Y minutos, donde X = Elixir Documentación de LiveCodes Cargar plantilla de inicio " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm se compila a JavaScript en el navegador mediante una versión de WebAssembly del compilador de Elm." }, diff --git a/src/livecodes/i18n/locales/es/language-info.ts b/src/livecodes/i18n/locales/es/language-info.ts index da4d39fb3..a905e91bb 100644 --- a/src/livecodes/i18n/locales/es/language-info.ts +++ b/src/livecodes/i18n/locales/es/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Sitio web oficial <3> <4>Documentación de LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilador Elixir que se ejecuta en WebAssembly, utilizando <1>AtomVM', + link: '<1> <2>Página web de Elixir <3> <4>Documentación del lenguaje Elixir <5> <6>Aprende X en Y minutos, donde X = Elixir <7> <8>Documentación de LiveCodes <9> <10>Cargar plantilla de inicio ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm se compila a JavaScript en el navegador mediante una versión de WebAssembly del compilador de Elm.', link: '<1><2>Página web oficial de Elm <3><4>Guía de Elm <5> <6>Documentación de LiveCodes <7> <8>Cargar plantilla de inicio ', diff --git a/src/livecodes/i18n/locales/es/translation.lokalise.json b/src/livecodes/i18n/locales/es/translation.lokalise.json index bea02ee7c..555f849a3 100644 --- a/src/livecodes/i18n/locales/es/translation.lokalise.json +++ b/src/livecodes/i18n/locales/es/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Inicio DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Inicio Elixir" + }, "templates.starter.elm": { "translation": "Inicio Elm" }, diff --git a/src/livecodes/i18n/locales/es/translation.ts b/src/livecodes/i18n/locales/es/translation.ts index 31fe2c642..b2e73f1c5 100644 --- a/src/livecodes/i18n/locales/es/translation.ts +++ b/src/livecodes/i18n/locales/es/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'Inicio daisyUI', dart: 'Inicio Dart', diagrams: 'Inicio Diagramas', + 'elixir-wasm': 'Inicio Elixir', elm: 'Inicio Elm', fanak: 'Inicio Fanak', fennel: 'Inicio Fennel', diff --git a/src/livecodes/i18n/locales/fa/language-info.lokalise.json b/src/livecodes/i18n/locales/fa/language-info.lokalise.json index 438f25970..b9a0b9d0e 100644 --- a/src/livecodes/i18n/locales/fa/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/fa/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "کامپایلر Elixir که روی WebAssembly اجرا می‌شود، با استفاده از AtomVM" + }, + "elixirWasm.link": { + "translation": " وبسایت Elixir مستندات زبان Elixir زبان Elixir را در چند دقیقه بیاموزید مستندات LiveCodes بارگذاری قالب شروع " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "زبان Elm توسط یک نسخه WebAssembly از کامپایلر Elm، در مرورگر به جاوااسکریپت کامپایل می‌شود." }, diff --git a/src/livecodes/i18n/locales/fa/language-info.ts b/src/livecodes/i18n/locales/fa/language-info.ts index 87d5b755b..75dfc3089 100644 --- a/src/livecodes/i18n/locales/fa/language-info.ts +++ b/src/livecodes/i18n/locales/fa/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>وب‌سایت رسمی <3> <4>مستندات LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'کامپایلر Elixir که روی WebAssembly اجرا می‌شود، با استفاده از <1>AtomVM', + link: '<1> <2>وبسایت Elixir <3> <4>مستندات زبان Elixir <5> <6>زبان Elixir را در چند دقیقه بیاموزید <7> <8>مستندات LiveCodes <9> <10>بارگذاری قالب شروع ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'زبان Elm توسط یک نسخه WebAssembly از کامپایلر Elm، در مرورگر به جاوااسکریپت کامپایل می‌شود.', link: '<1><2>وب‌سایت رسمی Elm <3><4>راهنمای Elm <5> <6>مستندات LiveCodes <7> <8>بارگذاری الگوی اولیه ', diff --git a/src/livecodes/i18n/locales/fa/translation.lokalise.json b/src/livecodes/i18n/locales/fa/translation.lokalise.json index 1235af113..c8b8bde19 100644 --- a/src/livecodes/i18n/locales/fa/translation.lokalise.json +++ b/src/livecodes/i18n/locales/fa/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "شروع کننده DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "شروع کننده Elixir" + }, "templates.starter.elm": { "translation": "شروع کننده Elm" }, diff --git a/src/livecodes/i18n/locales/fa/translation.ts b/src/livecodes/i18n/locales/fa/translation.ts index a6e426f02..4c61d8610 100644 --- a/src/livecodes/i18n/locales/fa/translation.ts +++ b/src/livecodes/i18n/locales/fa/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'شروع کننده daisyUI', dart: 'شروع کننده Dart', diagrams: 'شروع کننده نمودارها', + 'elixir-wasm': 'شروع کننده Elixir', elm: 'شروع کننده Elm', fanak: 'شروع کننده Fanak', fennel: 'شروع کننده Fennel', diff --git a/src/livecodes/i18n/locales/fr/language-info.lokalise.json b/src/livecodes/i18n/locales/fr/language-info.lokalise.json index 9f4d2e8bb..4ddf38174 100644 --- a/src/livecodes/i18n/locales/fr/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/fr/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilateur Elixir fonctionnant sur WebAssembly, utilisant AtomVM" + }, + "elixirWasm.link": { + "translation": " Site web d'Elixir Documentation du langage Elixir Apprenez X en Y minutes, où X = Elixir Documentation LiveCodes Charger un modèle de démarrage " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm est compilé en JavaScript dans le navigateur grâce à une version WebAssembly du compilateur Elm." }, diff --git a/src/livecodes/i18n/locales/fr/language-info.ts b/src/livecodes/i18n/locales/fr/language-info.ts index efe967ae4..f00ca84fa 100644 --- a/src/livecodes/i18n/locales/fr/language-info.ts +++ b/src/livecodes/i18n/locales/fr/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Site officiel <3> <4>Documentation LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilateur Elixir fonctionnant sur WebAssembly, utilisant <1>AtomVM', + link: "<1> <2>Site web d'Elixir <3> <4>Documentation du langage Elixir <5> <6>Apprenez X en Y minutes, où X = Elixir <7> <8>Documentation LiveCodes <9> <10>Charger un modèle de démarrage ", + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm est compilé en JavaScript dans le navigateur grâce à une version WebAssembly du compilateur Elm.', link: "<1><2>Site officiel d'Elm <3><4>Guide d'Elm <5> <6>Documentation LiveCodes <7> <8>Charger un modèle de démarrage ", diff --git a/src/livecodes/i18n/locales/fr/translation.lokalise.json b/src/livecodes/i18n/locales/fr/translation.lokalise.json index cbd869241..2cd5c4022 100644 --- a/src/livecodes/i18n/locales/fr/translation.lokalise.json +++ b/src/livecodes/i18n/locales/fr/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Démarrage DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Démarrage Elixir" + }, "templates.starter.elm": { "translation": "Démarrage Elm" }, diff --git a/src/livecodes/i18n/locales/fr/translation.ts b/src/livecodes/i18n/locales/fr/translation.ts index 68bc9e254..ead85e3eb 100644 --- a/src/livecodes/i18n/locales/fr/translation.ts +++ b/src/livecodes/i18n/locales/fr/translation.ts @@ -1004,6 +1004,7 @@ const translation: I18nTranslation = { daisyui: 'Démarrage daisyUI', dart: 'Démarrage Dart', diagrams: 'Démarrage Diagrammes', + 'elixir-wasm': 'Démarrage Elixir', elm: 'Démarrage Elm', fanak: 'Démarrage Fanak', fennel: 'Démarrage Fennel', diff --git a/src/livecodes/i18n/locales/hi/language-info.lokalise.json b/src/livecodes/i18n/locales/hi/language-info.lokalise.json index 38b80310f..5031efeee 100644 --- a/src/livecodes/i18n/locales/hi/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/hi/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "वेबअसेंबली पर चल रहा Elixir कंपाइलर, AtomVM का उपयोग करके" + }, + "elixirWasm.link": { + "translation": " Elixir वेबसाइट Elixir भाषा दस्तावेज़ीकरण वाई मिनटों में एक्स सीखें, जहां एक्स=Elixir लाइवकोड दस्तावेज़ीकरण स्टार्टर टेम्पलेट लोड करें " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "एल्म को ब्राउज़र में एल्म कंपाइलर के एक वेबअसेंबली (WebAssembly) बिल्ड द्वारा जावास्क्रिप्ट में कंपाइल किया जाता है।" }, diff --git a/src/livecodes/i18n/locales/hi/language-info.ts b/src/livecodes/i18n/locales/hi/language-info.ts index a1ba86c44..e8b4d8553 100644 --- a/src/livecodes/i18n/locales/hi/language-info.ts +++ b/src/livecodes/i18n/locales/hi/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>आधिकारिक वेबसाइट <3> <4>LiveCodes दस्तावेज़ीकरण ', name: 'EJS', }, + elixirWasm: { + desc: 'वेबअसेंबली पर चल रहा Elixir कंपाइलर, <1>AtomVM का उपयोग करके', + link: '<1> <2>Elixir वेबसाइट <3> <4>Elixir भाषा दस्तावेज़ीकरण <5> <6>वाई मिनटों में एक्स सीखें, जहां एक्स=Elixir <7> <8>लाइवकोड दस्तावेज़ीकरण <9> <10>स्टार्टर टेम्पलेट लोड करें ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'एल्म को ब्राउज़र में एल्म कंपाइलर के एक वेबअसेंबली (WebAssembly) बिल्ड द्वारा जावास्क्रिप्ट में कंपाइल किया जाता है।', link: '<1><2>Elm आधिकारिक वेबसाइट <3><4>Elm गाइड <5> <6>LiveCodes दस्तावेज़ीकरण <7> <8>स्टार्टर टेम्पलेट लोड करें ', diff --git a/src/livecodes/i18n/locales/hi/translation.lokalise.json b/src/livecodes/i18n/locales/hi/translation.lokalise.json index a92a6f646..da8809531 100644 --- a/src/livecodes/i18n/locales/hi/translation.lokalise.json +++ b/src/livecodes/i18n/locales/hi/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB स्टार्टर" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir स्टार्टर" + }, "templates.starter.elm": { "translation": "Elm स्टार्टर" }, diff --git a/src/livecodes/i18n/locales/hi/translation.ts b/src/livecodes/i18n/locales/hi/translation.ts index cb7bccbd4..5a437e11e 100644 --- a/src/livecodes/i18n/locales/hi/translation.ts +++ b/src/livecodes/i18n/locales/hi/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI स्टार्टर', dart: 'Dart स्टार्टर', diagrams: 'आरेख स्टार्टर', + 'elixir-wasm': 'Elixir स्टार्टर', elm: 'Elm स्टार्टर', fanak: 'Fanak स्टार्टर', fennel: 'Fennel स्टार्टर', diff --git a/src/livecodes/i18n/locales/hu/language-info.lokalise.json b/src/livecodes/i18n/locales/hu/language-info.lokalise.json index f529f99b2..0cf01a463 100644 --- a/src/livecodes/i18n/locales/hu/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/hu/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Az Elixir fordító WebAssembly-en fut, a AtomVM használatával" + }, + "elixirWasm.link": { + "translation": " Az Elixir weboldala Az Elixir nyelv dokumentációja Tanulj meg X-et Y perc alatt, ahol X=Elixir LiveCodes dokumentáció Indító sablon betöltése " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Az Elm-et a böngészőben az Elm-fordító WebAssembly-verziója fordítja le JavaScript-re." }, diff --git a/src/livecodes/i18n/locales/hu/language-info.ts b/src/livecodes/i18n/locales/hu/language-info.ts index d4ecc7148..937852a4e 100644 --- a/src/livecodes/i18n/locales/hu/language-info.ts +++ b/src/livecodes/i18n/locales/hu/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Hivatalos weboldal <3> <4>LiveCodes dokumentáció ', name: 'EJS', }, + elixirWasm: { + desc: 'Az Elixir fordító WebAssembly-en fut, a <1>AtomVM használatával', + link: '<1> <2>Az Elixir weboldala <3> <4>Az Elixir nyelv dokumentációja <5> <6>Tanulj meg X-et Y perc alatt, ahol X=Elixir <7> <8>LiveCodes dokumentáció <9> <10>Indító sablon betöltése ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Az Elm-et a böngészőben az Elm-fordító WebAssembly-verziója fordítja le JavaScript-re.', link: '<1><2>Az Elm hivatalos weboldala <3><4>Elm útmutató <5> <6>LiveCodes dokumentáció <7> <8>Kezdő sablon betöltése ', diff --git a/src/livecodes/i18n/locales/hu/translation.lokalise.json b/src/livecodes/i18n/locales/hu/translation.lokalise.json index 2a9352303..784514579 100644 --- a/src/livecodes/i18n/locales/hu/translation.lokalise.json +++ b/src/livecodes/i18n/locales/hu/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB kezdő" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir kezdő" + }, "templates.starter.elm": { "translation": "Elm kezdő" }, diff --git a/src/livecodes/i18n/locales/hu/translation.ts b/src/livecodes/i18n/locales/hu/translation.ts index 5aa168ffe..719f9548f 100644 --- a/src/livecodes/i18n/locales/hu/translation.ts +++ b/src/livecodes/i18n/locales/hu/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI kezdő', dart: 'Dart kezdő', diagrams: 'Diagramok kezdő', + 'elixir-wasm': 'Elixir kezdő', elm: 'Elm kezdő', fanak: 'Fanak kezdő', fennel: 'Fennel kezdő', diff --git a/src/livecodes/i18n/locales/id/language-info.lokalise.json b/src/livecodes/i18n/locales/id/language-info.lokalise.json index b468e6269..5fdf7feb4 100644 --- a/src/livecodes/i18n/locales/id/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/id/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Kompiler Elixir yang berjalan di WebAssembly, menggunakan AtomVM" + }, + "elixirWasm.link": { + "translation": " Situs web Elixir Dokumentasi bahasa Elixir Pelajari X dalam Y menit, di mana X = Elixir Dokumentasi LiveCodes Muat templat awal " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm dikompilasi menjadi JavaScript di dalam browser melalui versi WebAssembly dari kompiler Elm." }, diff --git a/src/livecodes/i18n/locales/id/language-info.ts b/src/livecodes/i18n/locales/id/language-info.ts index 0c4c488a2..84a9325d8 100644 --- a/src/livecodes/i18n/locales/id/language-info.ts +++ b/src/livecodes/i18n/locales/id/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Situs web resmi <3> <4>Dokumentasi LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Kompiler Elixir yang berjalan di WebAssembly, menggunakan <1>AtomVM', + link: '<1> <2>Situs web Elixir <3> <4>Dokumentasi bahasa Elixir <5> <6>Pelajari X dalam Y menit, di mana X = Elixir <7> <8>Dokumentasi LiveCodes <9> <10>Muat templat awal ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm dikompilasi menjadi JavaScript di dalam browser melalui versi WebAssembly dari kompiler Elm.', link: '<1><2>Situs web resmi Elm <3><4>Panduan Elm <5> <6>Dokumentasi LiveCodes <7> <8>Muat templat awal ', diff --git a/src/livecodes/i18n/locales/id/translation.lokalise.json b/src/livecodes/i18n/locales/id/translation.lokalise.json index 657a650ea..c0f4f5313 100644 --- a/src/livecodes/i18n/locales/id/translation.lokalise.json +++ b/src/livecodes/i18n/locales/id/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Pemula DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Pemula Elixir" + }, "templates.starter.elm": { "translation": "Pemula Elm" }, diff --git a/src/livecodes/i18n/locales/id/translation.ts b/src/livecodes/i18n/locales/id/translation.ts index 1318ce53e..e6f74c52a 100644 --- a/src/livecodes/i18n/locales/id/translation.ts +++ b/src/livecodes/i18n/locales/id/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'Pemula daisyUI', dart: 'Pemula Dart', diagrams: 'Pemula Diagram', + 'elixir-wasm': 'Pemula Elixir', elm: 'Pemula Elm', fanak: 'Pemula Fanak', fennel: 'Pemula Fennel', diff --git a/src/livecodes/i18n/locales/it/language-info.lokalise.json b/src/livecodes/i18n/locales/it/language-info.lokalise.json index c9b4ef01e..02b5d7eae 100644 --- a/src/livecodes/i18n/locales/it/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/it/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilatore Elixir in esecuzione su WebAssembly, utilizzando AtomVM" + }, + "elixirWasm.link": { + "translation": " Sito web di Elixir Documentazione sul linguaggio Elixir Impara X in Y minuti, dove X = Elixir Documentazione LiveCodes Carica il modello di avvio " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm viene compilato in JavaScript nel browser tramite una versione WebAssembly del compilatore Elm." }, diff --git a/src/livecodes/i18n/locales/it/language-info.ts b/src/livecodes/i18n/locales/it/language-info.ts index 181e580f2..db450512b 100644 --- a/src/livecodes/i18n/locales/it/language-info.ts +++ b/src/livecodes/i18n/locales/it/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Sito ufficiale <3> <4>Documentazione LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilatore Elixir in esecuzione su WebAssembly, utilizzando <1>AtomVM', + link: '<1> <2>Sito web di Elixir <3> <4>Documentazione sul linguaggio Elixir <5> <6>Impara X in Y minuti, dove X = Elixir <7> <8>Documentazione LiveCodes <9> <10>Carica il modello di avvio ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm viene compilato in JavaScript nel browser tramite una versione WebAssembly del compilatore Elm.', link: '<1><2>Sito web ufficiale di Elm <3><4>Guida a Elm <5> <6>Documentazione LiveCodes <7> <8>Carica il modello iniziale ', diff --git a/src/livecodes/i18n/locales/it/translation.lokalise.json b/src/livecodes/i18n/locales/it/translation.lokalise.json index b2730128e..a7ff60c06 100644 --- a/src/livecodes/i18n/locales/it/translation.lokalise.json +++ b/src/livecodes/i18n/locales/it/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Starter DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Starter Elixir" + }, "templates.starter.elm": { "translation": "Starter Elm" }, diff --git a/src/livecodes/i18n/locales/it/translation.ts b/src/livecodes/i18n/locales/it/translation.ts index da7c091f4..bd7548d38 100644 --- a/src/livecodes/i18n/locales/it/translation.ts +++ b/src/livecodes/i18n/locales/it/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'Starter daisyUI', dart: 'Starter Dart', diagrams: 'Starter Diagrammi', + 'elixir-wasm': 'Starter Elixir', elm: 'Starter Elm', fanak: 'Starter Fanak', fennel: 'Starter Fennel', diff --git a/src/livecodes/i18n/locales/ja/language-info.lokalise.json b/src/livecodes/i18n/locales/ja/language-info.lokalise.json index 3018f9b0b..bc2683998 100644 --- a/src/livecodes/i18n/locales/ja/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ja/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "WebAssembly上で動作するElixirコンパイラ。AtomVM を使用。" + }, + "elixirWasm.link": { + "translation": " Elixirのウェブサイト Elixir言語のドキュメント 「XをY分で学ぶ」(X=Elixir) LiveCodes ドキュメント スターターテンプレートを読み込む " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elmは、ElmコンパイラのWebAssemblyビルドによって、ブラウザ上でJavaScriptにコンパイルされます。" }, diff --git a/src/livecodes/i18n/locales/ja/language-info.ts b/src/livecodes/i18n/locales/ja/language-info.ts index 3cd5030c2..763ac016b 100644 --- a/src/livecodes/i18n/locales/ja/language-info.ts +++ b/src/livecodes/i18n/locales/ja/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>公式ウェブサイト <3> <4>LiveCodes ドキュメント ', name: 'EJS', }, + elixirWasm: { + desc: 'WebAssembly上で動作するElixirコンパイラ。<1>AtomVM を使用。', + link: '<1> <2>Elixirのウェブサイト <3> <4>Elixir言語のドキュメント <5> <6>「XをY分で学ぶ」(X=Elixir) <7> <8>LiveCodes ドキュメント <9> <10>スターターテンプレートを読み込む ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elmは、ElmコンパイラのWebAssemblyビルドによって、ブラウザ上でJavaScriptにコンパイルされます。', link: '<1><2>Elm公式サイト <3><4>Elmガイド <5> <6>LiveCodes ドキュメント <7> <8>スターターテンプレートの読み込み ', diff --git a/src/livecodes/i18n/locales/ja/translation.lokalise.json b/src/livecodes/i18n/locales/ja/translation.lokalise.json index 2a3830c8b..2ee7780a5 100644 --- a/src/livecodes/i18n/locales/ja/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ja/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDBスターター" }, + "templates.starter.elixir-wasm": { + "translation": "Elixirスターター" + }, "templates.starter.elm": { "translation": "Elmスターター" }, diff --git a/src/livecodes/i18n/locales/ja/translation.ts b/src/livecodes/i18n/locales/ja/translation.ts index 7db0a2128..9cabe9270 100644 --- a/src/livecodes/i18n/locales/ja/translation.ts +++ b/src/livecodes/i18n/locales/ja/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUIスターター', dart: 'Dart スターター', diagrams: 'ダイアグラムスターター', + 'elixir-wasm': 'Elixirスターター', elm: 'Elmスターター', fanak: 'Fanakスターター', fennel: 'Fennelスターター', diff --git a/src/livecodes/i18n/locales/nl/language-info.lokalise.json b/src/livecodes/i18n/locales/nl/language-info.lokalise.json index 2ddda79bf..b25e0b5b4 100644 --- a/src/livecodes/i18n/locales/nl/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/nl/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Elixir-compiler die op WebAssembly draait, met AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir-website Documentatie over de taal Elixir Leer X in Y minuten, waarbij X = Elixir LiveCodes-documentatie Starterssjabloon laden " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm wordt in de browser naar JavaScript gecompileerd door een WebAssembly-versie van de Elm-compiler." }, diff --git a/src/livecodes/i18n/locales/nl/language-info.ts b/src/livecodes/i18n/locales/nl/language-info.ts index 896f93fcb..749f472a8 100644 --- a/src/livecodes/i18n/locales/nl/language-info.ts +++ b/src/livecodes/i18n/locales/nl/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Officiële website <3> <4>LiveCodes Documentatie ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir-compiler die op WebAssembly draait, met <1>AtomVM', + link: '<1> <2>Elixir-website <3> <4>Documentatie over de taal Elixir <5> <6>Leer X in Y minuten, waarbij X = Elixir <7> <8>LiveCodes-documentatie <9> <10>Starterssjabloon laden ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm wordt in de browser naar JavaScript gecompileerd door een WebAssembly-versie van de Elm-compiler.', link: '<1><2>Officiële website van Elm <3><4>Elm-handleiding <5> <6>LiveCodes-documentatie <7> <8>Startersjabloon laden ', diff --git a/src/livecodes/i18n/locales/nl/translation.lokalise.json b/src/livecodes/i18n/locales/nl/translation.lokalise.json index 92d014187..a4730e20a 100644 --- a/src/livecodes/i18n/locales/nl/translation.lokalise.json +++ b/src/livecodes/i18n/locales/nl/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB-starter" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir-starter" + }, "templates.starter.elm": { "translation": "Elm-starter" }, diff --git a/src/livecodes/i18n/locales/nl/translation.ts b/src/livecodes/i18n/locales/nl/translation.ts index b74213f83..7a9414e12 100644 --- a/src/livecodes/i18n/locales/nl/translation.ts +++ b/src/livecodes/i18n/locales/nl/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI-starter', dart: 'Dart-starter', diagrams: 'Diagrammen-starter', + 'elixir-wasm': 'Elixir-starter', elm: 'Elm-starter', fanak: 'Fanak-starter', fennel: 'Fennel-starter', diff --git a/src/livecodes/i18n/locales/pt/language-info.lokalise.json b/src/livecodes/i18n/locales/pt/language-info.lokalise.json index 9e24a65d8..60f861b89 100644 --- a/src/livecodes/i18n/locales/pt/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/pt/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilador Elixir a funcionar no WebAssembly, usando AtomVM" + }, + "elixirWasm.link": { + "translation": " Site do Elixir Documentação da linguagem Elixir Aprenda X em Y minutos, em que X = Elixir Documentação do LiveCodes Carregar modelo inicial " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "O Elm é compilado para JavaScript no navegador por uma versão do compilador Elm para WebAssembly." }, diff --git a/src/livecodes/i18n/locales/pt/language-info.ts b/src/livecodes/i18n/locales/pt/language-info.ts index 0f6c71dee..43b2606a8 100644 --- a/src/livecodes/i18n/locales/pt/language-info.ts +++ b/src/livecodes/i18n/locales/pt/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Site oficial <3> <4>Documentação do LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilador Elixir a funcionar no WebAssembly, usando <1>AtomVM', + link: '<1> <2>Site do Elixir <3> <4>Documentação da linguagem Elixir <5> <6>Aprenda X em Y minutos, em que X = Elixir <7> <8>Documentação do LiveCodes <9> <10>Carregar modelo inicial ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'O Elm é compilado para JavaScript no navegador por uma versão do compilador Elm para WebAssembly.', link: '<1><2>Site oficial do Elm <3><4>Guia do Elm <5> <6>Documentação do LiveCodes <7> <8>Carregar modelo inicial ', diff --git a/src/livecodes/i18n/locales/pt/translation.lokalise.json b/src/livecodes/i18n/locales/pt/translation.lokalise.json index ec59ffe9a..4fd70e09e 100644 --- a/src/livecodes/i18n/locales/pt/translation.lokalise.json +++ b/src/livecodes/i18n/locales/pt/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Iniciante DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Iniciante Elixir" + }, "templates.starter.elm": { "translation": "Iniciante Elm" }, diff --git a/src/livecodes/i18n/locales/pt/translation.ts b/src/livecodes/i18n/locales/pt/translation.ts index 5b73c638f..51012577c 100644 --- a/src/livecodes/i18n/locales/pt/translation.ts +++ b/src/livecodes/i18n/locales/pt/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'Iniciante daisyUI', dart: 'Iniciante Dart', diagrams: 'Iniciante Diagrams', + 'elixir-wasm': 'Iniciante Elixir', elm: 'Iniciante Elm', fanak: 'Iniciante Fanak', fennel: 'Iniciante Fennel', diff --git a/src/livecodes/i18n/locales/ru/language-info.lokalise.json b/src/livecodes/i18n/locales/ru/language-info.lokalise.json index dae9af844..94c2396c9 100644 --- a/src/livecodes/i18n/locales/ru/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ru/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Компилятор Elixir, работающий на WebAssembly, с использованием AtomVM" + }, + "elixirWasm.link": { + "translation": " Сайт Elixir Документация по языку Elixir Изучите X за Y минут, где X = Elixir Документация LiveCodes Загрузить стартовый шаблон " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm компилируется в JavaScript в браузере с помощью версии компилятора Elm, оптимизированной для WebAssembly." }, diff --git a/src/livecodes/i18n/locales/ru/language-info.ts b/src/livecodes/i18n/locales/ru/language-info.ts index c2dc74f98..9e2efc095 100644 --- a/src/livecodes/i18n/locales/ru/language-info.ts +++ b/src/livecodes/i18n/locales/ru/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Официальный сайт <3> <4>Документация LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Компилятор Elixir, работающий на WebAssembly, с использованием <1>AtomVM', + link: '<1> <2>Сайт Elixir <3> <4>Документация по языку Elixir <5> <6>Изучите X за Y минут, где X = Elixir <7> <8>Документация LiveCodes <9> <10>Загрузить стартовый шаблон ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm компилируется в JavaScript в браузере с помощью версии компилятора Elm, оптимизированной для WebAssembly.', link: '<1><2>Официальный сайт Elm <3><4>Руководство по Elm <5> <6>Документация по LiveCodes <7> <8>Загрузить стартовый шаблон ', diff --git a/src/livecodes/i18n/locales/ru/translation.lokalise.json b/src/livecodes/i18n/locales/ru/translation.lokalise.json index b2269e3cf..47357510d 100644 --- a/src/livecodes/i18n/locales/ru/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ru/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Стартер DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Стартер Elixir" + }, "templates.starter.elm": { "translation": "Стартер Elm" }, diff --git a/src/livecodes/i18n/locales/ru/translation.ts b/src/livecodes/i18n/locales/ru/translation.ts index b5db663b5..cdd0fb448 100644 --- a/src/livecodes/i18n/locales/ru/translation.ts +++ b/src/livecodes/i18n/locales/ru/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'Стартер daisyUI', dart: 'Стартер Dart', diagrams: 'Стартер диаграмм', + 'elixir-wasm': 'Стартер Elixir', elm: 'Стартер Elm', fanak: 'Стартер Fanak', fennel: 'Стартер Fennel', diff --git a/src/livecodes/i18n/locales/tr/language-info.lokalise.json b/src/livecodes/i18n/locales/tr/language-info.lokalise.json index 79c4b1c82..ef300f65e 100644 --- a/src/livecodes/i18n/locales/tr/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/tr/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "WebAssembly üzerinde çalışan Elixir derleyicisi, AtomVM kullanılarak." + }, + "elixirWasm.link": { + "translation": " Elixir web sitesi Elixir dil belgeleri Y dakikada X'i öğrenin, burada X=Elixir LiveCodes Belgeleri Başlangıç şablonunu yükle " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm, Elm derleyicisinin bir WebAssembly derlemesi aracılığıyla tarayıcıda JavaScript'e derlenir." }, diff --git a/src/livecodes/i18n/locales/tr/language-info.ts b/src/livecodes/i18n/locales/tr/language-info.ts index 7f9d05116..36cfca970 100644 --- a/src/livecodes/i18n/locales/tr/language-info.ts +++ b/src/livecodes/i18n/locales/tr/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Resmi web sitesi <3> <4>LiveCodes Dokümantasyonu ', name: 'EJS', }, + elixirWasm: { + desc: 'WebAssembly üzerinde çalışan Elixir derleyicisi, <1>AtomVM kullanılarak.', + link: "<1> <2>Elixir web sitesi <3> <4>Elixir dil belgeleri <5> <6>Y dakikada X'i öğrenin, burada X=Elixir <7> <8>LiveCodes Belgeleri <9> <10>Başlangıç şablonunu yükle ", + name: 'Elixir (Wasm)', + }, elm: { desc: "Elm, Elm derleyicisinin bir WebAssembly derlemesi aracılığıyla tarayıcıda JavaScript'e derlenir.", link: '<1><2>Elm resmi web sitesi <3><4>Elm kılavuzu <5> <6>LiveCodes Belgeleri <7> <8>Başlangıç şablonunu yükle ', diff --git a/src/livecodes/i18n/locales/tr/translation.lokalise.json b/src/livecodes/i18n/locales/tr/translation.lokalise.json index f7879cba0..aaa8ea4ac 100644 --- a/src/livecodes/i18n/locales/tr/translation.lokalise.json +++ b/src/livecodes/i18n/locales/tr/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB Başlangıç" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir Başlangıç" + }, "templates.starter.elm": { "translation": "Elm Başlangıç" }, diff --git a/src/livecodes/i18n/locales/tr/translation.ts b/src/livecodes/i18n/locales/tr/translation.ts index 78d45c04b..a61513cf2 100644 --- a/src/livecodes/i18n/locales/tr/translation.ts +++ b/src/livecodes/i18n/locales/tr/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI Başlangıç', dart: 'Dart Başlangıç', diagrams: 'Diyagramlar Başlangıç', + 'elixir-wasm': 'Elixir Başlangıç', elm: 'Elm Başlangıç', fanak: 'Fanak Başlangıç', fennel: 'Fennel Başlangıç', diff --git a/src/livecodes/i18n/locales/ur/language-info.lokalise.json b/src/livecodes/i18n/locales/ur/language-info.lokalise.json index 03b8c931a..aa7b74259 100644 --- a/src/livecodes/i18n/locales/ur/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ur/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "ای جے ایس" }, + "elixirWasm.desc": { + "translation": "Elixir کمپائلر WebAssembly پر چل رہا ہے، استعمال کر رہا ہے AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir ویب سائٹ Elixir زبان کی دستاویزات X=Elixir — Elixir کو چند منٹوں میں سیکھیں LiveCodes دستاویزات اسٹارٹر ٹیمپلیٹ لوڈ کریں " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "ایلم (Elm) کو براؤزر میں ایلم کمپائلر کے ویب اسمبلی (WebAssembly) بلڈ کے ذریعے جاوا اسکرپٹ میں کمپائل کیا جاتا ہے۔" }, diff --git a/src/livecodes/i18n/locales/ur/language-info.ts b/src/livecodes/i18n/locales/ur/language-info.ts index 5037e9f81..8638d07e1 100644 --- a/src/livecodes/i18n/locales/ur/language-info.ts +++ b/src/livecodes/i18n/locales/ur/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>سرکاری ویب سائٹ <3> <4>لائیو کوڈز دستاویزات ', name: 'ای جے ایس', }, + elixirWasm: { + desc: 'Elixir کمپائلر WebAssembly پر چل رہا ہے، استعمال کر رہا ہے <1>AtomVM', + link: '<1> <2>Elixir ویب سائٹ <3> <4>Elixir زبان کی دستاویزات <5> <6>X=Elixir — Elixir کو چند منٹوں میں سیکھیں <7> <8>LiveCodes دستاویزات <9> <10>اسٹارٹر ٹیمپلیٹ لوڈ کریں ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'ایلم (Elm) کو براؤزر میں ایلم کمپائلر کے ویب اسمبلی (WebAssembly) بلڈ کے ذریعے جاوا اسکرپٹ میں کمپائل کیا جاتا ہے۔', link: '<1><2>Elm کی سرکاری ویب سائٹ <3><4>Elm گائیڈ <5> <6>LiveCodes دستاویزی مواد <7> <8>سٹارٹر ٹیمپلیٹ لوڈ کریں ', diff --git a/src/livecodes/i18n/locales/ur/translation.lokalise.json b/src/livecodes/i18n/locales/ur/translation.lokalise.json index c211847c4..2830f0328 100644 --- a/src/livecodes/i18n/locales/ur/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ur/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "ڈک ڈی بی شروعاتی" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir شروعاتی" + }, "templates.starter.elm": { "translation": "ایلم شروعاتی" }, diff --git a/src/livecodes/i18n/locales/ur/translation.ts b/src/livecodes/i18n/locales/ur/translation.ts index c100013f9..c6471075c 100644 --- a/src/livecodes/i18n/locales/ur/translation.ts +++ b/src/livecodes/i18n/locales/ur/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI شروعاتی', dart: 'Dart شروعاتی', diagrams: 'ڈایاگرامز شروعاتی', + 'elixir-wasm': 'Elixir شروعاتی', elm: 'ایلم شروعاتی', fanak: 'Fanak شروعاتی', fennel: 'فینل شروعاتی', diff --git a/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json b/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json index ffb1e76ff..b30c4134f 100644 --- a/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "在 WebAssembly 上运行的 Elixir 编译器,使用AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir 网站 Elixir 语言文档 Y分钟掌握X,其中X=Elixir LiveCodes 文档 加载入门模板 " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm 通过 Elm 编译器的 WebAssembly 构建版本在浏览器中编译为 JavaScript。" }, diff --git a/src/livecodes/i18n/locales/zh-CN/language-info.ts b/src/livecodes/i18n/locales/zh-CN/language-info.ts index 6b88d8b46..ea244df6b 100644 --- a/src/livecodes/i18n/locales/zh-CN/language-info.ts +++ b/src/livecodes/i18n/locales/zh-CN/language-info.ts @@ -144,6 +144,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>官方网站 <3> <4>LiveCodes 文档 ', name: 'EJS', }, + elixirWasm: { + desc: '在 WebAssembly 上运行的 Elixir 编译器,使用<1>AtomVM', + link: '<1> <2>Elixir 网站 <3> <4>Elixir 语言文档 <5> <6>Y分钟掌握X,其中X=Elixir <7> <8>LiveCodes 文档 <9> <10>加载入门模板 ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm 通过 Elm 编译器的 WebAssembly 构建版本在浏览器中编译为 JavaScript。', link: '<1><2>Elm 官方网站 <3><4>Elm 指南 <5> <6>LiveCodes 文档 <7> <8>加载入门模板 ', diff --git a/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json b/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json index 7b8c965e1..c30ee36c1 100644 --- a/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json +++ b/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB 启动模板" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir 启动模板" + }, "templates.starter.elm": { "translation": "Elm 启动模板" }, diff --git a/src/livecodes/i18n/locales/zh-CN/translation.ts b/src/livecodes/i18n/locales/zh-CN/translation.ts index d63f874ed..ce80e6d9c 100644 --- a/src/livecodes/i18n/locales/zh-CN/translation.ts +++ b/src/livecodes/i18n/locales/zh-CN/translation.ts @@ -1000,6 +1000,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI 启动模板', dart: 'Dart 启动模板', diagrams: '图表启动模板', + 'elixir-wasm': 'Elixir 启动模板', elm: 'Elm 启动模板', fanak: 'Fanak 启动模板', fennel: 'Fennel 启动模板', diff --git a/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js b/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js new file mode 100644 index 000000000..609594d3e --- /dev/null +++ b/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js @@ -0,0 +1,299 @@ +/* eslint-disable no-eval -- indirect eval is required by AtomVM JS interop (onRunTrackedJs) */ +// @ts-nocheck -- vendored fixed Popcorn worker runtime (plain JS, typed as text import) +/* + * Persistent Popcorn worker runtime for LiveCodes Beam (Elixir/Erlang) Wasm. + * + * Based on Software Mansion Popcorn's iframe runtime, originally vendored through + * `live-codes/elixir-browser-eval`. + * The runtime is hosted in a Worker owned by the result window, not in an + * iframe owned by the result document. `document.open()/write()/close()` used + * by LiveReload replaces the document but keeps the window (and therefore this + * worker) alive, so a code edit no longer reboots the VM. + * + * Changes from the upstream iframe runtime: + * + * 1. `BUNDLE_PATH` is injected by the main thread when the Blob worker is + * created, so the runtime does not depend on a DOM meta element. + * 2. Messages go through `self` instead of `window.parent`. The main thread + * owns the Worker object, so no cross-origin relay is required. + * 3. The AtomVM build is single-threaded (no Emscripten pthreads), so the + * runtime runs entirely in this Worker. `main()` is started explicitly with + * `Module.callMain()` after the JS interop hooks are installed; otherwise the + * synchronous Elixir boot would call `onElixirReady` before the hooks exist. + * 4. The Elixir boot code generated by the pinned bundle expects + * `window.parent.document` in its JS interop. A minimal shim is installed + * immediately before `init()`; it is deliberately installed after the + * AtomVM module is imported so Emscripten still detects a worker + * environment. + * + * Message protocol (postMessage types) is unchanged from upstream, with an + * extra `popcorn-timing` diagnostic channel used by the benchmarks/tests. + */ + +const MESSAGES = { + INIT: 'popcorn-init', + START_VM: 'popcorn-startVm', + CALL: 'popcorn-call', + CAST: 'popcorn-cast', + CALL_ACK: 'popcorn-callAck', + STDOUT: 'popcorn-stdout', + STDERR: 'popcorn-stderr', + HEARTBEAT: 'popcorn-heartbeat', + RELOAD: 'popcorn-reload', + TIMING: 'popcorn-timing', + FATAL: 'popcorn-fatal', +}; + +const BUNDLE_PATH = '__LIVECODES_BEAM_BUNDLE_URL__'; + +const HEARTBEAT_INTERVAL_MS = 500; + +let Module = null; +let binaryDir = ''; + +const mark = (name, detail) => { + send(MESSAGES.TIMING, { name, at: performance.now(), detail }); +}; +mark('worker-start'); + +// The binaries sit next to the bundle, which may be a CDN mirror. +const locateBinary = (path) => binaryDir + path; + +class TrackedValue { + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility -- plain JS file, no TS modifiers + constructor({ key, value }) { + if (typeof key !== 'number') { + throw new Error('key property in TrackedValue must be a number'); + } + this.key = key; + this.value = value; + } +} + +globalThis.TrackedValue = TrackedValue; + +async function runWorker() { + try { + await startPopcorn(); + } catch (error) { + // A worker that dies silently is near-impossible to debug — report it and + // let the main thread reject the boot waiters immediately instead of + // waiting for the init timeout. + const detail = (error && (error.stack || error.message)) || String(error); + send(MESSAGES.STDERR, `Runtime failed: ${detail}\n`); + send(MESSAGES.FATAL, { message: detail }); + throw error; + } +} + +async function startPopcorn() { + const bundlePath = BUNDLE_PATH; + binaryDir = new URL('.', bundlePath).href; + + mark('atomvm-import-start'); + const { default: init } = await import(binaryDir + 'AtomVM.mjs'); + mark('atomvm-import-end'); + + mark('bundle-fetch-start'); + const bundleBuffer = await fetch(bundlePath).then((resp) => resp.arrayBuffer()); + const bundle = new Int8Array(bundleBuffer); + mark('bundle-fetch-end', { bytes: bundle.length }); + send(MESSAGES.INIT); + // init() hanging would leave the playground loading forever with no output, + // so fail loudly instead: the parent surfaces everything sent before dying. + const startTimeout = new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('AtomVM start timed out (VM did not boot).')), 100000); + }); + mark('vm-init-start'); + const initProcess = await Promise.race([startVm(bundle, init), startTimeout]); + mark('vm-init-end'); + + self.addEventListener('message', async ({ data }) => { + const type = data.type; + + if (type === MESSAGES.CALL) { + await handleCall(data); + } else if (type.startsWith('popcorn')) { + send(MESSAGES.STDERR, `Worker: received unhandled popcorn event: ${JSON.stringify(data)}\n`); + } + }); + + send(MESSAGES.START_VM, initProcess); + mark('vm-ready'); + setInterval(() => send(MESSAGES.HEARTBEAT, null), HEARTBEAT_INTERVAL_MS); +} + +async function startVm(avmBundle, init) { + let resolveResultPromise = null; + const resultPromise = new Promise((resolve) => { + resolveResultPromise = resolve; + }); + // The pinned Elixir boot bundle was built for an iframe and its JS interop + // reads `globalThis.window.parent.document` before calling back into + // `Module.onElixirReady`. Install the smallest possible shim after the + // AtomVM module has been imported (so Emscripten's worker detection is not + // affected) and before init() starts the Elixir boot process. + globalThis.window = globalThis; + globalThis.window.parent = globalThis; + globalThis.window.document = globalThis.document || {}; + Module = await init({ + noInitialRun: true, + locateFile: locateBinary, + preRun: [ + function ({ FS }) { + FS.mkdir('/data'); + FS.writeFile('/data/bundle.avm', avmBundle); + }, + ], + print(text) { + send(MESSAGES.STDOUT, text); + }, + printErr(text) { + send(MESSAGES.STDERR, text); + }, + onAbort() { + // Timeout so that error logs are (hopefully) printed + // before we terminate + setTimeout(() => send(MESSAGES.RELOAD, null), 100); + }, + }); + + Module.serialize = JSON.stringify; + Module.deserialize = deserialize; + Module.cleanupFunctions = new Map(); + Module.onTrackedObjectDelete = (key) => { + const fns = Module.cleanupFunctions; + const fn = fns.get(key); + fns.delete(key); + try { + if (fn) fn(); + } catch (e) { + send( + MESSAGES.STDERR, + `onTrackedObjectDelete failed: ${(e && (e.stack || e.message)) || e}\n`, + ); + } finally { + Module.trackedObjectsMap.delete(key); + } + }; + const origCast = Module.cast; + const origCall = Module.call; + + Module.cast = (process, args) => { + const serialized = Module.serialize(args); + origCast(process, serialized); + }; + Module.call = (process, args) => { + const serialized = Module.serialize(args); + return origCall(process, serialized); + }; + + Module.onRunTrackedJs = (scriptString, isDebug) => { + const trackValue = (tracked) => { + const getKey = Module.nextTrackedObjectKey; + const map = Module.trackedObjectsMap; + + if (tracked instanceof TrackedValue) { + map.set(tracked.key, tracked.value); + return tracked.key; + } + + const key = getKey(); + map.set(key, tracked); + return key; + }; + + let fn; + try { + const indirectEval = eval; + fn = indirectEval(scriptString); // NOSONAR - required by AtomVM's sandboxed JS interop + } catch (e) { + send(MESSAGES.STDERR, `onRunTrackedJs eval failed: ${(e && (e.stack || e.message)) || e}\n`); + return null; + } + if (isDebug) ensureFunctionEval(fn); + let result; + try { + result = fn(Module); + } catch (e) { + send(MESSAGES.STDERR, `onRunTrackedJs failed: ${(e && (e.stack || e.message)) || e}\n`); + return null; + } + if (isDebug) ensureResultKeyList(result); + + return result == null ? [] : result.map(trackValue); + }; + Module.onGetTrackedObjects = (keys) => { + const getTrackedObject = (key) => { + const serialize = Module.serialize; + const map = Module.trackedObjectsMap; + + return serialize(map.get(key)); + }; + return keys.map(getTrackedObject); + }; + + Module.onElixirReady = (initProcess) => { + Module.onElixirReady = null; + resolveResultPromise(initProcess); + }; + + Module.callMain(['/data/bundle.avm']); + + return resultPromise; +} + +async function handleCall(data) { + const { requestId, process, args } = data.value; + send(MESSAGES.CALL_ACK, { requestId }); + mark('call-start', { requestId }); + + try { + const result = await Module.call(process, args); + mark('call-end', { requestId }); + send(MESSAGES.CALL, { requestId, data: Module.deserialize(result) }); + } catch (error) { + mark('call-error', { requestId, error: String(error) }); + if (error === 'noproc') { + send(MESSAGES.RELOAD, null); + send(MESSAGES.STDERR, 'Runtime VM crashed, worker will be restarted.\n'); + return; + } + send(MESSAGES.CALL, { requestId, error: Module.deserialize(error) }); + } +} + +function ensureFunctionEval(maybeFunction) { + if (typeof maybeFunction !== 'function') { + throw new Error('Script passed to onRunTrackedJs() is not wrapped in a function'); + } +} + +function ensureResultKeyList(result) { + if (!Array.isArray(result) && result !== undefined) { + throw new Error( + 'Script passed to onRunTrackedJs() returned invalid value, accepted values are arrays and undefined', + ); + } +} + +function send(type, data) { + self.postMessage({ type, value: data }); +} + +function deserialize(message) { + return JSON.parse(message, (key, value) => { + const isRef = + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'popcorn_ref') && + Object.getOwnPropertyNames(value).length === 1; + + if (!isRef) { + return value; + } + return Module.trackedObjectsMap.get(value.popcorn_ref); + }); +} + +void runWorker(); diff --git a/src/livecodes/languages/beam-wasm/prelude.ts b/src/livecodes/languages/beam-wasm/prelude.ts new file mode 100644 index 000000000..231ac2ad8 --- /dev/null +++ b/src/livecodes/languages/beam-wasm/prelude.ts @@ -0,0 +1,41 @@ +// AtomVM compiles this module on first boot, and compile time is the dominant +// cold-start cost. Keep it small; the worker keeps the compiled module alive +// across LiveReload edits. The device is shared by Elixir and Erlang. +export const BEAM_PRELUDE = `defmodule :livecodes_io do + def start(stdin) do + lines = + stdin + |> to_string() + |> String.split("\\n", trim: true) + |> Enum.map(&(&1 <> "\\n")) + + spawn(fn -> loop(lines) end) + end + + def stop(dev), do: send(dev, :lc_stop) + + def halt, do: throw({:lc_exit, 0}) + def halt(status), do: throw({:lc_exit, status}) + + defp loop(lines) do + receive do + :lc_stop -> :ok + {:io_request, from, reply_as, req} -> + {reply, rest} = handle(req, lines) + send(from, {:io_reply, reply_as, reply}) + loop(rest) + end + end + + defp handle({:get_line, _enc, _prompt}, [line | rest]), do: {line, rest} + defp handle({:get_line, _enc, _prompt}, []), do: {:eof, []} + defp handle({:put_chars, _enc, chars}, lines) do + IO.write(chars) + {:ok, lines} + end + defp handle({:put_chars, chars}, lines) do + IO.write(chars) + {:ok, lines} + end + defp handle(other, lines), do: {{:error, {:enotsup, other}}, lines} +end`; diff --git a/src/livecodes/languages/beam-wasm/runtime.ts b/src/livecodes/languages/beam-wasm/runtime.ts new file mode 100644 index 000000000..00d6c54b1 --- /dev/null +++ b/src/livecodes/languages/beam-wasm/runtime.ts @@ -0,0 +1,856 @@ +/* eslint-disable no-console */ +import { getErrorMessage } from '../../utils/utils'; +import { createLoadingReporter } from '../wasm-runtime'; +// @ts-ignore +// eslint-disable-next-line import/no-unresolved +import workerSource from './lang-beam-wasm-worker.raw.js?raw'; + +export interface RunResult { + output: string | null; + error: string | null; + exitCode: number | null; + value?: string | null; + warnings?: string | null; + cancelled?: boolean; +} + +export interface ParsedResult { + value: string | null; + error: string | null; + exitCode: number; +} + +export interface BeamTiming { + name: string; + at: number; + detail?: Record; +} + +export interface BeamApi { + ready: boolean; + failed?: boolean; + input?: string; + output: string | null; + error: string | null; + exitCode: number | null; + value?: string | null; + warnings?: string | null; + loaded: Promise; + evals?: number; + runId?: number; + currentRun?: Promise | null; + init?: Promise; + ensureInit?: () => Promise; + retry?: () => Promise; + popcorn?: any; + stdout?: string[]; + stderr?: string[]; + timings?: BeamTiming[]; + run: (input?: string) => Promise; +} + +export interface BeamRuntimeConfig { + /** Key on `window.livecodes` used by the public API, e.g. `elixir`. */ + apiKey: string; + /** Optional alias key, e.g. `elixir-wasm`. */ + apiAlias?: string; + /** Human-readable language name used in error messages. */ + label: string; + /** Result script tag type, e.g. `text/elixir-wasm`. */ + scriptType: string; + /** Pinned runtime bundle URL. */ + bundleUrl: string; + /** Language prelude, compiled once per worker with `eval_elixir`. */ + prelude: string; + /** Builds the runtime call payload for a run. */ + buildCall: (code: string, stdin: string) => unknown[]; + /** Parses the language-specific result protocol. */ + parseResult: (text: string) => ParsedResult; + /** Parses the Elixir prelude result. Defaults to `parseTaggedResult`. */ + parsePreludeResult?: (text: string) => ParsedResult; + /** Normalizes the raw runtime result before parsing. */ + normalizeResult?: (data: unknown) => string; + /** Whether the wrapping language adds a trailing newline that should be dropped. */ + stripTrailingNewline?: boolean; +} + +declare const window: Window & { + livecodes: Record; +}; + +const EVAL_TIMEOUT_MS = 60_000; +// First boot downloads ~8MB (bundle.avm + AtomVM.wasm) and starts the VM, +// so allow generous time before giving up. +const INIT_VM_TIMEOUT_MS = 120_000; +const SUPERSEDE_TIMEOUT_MS = 10_000; + +// Message protocol spoken by the runtime worker (same types as upstream Popcorn). +const bridgeMessages = { + init: 'popcorn-init', + startVm: 'popcorn-startVm', + call: 'popcorn-call', + callAck: 'popcorn-callAck', + stdout: 'popcorn-stdout', + stderr: 'popcorn-stderr', + heartbeat: 'popcorn-heartbeat', + reload: 'popcorn-reload', + timing: 'popcorn-timing', + fatal: 'popcorn-fatal', +} as const; + +export const OK_TAG = '__LC_OK__'; +export const ERROR_TAG = '__LC_ERROR__'; +export const EXIT_TAG = '__LC_EXIT__'; +const LEGACY_EXIT_TAG = /^\{:?lc_exit, (-?\d+)\}$/; +const LEGACY_ERROR_TAG = /^\{:?lc_error, ([\s\S]*)\}$/; + +export const decodeBase64 = (value: string): string => { + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return value; + } +}; + +// Popcorn serializes a returned Elixir string as a JSON string. A tagged +// protocol string therefore arrives as `"\"__LC_OK__...\""`; unwrap it before +// parsing. Atoms/tuples arrive without the extra quoting. +export const normalizeResultText = (data: unknown): string => { + if (data == null) return ''; + let text = String(data); + if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { + try { + const parsed = JSON.parse(text); + if (typeof parsed === 'string') text = parsed; + } catch { + // Keep the original text if it is not valid JSON. + } + } + return text; +}; + +export const parseTaggedResult = (text: string, defaultError = 'Runtime error'): ParsedResult => { + if (text.startsWith(OK_TAG)) { + return { value: decodeBase64(text.slice(OK_TAG.length)), error: null, exitCode: 0 }; + } + if (text.startsWith(EXIT_TAG)) { + const exitCode = Number(text.slice(EXIT_TAG.length)); + return { value: null, error: null, exitCode: Number.isFinite(exitCode) ? exitCode : 1 }; + } + if (text.startsWith(ERROR_TAG)) { + const [kind, message, stack] = text + .slice(ERROR_TAG.length) + .split('|') + .map((part) => decodeBase64(part)); + const detail = message || stack || defaultError; + const prefix = kind && kind !== 'error' && kind !== 'syntax' ? `${kind}: ` : ''; + return { value: null, error: `${prefix}${detail}`, exitCode: 1 }; + } + const legacyExit = LEGACY_EXIT_TAG.exec(text); + if (legacyExit) { + return { value: null, error: null, exitCode: Number(legacyExit[1]) }; + } + const legacyError = LEGACY_ERROR_TAG.exec(text); + if (legacyError) { + return { value: null, error: legacyError[1], exitCode: 1 }; + } + return { value: text === '' ? null : text, error: null, exitCode: 0 }; +}; + +export const readBeamCode = (scriptType: string): string => { + let code = ''; + document.querySelectorAll(`script[type="${scriptType}"]`).forEach((script) => { + code += `${script.textContent ?? script.innerHTML}\n`; + }); + return code; +}; + +const drain = (arr: string[]) => { + const out = arr.join('\n'); + arr.length = 0; + return out; +}; + +interface PendingCall { + startTimeMs: number; + resolve: (data: any) => void; + reject: (reason?: unknown) => void; + timer: ReturnType | null; +} + +interface Waiter { + resolve: (value: any) => void; + reject: (reason?: unknown) => void; + timer: ReturnType | null; +} + +export const createBeamRuntime = (config: BeamRuntimeConfig): BeamApi => { + const postLoading = createLoadingReporter(); + const normalizeResult = config.normalizeResult ?? normalizeResultText; + const stripTrailingNewline = config.stripTrailingNewline ?? true; + + window.livecodes ??= {}; + const existing = window.livecodes[config.apiKey] as BeamApi | undefined; + const timings: BeamTiming[] = existing?.timings ?? []; + const mark = (name: string, detail?: Record) => { + timings.push({ name, at: performance.now(), ...(detail == null ? {} : { detail }) }); + if (timings.length > 1000) timings.splice(0, timings.length - 1000); + }; + + const api = (window.livecodes[config.apiKey] ??= {} as BeamApi); + api.ready = false; + api.evals = (api.evals ?? 0) + 1; + const thisEval = api.evals; + mark('script-eval', { + evalId: thisEval, + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + }); + + const readCode = () => readBeamCode(config.scriptType); + + /** + * Main-thread side of the Beam runtime. + * + * The runtime runs in a Worker owned by the result window. LiveReload rewrites + * the result document with `document.open()/write()/close()`, which keeps the + * window (and therefore this Worker) alive; editing code no longer destroys + * the VM. The bridge is stored on `window.livecodes[apiKey]`, which also + * survives document rewrites. + */ + class BeamBridge { + private bundlePath: string; + private label: string; + private markTiming: (name: string, detail?: Record) => void; + private onStdout: (text: string) => void; + private onStderr: (text: string) => void; + private onReady: ((bridge: BeamBridge) => Promise) | null; + private worker: Worker | null = null; + private workerUrl: string | null = null; + private initProcess: string | null = null; + private requestId = 0; + private calls = new Map(); + private waiters = new Map(); + private booting: Promise | null = null; + private ready = false; + private failed: string | null = null; + private generation = 0; + private destroyed = false; + + private constructor(options: { + bundlePath: string; + label: string; + mark: (name: string, detail?: Record) => void; + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; + onReady?: (bridge: BeamBridge) => Promise; + }) { + this.bundlePath = options.bundlePath; + this.label = options.label; + this.markTiming = options.mark; + this.onStdout = options.onStdout ?? console.log; + this.onStderr = options.onStderr ?? console.warn; + this.onReady = options.onReady ?? null; + } + + public get error(): string | null { + return this.failed; + } + + public get isReady(): boolean { + return this.ready && this.worker != null; + } + + public static async init(options: { + bundlePath: string; + label: string; + mark: (name: string, detail?: Record) => void; + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; + onReady?: (bridge: BeamBridge) => Promise; + }): Promise { + const bridge = new BeamBridge(options); + await bridge.start(); + return bridge; + } + + public ensureReady(): Promise { + return this.start(); + } + + public async call(args: unknown, options: { timeoutMs?: number } = {}): Promise { + await this.start(); + if (!this.isReady || this.initProcess == null) { + throw new Error(this.failed || `${this.label} runtime is not ready`); + } + const requestId = this.requestId; + this.requestId += 1; + const targetProcess = this.initProcess; + const timeoutMs = options.timeoutMs ?? EVAL_TIMEOUT_MS; + const callPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.calls.delete(requestId); + // Module.call is synchronous inside the worker. If it does not return, + // terminating the worker is the only reliable way to unblock newer + // work. This happens on timeouts only, never on a normal edit. + this.stopWorker(`${this.label} evaluation timed out after ${timeoutMs} ms`); + reject(new Error(`${this.label} evaluation timed out after ${timeoutMs} ms`)); + }, timeoutMs); + this.calls.set(requestId, { startTimeMs: performance.now(), resolve, reject, timer }); + }); + try { + this.post(bridgeMessages.call, { requestId, process: targetProcess, args }); + } catch (err) { + const call = this.calls.get(requestId); + if (call != null) { + this.calls.delete(requestId); + if (call.timer != null) clearTimeout(call.timer); + call.reject(err); + } + } + return callPromise; + } + + /** Terminate the worker without waiting for the current evaluation. */ + public cancel(reason = 'superseded by a newer run'): void { + if (this.worker == null) return; + this.stopWorker(reason); + } + + public async restart(): Promise { + this.stopWorker('manual restart'); + await this.start(); + } + + public destroy(): void { + this.destroyed = true; + this.stopWorker('destroyed'); + } + + private async start(): Promise { + if (this.destroyed) throw new Error(`${this.label} runtime was destroyed`); + if (this.isReady) return; + if (this.booting != null) return this.booting; + const attempt = this.boot(); + this.booting = attempt; + try { + await attempt; + } finally { + if (this.booting === attempt) this.booting = null; + } + } + + private async boot(): Promise { + this.stopWorker('restarting'); + this.failed = null; + const generation = this.generation; + this.markTiming('worker-create-start'); + const source = String(workerSource).replace('__LIVECODES_BEAM_BUNDLE_URL__', this.bundlePath); + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + this.workerUrl = url; + try { + const worker = new Worker(url, { type: 'module' }); + this.worker = worker; + worker.addEventListener('message', this.onWorkerMessage); + worker.addEventListener('error', this.onWorkerError); + worker.addEventListener('messageerror', this.onWorkerMessageError); + this.markTiming('worker-created'); + const initPromise = this.waitFor(bridgeMessages.init, INIT_VM_TIMEOUT_MS); + const startPromise = this.waitFor(bridgeMessages.startVm, INIT_VM_TIMEOUT_MS); + await initPromise; + this.markTiming('worker-init-message'); + this.initProcess = await startPromise; + if (generation !== this.generation) { + throw new Error(`${this.label} runtime was restarted`); + } + this.markTiming('worker-vm-ready'); + this.ready = true; + if (this.onReady != null) { + await this.onReady(this); + } + this.markTiming('worker-prelude-ready'); + } catch (err) { + this.stopWorker(`boot failed: ${getErrorMessage(err)}`); + throw err; + } + } + + private waitFor(type: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const waiter: Waiter = { resolve, reject, timer: null }; + waiter.timer = setTimeout(() => { + const list = this.waiters.get(type); + const index = list?.indexOf(waiter) ?? -1; + if (list != null && index >= 0) list.splice(index, 1); + reject(new Error(`Timed out waiting for ${type}`)); + }, timeoutMs); + const list = this.waiters.get(type) ?? []; + list.push(waiter); + this.waiters.set(type, list); + }); + } + + private resolveWaiter(type: string, value: any): void { + const list = this.waiters.get(type); + const waiter = list?.shift(); + if (list != null && list.length === 0) this.waiters.delete(type); + if (waiter == null) return; + if (waiter.timer != null) clearTimeout(waiter.timer); + waiter.resolve(value); + } + + private post(type: string, value: unknown): void { + if (this.worker == null) throw new Error(`${this.label} worker is not running`); + this.worker.postMessage({ type, value }); + } + + private onWorkerMessage = (event: MessageEvent): void => { + const data = event.data as { type?: unknown; value?: any } | null; + if (data == null || typeof data.type !== 'string') return; + const value = data.value; + switch (data.type) { + case bridgeMessages.init: + case bridgeMessages.startVm: + this.resolveWaiter(data.type, value); + break; + case bridgeMessages.stdout: + this.onStdout(typeof value === 'string' ? value : String(value)); + break; + case bridgeMessages.stderr: + this.onStderr(typeof value === 'string' ? value : String(value)); + break; + case bridgeMessages.call: + this.handleCall(value); + break; + case bridgeMessages.callAck: + break; + case bridgeMessages.heartbeat: + break; + case bridgeMessages.reload: + this.stopWorker(`${this.label} runtime requested a restart`); + break; + case bridgeMessages.fatal: + { + const detail = + typeof value?.message === 'string' ? value.message : 'unknown runtime failure'; + this.onStderr(`Runtime failed: ${detail}\n`); + this.stopWorker(`${this.label} runtime failed: ${detail}`); + } + break; + case bridgeMessages.timing: + this.recordWorkerTiming(value); + break; + default: + break; + } + }; + + private onWorkerError = (event: ErrorEvent): void => { + const detail = + event.error instanceof Error && event.error.message + ? event.error.message + : event.message || 'unknown error'; + const message = `${this.label} worker error: ${detail}`; + this.onStderr(`${message}\n`); + this.stopWorker(message); + }; + + private onWorkerMessageError = (): void => { + const message = `${this.label} worker message could not be deserialized`; + this.onStderr(`${message}\n`); + this.stopWorker(message); + }; + + private handleCall(value: { requestId: number; error?: unknown; data?: unknown }): void { + const call = this.calls.get(value.requestId); + if (call == null) return; + this.calls.delete(value.requestId); + if (call.timer != null) clearTimeout(call.timer); + if (value.error !== undefined) { + call.reject(value.error); + } else { + call.resolve({ data: value.data }); + } + } + + private recordWorkerTiming(value: { + name?: unknown; + at?: unknown; + detail?: Record; + }): void { + if (typeof value?.name !== 'string') return; + this.markTiming(`worker:${value.name}`, { + workerAt: typeof value.at === 'number' ? value.at : null, + ...(value.detail ?? {}), + }); + } + + private stopWorker(reason: string): void { + const pendingCalls = this.calls.size; + this.generation += 1; + this.ready = false; + this.initProcess = null; + this.failed = reason; + if (this.worker != null) { + this.worker.removeEventListener('message', this.onWorkerMessage); + this.worker.removeEventListener('error', this.onWorkerError); + this.worker.removeEventListener('messageerror', this.onWorkerMessageError); + this.worker.terminate(); + this.worker = null; + } + if (this.workerUrl != null) { + URL.revokeObjectURL(this.workerUrl); + this.workerUrl = null; + } + for (const call of this.calls.values()) { + if (call.timer != null) clearTimeout(call.timer); + call.reject(new Error(reason)); + } + this.calls.clear(); + for (const list of this.waiters.values()) { + for (const waiter of list) { + if (waiter.timer != null) clearTimeout(waiter.timer); + waiter.reject(new Error(reason)); + } + } + this.waiters.clear(); + mark('worker-stopped', { reason, pendingCalls }); + } + } + + const initBridge = async (): Promise => { + mark('init-start'); + postLoading(true); + const stdout: string[] = api.stdout ?? []; + const stderr: string[] = api.stderr ?? []; + api.stdout = stdout; + api.stderr = stderr; + try { + const bridge = await BeamBridge.init({ + bundlePath: config.bundleUrl, + label: config.label, + mark, + onStdout: (text: string) => stdout.push(text), + onStderr: (text: string) => stderr.push(text), + onReady: async (readyBridge) => { + // Drop boot chatter (module import, fetches, wasm init) so it does not + // leak into the first run's output after a boot or crash recovery. + drain(stdout); + drain(stderr); + mark('prelude-start'); + const prelude = await readyBridge.call(['eval_elixir', config.prelude], { + timeoutMs: EVAL_TIMEOUT_MS, + }); + mark('prelude-end'); + const preludeText = normalizeResultText(prelude?.data); + const parsePrelude = config.parsePreludeResult ?? parseTaggedResult; + const preludeError = parsePrelude(preludeText).error; + if (preludeError != null) { + throw new Error(`${config.label} prelude failed: ${preludeError}`); + } + // Warnings emitted while compiling the prelude are not user output. + drain(stdout); + drain(stderr); + }, + }); + api.popcorn = bridge; + api.failed = false; + mark('init-ready'); + } catch (err) { + // Surface anything the runtime reported before dying (e.g. bundle/AtomVM fetch failures). + if (stderr.length > 0) console.error(drain(stderr)); + if (stdout.length > 0) console.log(drain(stdout)); + api.failed = true; + api.error = getErrorMessage(err); + mark('init-failed', { error: api.error }); + throw err; + } finally { + postLoading(false); + } + }; + + // `ensureInit` is stored on the API object so it survives LiveReload + // document rewrites and can reuse the existing worker. A rejected attempt is + // cleared, so the next run (or retry()) starts a fresh boot instead of + // replaying a memoized failure forever. + api.ensureInit ??= async () => { + const existingBridge = api.popcorn as BeamBridge | undefined; + if (existingBridge != null) { + await existingBridge.ensureReady(); + return; + } + if (api.init == null) { + const attempt = initBridge(); + api.init = attempt; + attempt.catch(() => { + if (api.init === attempt) api.init = undefined; + }); + } + await api.init; + }; + + const runOnce = async (input?: string, runId?: number): Promise => { + if (input !== undefined) api.input = input; + const stdin = `${input ?? api.input ?? ''}`; + const isLatest = () => runId == null || runId === api.runId; + api.output = null; + api.error = null; + api.exitCode = null; + api.value = null; + api.warnings = null; + api.ready = false; + + const code = readCode(); + mark('run-start', { runId, codeBytes: code.length, inputBytes: stdin.length }); + if (!code.trim()) { + mark('run-empty', { runId }); + if (isLatest()) { + api.output = null; + api.error = null; + api.exitCode = 0; + api.value = null; + api.warnings = null; + api.ready = true; + api.failed = false; + } + mark('run-end', { + runId, + exitCode: 0, + stdoutBytes: 0, + stderrBytes: 0, + hasError: false, + latest: isLatest(), + empty: true, + }); + return { output: null, error: null, exitCode: 0, value: null, warnings: null }; + } + + try { + await api.ensureInit?.(); + const bridge = api.popcorn as BeamBridge | undefined; + if (bridge == null) throw new Error(`${config.label} runtime is not initialized`); + api.stdout!.length = 0; + api.stderr!.length = 0; + mark('eval-start', { runId }); + const { data } = await bridge.call(config.buildCall(code, stdin), { + timeoutMs: EVAL_TIMEOUT_MS, + }); + const printed = drain(api.stdout!); + const errPrinted = drain(api.stderr!); + const parsed = config.parseResult(normalizeResult(data)); + // The wrapper appends one newline to flush line-buffered IO.write/1. Drop + // exactly that newline; a single IO.puts chunk may include it. + const outputText = + stripTrailingNewline && printed.endsWith('\n') ? printed.slice(0, -1) : printed; + const output = outputText || null; + const warnings = errPrinted || null; + const latest = isLatest(); + if (latest) { + api.output = output; + api.error = parsed.error; + api.exitCode = parsed.exitCode; + api.value = parsed.value; + api.warnings = warnings; + api.ready = true; + api.failed = false; + if (parsed.error != null) console.error(parsed.error); + if (output != null) console.log(output); + } + mark('run-end', { + runId, + exitCode: parsed.exitCode, + stdoutBytes: printed.length, + stderrBytes: errPrinted.length, + hasError: parsed.error != null, + latest, + }); + return { + output, + error: parsed.error, + exitCode: parsed.exitCode, + value: parsed.value, + warnings, + ...(latest ? {} : { cancelled: true }), + }; + } catch (err) { + const latest = isLatest(); + const runtimeStderr = latest ? drain(api.stderr ?? []) : ''; + const base = 'Error: ' + getErrorMessage(err); + const message = runtimeStderr.trim() ? `${base}\n${runtimeStderr.trim()}` : base; + if (latest) { + api.output = null; + api.error = message; + api.exitCode = 1; + api.value = null; + api.warnings = null; + api.ready = true; + api.failed = true; + console.error(message); + } + mark('run-error', { runId, error: message, latest }); + return { + output: null, + error: message, + exitCode: 1, + value: null, + warnings: null, + ...(latest ? {} : { cancelled: true }), + }; + } + }; + + let activeRun: Promise | null = null; + let pendingRun: { + input?: string; + runId: number; + resolve: (result: RunResult) => void; + reject: (reason?: unknown) => void; + } | null = null; + let supersedeTimer: ReturnType | null = null; + + const clearSupersedeTimer = () => { + if (supersedeTimer == null) return; + clearTimeout(supersedeTimer); + supersedeTimer = null; + }; + + const scheduleSupersede = () => { + if (supersedeTimer != null) return; + supersedeTimer = setTimeout(() => { + supersedeTimer = null; + if (activeRun == null || pendingRun == null) return; + const bridge = api.popcorn as BeamBridge | undefined; + mark('run-supersede-timeout', { pendingRunId: pendingRun.runId }); + bridge?.cancel('superseded by a newer run'); + }, SUPERSEDE_TIMEOUT_MS); + }; + + const finishRun = (task: Promise) => { + if (activeRun !== task) return; + activeRun = null; + api.currentRun = null; + clearSupersedeTimer(); + const next = pendingRun; + pendingRun = null; + if (next != null) { + mark('run-dequeued', { runId: next.runId }); + const nextTask = startRun(next.input, next.runId); + void nextTask.then(next.resolve, next.reject); + } + }; + + const startRun = (input: string | undefined, runId: number): Promise => { + const task = runOnce(input, runId); + activeRun = task; + api.currentRun = task.then( + () => undefined, + () => undefined, + ); + void task.then( + () => finishRun(task), + () => finishRun(task), + ); + return task; + }; + + api.run ??= async (input?: string): Promise => { + const runId = (api.runId ?? 0) + 1; + api.runId = runId; + mark('run-queued', { runId, hasInput: input != null }); + if (activeRun == null) { + return startRun(input, runId); + } + if (pendingRun != null) { + const superseded = pendingRun; + pendingRun = null; + mark('run-superseded', { runId: superseded.runId, by: runId }); + superseded.resolve({ + output: null, + error: null, + exitCode: null, + value: null, + warnings: null, + cancelled: true, + }); + } + scheduleSupersede(); + return new Promise((resolve, reject) => { + pendingRun = { input, runId, resolve, reject }; + mark('run-pending', { runId }); + }); + }; + + api.retry ??= async () => { + api.failed = false; + api.error = null; + api.ready = false; + api.init = undefined; + const bridge = api.popcorn as BeamBridge | undefined; + try { + if (bridge != null) { + await bridge.restart(); + } else { + await api.ensureInit?.(); + } + } catch (err) { + api.failed = true; + api.error = getErrorMessage(err); + throw err; + } + }; + + // LiveReload updates rewrite the result document in rapid succession and the + // window `load` event is not reliably fired after coalesced rewrites, so runs + // are scheduled by polling the parser state instead of listening for `load`. + // Only the latest evaluation runs; superseded ones abort silently. + const waitForParseThenRun = async (evalId: number): Promise => { + const start = Date.now(); + while (document.readyState === 'loading' && Date.now() - start < 30_000) { + if (api.evals !== evalId) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (api.evals !== evalId) return; + postLoading(true); + try { + await api.run(api.input); + } finally { + postLoading(false); + } + }; + void waitForParseThenRun(thisEval); + + api.timings = timings; + const LOADED_TIMEOUT_MS = INIT_VM_TIMEOUT_MS + EVAL_TIMEOUT_MS; + api.loaded = new Promise((resolve, reject) => { + const started = performance.now(); + const interval = setInterval(() => { + if (api.failed) { + clearInterval(interval); + mark('loaded-rejected', { error: api.error }); + reject(new Error(api.error || `Failed to initialize the ${config.label} environment`)); + } else if (api.ready) { + clearInterval(interval); + mark('loaded-resolved'); + resolve(); + } else if (performance.now() - started > LOADED_TIMEOUT_MS) { + clearInterval(interval); + api.failed = true; + api.error = `Timed out initializing the ${config.label} runtime`; + mark('loaded-timeout'); + reject(new Error(api.error)); + } + }, 50); + }); + // The starter awaits `loaded` and may not catch. Attaching a no-op handler + // keeps a recoverable boot failure from becoming an unhandled rejection; the + // original promise still rejects for callers that want the error. + void api.loaded.catch(() => undefined); + + window.livecodes[config.apiKey] = api; + if (config.apiAlias != null) { + window.livecodes[config.apiAlias] = api; + } + return api; +}; diff --git a/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts b/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts new file mode 100644 index 000000000..39e1ada97 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { elixirWasm } from '../lang-elixir-wasm'; + +const readSource = (file: string) => + readFileSync(resolve(__dirname, '..', '..', 'beam-wasm', file), 'utf8'); +const readLanguageSource = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); + +describe('Elixir (Wasm) language spec', () => { + test('is registered as elixir-wasm, with the wasm compiler script', () => { + expect(elixirWasm.name).toBe('elixir-wasm'); + expect(elixirWasm.title).toBe('Elixir (Wasm)'); + expect(elixirWasm.compiler).toMatchObject({ + scriptType: 'text/elixir-wasm', + compiledCodeLanguage: 'elixir', + liveReload: true, + }); + }); + + test('accepts the common Elixir extensions', () => { + expect(elixirWasm.extensions).toEqual( + expect.arrayContaining(['ex', 'exs', 'elixir', 'elixir-wasm', 'wasm.ex']), + ); + }); + + test('warns that the first run is a large download', () => { + expect(elixirWasm.largeDownload).toBe(true); + }); + + test('uses a code editor with Elixir support', () => { + expect(elixirWasm.editor).toBe('script'); + expect(elixirWasm.editorSupport?.monaco?.language).toBe('elixir'); + expect(elixirWasm.editorSupport?.codejar?.language).toBe('elixir'); + }); +}); + +describe('Elixir (Wasm) bridge', () => { + const bridge = readSource('runtime.ts'); + const worker = readSource('lang-beam-wasm-worker.raw.js'); + const script = readLanguageSource('lang-elixir-wasm-script.ts'); + + test('keeps the VM in a module worker instead of an iframe', () => { + expect(bridge).toContain("new Worker(url, { type: 'module' })"); + expect(bridge).toContain('worker.terminate()'); + expect(bridge).not.toContain("createElement('iframe')"); + }); + + test('uses tagged results for values, errors, and exits', () => { + expect(bridge).toContain("OK_TAG = '__LC_OK__'"); + expect(bridge).toContain("ERROR_TAG = '__LC_ERROR__'"); + expect(bridge).toContain("EXIT_TAG = '__LC_EXIT__'"); + expect(script).toContain('Base.encode64'); + }); + + test('bounds calls and supersedes a blocked run', () => { + expect(bridge).toContain('EVAL_TIMEOUT_MS = 60_000'); + expect(bridge).toContain('SUPERSEDE_TIMEOUT_MS = 10_000'); + expect(bridge).toContain("mark('run-supersede-timeout'"); + expect(bridge).toContain('const latest = isLatest()'); + }); + + test('boots AtomVM in a worker with the required interop shim', () => { + expect(worker).toContain("INIT: 'popcorn-init'"); + expect(worker).toContain("CALL: 'popcorn-call'"); + expect(worker).toContain('globalThis.window = globalThis'); + expect(worker).toContain('noInitialRun: true'); + expect(worker).toContain("Module.callMain(['/data/bundle.avm'])"); + expect(worker).toContain('Module.onElixirReady'); + expect(worker).toContain('self.postMessage'); + expect(worker).not.toContain('window.parent.postMessage'); + }); +}); diff --git a/src/livecodes/languages/elixir-wasm/index.ts b/src/livecodes/languages/elixir-wasm/index.ts new file mode 100644 index 000000000..ed93b98ef --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/index.ts @@ -0,0 +1 @@ +export * from './lang-elixir-wasm'; diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts new file mode 100644 index 000000000..10a9463ac --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts @@ -0,0 +1,74 @@ +import { atomvmWasmBaseUrl } from '../../vendors'; +import { BEAM_PRELUDE } from '../beam-wasm/prelude'; +import { createBeamRuntime, parseTaggedResult, type ParsedResult } from '../beam-wasm/runtime'; + +const BUNDLE_URL = `${atomvmWasmBaseUrl}bundle.avm`; +const SCRIPT_TYPE = 'text/elixir-wasm'; + +const escapeElixirString = (value: string) => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/#{/g, '#\\{'); + +const wrapElixir = (code: string, input: string) => { + // The user source is embedded as a string and parsed first with + // Code.string_to_quoted/1: a syntax error becomes a tagged value instead of + // an exception that would wedge (or kill) the VM with no reply. + const source = escapeElixirString(code); + const stdin = escapeElixirString(input); + return `livecodes_input = "${stdin}" +livecodes_source = "${source}" +gl = Process.group_leader() +dev = :livecodes_io.start(livecodes_input) +Process.group_leader(self(), dev) +result = + try do + case Code.string_to_quoted(livecodes_source) do + {:error, {meta, message, _token}} -> + line = if is_list(meta), do: Keyword.get(meta, :line, 0), else: 0 + {:lc_error, :syntax, "SyntaxError: " <> to_string(message) <> " (line " <> to_string(line) <> ")", ""} + {:error, err} -> + {:lc_error, :syntax, "SyntaxError: " <> inspect(err), ""} + {:ok, _} -> + try do + {value, _binding} = Code.eval_string(livecodes_source, livecodes_input: livecodes_input) + {:lc_ok, value} + rescue + e -> {:lc_error, :error, inspect(e.__struct__) <> ": " <> Exception.message(e), ""} + catch + :throw, {:lc_exit, status} -> {:lc_exit, status} + kind, other -> {:lc_error, kind, inspect(other), ""} + end + end + after + Process.group_leader(self(), gl) + # Terminals are line buffered: a final newline flushes an IO.write/1 that + # did not end in a newline. The main thread drops the resulting trailing + # newline so the observable output does not change. + IO.write("\\n") + end +:livecodes_io.stop(dev) +case result do + {:lc_ok, value} -> + text = inspect(value) + text = if byte_size(text) > 1500, do: binary_part(text, 0, 1500) <> "...", else: text + "__LC_OK__" <> Base.encode64(text) + {:lc_error, kind, message, _stack} -> + message = if byte_size(message) > 1000, do: binary_part(message, 0, 1000) <> "...", else: message + "__LC_ERROR__" <> Base.encode64(to_string(kind)) <> "|" <> Base.encode64(message) + {:lc_exit, status} -> + "__LC_EXIT__" <> Integer.to_string(status) +end`; +}; + +const parseElixirResult = (text: string): ParsedResult => parseTaggedResult(text, 'Elixir error'); + +createBeamRuntime({ + apiKey: 'elixir', + apiAlias: 'elixir-wasm', + label: 'Elixir', + scriptType: SCRIPT_TYPE, + bundleUrl: BUNDLE_URL, + prelude: BEAM_PRELUDE, + buildCall: (code, stdin) => ['eval_elixir', wrapElixir(code, stdin)], + parseResult: parseElixirResult, + parsePreludeResult: parseElixirResult, +}); diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts new file mode 100644 index 000000000..9a7137be0 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts @@ -0,0 +1,29 @@ +import { codemirrorLegacy } from '../../editor/codemirror/utils'; +import type { LanguageSpecs } from '../../models'; +import { codeMirrorBaseUrl, monacoLanguagesBaseUrl } from '../../vendors'; + +export const elixirWasm: LanguageSpecs = { + name: 'elixir-wasm', + title: 'Elixir (Wasm)', + compiler: { + factory: () => async (code) => code, + scripts: ({ baseUrl }) => [baseUrl + '{{hash:lang-elixir-wasm-script.js}}'], + scriptType: 'text/elixir-wasm', + compiledCodeLanguage: 'elixir', + liveReload: true, + }, + extensions: ['ex', 'exs', 'elixir', 'elixir-wasm', 'wasm.ex'], + editor: 'script', + editorSupport: { + monaco: { + languageSupport: monacoLanguagesBaseUrl + 'elixir.js', + language: 'elixir', + }, + codemirror: { + languageSupport: async () => + codemirrorLegacy((await import(codeMirrorBaseUrl + 'codemirror-lang-ruby.js')).ruby), + }, + codejar: { language: 'elixir' }, + }, + largeDownload: true, +}; diff --git a/src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts b/src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts new file mode 100644 index 000000000..e117276d2 --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { erlangWasm } from '../lang-erlang-wasm'; + +const readSource = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); + +describe('Erlang (Wasm) language spec', () => { + test('is registered as erlang-wasm, with the wasm compiler script', () => { + expect(erlangWasm.name).toBe('erlang-wasm'); + expect(erlangWasm.title).toBe('Erlang (Wasm)'); + expect(erlangWasm.compiler).toMatchObject({ + scriptType: 'text/erlang-wasm', + compiledCodeLanguage: 'erlang', + liveReload: true, + }); + }); + + test('accepts the common Erlang extensions', () => { + expect(erlangWasm.extensions).toEqual( + expect.arrayContaining(['erl', 'erlang', 'erlang-wasm', 'wasm.erl']), + ); + }); + + test('warns that the first run is a large download', () => { + expect(erlangWasm.largeDownload).toBe(true); + }); + + test('uses a code editor with Erlang support', () => { + expect(erlangWasm.editor).toBe('script'); + expect(erlangWasm.editorSupport?.monaco?.language).toBe('erlang'); + expect(erlangWasm.editorSupport?.codejar?.language).toBe('erlang'); + }); +}); + +describe('Erlang (Wasm) runtime', () => { + const script = readSource('lang-erlang-wasm-script.ts'); + + test('reuses the shared beam runtime', () => { + expect(script).toContain("from '../beam-wasm/runtime'"); + expect(script).toContain("from '../beam-wasm/prelude'"); + expect(script).toContain("apiKey: 'erlang'"); + expect(script).toContain("apiAlias: 'erlang-wasm'"); + }); + + test('wraps expressions and compiles modules', () => { + expect(script).toContain("'eval_erlang_module'"); + expect(script).toContain('wrapErlang'); + expect(script).toContain('stripTrailingDot'); + expect(script).toContain("replace(/\\berlang:halt\\(/g, 'livecodes_io:halt(')"); + }); + + test('keeps the Erlang output newline and inspected result text', () => { + expect(script).toContain('stripTrailingNewline: false'); + expect(script).toContain('normalizeResult: (data) =>'); + }); +}); diff --git a/src/livecodes/languages/erlang-wasm/index.ts b/src/livecodes/languages/erlang-wasm/index.ts new file mode 100644 index 000000000..7d3d70555 --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/index.ts @@ -0,0 +1 @@ +export * from './lang-erlang-wasm'; diff --git a/src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts new file mode 100644 index 000000000..bbdca0fbe --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts @@ -0,0 +1,64 @@ +import { atomvmWasmBaseUrl } from '../../vendors'; +import { BEAM_PRELUDE } from '../beam-wasm/prelude'; +import { createBeamRuntime, parseTaggedResult, type ParsedResult } from '../beam-wasm/runtime'; + +const BUNDLE_URL = `${atomvmWasmBaseUrl}bundle.avm`; +const SCRIPT_TYPE = 'text/erlang-wasm'; + +const erlangEscapes: Record = { + '\\': '\\\\', + '"': '\\"', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', +}; + +// An Erlang string literal is a charlist; `:livecodes_io.start/1` normalizes it. +const escapeErlangString = (value: string) => + value.replace(/[\\"\n\r\t]/g, (char) => erlangEscapes[char] ?? char); + +const stripTrailingDot = (code: string) => code.replace(/\.(\s*(?:%[^\n]*)?\s*)$/, '$1'); + +const wrapErlang = (code: string, input: string) => { + // `erlang:halt/1` is a NIF this build does not have, so route it through the + // shared device. A bare `halt/0-1` is rewritten too, as in upstream Popcorn. + // The final expression dot is removed so the code can be spliced into `try`. + const source = code + .replace(/\berlang:halt\(/g, 'livecodes_io:halt(') + .replace(/(? {lc_exit, LcStatus}; LcKind:LcReason -> {lc_error, {LcKind, LcReason}} end, +group_leader(LcGL, self()), +livecodes_io:stop(LcDev), +LcValue.`; +}; + +// Erlang modules are compiled and loaded as written, so they stay callable in +// later runs; expressions are wrapped for stdin, exit codes and error handling. +const actionFor = (code: string) => + code.startsWith('-module(') ? 'eval_erlang_module' : 'eval_erlang'; + +const parseErlangResult = (text: string): ParsedResult => parseTaggedResult(text, 'Erlang error'); + +createBeamRuntime({ + apiKey: 'erlang', + apiAlias: 'erlang-wasm', + label: 'Erlang', + scriptType: SCRIPT_TYPE, + bundleUrl: BUNDLE_URL, + prelude: BEAM_PRELUDE, + buildCall: (code, stdin) => { + const action = actionFor(code); + return [action, action === 'eval_erlang_module' ? code : wrapErlang(code, stdin)]; + }, + parseResult: parseErlangResult, + parsePreludeResult: (text) => parseTaggedResult(text, 'Elixir prelude error'), + // Unlike the Elixir wrapper, the Erlang wrapper does not append a newline. + stripTrailingNewline: false, + // The runtime returns the inspected term as a string; keep it as-is. + normalizeResult: (data) => (data == null ? '' : String(data)), +}); diff --git a/src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts new file mode 100644 index 000000000..848880414 --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts @@ -0,0 +1,29 @@ +import { codemirrorLegacy } from '../../editor/codemirror/utils'; +import type { LanguageSpecs } from '../../models'; +import { codeMirrorBaseUrl, monacoLanguagesBaseUrl } from '../../vendors'; + +export const erlangWasm: LanguageSpecs = { + name: 'erlang-wasm', + title: 'Erlang (Wasm)', + compiler: { + factory: () => async (code) => code, + scripts: ({ baseUrl }) => [baseUrl + '{{hash:lang-erlang-wasm-script.js}}'], + scriptType: 'text/erlang-wasm', + compiledCodeLanguage: 'erlang', + liveReload: true, + }, + extensions: ['erl', 'erlang', 'erlang-wasm', 'wasm.erl'], + editor: 'script', + editorSupport: { + monaco: { + languageSupport: monacoLanguagesBaseUrl + 'erlang.js', + language: 'erlang', + }, + codemirror: { + languageSupport: async () => + codemirrorLegacy((await import(codeMirrorBaseUrl + 'codemirror-lang-erlang.js')).erlang), + }, + codejar: { language: 'erlang' }, + }, + largeDownload: true, +}; diff --git a/src/livecodes/languages/languages.ts b/src/livecodes/languages/languages.ts index 09cf7fdcf..efc553e2a 100644 --- a/src/livecodes/languages/languages.ts +++ b/src/livecodes/languages/languages.ts @@ -27,7 +27,9 @@ import { diagrams } from './diagrams'; import { dot } from './dot'; import { duckdb } from './duckdb'; import { ejs } from './ejs'; +import { elixirWasm } from './elixir-wasm'; import { elm } from './elm'; +import { erlangWasm } from './erlang-wasm'; import { eta } from './eta'; import { fanak } from './fanak'; import { fennel } from './fennel'; @@ -207,6 +209,8 @@ export const languages: LanguageSpecs[] = [ fsharp, fsharpWasm, fanak, + elixirWasm, + erlangWasm, haskell, haskellWasm, scheme, diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index ef9cc9632..d966b1636 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -243,6 +243,8 @@ export type ScriptType = | 'text/swift-wasm' | 'text/ada-wasm' | 'text/v-wasm' + | 'text/elixir-wasm' + | 'text/erlang-wasm' | 'text/assembly' | 'application/json' | 'application/lua' diff --git a/src/livecodes/templates/starter/elixir-wasm-starter.ts b/src/livecodes/templates/starter/elixir-wasm-starter.ts new file mode 100644 index 000000000..659bbb29d --- /dev/null +++ b/src/livecodes/templates/starter/elixir-wasm-starter.ts @@ -0,0 +1,90 @@ +import type { Template } from '../../models'; + +export const elixirWasmStarter: Template = { + name: 'elixir-wasm', + title: window.deps.translateString('templates.starter.elixir-wasm', 'Elixir Starter'), + thumbnail: 'assets/templates/elixir.png', + activeEditor: 'script', + markup: { + language: 'html', + content: ` +
    +

    Hello, World!

    + +

    You clicked 0 times.

    + +
    + + +`.trimStart(), + }, + style: { + language: 'css', + content: ` +.container, +.container button { + text-align: center; + font: 1em sans-serif; +} +.logo { + width: 150px; +} +`.trimStart(), + }, + script: { + language: 'elixir-wasm', + content: ` +count = + case livecodes_input do + "" -> 0 + value -> String.to_integer(String.trim(value)) + end + +IO.puts("Elixir") +IO.puts(count + 1) +`.trimStart(), + }, +}; diff --git a/src/livecodes/templates/starter/erlang-wasm-starter.ts b/src/livecodes/templates/starter/erlang-wasm-starter.ts new file mode 100644 index 000000000..b89cc4662 --- /dev/null +++ b/src/livecodes/templates/starter/erlang-wasm-starter.ts @@ -0,0 +1,89 @@ +import type { Template } from '../../models'; + +export const erlangWasmStarter: Template = { + name: 'erlang-wasm', + title: window.deps.translateString('templates.starter.erlang-wasm', 'Erlang Starter'), + thumbnail: 'assets/templates/erlang.svg', + activeEditor: 'script', + markup: { + language: 'html', + content: ` +
    +

    Hello, World!

    + +

    You clicked 0 times.

    + +
    + + +`.trimStart(), + }, + style: { + language: 'css', + content: ` +.container, +.container button { + text-align: center; + font: 1em sans-serif; +} +.logo { + width: 150px; +} +`.trimStart(), + }, + script: { + language: 'erlang-wasm', + content: ` +Count = + case io:get_line("") of + eof -> 0; + Value -> binary_to_integer(binary:replace(Value, <<"\\n">>, <<>>, [global])) + end, +io:format("Erlang~n"), +io:format("~p~n", [Count + 1]). +`.trimStart(), + }, +}; diff --git a/src/livecodes/templates/starter/index.ts b/src/livecodes/templates/starter/index.ts index a3d668b49..e1da2a56a 100644 --- a/src/livecodes/templates/starter/index.ts +++ b/src/livecodes/templates/starter/index.ts @@ -27,7 +27,9 @@ import { daisyuiStarter } from './daisyui-starter'; import { dartStarter } from './dart-starter'; import { diagramsStarter } from './diagrams-starter'; import { duckdbStarter } from './duckdb-starter'; +import { elixirWasmStarter } from './elixir-wasm-starter'; import { elmStarter } from './elm-starter'; +import { erlangWasmStarter } from './erlang-wasm-starter'; import { fanakStarter } from './fanak-starter'; import { fennelStarter } from './fennel-starter'; import { flutterStarter } from './flutter-starter'; @@ -160,6 +162,8 @@ export const starterTemplates = [ cobolWasmStarter, fortranWasmStarter, dWasmStarter, + elixirWasmStarter, + erlangWasmStarter, vWasmStarter, rustWasmStarter, scalaStarter, diff --git a/src/livecodes/vendors.ts b/src/livecodes/vendors.ts index 486c16fb1..fbee98971 100644 --- a/src/livecodes/vendors.ts +++ b/src/livecodes/vendors.ts @@ -152,6 +152,10 @@ export const duckdbWasmModuleUrl = /* @__PURE__ */ getModuleUrl('@duckdb/duckdb- export const ejsUrl = /* @__PURE__ */ getUrl('ejs@4.0.1/ejs.js'); +export const atomvmWasmBaseUrl = /* @__PURE__ */ getUrl( + 'gh:live-codes/browser-elixir@e9bd2faed8eb6e2bcacd4b42f8b57ea26a770831/runtime/wasm/', +); + export const elkjsBaseUrl = /* @__PURE__ */ getUrl('elkjs@0.8.2/lib/'); export const elmWasmBaseUrl = /* @__PURE__ */ getUrl('@live-codes/elm-wasm@0.2.1/dist/'); diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 3f581849f..5e17b1ba7 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -253,6 +253,15 @@ export type Language = | 'wasm.fsx' | 'fanak' | 'fnk' + | 'elixir' + | 'ex' + | 'exs' + | 'elixir-wasm' + | 'wasm.ex' + | 'erlang' + | 'erl' + | 'erlang-wasm' + | 'wasm.erl' | 'rust' | 'rs' | 'rust-wasm' @@ -541,6 +550,8 @@ export type TemplateName = | 'odin-wasm' | 'cobol-wasm' | 'v-wasm' + | 'elixir-wasm' + | 'erlang-wasm' | 'java' | 'csharp-wasm' | 'blazor-wasm' @@ -975,6 +986,8 @@ export type CustomSettings = Partial< | 'text/go-wasm' | 'text/zig-wasm' | 'text/v-wasm' + | 'text/elixir-wasm' + | 'text/erlang-wasm' | 'application/json' | 'application/lua' | 'text/fennel' diff --git a/storybook/_stories/EmbedOptions/template.ts b/storybook/_stories/EmbedOptions/template.ts index bc044c84d..002fb262a 100644 --- a/storybook/_stories/EmbedOptions/template.ts +++ b/storybook/_stories/EmbedOptions/template.ts @@ -61,6 +61,8 @@ const storyDef: StoryDef = { CobolWasm: { props: { template: 'cobol-wasm' }, storyName: 'COBOL (Wasm)' }, FortranWasm: { props: { template: 'fortran-wasm' }, storyName: 'Fortran (Wasm)' }, VWasm: { props: { template: 'v-wasm' } }, + ElixirWasm: { props: { template: 'elixir-wasm' } }, + ErlangWasm: { props: { template: 'erlang-wasm' } }, RustWasm: { props: { template: 'rust-wasm' } }, Scala: { props: { template: 'scala' } }, SwiftWasm: { props: { template: 'swift-wasm' } }, diff --git a/storybook/preact/stories/EmbedOptions/template.stories.ts b/storybook/preact/stories/EmbedOptions/template.stories.ts index 994740ac4..3b0c2c8a1 100644 --- a/storybook/preact/stories/EmbedOptions/template.stories.ts +++ b/storybook/preact/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,8 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const Scala = livecodesStory({ template: 'scala' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); diff --git a/storybook/react/stories/EmbedOptions/template.stories.ts b/storybook/react/stories/EmbedOptions/template.stories.ts index 994740ac4..3b0c2c8a1 100644 --- a/storybook/react/stories/EmbedOptions/template.stories.ts +++ b/storybook/react/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,8 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const Scala = livecodesStory({ template: 'scala' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); diff --git a/storybook/solid/stories/EmbedOptions/template.stories.ts b/storybook/solid/stories/EmbedOptions/template.stories.ts index 994740ac4..3b0c2c8a1 100644 --- a/storybook/solid/stories/EmbedOptions/template.stories.ts +++ b/storybook/solid/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,8 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const Scala = livecodesStory({ template: 'scala' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); diff --git a/storybook/svelte/stories/EmbedOptions/template.stories.ts b/storybook/svelte/stories/EmbedOptions/template.stories.ts index 994740ac4..3b0c2c8a1 100644 --- a/storybook/svelte/stories/EmbedOptions/template.stories.ts +++ b/storybook/svelte/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,8 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const Scala = livecodesStory({ template: 'scala' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); diff --git a/storybook/vue/stories/EmbedOptions/template.stories.ts b/storybook/vue/stories/EmbedOptions/template.stories.ts index 994740ac4..3b0c2c8a1 100644 --- a/storybook/vue/stories/EmbedOptions/template.stories.ts +++ b/storybook/vue/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,8 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const Scala = livecodesStory({ template: 'scala' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); diff --git a/storybook/web-components/stories/EmbedOptions/template.stories.ts b/storybook/web-components/stories/EmbedOptions/template.stories.ts index 994740ac4..3b0c2c8a1 100644 --- a/storybook/web-components/stories/EmbedOptions/template.stories.ts +++ b/storybook/web-components/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,8 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const Scala = livecodesStory({ template: 'scala' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); diff --git a/tsconfig.json b/tsconfig.json index fa9076966..a2bcbf7eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -55,7 +55,8 @@ "src/sdk/react.tsx", "src/livecodes/languages/fsharp-wasm/lang-fsharp-wasm-worker.raw.js", "src/livecodes/languages/haskell-wasm/lang-haskell-wasm-worker.raw.js", - "src/livecodes/languages/vb-wasm/lang-vb-wasm-worker.raw.js" + "src/livecodes/languages/vb-wasm/lang-vb-wasm-worker.raw.js", + "src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js" ], "exclude": ["**/node_modules/**", "src/livecodes/i18n/locales/**"] } diff --git a/vendor-licenses.md b/vendor-licenses.md index 9ebb167fe..f3a997b23 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -384,6 +384,16 @@ LFortran (Wasm): [BSD 3-Clause License](https://github.com/lfortran/lfortran/blo V (Wasm): [MIT License](https://github.com/vlang/v/blob/master/LICENSE) (the V compiler and its standard library, compiled to WebAssembly by @live-codes/v-wasm; the Clang toolchain it links through is covered by LLVM / LLD below) +@live-codes/browser-elixir: [MIT License](https://github.com/live-codes/browser-elixir/blob/main/LICENSE) (the build scripts and checksums for the single-threaded AtomVM WebAssembly runtime and bytecode bundle used by Elixir (Wasm) and Erlang (Wasm); the artifacts those scripts produce are built from AtomVM and Popcorn, both Apache-2.0 and listed below) + +Elixir (Wasm): [Apache License 2.0](https://github.com/elixir-lang/elixir/blob/main/LICENSE) (the Elixir 1.17.3 compiler and standard library, packaged for browser execution) + +Elixir logo: registered trademark of The Elixir Team, used under [nominative use](https://elixir-lang.org/trademarks/) to identify the supported language. + +Popcorn: [Apache License 2.0](https://github.com/software-mansion/popcorn/blob/main/LICENSE) (the eval server cooked into the bytecode bundle, and the browser bridge the worker runtime derives from) + +AtomVM: [Apache License 2.0](https://github.com/atomvm/AtomVM/blob/main/LICENSE) (the lightweight Erlang VM running the compiled Elixir code in WebAssembly; the Emscripten build comes from [FissionVM](https://github.com/software-mansion-labs/FissionVM), also Apache-2.0) + @live-codes/janet-wasm: [MIT License](https://github.com/live-codes/browser-janet/blob/main/packages/janet-wasm/LICENSE) Janet (Wasm): [MIT License](https://github.com/janet-lang/janet/blob/master/LICENSE) (the Janet interpreter, compiled to WebAssembly by @live-codes/janet-wasm)