Skip to main content

logicaffeine_compile/
web_host.rs

1//! The browser host for a LOGOS→WASM DOM app — shared by the CLI (`largo emit
2//! wasm-dom` / `largo serve`) and the web Studio's live preview so both render a
3//! LOGOS app identically. It supplies the real DOM behind the `dom_*` imports,
4//! allocates host `Text` into linear memory, and fires event/fetch callbacks through
5//! the exported `__logos_call_closure` trampoline (the production analog of the test
6//! `MockDom`). It also carries the default theme, the favicon, and the build-error
7//! overlay so a fresh LOGOS web app is beautiful — and legible when it breaks — with
8//! zero CSS written.
9
10/// A clay-λ favicon (an inline SVG data-URI) so a fresh LOGOS app is branded in the
11/// tab bar with no asset file. `#` is `%23`-encoded (a `#` is a URI fragment marker).
12pub const FAVICON_DATA_URI: &str = "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23cc785c'/><text x='16' y='23' font-family='sans-serif' font-size='21' font-weight='700' text-anchor='middle' fill='white'>λ</text></svg>";
13
14/// The default theme — a small, opinionated design system in Claude's language: a warm
15/// cream surface, a clay accent, ink text, generous spacing, rounded controls, and an
16/// automatic dark mode via `prefers-color-scheme`. Every value is a `--token` on
17/// `:root`, so a user overrides one line to reskin — and the tokens are the ones `largo new
18/// --web` scaffolds into `static/theme.css` (`--paper`, `--ink`, `--accent`, `--muted`,
19/// `--border`, `--radius`, `--shadow`), so a project's theme file reskins these styles too.
20/// Ships in every generated page so a LOGOS web app looks *designed* with zero CSS written,
21/// unless the site opts out ([`ShellTheme::None`]).
22pub const DEFAULT_THEME_CSS: &str = r#":root {
23  --paper: #faf9f5;
24  --surface: #ffffff;
25  --ink: #1f1e1d;
26  --muted: #6b6a67;
27  --accent: #cc785c;
28  --accent-hover: #b8613f;
29  --accent-contrast: #ffffff;
30  --border: #e8e6df;
31  --radius: 10px;
32  --shadow: 0 1px 3px rgba(31,30,29,.06), 0 8px 24px rgba(31,30,29,.05);
33  --font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34  --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
35  --measure: 720px;
36  --space: 1rem;
37}
38@media (prefers-color-scheme: dark) {
39  :root {
40    --paper: #1f1e1d;
41    --surface: #262624;
42    --ink: #f5f4ef;
43    --muted: #a3a29e;
44    --accent: #e0906f;
45    --accent-hover: #ecab8f;
46    --accent-contrast: #1f1e1d;
47    --border: #3a3936;
48    --shadow: 0 1px 3px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.25);
49  }
50}
51* { box-sizing: border-box; }
52html { -webkit-text-size-adjust: 100%; }
53body {
54  margin: 0 auto;
55  max-width: min(var(--measure), 92vw);
56  padding: 2.5rem 1.25rem 4rem;
57  background: var(--paper);
58  color: var(--ink);
59  font-family: var(--font-sans);
60  font-size: 16px;
61  line-height: 1.6;
62  -webkit-font-smoothing: antialiased;
63}
64#root { display: block; }
65h1, h2, h3, h4, h5, h6 { line-height: 1.2; font-weight: 600; letter-spacing: -0.011em; margin: 1.6em 0 0.5em; }
66h1 { font-size: 2rem; margin-top: 0; }
67h2 { font-size: 1.5rem; }
68h3 { font-size: 1.25rem; }
69p { margin: 0 0 1em; }
70a { color: var(--accent); text-decoration: none; }
71a:hover { text-decoration: underline; }
72small, .muted { color: var(--muted); }
73ul, ol { padding-left: 1.4em; margin: 0 0 1em; }
74li { margin: 0.25em 0; }
75hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
76button {
77  font: inherit;
78  font-weight: 550;
79  color: var(--accent-contrast);
80  background: var(--accent);
81  border: none;
82  border-radius: var(--radius);
83  padding: 0.6em 1.15em;
84  cursor: pointer;
85  transition: background .15s ease, transform .04s ease;
86}
87button:hover { background: var(--accent-hover); }
88button:active { transform: translateY(1px); }
89button:disabled { opacity: .5; cursor: default; }
90button.secondary { background: transparent; color: var(--ink); border: 1px solid var(--border); }
91input, textarea, select {
92  font: inherit;
93  color: var(--ink);
94  background: var(--surface);
95  border: 1px solid var(--border);
96  border-radius: var(--radius);
97  padding: 0.55em 0.7em;
98}
99input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible {
100  outline: 2px solid var(--accent);
101  outline-offset: 2px;
102}
103.card, section {
104  background: var(--surface);
105  border: 1px solid var(--border);
106  border-radius: var(--radius);
107  padding: 1.25rem;
108  box-shadow: var(--shadow);
109}
110code { font-family: var(--font-mono); font-size: 0.9em; background: rgba(31,30,29,.06); padding: 0.1em 0.35em; border-radius: 5px; }
111pre { font-family: var(--font-mono); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; overflow: auto; }
112pre code { background: none; padding: 0; }
113table { border-collapse: collapse; width: 100%; }
114th, td { text-align: left; padding: 0.5em 0.75em; border-bottom: 1px solid var(--border); }
115"#;
116
117/// The HTML shell that loads a LOGOS→WASM DOM app in a browser — carrying the default
118/// theme, the clay-λ favicon, and a light/dark `theme-color` so a fresh app is
119/// beautiful out of the box.
120pub fn browser_host_html(name: &str) -> String {
121    browser_host_html_with_head(name, None, None)
122}
123
124/// The tags the shell contributes when a page declares nothing — good defaults, in the
125/// order a document wants them. Each is REPLACEABLE by a page's own tag of the same
126/// identity (see [`head_identity`]); everything a page adds beyond these is appended.
127fn default_head_tags(name: &str, favicon: Option<&str>) -> Vec<String> {
128    vec![
129        "<meta charset=\"utf-8\">".to_string(),
130        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">".to_string(),
131        "<meta name=\"theme-color\" content=\"#faf9f5\" media=\"(prefers-color-scheme: light)\">"
132            .to_string(),
133        "<meta name=\"theme-color\" content=\"#1f1e1d\" media=\"(prefers-color-scheme: dark)\">"
134            .to_string(),
135        format!(
136            "<link rel=\"icon\" href=\"{}\">",
137            favicon.unwrap_or(FAVICON_DATA_URI)
138        ),
139        format!("<title>{name}</title>"),
140    ]
141}
142
143/// What makes two head tags THE SAME TAG — the thing a page's own version replaces.
144///
145/// ★ IDENTITY, NOT POSITION. A document with two `<title>`s has whichever title a
146/// browser read last, and two `viewport` metas is a page that renders differently on
147/// two phones. A page that writes a viewport means *instead of*, never *as well as*.
148///
149/// A tag with no identity here (`<meta http-equiv=…>`, a second `<link rel="preload">`)
150/// replaces nothing and is simply added: a list of what is REPLACEABLE can only fail by
151/// being too small, which shows up as one extra tag. A rule that guessed could delete a
152/// tag the author wrote.
153///
154/// The identity is the document head's own ([`crate::semantics::dom::HeadTag::identity`]), read
155/// off the tag's parsed attributes, so the server's composition and the in-process document merge
156/// a page's `Head:` by one rule — the one `runtime.js`'s `headIdentity` applies in a browser.
157fn head_identity(tag: &str) -> Option<String> {
158    crate::semantics::dom::parse_head(tag)
159        .first()
160        .and_then(crate::semantics::dom::HeadTag::identity)
161}
162
163/// Split a rendered head fragment into its top-level tags. The fragment comes from the
164/// LGX renderer, so it is well-formed markup of `<title>`/`<meta>`/`<link>`/`<base>`
165/// only — the parser refuses anything else in a `Head:`.
166fn split_head_tags(fragment: &str) -> Vec<String> {
167    let mut out = Vec::new();
168    let bytes = fragment.as_bytes();
169    let mut i = 0;
170    while i < bytes.len() {
171        if bytes[i] != b'<' {
172            i += 1;
173            continue;
174        }
175        // `<title>…</title>` carries text; the void tags do not.
176        let rest = &fragment[i..];
177        let close = match rest.to_ascii_lowercase().starts_with("<title") {
178            true => rest
179                .to_ascii_lowercase()
180                .find("</title>")
181                .map(|e| e + "</title>".len()),
182            false => rest.find('>').map(|e| e + 1),
183        };
184        match close {
185            Some(end) => {
186                out.push(rest[..end].trim().to_string());
187                i += end;
188            }
189            None => break,
190        }
191    }
192    out
193}
194
195/// Whether a page's shell carries the built-in theme ([`DEFAULT_THEME_CSS`]).
196#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
197pub enum ShellTheme {
198    /// The built-in design tokens and element styles, under everything the site adds.
199    #[default]
200    Default,
201    /// None of it: the site brings its whole design itself (`Largo.toml`: `[web]` `theme = false`).
202    None,
203}
204
205/// The HTML shell that loads a LOGOS→WASM DOM app, with a page's own `Head:` merged
206/// over the defaults, carrying the built-in theme — [`browser_host_html_themed`] with
207/// [`ShellTheme::Default`].
208pub fn browser_host_html_with_head(
209    name: &str,
210    declared: Option<&str>,
211    favicon: Option<&str>,
212) -> String {
213    browser_host_html_themed(name, declared, favicon, ShellTheme::Default)
214}
215
216/// The HTML shell that loads a LOGOS→WASM DOM app, with a page's own `Head:` merged
217/// over the defaults.
218///
219/// The one merge every document builder reads — `largo build`'s pre-render, `largo serve`'s dev
220/// response and `largo emit` — so development and production serve the same document.
221/// `favicon` is the SITE's own icon when it ships one (`static/favicon.svg`), which
222/// stands in for the built-in clay-λ data URI as the DEFAULT — so a page that declares
223/// its own `<link rel="icon">` still wins over both. `theme` is whether the built-in theme
224/// comes with it.
225pub fn browser_host_html_themed(
226    name: &str,
227    declared: Option<&str>,
228    favicon: Option<&str>,
229    theme: ShellTheme,
230) -> String {
231    let declared: Vec<String> = declared.map(split_head_tags).unwrap_or_default();
232    // A page declares its language in `Head:` as `<html lang="…">` (default `en`). `lang` is what
233    // a screen reader picks a voice from and what translation, hyphenation and quotation rules
234    // key off. The tag is LIFTED onto the document element rather than rendered, since an `<html>`
235    // inside `<head>` is malformed.
236    let (lang, declared): (String, Vec<String>) = {
237        let mut lang = "en".to_string();
238        let mut rest = Vec::with_capacity(declared.len());
239        for tag in declared {
240            match html_lang_of(&tag) {
241                Some(l) => lang = l,
242                None => rest.push(tag),
243            }
244        }
245        (lang, rest)
246    };
247    let claimed: std::collections::HashSet<String> =
248        declared.iter().filter_map(|t| head_identity(t)).collect();
249    let mut head: Vec<String> = default_head_tags(name, favicon)
250        .into_iter()
251        .filter(|d| head_identity(d).is_none_or(|k| !claimed.contains(&k)))
252        .collect();
253    head.extend(declared);
254    // The module script sits in the HEAD (module scripts run after parsing, so placement does not
255    // delay it), which leaves the body holding exactly the page: a pre-render replaces the
256    // `<body>\n` line with the body and its markup, and the host adopts every child of the body as
257    // the page's first paint.
258    let style = match theme {
259        ShellTheme::Default => format!("<style>\n{DEFAULT_THEME_CSS}</style>\n"),
260        ShellTheme::None => String::new(),
261    };
262    format!(
263        "<!doctype html>\n<html lang=\"{lang}\">\n<head>\n{head}\n{style}\
264         <script type=\"module\" src=\"./{name}.js\"></script>\n</head>\n<body>\n</body>\n</html>\n",
265        lang = escape_html(&lang),
266        head = head.join("\n"),
267    )
268}
269
270/// `document` carrying the visitor's CSRF `token` in its head, as `<meta name="csrf-token">` — what
271/// the server writes into every page it composes under a `## Session`, and what the browser host
272/// (`runtime.js`'s `requestHeaders`) sends back as `x-csrf-token` on the page's state-changing
273/// requests to its own server. The meta opens the head; a page's own `Head:` merges over it by
274/// identity like every other head element. A document with no `<head>` is returned as it is.
275pub fn with_csrf_meta(document: &str, token: &str) -> String {
276    logicaffeine_system::csrf::with_csrf_meta(document, token)
277}
278
279/// The `lang` an author declared with `<html lang="…">` in a `Head:` block, if this tag is one.
280///
281/// Returns `None` for every ordinary head tag, so the caller can partition in one pass. An
282/// `<html>` with no `lang` is still lifted (and answers `""`), because rendering it into the head
283/// would be malformed either way — the author plainly meant it as a statement about the document.
284fn html_lang_of(tag: &str) -> Option<String> {
285    let t = tag.trim();
286    let rest = t.strip_prefix("<html")?;
287    if !rest.starts_with(|c: char| c.is_whitespace() || c == '>') {
288        return None; // `<htmlfoo…>` is not the document element
289    }
290    let Some(at) = rest.find("lang") else {
291        return Some(String::new());
292    };
293    let after = rest[at + 4..].trim_start();
294    let Some(after) = after.strip_prefix('=') else {
295        return Some(String::new());
296    };
297    let after = after.trim_start();
298    let quote = after.chars().next().filter(|c| *c == '"' || *c == '\'')?;
299    after[1..].split(quote).next().map(str::to_string)
300}
301
302/// Escape the HTML-significant characters so a source line / message embeds safely.
303fn escape_html(s: &str) -> String {
304    s.replace('&', "&amp;")
305        .replace('<', "&lt;")
306        .replace('>', "&gt;")
307        .replace('"', "&quot;")
308        .replace('\'', "&#39;")
309}
310
311/// Overlay-specific styling, layered on the shared theme tokens.
312const OVERLAY_CSS: &str = r#"
313.err-wrap { max-width: min(820px, 94vw); margin: 3rem auto; }
314.err-card { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius); padding: 1.5rem 1.75rem; box-shadow: var(--shadow); }
315.err-title { color: var(--accent); font-weight: 650; font-size: 1.05rem; margin: 0 0 .4rem; }
316.err-msg { color: var(--ink); margin: 0 0 1.1rem; line-height: 1.55; }
317.err-src { font-family: var(--font-mono); font-size: .9rem; line-height: 1.5; background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; overflow-x: auto; white-space: pre; }
318.err-src .gutter { color: var(--muted); }
319.err-src .caret { color: #cf5a4e; font-weight: 700; }
320.err-hint { color: #2f7d8a; margin-top: .9rem; font-size: .95rem; }
321"#;
322
323/// Render a build error as a themed HTML overlay — the SAME source line + caret +
324/// Socratic message + spelling suggestion the terminal shows, in the Claude palette,
325/// with `reload_client` embedded so fixing the source snaps the page back to green.
326/// Reuses [`ParseError::diagnostic_parts`] so the terminal and browser never drift.
327///
328/// [`ParseError::diagnostic_parts`]: logicaffeine_language::error::ParseError::diagnostic_parts
329pub fn error_overlay_html(
330    err: &logicaffeine_language::error::ParseError,
331    source: &str,
332    reload_client: &str,
333) -> String {
334    error_overlay_html_in(err, source, reload_client, None)
335}
336
337/// The same overlay, told WHICH FILE it is drawing.
338///
339/// A dev server serves many pages and each may import several modules, so "line 24" on its own
340/// sends a reader to look in whichever file they had open. The name costs one line and removes
341/// the guess.
342pub fn error_overlay_html_in(
343    err: &logicaffeine_language::error::ParseError,
344    source: &str,
345    reload_client: &str,
346    file: Option<&str>,
347) -> String {
348    let p = err.diagnostic_parts(source);
349    // Carets align by GLYPH: the browser renders each escaped entity (`&lt;`) as one
350    // character in a `white-space: pre` block, so the offset is the unescaped prefix's
351    // char count — not its byte length nor the escaped length.
352    let col_chars = p
353        .line
354        .get(..p.column)
355        .map_or(p.column, |s| s.chars().count());
356    let caret = format!("{}{}", " ".repeat(col_chars), "^".repeat(p.caret_len));
357    let suggestion = match p.suggestion {
358        Some(s) => format!(
359            "<p class=\"err-hint\">help: did you mean <b>{}</b>?</p>",
360            escape_html(s)
361        ),
362        None => String::new(),
363    };
364    format!(
365        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
366<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
367<title>build error</title><style>{theme}{overlay}</style></head><body>\
368<div class=\"err-wrap\"><div class=\"err-card\">\
369<p class=\"err-title\">LOGOS build error — {where_}line {line_num}</p>\
370<p class=\"err-msg\">{message}</p>\
371<div class=\"err-src\"><span class=\"gutter\">{line_num:>4} | </span>{line}\n\
372<span class=\"gutter\">     | </span><span class=\"caret\">{caret}</span></div>\
373{suggestion}</div></div><script>{reload}</script></body></html>",
374        theme = DEFAULT_THEME_CSS,
375        overlay = OVERLAY_CSS,
376        line_num = p.line_num,
377        where_ = file.map(|f| format!("{f}, ")).unwrap_or_default(),
378        message = escape_html(&p.message),
379        line = escape_html(&p.line),
380        caret = caret,
381        suggestion = suggestion,
382        reload = reload_client,
383    )
384}
385
386/// The browser host: the production analog of the test `MockDom`. It supplies the
387/// real DOM behind the `dom_*` imports, allocates host Text into linear memory, and
388/// fires event/fetch callbacks through the exported `__logos_call_closure`
389/// trampoline. Loading the emitted `.wasm` and calling `main()` builds the page.
390pub fn browser_host_js(name: &str) -> String {
391    let wasm_ref = serde_json::to_string(&format!("./{name}.wasm")).expect("json");
392    // The runtime is a real file (`assets/web/runtime.js`), read at build time. The only thing
393    // that varies per page is where the module lives, so that is all the caller supplies.
394    format!("const WASM_URL = {wasm_ref};\n{}", BROWSER_RUNTIME_JS)
395}
396
397/// The browser host runtime, verbatim. Kept as a file so it can be executed by a test rather
398/// than only string-matched — see `runtime_js_lock`.
399pub const BROWSER_RUNTIME_JS: &str = include_str!("../assets/web/runtime.js");
400
401/// Assemble a self-contained, sandboxable iframe `srcdoc` that COMPILES-and-RUNS an LGX
402/// app entirely in the browser — the wasm module is embedded inline (no file to fetch), the
403/// shared DOM runtime drives the iframe's own `document`, and the default theme makes it
404/// beautiful. Powers the Studio's live "Web" preview. On a build failure returns the
405/// `ParseError` (render it with [`error_overlay_html`]).
406pub fn assemble_preview(source: &str) -> Result<String, logicaffeine_language::error::ParseError> {
407    let wasm = crate::compile::compile_to_wasm(source)?;
408    let bytes_csv = wasm
409        .iter()
410        .map(|b| b.to_string())
411        .collect::<Vec<_>>()
412        .join(",");
413    // The browser host itself, with its one fixed loader line swapped for the inline bytes, so the
414    // module needs no network. The script sits in the head, leaving the body to the page.
415    // (`assemble_preview_*` fails if the loader line's shape drifts.)
416    let from = "const bytes = await (await fetch(WASM_URL)).arrayBuffer();";
417    let to = format!("const bytes = new Uint8Array([{bytes_csv}]);");
418    let js = browser_host_js("__preview__").replace(from, &to);
419    Ok(format!(
420        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
421<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
422<title>preview</title><style>{css}</style><script type=\"module\">{js}</script></head>\
423<body></body></html>",
424        css = DEFAULT_THEME_CSS,
425        js = js,
426    ))
427}
428
429/// A float as LOGOS shows it, in the JavaScript every WebAssembly host runs: the display
430/// [`logicaffeine_data::fmt::fmt_f64`] defines, byte for byte. ECMAScript's `Number.prototype.toString` gives the
431/// shortest digits that read back to the same bits, as Rust's `{}` does, except where the double lies exactly halfway
432/// between two shortest candidates: ECMAScript takes the even one and Rust the larger, so an exact tie (screened by
433/// parsing, settled with the double's exact binary value in `BigInt`) moves up. The digits are spelled without
434/// exponent notation, an integral value bare, and `NaN`, `inf`, `-inf` and `-0` as Rust spells them. The node shim
435/// interpolates it and `runtime.js` carries the same text (`wasm_float_display_lock`).
436pub const FMT_F64_JS: &str = "const fmtF = (v) => {
437  if (Number.isNaN(v)) return 'NaN';
438  if (v === Infinity) return 'inf';
439  if (v === -Infinity) return '-inf';
440  if (v === 0) return Object.is(v, -0) ? '-0' : '0';
441  const sign = v < 0 ? '-' : '';
442  const a = Math.abs(v);
443  const s = a.toString();
444  const e = s.indexOf('e');
445  const mantissa = e < 0 ? s : s.slice(0, e);
446  const dot = mantissa.indexOf('.');
447  let digits = dot < 0 ? mantissa : mantissa.slice(0, dot) + mantissa.slice(dot + 1);
448  let point = (dot < 0 ? mantissa.length : dot) + (e < 0 ? 0 : Number(s.slice(e + 1)));
449  const lead = digits.length - digits.replace(/^0+/, '').length;
450  digits = digits.slice(lead).replace(/0+$/, '');
451  point -= lead;
452  const spell = (d, p) => p <= 0 ? '0.' + '0'.repeat(-p) + d : p >= d.length ? d + '0'.repeat(p - d.length) : d.slice(0, p) + '.' + d.slice(p);
453  if (Number(spell(digits + '5', point)) === a) {
454    const view = new DataView(new ArrayBuffer(8));
455    view.setFloat64(0, a);
456    const bits = view.getBigUint64(0);
457    const biased = Number(bits >> 52n);
458    const m = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n);
459    const b = biased === 0 ? -1074 : biased - 1075;
460    const q = point - digits.length - 1;
461    const half = BigInt(digits + '5') * 10n ** BigInt(Math.max(q, 0)) * 2n ** BigInt(Math.max(-b, 0));
462    const exact = m * 2n ** BigInt(Math.max(b, 0)) * 10n ** BigInt(Math.max(-q, 0));
463    if (half === exact) {
464      const up = (BigInt(digits) + 1n).toString();
465      point += up.length - digits.length;
466      digits = up.replace(/0+$/, '');
467    }
468  }
469  return sign + spell(digits, point);
470};";
471
472/// The host's half of a module's exact integers (`exact_*`, `vm::wasm::module::exact`), as JavaScript: a
473/// tagged word is `BigInt`, an even one the integer `t >> 1`, an odd one a slot of `exactHeld` that a dropped
474/// handle gives back to `exactFree`. The arithmetic is `BigInt`'s, which truncates a quotient and gives a
475/// remainder the dividend's sign as the reference tier does; a floored quotient corrects the truncated one.
476/// `Number` of a `BigInt` is the nearest binary64, ties to even, as an `Int` widens to a Real. The node shim
477/// interpolates it and `runtime.js` carries the same text (`exact_int_every_tier_lock`).
478pub const EXACT_INT_JS: &str = "const exactHeld = [], exactFree = [];
479const exactValue = (t) => ((t & 1n) === 0n ? t >> 1n : exactHeld[Number(t >> 1n)]);
480const exactWord = (v) => {
481  if (v >= -(1n << 62n) && v < (1n << 62n)) return v << 1n;
482  const h = exactFree.length > 0 ? exactFree.pop() : exactHeld.length;
483  exactHeld[h] = v;
484  return (BigInt(h) << 1n) | 1n;
485};
486const exactImports = {
487  exact_box: (v) => exactWord(v),
488  exact_narrow: (t) => { const v = exactValue(t); return v >= -(1n << 63n) && v < (1n << 63n) ? v : 0n; },
489  exact_add: (a, b) => exactWord(exactValue(a) + exactValue(b)),
490  exact_sub: (a, b) => exactWord(exactValue(a) - exactValue(b)),
491  exact_mul: (a, b) => exactWord(exactValue(a) * exactValue(b)),
492  exact_div: (a, b) => exactWord(exactValue(a) / exactValue(b)),
493  exact_mod: (a, b) => exactWord(exactValue(a) % exactValue(b)),
494  exact_floordiv: (a, b) => { const x = exactValue(a), y = exactValue(b), q = x / y; return exactWord(x % y !== 0n && (x < 0n) !== (y < 0n) ? q - 1n : q); },
495  exact_pow: (a, e) => exactWord(exactValue(a) ** e),
496  exact_cmp: (a, b) => { const x = exactValue(a), y = exactValue(b); return x < y ? -1 : x > y ? 1 : 0; },
497  exact_f64: (t) => Number(exactValue(t)),
498  exact_clone: (t) => exactWord(exactValue(t)),
499  exact_drop: (t) => { const h = Number(t >> 1n); exactHeld[h] = undefined; exactFree.push(h); },
500};";
501
502/// The page's markup: the first paint of the page's own lowered program, the one its module runs,
503/// drawn in process, or the reason it cannot be rendered without a browser. `largo build` writes it
504/// into the document it ships, and [`assemble_preview_painted`] into a preview's frame; either way
505/// the page's mount adopts it (`data-logos-prerendered`) rather than drawing it again.
506pub fn first_paint(source: &str) -> Result<String, String> {
507    crate::semantics::dom::reset();
508    // The markup a reader without scripts sees is the page at its own URL: `routePath()` answers
509    // the `## Page` path during the render, as it does on every tier a `## Test` runs.
510    crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
511        if let Ok((stmts, ..)) = parsed {
512            crate::page_host::boot_at_declared_route(stmts, interner);
513        }
514    });
515    let out = crate::compile::vm_outcome(source);
516    if let Some(e) = out.error {
517        return Err(e);
518    }
519    let rendered = crate::semantics::dom::render_served();
520    // `render_served()` wraps the page in the recorder's `<body>`; the document already has one.
521    let inner = rendered
522        .strip_prefix("<body>")
523        .and_then(|r| r.strip_suffix("</body>"))
524        .ok_or_else(|| "the page's render did not produce a document body".to_string())?;
525    if inner.is_empty() {
526        // The commonest shape by far, and the one that used to look like success: nothing was
527        // mounted, so there was nothing to draw.
528        return Err(
529            "it renders nothing — no `## Page` and no `Mount`, so the document is empty"
530                .to_string(),
531        );
532    }
533    Ok(inner.to_string())
534}
535
536/// [`assemble_preview`] with the page's [`first_paint`] already in the frame's body, marked for the
537/// page's mount to adopt, so the frame shows the page the moment its document parses rather than
538/// once its module has run, and a frame that reloads with the same page shows it throughout. A
539/// program with no first paint is previewed as [`assemble_preview`] previews it.
540pub fn assemble_preview_painted(
541    source: &str,
542) -> Result<String, logicaffeine_language::error::ParseError> {
543    let document = assemble_preview(source)?;
544    let Ok(markup) = first_paint(source) else {
545        return Ok(document);
546    };
547    // The body closes the document; the runtime in the head may spell the same tags in its text.
548    let body = "<body></body>";
549    let Some(at) = document.rfind(body) else {
550        return Ok(document);
551    };
552    let mark = logicaffeine_language::parser::lgx_lower::PRERENDERED_ATTR;
553    Ok(format!(
554        "{}<body {mark}=\"1\">\n{markup}\n</body>{}",
555        &document[..at],
556        &document[at + body.len()..]
557    ))
558}
559
560/// The JavaScript host shim written beside an emitted `.wasm` — supplies every `env.*` import the
561/// backend can emit (the display sinks, the float display [`FMT_F64_JS`],
562/// the scalar→Text formatters, `parse_int`, `pow`, and an `args` host that materializes `process.argv`
563/// as a `Seq of Text` in the module's linear memory). Running it needs only a wasm engine (node/V8).
564pub fn wasm_host_shim(name: &str) -> String {
565    // The module filename lands inside generated JavaScript — JSON-encode it
566    // so apostrophes/backslashes in a file stem can't corrupt the shim.
567    let wasm_ref =
568        serde_json::to_string(&format!("./{name}.wasm")).expect("a string always JSON-encodes");
569    let fail_cases = crate::semantics::failcode::js_cases("failMessage", "v", "code");
570    let fmt_f64_js = FMT_F64_JS;
571    let exact_int_js = EXACT_INT_JS;
572    format!(
573        r#"// Auto-generated by `largo build --emit wasm`. Run: node <this file> [args…]
574import {{ readFileSync }} from 'node:fs';
575const bytes = readFileSync(new URL({wasm_ref}, import.meta.url));
576let inst;
577const dv = () => new DataView(inst.exports.memory.buffer);
578const u8 = () => new Uint8Array(inst.exports.memory.buffer);
579const dec = new TextDecoder(), enc = new TextEncoder();
580const readText = (h) => {{ const d = dv(); const len = d.getInt32(h, true), dp = d.getInt32(h + 8, true); return dec.decode(u8().subarray(dp, dp + len)); }};
581const writeStr = (buf, s) => {{ const b = enc.encode(s); u8().set(b, buf); return b.length; }};
582const allocText = (s) => {{ const by = enc.encode(s); const t = inst.exports.__logos_text_new(by.length); u8().set(by, dv().getInt32(t + 8, true)); return t; }};
583{exact_int_js}
584{fmt_f64_js}
585// A uniform draw from lo..=hi (`randomInt`): 64-bit words from `crypto.getRandomValues`, a word at
586// or above the largest multiple of the span drawn again, so no value is likelier than another.
587const randomWord = () => {{ const w = new BigUint64Array(1); crypto.getRandomValues(w); return w[0]; }};
588const randomIntIn = (lo, hi) => {{
589  if (lo > hi) throw new Error(`randomInt(${{lo}}, ${{hi}}): the low bound is above the high bound`);
590  const span = hi - lo + 1n;
591  if (span === 1n << 64n) return BigInt.asIntN(64, randomWord());
592  const limit = (1n << 64n) - (1n << 64n) % span;
593  for (;;) {{ const r = randomWord(); if (r < limit) return lo + r % span; }}
594}};
595const seqInts = (h, fmt) => {{ const d = dv(); const len = d.getInt32(h, true), dp = d.getInt32(h + 8, true); const e = []; for (let i = 0; i < len; i++) e.push(fmt(d, dp + i * 8)); return e; }};
596// Calendar arithmetic — Howard Hinnant's `days_from_civil`/`civil_from_days` over BigInt, the
597// same algorithm `logicaffeine_base::temporal` runs, so `temporal_component`/`temporal_component_date`
598// answer bit-identically to the reference tier.
599const daysFromCivil = (year, month, day) => {{
600  const y = month <= 2n ? year - 1n : year;
601  const era = (y >= 0n ? y : y - 399n) / 400n;
602  const yoe = y - era * 400n;
603  const doy = (153n * (month > 2n ? month - 3n : month + 9n) + 2n) / 5n + day - 1n;
604  const doe = yoe * 365n + yoe / 4n - yoe / 100n + doy;
605  return era * 146097n + doe - 719468n;
606}};
607const civilFromDays = (z) => {{
608  const zz = z + 719468n;
609  const era = (zz >= 0n ? zz : zz - 146096n) / 146097n;
610  const doe = zz - era * 146097n;
611  const yoe = (doe - doe / 1460n + doe / 36524n - doe / 146096n) / 365n;
612  const y = yoe + era * 400n;
613  const doy = doe - (365n * yoe + yoe / 4n - yoe / 100n);
614  const mp = (5n * doy + 2n) / 153n;
615  const day = doy - (153n * mp + 2n) / 5n + 1n;
616  const month = mp < 10n ? mp + 3n : mp - 9n;
617  const year = month <= 2n ? y + 1n : y;
618  return [year, month, day];
619}};
620const isoWeekdayFromDays = (z) => ((((z + 3n) % 7n) + 7n) % 7n) + 1n;
621const isoWeekFromDays = (z) => {{
622  const isoDow = isoWeekdayFromDays(z);
623  const thursday = z + (4n - isoDow);
624  const [isoYear] = civilFromDays(thursday);
625  const jan4 = daysFromCivil(isoYear, 1n, 4n);
626  const week1Monday = jan4 - (isoWeekdayFromDays(jan4) - 1n);
627  return (z - week1Monday) / 7n + 1n;
628}};
629const NANOS_PER_DAY = 86400000000000n;
630const NANOS_PER_SECOND = 1000000000n;
631const divEuclid = (a, b) => {{ const q = a / b; return a % b < 0n ? q - 1n : q; }};
632const remEuclid = (a, b) => {{ const r = a % b; return r < 0n ? r + b : r; }};
633const isGregorianLeap = (year) => (year % 4n === 0n && year % 100n !== 0n) || year % 400n === 0n;
634const lastDayOfMonth = (year, month) => {{
635  if ([1n, 3n, 5n, 7n, 8n, 10n, 12n].includes(month)) return 31n;
636  if ([4n, 6n, 9n, 11n].includes(month)) return 30n;
637  if (month === 2n) return isGregorianLeap(year) ? 29n : 28n;
638  return 0n;
639}};
640const parseRfc3339 = (s) => {{
641  const bad = () => new Error('invalid RFC 3339 timestamp: ' + s);
642  if (s.length < 20) throw bad();
643  const digits = (start, len) => {{
644    let v = 0n;
645    for (let i = start; i < start + len; i++) {{
646      const c = s.charCodeAt(i);
647      if (c < 48 || c > 57) throw bad();
648      v = v * 10n + BigInt(c - 48);
649    }}
650    return v;
651  }};
652  if (s[4] !== '-' || s[7] !== '-' || s[10] !== 'T' || s[13] !== ':' || s[16] !== ':') throw bad();
653  const year = digits(0, 4), month = digits(5, 2), day = digits(8, 2);
654  const hour = digits(11, 2), minute = digits(14, 2), second = digits(17, 2);
655  if (month < 1n || month > 12n || day < 1n || day > lastDayOfMonth(year, month)) throw bad();
656  if (hour > 23n || minute > 59n || second > 59n) throw bad();
657  let pos = 19;
658  let nanosecond = 0n;
659  if (s[pos] === '.') {{
660    pos += 1;
661    let frac = 0n, count = 0;
662    while (pos < s.length && s[pos] >= '0' && s[pos] <= '9') {{
663      if (count < 9) {{ frac = frac * 10n + BigInt(s.charCodeAt(pos) - 48); count += 1; }}
664      pos += 1;
665    }}
666    if (count === 0) throw bad();
667    for (let i = count; i < 9; i++) frac *= 10n;
668    nanosecond = frac;
669  }}
670  let offsetSeconds = 0n;
671  if (s[pos] === 'Z') {{
672    pos += 1;
673  }} else if (s[pos] === '+' || s[pos] === '-') {{
674    const sign = s[pos] === '+' ? 1n : -1n;
675    const oh = digits(pos + 1, 2);
676    if (s[pos + 3] !== ':') throw bad();
677    const om = digits(pos + 4, 2);
678    if (oh > 23n || om > 59n) throw bad();
679    pos += 6;
680    offsetSeconds = sign * (oh * 3600n + om * 60n);
681  }} else {{
682    throw bad();
683  }}
684  if (pos !== s.length) throw bad();
685  const days = daysFromCivil(year, month, day);
686  const nanos = days * NANOS_PER_DAY + (hour * 3600n + minute * 60n + second) * NANOS_PER_SECOND + nanosecond;
687  return nanos - offsetSeconds * NANOS_PER_SECOND;
688}};
689const temporalComponent = (nanos, which) => {{
690  const day = divEuclid(nanos, NANOS_PER_DAY);
691  const rem = remEuclid(nanos, NANOS_PER_DAY);
692  const [year, month, d] = civilFromDays(day);
693  const secs = rem / NANOS_PER_SECOND;
694  switch (Number(which)) {{
695    case 0: return year;
696    case 1: return month;
697    case 2: return d;
698    case 3: return secs / 3600n;
699    case 4: return (secs % 3600n) / 60n;
700    case 5: return secs % 60n;
701    case 6: return isoWeekdayFromDays(day);
702    case 7: return isoWeekFromDays(day);
703    case 8: return (month - 1n) / 3n + 1n;
704    default: throw new Error('temporal_component: unknown component selector ' + which);
705  }}
706}};
707const temporalComponentDate = (days, which) => {{
708  const z = BigInt(days);
709  const [year, month, d] = civilFromDays(z);
710  switch (Number(which)) {{
711    case 0: return year;
712    case 1: return month;
713    case 2: return d;
714    case 6: return isoWeekdayFromDays(z);
715    case 7: return isoWeekFromDays(z);
716    case 8: return (month - 1n) / 3n + 1n;
717    default: throw new Error('temporal_component_date: unknown component selector ' + which);
718  }}
719}};
720const padSigned = (n, w) => {{
721  const neg = n < 0;
722  const digits = String(neg ? -n : n).padStart(neg ? w - 1 : w, '0');
723  return neg ? '-' + digits : digits;
724}};
725const pad2 = (n) => String(n).padStart(2, '0');
726const printDate = (days) => {{
727  const [y, m, d] = civilFromDays(BigInt(days));
728  return padSigned(Number(y), 4) + '-' + pad2(m) + '-' + pad2(d);
729}};
730const printMoment = (nanos) => {{
731  const totalSeconds = divEuclid(nanos, NANOS_PER_SECOND);
732  const days = divEuclid(totalSeconds, 86400n);
733  const daySeconds = remEuclid(totalSeconds, 86400n);
734  const hours = daySeconds / 3600n, minutes = (daySeconds % 3600n) / 60n;
735  const [y, m, d] = civilFromDays(days);
736  return padSigned(Number(y), 4) + '-' + pad2(m) + '-' + pad2(d) + ' ' + pad2(hours) + ':' + pad2(minutes);
737}};
738const printTime = (nanosFromMidnight) => {{
739  const rem = remEuclid(nanosFromMidnight, NANOS_PER_DAY);
740  const secs = rem / NANOS_PER_SECOND;
741  let s = pad2(secs / 3600n) + ':' + pad2((secs % 3600n) / 60n) + ':' + pad2(secs % 60n);
742  const nanos = rem % NANOS_PER_SECOND;
743  if (nanos !== 0n) {{
744    let frac = nanos.toString().padStart(9, '0');
745    while (frac.endsWith('0')) frac = frac.slice(0, -1);
746    s += '.' + frac;
747  }}
748  return s;
749}};
750const printDuration = (nanos) => {{
751  const abs = nanos < 0n ? -nanos : nanos;
752  const sign = nanos < 0n ? '-' : '';
753  if (abs >= 3600000000000n) return sign + (abs / 3600000000000n) + 'h';
754  if (abs >= 60000000000n) return sign + (abs / 60000000000n) + 'min';
755  if (abs >= 1000000000n) return sign + (abs / 1000000000n) + 's';
756  if (abs >= 1000000n) return sign + (abs / 1000000n) + 'ms';
757  if (abs >= 1000n) return sign + (abs / 1000n) + 'μs';
758  return sign + abs + 'ns';
759}};
760const printSpan = (packed) => {{
761  const months = Number(BigInt.asIntN(32, packed >> 32n));
762  const days = Number(BigInt.asIntN(32, packed));
763  const parts = [];
764  const years = Math.trunc(months / 12), remMonths = months % 12;
765  if (years !== 0) parts.push(Math.abs(years) === 1 ? (years + ' year') : (years + ' years'));
766  if (remMonths !== 0) parts.push(Math.abs(remMonths) === 1 ? (remMonths + ' month') : (remMonths + ' months'));
767  if (days !== 0 || parts.length === 0) parts.push(Math.abs(days) === 1 ? (days + ' day') : (days + ' days'));
768  return parts.join(' and ');
769}};
770const alignPad = (s, width, align) => {{
771  const chars = Array.from(s);
772  const pad = width - chars.length;
773  if (pad <= 0) return s;
774  if (align === 1) return s + ' '.repeat(pad);
775  if (align === 2) {{ const l = Math.floor(pad / 2); return ' '.repeat(l) + s + ' '.repeat(pad - l); }}
776  return ' '.repeat(pad) + s;
777}};
778const out = [];
779let failMessage = '';
780const env = {{
781  ...exactImports,
782  print_exact: (t) => out.push(exactValue(t).toString()),
783  exact_text: (t) => allocText(exactValue(t).toString()),
784  print_i64: (v) => out.push(v.toString()),
785  // A runtime failure hands its reason over just before the module traps. Without these two,
786  // a module that CAN fail does not instantiate at all — the program never starts, and the
787  // error reads nothing like the mistake that caused it.
788  logos_rt_fail: (h) => {{ failMessage = readText(h); }},
789  logos_rt_fail_at: (code, value) => {{
790    const v = typeof value === 'bigint' ? value.toString() : String(value);
791    switch (code) {{ {fail_cases} }}
792  }},
793  print_bool: (v) => out.push(v ? 'true' : 'false'),
794  print_f64: (v) => out.push(fmtF(v)),
795  print_char: (v) => out.push(String.fromCodePoint(Number(v))),
796  print_word: (v) => out.push(BigInt.asUintN(64, v).toString()),
797  print_text: (h) => out.push(readText(h)),
798  print_date: (d) => out.push(printDate(d)),
799  print_moment: (n) => out.push(printMoment(n)),
800  print_duration: (n) => out.push(printDuration(n)),
801  print_time: (n) => out.push(printTime(n)),
802  print_span: (packed) => out.push(printSpan(packed)),
803  print_rational: (num, den) => out.push(den === 1n ? num.toString() : (num + '/' + den)),
804  print_nothing: () => out.push('nothing'),
805  print_seq_i64: (h) => out.push('[' + seqInts(h, (d, a) => d.getBigInt64(a, true).toString()).join(', ') + ']'),
806  print_seq_bool: (h) => out.push('[' + seqInts(h, (d, a) => (d.getBigInt64(a, true) !== 0n).toString()).join(', ') + ']'),
807  print_seq_word32: (h) => out.push('[' + seqInts(h, (d, a) => d.getUint32(a, true).toString()).join(', ') + ']'),
808  print_seq_word64: (h) => out.push('[' + seqInts(h, (d, a) => d.getBigUint64(a, true).toString()).join(', ') + ']'),
809  print_seq_f64: (h) => out.push('[' + seqInts(h, (d, a) => fmtF(d.getFloat64(a, true))).join(', ') + ']'),
810  print_seq_text: (h) => out.push('[' + seqInts(h, (d, a) => readText(d.getInt32(a, true))).join(', ') + ']'),
811  print_set_i64: (h) => out.push('{{' + seqInts(h, (d, a) => d.getBigInt64(a, true).toString()).join(', ') + '}}'),
812  print_set_text: (h) => out.push('{{' + seqInts(h, (d, a) => readText(d.getInt32(a, true))).join(', ') + '}}'),
813  fmt_i64_into: (buf, v) => writeStr(buf, v.toString()),
814  fmt_f64_into: (buf, v) => writeStr(buf, fmtF(v)),
815  fmt_bool_into: (buf, v) => writeStr(buf, v ? 'true' : 'false'),
816  fmt_f64_prec_into: (buf, v, prec) => writeStr(buf, v.toFixed(prec)),
817  fmt_align_into: (buf, text, width, align) => writeStr(buf, alignPad(readText(text), Number(width), Number(align))),
818  fmt_seq_i64_into: (buf, h) => writeStr(buf, '[' + seqInts(h, (d, a) => d.getBigInt64(a, true).toString()).join(', ') + ']'),
819  fmt_seq_bool_into: (buf, h) => writeStr(buf, '[' + seqInts(h, (d, a) => (d.getBigInt64(a, true) !== 0n).toString()).join(', ') + ']'),
820  fmt_set_i64_into: (buf, h) => writeStr(buf, '{{' + seqInts(h, (d, a) => d.getBigInt64(a, true).toString()).join(', ') + '}}'),
821  parse_int: (h) => BigInt(parseInt(readText(h).trim(), 10) || 0),
822  parse_float: (h) => parseFloat(readText(h).trim()),
823  read_reals: (h) => {{
824    // The file's binary64 values, little-endian as the module's memory is, on a page the shim grows (the
825    // module's own allocator never grows memory), refused as `logicaffeine_system::file::read_reals` refuses.
826    const path = readText(h);
827    let bytes;
828    try {{ bytes = readFileSync(path); }} catch (e) {{ throw new Error(`readReals: cannot read '${{path}}': ${{e.message}}`); }}
829    if (bytes.length % 8 !== 0) throw new Error(`readReals: '${{path}}' holds ${{bytes.length}} bytes, not a whole number of 8-byte Reals`);
830    const n = bytes.length / 8;
831    const seq = inst.exports.memory.grow(Math.ceil((16 + bytes.length) / 65536)) * 65536;
832    const d = dv();
833    d.setInt32(seq, n, true); d.setInt32(seq + 4, n, true); d.setInt32(seq + 8, seq + 16, true);
834    u8().set(bytes, seq + 16);
835    return seq;
836  }},
837  parse_timestamp: (h) => parseRfc3339(readText(h)),
838  text_contains: (haystack, needle) => (readText(haystack).includes(readText(needle)) ? 1n : 0n),
839  random_int: (lo, hi) => randomIntIn(lo, hi),
840  env_len: (h) => {{ const v = process.env[readText(h)]; return v === undefined ? -1 : enc.encode(v).length; }},
841  env_into: (buf, h) => writeStr(buf, process.env[readText(h)] ?? ''),
842  time_now: () => BigInt(Date.now()),
843  pow_ff: (a, b) => Math.pow(a, b),
844  pow_fi: (a, b) => Math.pow(a, Number(b)),
845  // Real wall-clock time: a compiled module running under `node` has no test-fixed-clock concept
846  // (only the Rust `wasmi` test host, reading the tree-walker's own thread-local fixed clock,
847  // needs that determinism).
848  today: () => Math.floor(Date.now() / 86400000),
849  now: () => BigInt(Date.now()) * 1000000n,
850  // `write_wire_residual(data_ptr, len) -> the byte count` — the raw bytes written straight to
851  // real stdout, exactly as the tree-walker's own `BuiltinId::WriteWireResidual` does.
852  write_wire_residual: (dataPtr, len) => {{
853    const bytes = u8().slice(Number(dataPtr), Number(dataPtr) + Number(len));
854    process.stdout.write(Buffer.from(bytes));
855    return BigInt(len);
856  }},
857  // Wave 13 observability: `Op::Obs` is populated only by the OBSERVED compile profile, which
858  // `largo build --emit wasm` never uses — declared for ABI completeness, never actually reached.
859  obs: (_site, _p0, _p1, _p2) => {{}},
860  temporal_component: (nanos, which) => temporalComponent(nanos, which),
861  temporal_component_date: (days, which) => temporalComponentDate(days, which),
862  args: () => {{
863    // argv lives on a page the SHIM grows: the program's bump allocator and
864    // iterator stack are confined to the module's original pages (the
865    // emitter never calls memory.grow), so a fresh page can never collide
866    // with live program data. Cached: repeated args() calls share one page.
867    if (argvSeq) return argvSeq;
868    const argv = process.argv.slice(1); // [scriptPath, ...userArgs] — Logos `item 1` is the program
869    const seq = inst.exports.memory.grow(1) * 65536;
870    const d = dv(), b = u8(); const n = argv.length, seqData = seq + 16; let p = seqData + n * 8; const hs = [];
871    for (const s of argv) {{ const t = p, by = enc.encode(s), td = t + 16; d.setInt32(t, by.length, true); d.setInt32(t + 4, by.length, true); d.setInt32(t + 8, td, true); b.set(by, td); hs.push(t); p = (td + by.length + 7) & ~7; }}
872    d.setInt32(seq, n, true); d.setInt32(seq + 4, n, true); d.setInt32(seq + 8, seqData, true);
873    for (let i = 0; i < n; i++) d.setBigInt64(seqData + i * 8, BigInt(hs[i]), true);
874    argvSeq = seq;
875    return seq;
876  }},
877}};
878let argvSeq = 0;
879const {{ instance }} = await WebAssembly.instantiate(bytes, {{ env }});
880inst = instance;
881// Flush whatever the program produced even when it traps (overflow etc.);
882// a trap still fails the run loudly after the flush.
883let trapped = null;
884try {{
885  instance.exports.main();
886}} catch (e) {{
887  trapped = e;
888}}
889process.stdout.write(out.join('\n') + (out.length ? '\n' : ''));
890if (trapped) {{
891  // A failed run ends as it ends on every other tier: its reason and a newline on stderr, exit status 1.
892  // `failMessage` is the reference tier's sentence; a trap the module gave no reason for prints the engine's.
893  process.stderr.write((failMessage || String(trapped && trapped.message ? trapped.message : trapped)) + '\n');
894  process.exit(1);
895}}
896"#
897    )
898}
899
900#[cfg(test)]
901#[path = "../../logicaffeine_tests/unit/logicaffeine_compile/web_host/tests.rs"]
902mod tests;
903
904#[cfg(test)]
905#[path = "../../logicaffeine_tests/unit/logicaffeine_compile/web_host/first_paint_tests.rs"]
906mod first_paint_tests;