Skip to main content

DOM_JS

Constant DOM_JS 

Source
pub const DOM_JS: &str = "// DOM rendering for ZDeceptron.\n//\n// Direct DOM manipulation, no virtual DOM. Every binding is an `effect`,\n// so a signal write reaches exactly the text nodes and attributes that\n// read it \u{2014} a component never re-renders as a unit because there are no\n// components at this layer, only bindings.\n//\n// Generated code calls into this module. It is not written by hand and is\n// not a user-facing API, which is why it optimises for the code generator\n// rather than for ergonomics.\n\nimport { signal, effect, batch, owned, onCleanup } from \'./signal.js\';\n\n/** A value that may be a signal getter or a constant. */\nfunction read(value) {\n  return typeof value === \'function\' ? value() : value;\n}\n\n/**\n * Create an element with reactive properties and children.\n *\n * `props` values may be getters; each becomes its own effect, so changing\n * one attribute does not touch the others.\n */\nexport function el(tag, props = {}, children = []) {\n  const node = document.createElement(tag);\n\n  for (const [name, value] of Object.entries(props)) {\n    if (name.startsWith(\'on\')) {\n      on(node, name.slice(2).toLowerCase(), value);\n    } else if (name === \'style\' && typeof value === \'object\') {\n      for (const [prop, v] of Object.entries(value)) {\n        effect(() => {\n          node.style.setProperty(prop, String(read(v)));\n        });\n      }\n    } else if (typeof value === \'function\') {\n      effect(() => setAttribute(node, name, value()));\n    } else {\n      setAttribute(node, name, value);\n    }\n  }\n\n  appendChildren(node, children);\n  return node;\n}\n\nfunction setAttribute(node, name, value) {\n  if (name === \'value\' && \'value\' in node) {\n    if (node.value !== String(value)) node.value = String(value);\n  } else if (name === \'checked\' && \'checked\' in node) {\n    node.checked = Boolean(value);\n  } else if (value === false || value === null || value === undefined) {\n    node.removeAttribute(name);\n  } else if (value === true) {\n    node.setAttribute(name, \'\');\n  } else {\n    node.setAttribute(name, String(value));\n  }\n}\n\nfunction appendChildren(parent, children) {\n  for (const child of [].concat(children)) {\n    if (child === null || child === undefined) continue;\n    if (child instanceof Node) {\n      parent.appendChild(child);\n    } else if (typeof child === \'function\') {\n      parent.appendChild(dynamic(child));\n    } else {\n      parent.appendChild(document.createTextNode(String(child)));\n    }\n  }\n}\n\n/**\n * A text node bound to a getter.\n *\n * Updating it writes `nodeValue` rather than replacing the node, so the\n * browser keeps selection and caret position \u{2014} one of the things a\n * virtual-DOM diff has to work to preserve and this gets for free.\n */\nexport function text(getter) {\n  const node = document.createTextNode(\'\');\n  effect(() => {\n    const value = read(getter);\n    node.nodeValue = value === null || value === undefined ? \'\' : String(value);\n  });\n  return node;\n}\n\n// --- the template surface (spec \u{a7}16.2 R2) --------------------------------\n//\n// Generated code does not build the DOM node by node. It parses one static\n// HTML string per view region into a `<template>`, clones it per\n// instantiation, walks to compile-time-computed offsets, and attaches a\n// binding only at the holes. Everything below is what that emission needs;\n// it is additive, and `dynamic`, `each` and `when` are re-expressed as thin\n// wrappers over it so there is one implementation of each rather than two.\n\n/**\n * Parse `html` once, then hand out a fresh clone per call.\n *\n * The returned value is a *fragment*, not its first child. A view region\n * may legally have several roots \u{2014} `view`, a `when` arm and an `each` body\n * are all node lists \u{2014} and returning `content.firstChild` silently discards\n * every root but the first (spec \u{a7}16.9, finding 8).\n *\n * `html` is never a runtime value. The compiler interpolates only\n * compile-time string *literals* into it, HTML-escaped (spec \u{a7}16.3.5);\n * every value a program computes reaches the DOM through `nodeValue`,\n * `setAttribute`, `.value` or `.checked`, none of which parses HTML. So\n * template cloning adds no injection surface over the node-by-node path.\n */\nexport function template(html) {\n  let content;\n  return () => {\n    if (content === undefined) {\n      const element = document.createElement(\'template\');\n      element.innerHTML = html;\n      content = element.content;\n    }\n    return content.cloneNode(true);\n  };\n}\n\n/**\n * A fragment holding an empty anchored region: a start and an end comment.\n *\n * A region that is nothing but a hole has no markup to clone, so the\n * emitter calls this instead of parsing a template made of two comments.\n */\nexport function anchors() {\n  const fragment = document.createDocumentFragment();\n  fragment.append(document.createComment(\'\'), document.createComment(\'\'));\n  return fragment;\n}\n\n/**\n * Bind an existing text node to a getter.\n *\n * The write is guarded by a comparison (spec \u{a7}16.2 R7). A list re-supplies\n * every surviving row\'s item on every change, which re-runs every row\'s\n * binding; without the guard, one changed row dirties layout for all of\n * them. `setAttribute` below already does exactly this for `value`.\n */\nexport function bindText(node, getter) {\n  effect(() => {\n    const value = read(getter);\n    const next = value === null || value === undefined ? \'\' : String(value);\n    if (node.nodeValue !== next) node.nodeValue = next;\n  });\n}\n\n/**\n * The schemes a URL-bearing attribute may name (spec \u{a7}16.3.5, corrected).\n *\n * \u{a7}16.3.5\'s escaping argument is about the *markup* grammar: it\n * establishes that a value cannot close a tag or open one. It says nothing\n * about `href` and `src`, which the browser hands to the URL parser\n * instead. `setAttribute(\'href\', v)` stores `v` verbatim, and\n * `javascript:alert(1)` in an `href` executes on click \u{2014} there is nothing\n * in it for an HTML escaper to escape. Escaping for HTML text is not\n * escaping for a URL; they are different grammars.\n *\n * An allowlist, not a list of the dangerous schemes. `javascript:`,\n * `data:` and `vbscript:` are the three usually named, but which schemes a\n * browser executes is the browser\'s decision and it changes; a denylist is\n * out of date the day it is written.\n *\n * The compiler settles every URL it can see \u{2014} a literal in an `href` is a\n * compile error, not a value filtered here \u{2014} so this runs only on values\n * the compiler could not see. It is the Rust half\'s exact mirror\n * (`zdc_hir::url_is_safe`), and `crates/zdc-codegen/tests/url.rs` runs the\n * two against one table so that changing one without the other fails.\n *\n * A refused URL becomes the empty string, not `#`: a link that goes\n * nowhere should not scroll the page to the top when it is clicked.\n */\nconst URL_SCHEMES = [\'http\', \'https\', \'mailto\', \'tel\'];\n\nexport function safeUrl(value) {\n  const url = value === null || value === undefined ? \'\' : String(value);\n  // Leading whitespace is stripped by the browser before it parses the\n  // scheme, so `\\njavascript:alert(1)` is a `javascript:` URL.\n  const trimmed = url.trimStart();\n  const colon = trimmed.indexOf(\':\');\n  if (colon === -1) return url;\n  const scheme = trimmed.slice(0, colon);\n  // A colon inside a path or a query is not a scheme: `/a:b` is relative.\n  if (/[/?#]/.test(scheme)) return url;\n  return URL_SCHEMES.includes(scheme.toLowerCase()) ? url : \'\';\n}\n\n/** Bind an existing element\'s attribute to a getter. */\nexport function bindAttr(node, name, getter) {\n  effect(() => setAttribute(node, name, read(getter)));\n}\n\n/** Bind one CSS property of an existing element to a getter. */\nexport function bindStyle(node, property, getter) {\n  effect(() => {\n    node.style.setProperty(property, String(read(getter)));\n  });\n}\n\n/**\n * Attach an event listener to an existing element.\n *\n * Batched, so generated code emits no `batch(...)` of its own, and `el`\n * routes here rather than repeating the listener: one place decides what a\n * handler is.\n *\n * **A handler that throws is contained and reported (#139)** \u{2014} the page\n * keeps running, its writes stand, and `reportError` is the platform\'s own\n * uncaught-error channel. `docs/reference.md` \u{a7}10 argues it.\n */\nexport function on(node, event, handler) {\n  node.addEventListener(event, (e) => {\n    try {\n      batch(() => handler(e));\n    } catch (failure) {\n      reportError(failure);\n    }\n  });\n}\n\n/**\n * A region between two existing anchors whose content is replaced when its\n * getter changes.\n */\nexport function dynamicInto(start, end, getter) {\n  effect(() => {\n    const value = read(getter);\n    clearBetween(start, end);\n    const rendered = value instanceof Node ? value : document.createTextNode(String(value ?? \'\'));\n    end.parentNode.insertBefore(rendered, end);\n  });\n}\n\n/**\n * A region whose content is replaced when its getter changes.\n *\n * Anchored between two comment nodes so the region\'s extent is known\n * without wrapping it in an element the program did not ask for.\n */\nexport function dynamic(getter) {\n  const fragment = anchors();\n  dynamicInto(fragment.firstChild, fragment.lastChild, getter);\n  return fragment;\n}\n\n/**\n * Variant dispatch \u{2014} `when value` over `Remote`, `Option`, or a `choice`.\n *\n * `arms` maps a variant name to a function receiving that variant\'s\n * fields positionally. Spec \u{a7}14G.1.6 requires every arm to be present,\n * so a missing arm is a compiler bug rather than a runtime fallback.\n */\nexport function when(getter, arms) {\n  const fragment = anchors();\n  whenInto(fragment.firstChild, fragment.lastChild, getter, arms);\n  return fragment;\n}\n\n/** Variant dispatch between two existing anchors. */\nexport function whenInto(start, end, getter, arms) {\n  // The arm\'s payload lives in a signal, and each field is handed to the\n  // arm as a getter. So a changed payload flows to the bindings that read\n  // it, and only a changed TAG rebuilds the subtree.\n  //\n  // The earlier implementation was `dynamic(derived(...))`, which rebuilt\n  // on any change. Since every list in the language sits inside a `when`\n  // arm, one changed cell tore down and recreated the entire list.\n  const [fields, setFields] = signal([]);\n  let currentTag = null;\n  let disposeArm = null;\n\n  onCleanup(() => disposeArm && disposeArm());\n\n  effect(() => {\n    const value = read(getter);\n    setFields(value.fields ?? []);\n    if (value.tag === currentTag) return;\n\n    const arm = arms[value.tag];\n    // $dev\n    // Development only (#140). \u{a7}14G.1.6 makes every arm present, so this\n    // states a compiler invariant rather than handling a case: a release\n    // build calling `arm(...)` on `undefined` throws too, and #139\'s\n    // containment reports it. What is lost is the sentence, not the\n    // failure.\n    if (arm === undefined) {\n      throw new Error(\n        `No arm for variant ${JSON.stringify(value.tag)}. The compiler should have rejected this.`\n      );\n    }\n    // $end\n    currentTag = value.tag;\n    // The outgoing arm\'s bindings read this `when`\'s own `fields` signal,\n    // which keeps being written, so leaving them subscribed would keep\n    // running them against detached nodes for the life of the page.\n    if (disposeArm !== null) disposeArm();\n    clearBetween(start, end);\n    const binders = (value.fields ?? []).map((_, index) => () => fields()[index]);\n    const [rendered, dispose] = owned(() => arm(...binders));\n    disposeArm = dispose;\n    end.parentNode.insertBefore(rendered, end);\n  });\n}\n\n/**\n * Conditional rendering between two existing anchors \u{2014} `if cond`.\n *\n * Not a `whenInto` with two arms: there is no variant here and no `choice`\n * the program declared, so there is no tag to switch on. The branch is\n * rebuilt only when the condition\'s *truth* changes, for exactly the reason\n * `whenInto` rebuilds only on a tag change \u{2014} a condition that reads a\n * signal which keeps changing without crossing the boundary would otherwise\n * tear down and recreate the whole subtree on every write.\n *\n * `otherwise` may be null, which renders nothing.\n */\nexport function ifInto(start, end, condition, render, otherwise) {\n  // `null` rather than a boolean, so the first run always renders: neither\n  // branch has been built yet, and `false` would look like \"already\n  // showing the else\".\n  let current = null;\n  let disposeBranch = null;\n\n  onCleanup(() => disposeBranch && disposeBranch());\n\n  effect(() => {\n    const taken = Boolean(read(condition));\n    if (taken === current) return;\n    current = taken;\n\n    // The outgoing branch\'s bindings read signals that keep being written,\n    // so leaving them subscribed would keep running them against detached\n    // nodes for the life of the page.\n    if (disposeBranch !== null) disposeBranch();\n    disposeBranch = null;\n    clearBetween(start, end);\n\n    const branch = taken ? render : otherwise;\n    if (branch === null || branch === undefined) return;\n    const [rendered, dispose] = owned(() => branch());\n    disposeBranch = dispose;\n    end.parentNode.insertBefore(rendered, end);\n  });\n}\n\n/** Construct a variant value. */\nexport function variant(tag, ...fields) {\n  return { tag, fields };\n}\n\n/** Mount a rendered tree into a container, replacing its contents. */\nexport function mount(node, container) {\n  container.replaceChildren(node);\n  return node;\n}\n\nfunction clearBetween(start, end) {\n  let node = start.nextSibling;\n  while (node && node !== end) {\n    const next = node.nextSibling;\n    node.remove();\n    node = next;\n  }\n}\n";
Expand description

DOM rendering. Requires a document, so it is embedded for shipping rather than for evaluation here.