1pub 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
14pub 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
117pub fn browser_host_html(name: &str) -> String {
121 browser_host_html_with_head(name, None, None)
122}
123
124fn 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
143fn 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
163fn 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
197pub enum ShellTheme {
198 #[default]
200 Default,
201 None,
203}
204
205pub 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
216pub 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 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 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
270pub fn with_csrf_meta(document: &str, token: &str) -> String {
276 logicaffeine_system::csrf::with_csrf_meta(document, token)
277}
278
279fn 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; }
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
302fn escape_html(s: &str) -> String {
304 s.replace('&', "&")
305 .replace('<', "<")
306 .replace('>', ">")
307 .replace('"', """)
308 .replace('\'', "'")
309}
310
311const 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
323pub 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
337pub 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 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
386pub fn browser_host_js(name: &str) -> String {
391 let wasm_ref = serde_json::to_string(&format!("./{name}.wasm")).expect("json");
392 format!("const WASM_URL = {wasm_ref};\n{}", BROWSER_RUNTIME_JS)
395}
396
397pub const BROWSER_RUNTIME_JS: &str = include_str!("../assets/web/runtime.js");
400
401pub 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 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
429pub 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
472pub 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
502pub fn first_paint(source: &str) -> Result<String, String> {
507 crate::semantics::dom::reset();
508 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 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 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
536pub 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 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
560pub fn wasm_host_shim(name: &str) -> String {
565 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;