☰ VanJS by Example

VanJS: Learning by Example

Despite being an ultra-lightweight UI framework, VanJS allows you to write incredibly elegant and expressive code for comprehensive application logic. This page is a curated list of cool things you can do with just a few lines of JavaScript code, including several handy utilities built with VanJS.

See also Community Examples.

Hello World!


This is the Hello World program shown in the Home page:

const Hello = () => div(
  p("πŸ‘‹Hello"),
  ul(
    li("πŸ—ΊοΈWorld"),
    li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
  ),
)

Demo:

Try on jsfiddle

This is the funnier Hello program shown in Getting Started page:

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

const Run = ({sleepMs}) => {
  const headingSpaces = van.state(40), trailingUnderscores = van.state(0)

  const animate = async () => {
    while (headingSpaces.val > 0) {
      await sleep(sleepMs)
      --headingSpaces.val, ++trailingUnderscores.val
    }
  }
  animate()

  const helloText = van.bind(headingSpaces, trailingUnderscores,
    (h, t) => `${" ".repeat(h)}πŸšπŸ’¨Hello VanJS!${"_".repeat(t)}`)
  return div(pre(helloText))
}

const Hello = () => {
  const dom = div()
  return div(
    dom,
    button({onclick: () => van.add(dom, Run({sleepMs: 2000}))}, "Hello 🐌"),
    button({onclick: () => van.add(dom, Run({sleepMs: 500}))}, "Hello 🐒"),
    button({onclick: () => van.add(dom, Run({sleepMs: 100}))}, "Hello πŸšΆβ€β™‚οΈ"),
    button({onclick: () => van.add(dom, Run({sleepMs: 10}))}, "Hello 🏎️"),
    button({onclick: () => van.add(dom, Run({sleepMs: 2}))}, "Hello πŸš€"),
  )
}

Demo:

Try on jsfiddle

An alternative implementation by @stephenhandley can be found here.

DOM Composition and Manipulation


Even without state and state binding, you can build interactive web pages thanks to VanJS's flexible API for DOM composition and manipulation: tag functions and van.add. Check out the example below:

const StaticDom = () => {
  const dom = div(
    div(
      button("Dummy Button"),
      button(
        {onclick: () =>
          van.add(dom,
            div(button("New Button")),
            div(a({href: "https://www.example.com/"}, "This is a link")),
          )
        },
        "Button to Add More Elements"),
      button({onclick: () => alert("Hello from 🍦VanJS")}, "Hello"),
    ),
  )
  return dom
}

Demo:

Try on jsfiddle

Counter


The Counter App is a good illustration on how to leverage States to make your application reactive. This is the program shown in the Home page:

const Counter = () => {
  const counter = van.state(0)
  return span(
    "❀️ ", counter, " ",
    button({onclick: () => ++counter.val}, "πŸ‘"),
    button({onclick: () => --counter.val}, "πŸ‘Ž"),
  )
}

Demo:

Try on jsfiddle

This is a slightly advanced version of Counter App:

const buttonStyleList = [
  ["πŸ‘†", "πŸ‘‡"],
  ["πŸ‘", "πŸ‘Ž"],
  ["πŸ”Ό", "πŸ”½"],
  ["⬆️", "⬇️"],
  ["⏫", "⏬"],
  ["πŸ“ˆ", "πŸ“‰"],
]

const Counter = ({buttons}) => {
  const counter = van.state(0)
  const dom = div(
    "❀️ ", counter, " ",
    button({onclick: () => ++counter.val}, buttons[0]),
    button({onclick: () => --counter.val}, buttons[1]),
    button({onclick: () => dom.remove()}, "❌"),
  )
  return dom
}

const CounterSet = () => {
  const containerDom = div()
  return div(
    containerDom,
    button({onclick: () => van.add(containerDom,
      Counter({buttons: buttonStyleList[Math.floor(Math.random() * buttonStyleList.length)]}))},
      "βž•",
    ),
  )
}

Demo:

Try on jsfiddle

Stopwatch


This is a Stopwatch App, similar to the Timer App shown in the tutorial:

const Stopwatch = () => {
  const elapsed = van.state("0.00")
  let id
  const start = () => id = id || setInterval(() =>
    elapsed.val = (Number(elapsed.val) + 0.01).toFixed(2), 10)
  return span(
    pre({style: "display: inline;"}, elapsed, "s "),
    button({onclick: start}, "Start"),
    button({onclick: () => (clearInterval(id), id = 0)}, "Stop"),
    button({onclick: () =>
      (clearInterval(id), id = 0, elapsed.val = "0.00")}, "Reset"),
  )
}

Demo:

Try on jsfiddle

Blog


VanJS doesn't have an equivalent to React's <Fragment>. For most of the cases, returning an array of HTML elements from your custom component would serve the similar purpose. Here is the sample code equivalent to the Blog example in React's official website:

const Blog = () => [
  Post({title: "An update", body: "It's been a while since I posted..."}),
  Post({title: "My new blog", body: "I am starting a new blog!"}),
]

const Post = ({title, body}) => [
  PostTitle({title}),
  PostBody({body}),
]

const PostTitle = ({title}) => h1(title)
const PostBody = ({body}) => article(p(body))

Try on jsfiddle

The sample code in React is 29 lines. Thus VanJS's equivalent code is ~3 times shorter by eliminating unnecessary boilerplate.

Note that: The result of complex state binding can't be an array of elements. You can wrap the result into a pass-through container (span for inline elements and div for block elements) if multiple elements need to be returned.

List


As an unopinionated framework, VanJS supports multiple programming paradigms. You can construct the DOM tree in an imperative way (modifying the DOM tree via van.add), or in a functional/declarative way.

Below is an example of building a list even numbers in 1..N, using an imperative way:

const EvenNumbers = ({N}) => {
  const listDom = ul()
  for (let i = 1; i <= N; ++i)
    if (i % 2 === 0)
      van.add(listDom, li(i))

  return div(
    p("List of even numbers in 1.." + N + ":"),
    listDom,
  )
}

Try on jsfiddle

Alternatively, you can build a list of even numbers in 1..N, using a functional/declarative way:

const EvenNumbers = ({N}) => div(
  p("List of even numbers in 1.." + N + ":"),
  ul(
    Array.from({length: N}, (_, i) => i + 1)
      .filter(i => i % 2 === 0)
      .map(i => li(i)),
  ),
)

Try on jsfiddle

TODO List


Similarly, to build reactive applications, you can build in a procedural way, which updates UI via the integration with native DOM API (it's easy to do with VanJS as it doesn't introduce an ad-hoc virtual-DOM layer), or in a functional/reactive way, which delegates UI changes to State Binding. You can also choose a hybrid approach between the 2 paradigms, depending on which approach fits well for a specific problem.

Below is an example of building a TODO List in a completely procedural way:

const TodoItem = ({text}) => div(
  input({type: "checkbox", onchange: e =>
    e.target.closest("div").querySelector("span").style["text-decoration"] =
      e.target.checked ? "line-through" : ""
  }),
  span(text),
  a({onclick: e => e.target.closest("div").remove()}, "❌"),
)

const TodoList = () => {
  const inputDom = input({type: "text"})
  const dom = div(
    inputDom,
    button({onclick: () => van.add(dom, TodoItem({text: inputDom.value}))}, "Add"),
  )
  return dom
}

Demo:

Try on jsfiddle

Alternatively, you can use a functional/reactive way to build TODO Items:

const TodoItem = ({text}) => {
  const done = van.state(false), deleted = van.state(false)
  return van.bind(deleted,
    d => d ? null : div(
      input({type: "checkbox", checked: done, onclick: e => done.val = e.target.checked}),
      van.bind(done, done => done ? del(text) : span(text)),
      a({onclick: () => deleted.val = true}, "❌"),
    )
  )
}

const TodoList = () => {
  const inputDom = input({type: "text"})
  const dom = div(inputDom,
    button({onclick: () => van.add(dom, TodoItem({text: inputDom.value}))}, "Add"),
  )
  return dom
}

Demo:

Try on jsfiddle

Stargazers


The following code can show the number of stars for a Github repo, and a list of most recent stargazers:

const Stars = async repo => {
  const repoJson = await fetch(`https://api.github.com/repos/${repo}`).then(r => r.json())
  const pageNum = Math.floor((repoJson.stargazers_count - 1) / 100) + 1
  const starsJson = await fetch(
    `https://api.github.com/repos/${repo}/stargazers?per_page=100&page=${pageNum}`)
    .then(r => r.json())
  return div(
    p(repoJson.stargazers_count, " ⭐️:"),
    ul(
      starsJson.reverse().map(u => li(a({href: u.html_url}, u.login))),
    ),
  )
}

Try it out here

Try on jsfiddle

Epoch Timestamp Converter


Below is an application which converts a Unix epoch timestamp into a human-readable datetime string:

const tsToDate = ts => {
  if (ts < 1e10) return new Date(ts * 1e3)
  if (ts < 1e13) return new Date(ts)
  if (ts < 1e16) return new Date(ts / 1e3)
  return new Date(ts / 1e6)
}

const Converter = () => {
  const nowTs = van.state(Math.floor(new Date().getTime() / 1e3))
  setInterval(() => ++nowTs.val, 1000)
  const inputDom = input({type: "text", size: 25, value: nowTs.val})
  let dateStrDom
  const resultDom = div(
    div(b("Now: "), nowTs),
    inputDom, " ",
    button({
      onclick: () => {
        const date = tsToDate(Number(inputDom.value))
        dateStrDom?.remove()
        dateStrDom = resultDom.appendChild(p(
          div(date.toString()),
          div(b("GMT: "), date.toGMTString()),
        ))
      }
    }, "Convert"),
    p(i("Supports Unix timestamps in seconds, milliseconds, microseconds and nanoseconds.")),
  )
  return resultDom
}

Demo:

Try on jsfiddle

Keyboard Event Inspector


Below is an application to inspect all relevant key codes in keyboard keydown events:

const Label = text => span({class: "label"}, text)
const Value = text => span({class: "value"}, text)

const Inspector = () => {
  const keyStates = {
    key: van.state(""),
    code: van.state(""),
    which: van.state(""),
    keyCode: van.state(""),
    ctrlKey: van.state(false),
    metaKey: van.state(false),
    altKey: van.state(false),
    shiftKey: van.state(false),
  }

  const Result = prop => span(Label(prop + ": "), Value(keyStates[prop]))

  const onkeydown = e => {
    e.preventDefault()
    Object.entries(keyStates).forEach(([k, v]) => v.val = e[k])
  }

  return div(
    div(input({placeholder: "Focus here and press keys…", onkeydown,
      style: "width: 260px"})),
    div(Result("key"), Result("code"), Result("which"), Result("keyCode")),
    div(Result("ctrlKey"), Result("metaKey"), Result("altKey"), Result("shiftKey")),
  )
}

Demo:

Try on jsfiddle

Diff


Here is a Diff App with the integration of jsdiff. The app can compare 2 pieces of text (very handy tool to check how your text is revised by ChatGPT πŸ™‚):

const autoGrow = e => {
  e.target.style.height = "5px"
  e.target.style.height = (e.target.scrollHeight + 5) + "px"
}

const Result = diff => div({class: "column", style: "white-space: pre-wrap;"},
  diff.map(d =>
    span({class: d.added ? "add" : (d.removed ? "remove" : "")}, d.value)),
)

const DiffApp = () => {
  const oldTextDom = textarea({oninput: autoGrow, rows: 1})
  const newTextDom = textarea({oninput: autoGrow, rows: 1})
  const diff = van.state([])
  return div(
    div({class: "row"},
      div({class: "column"}, oldTextDom),
      div({class: "column"}, newTextDom),
    ),
    div({class: "row"},
      button(
        {onclick: () => diff.val = Diff.diffWords(oldTextDom.value, newTextDom.value)},
        "Diff",
      ),
    ),
    div({class: "row"}, van.bind(diff, Result)),
  )
}

Demo:

Try on jsfiddle

Here is a more advanced Diff App that supports side-by-side and line-by-line comparison:

const autoGrow = e => {
  e.target.style.height = "5px"
  e.target.style.height = (e.target.scrollHeight + 5) + "px"
}

const Line = ({diff, skipAdd, skipRemove}) => div(
  {class: "column", style: "white-space: pre-wrap;"},
  diff.filter(d => !(skipAdd && d.added || skipRemove && d.removed)).map(d =>
    span({class: d.added ? "add" : (d.removed ? "remove" : "")}, d.value)),
)

const DiffLine = (oldLine, newLine, showMerged) => {
  const diff = Diff.diffWords(oldLine, newLine)
  return div({class: "row" + (showMerged ? " merged" : "")},
    showMerged ?
      Line({diff}) : [Line({diff, skipAdd: true}), Line({diff, skipRemove: true})],
  )
}

const DiffApp = () => {
  const oldTextDom = textarea({oninput: autoGrow, rows: 1})
  const newTextDom = textarea({oninput: autoGrow, rows: 1})
  const diff = van.state([])
  const showMerged = van.state(true)
  return div(
    div({class: "row"},
      div({class: "column"}, oldTextDom),
      div({class: "column"}, newTextDom),
    ),
    div({class: "row"},
      button({onclick: () => diff.val = Diff.diffLines(oldTextDom.value, newTextDom.value)},
        "Diff",
      ),
      input({type: "checkbox", checked: showMerged,
        oninput: e => showMerged.val = e.target.checked}),
      "show merged result"
    ),
    van.bind(diff, showMerged, (diff, showMerged) => {
      const resultDom = div()
      for (let i = 0; i < diff.length; ) {
        let line
        if (diff[i].added && diff[i + 1]?.removed) {
          line = DiffLine(diff[i + 1].value, diff[i].value, showMerged)
          i += 2
        } else if (diff[i].removed && diff[i + 1]?.added) {
          line = DiffLine(diff[i].value, diff[i + 1].value, showMerged)
          i += 2
        } else if (diff[i].added) {
          line = showMerged ? div({class: "merged add row"},
            div({class: "column", style: "white-space: pre-wrap;"}, diff[i].value),
          ) : div({class: "row"},
            div({class: "column"}),
            div({class: "add column", style: "white-space: pre-wrap;"}, diff[i].value),
          )
          ++i
        } else if (diff[i].removed) {
          line = showMerged ? div({class: "merged remove row"},
            div({class: "column", style: "white-space: pre-wrap;"}, diff[i].value),
          ) : div({class: "row"},
            div({class: "remove column", style: "white-space: pre-wrap;"}, diff[i].value),
          )
          ++i
        } else {
          line = div({class: "row", style: "white-space: pre-wrap;"},
            showMerged ? div({class: "merged column"}, diff[i].value) :
              [
                div({class: "column"}, diff[i].value),
                div({class: "column"}, diff[i].value),
              ],
          )
          ++i
        }
        van.add(resultDom, line)
      }
      return resultDom
    })
  )
}

Demo:

Try on jsfiddle

Calculator


The code below implements a Calculator App similar to the one that you are using on your smartphones:

const Calculator = () => {
  const displayNum = van.state(0)
  let lhs = null, op = null, rhs = 0

  const calc = (lhs, op, rhs) => {
    const rhsNumber = parseFloat(rhs)
    if (!op || lhs === null) return rhsNumber
    if (op === "+") return lhs + rhsNumber
    if (op === "-") return lhs - rhsNumber
    if (op === "x") return lhs * rhsNumber
    if (op === "Γ·") return lhs / rhsNumber
  }

  const onclick = e => {
    const str = e.target.innerText
    if (str >= "0" && str <= "9") {
      if (rhs) {
        if (typeof rhs === "string") rhs += str; else rhs = rhs * 10 + parseInt(str)
      } else
        rhs = parseInt(str)
    } else if (str === "AC") {
      lhs = op = null, rhs = 0
    } else if (str === "+/-") {
      if (rhs) rhs = -rhs
    } else if (str === "%") {
      if (rhs) rhs *= 0.01
    } else if (str === "+" || str === "-" || str === "x" || str === "Γ·") {
      if (rhs !== null) lhs = calc(lhs, op, rhs), rhs = null
      op = str
    } else if (str === "=") {
      if (op && rhs !== null) lhs = calc(lhs, op, rhs), op = null, rhs = null
    } else if (str === ".") {
      rhs = rhs ? rhs + "." : "0."
    }

    displayNum.val = rhs ?? lhs
  }

  const Button = str => div({class: "button"}, button(str))

  return div({id: "root"},
    div({id: "display"}, div(displayNum)),
    div({id: "panel", onclick},
      div(Button("AC"), Button("+/-"), Button("%"), Button("Γ·")),
      div(Button("7"), Button("8"), Button("9"), Button("x")),
      div(Button("4"), Button("5"), Button("6"), Button("-")),
      div(Button("1"), Button("2"), Button("3"), Button("+")),
      div(div({class: "button wide"}, button("0")), Button("."), Button("=")),
    ),
  )
}

Demo:

Try on jsfiddle

Notably, this Calculator App is equivalent to the React-based implementation here: github.com/ahfarmer/calculator. Here is the size comparison of the total package between the 2 apps:

VanJS-based AppReact-based App
# of files:216
# of lines:156616

As you can see, not only VanJS is ~50 times smaller than React, apps built with VanJS also tends to be much slimmer.

JSON/CSV Table Viewer


The following code implements a Table Viewer for JSON/CSV-based data by leveraging functional-style DOM tree building:

const TableViewer = ({inputText, inputType}) => {
  const resultDom = div()

  const jsonRadioDom = input({type: "radio", checked: inputType === "json",
    name: "inputType", value: "json"})
  const csvRadioDom = input({type: "radio", checked: inputType === "csv",
    name: "inputType", value: "csv"})
  const autoGrow = e => {
    e.style.height = "5px"
    e.style.height = (e.scrollHeight + 5) + "px"
  }
  const textareaDom = textarea({oninput: e => autoGrow(e.target)}, inputText)
  setTimeout(() => autoGrow(textareaDom), 10)

  const tableFromJson = text => {
    const json = JSON.parse(text)
    const head = Object.keys(json[0])
    return {
      head,
      data: json.map(row => head.map(h => row[h]))
    }
  }

  const tableFromCsv = text => {
    const lines = text.split("\n").filter(l => l.length > 0)
    return {
      head: lines[0].split(","),
      data: lines.slice(1).map(l => l.split(",")),
    }
  }

  const showTable = () => {
    try {
      let {head, data} = jsonRadioDom.checked ?
        tableFromJson(textareaDom.value) : tableFromCsv(textareaDom.value)
      resultDom.firstChild?.remove()
      van.add(resultDom, table(
        thead(tr(head.map(h => th(h)))),
        tbody(data.map(row => tr(row.map(col => td(col))))),
      ))
      dom.querySelector(".err").innerText = ""
    } catch (e) {
      dom.querySelector(".err").innerText = e.message
    }
  }

  const dom = div(
    div(jsonRadioDom, label("JSON"), csvRadioDom, label("CSV (Quoting not Supported)")),
    div(textareaDom),
    div(button({onclick: showTable}, "Show Table")),
    pre({class: "err"}),
    resultDom,
  )
  return dom
}

Demo:

Try on jsfiddle

JSON Inspector


This is another example of leveraging functional-style DOM tree building - to build a tree view for inspecting JSON data:

const ListItem = ({key, value, indent = 0}) => {
  const hide = van.state(key !== "")
  const style = {deps: [hide], f: hide => hide ? "display: none" : ""}
  let valueDom
  if (typeof value !== "object") valueDom = value
  else valueDom = div({style},
    Object.entries(value).map(([k, v]) =>
      ListItem({key: k, value: v, indent: indent + 2 * (key !== "")})),
  )
  return (key ? div : pre)(
    " ".repeat(indent),
    key ? (
      typeof valueDom !== "object" ? ["🟰 ", b(`${key}: `)] :
        a({onclick: () => hide.val = !hide.val, style: "cursor: pointer"},
          van.bind(hide, hide => hide ? "βž• " : "βž– "),
          b(`${key}: `),
          van.bind(hide, hide => hide ? "…" : ""),
        )
    ) : [],
    valueDom,
  )
}

const JsonInspector = ({initInput}) => {
  const autoGrow = e => {
    e.style.height = "5px"
    e.style.height = (e.scrollHeight + 5) + "px"
  }
  const textareaDom = textarea({oninput: e => autoGrow(e.target)}, initInput)
  setTimeout(() => autoGrow(textareaDom), 10)
  const errmsg = van.state(""), json = van.state(null)

  const inspect = () => {
    try {
      json.val = JSON.parse(textareaDom.value)
      errmsg.val = ""
    } catch (e) {
      errmsg.val = e.message
    }
  }

  return div(
    div(textareaDom),
    div(button({onclick: inspect}, "Inspect")),
    pre({style: "color: red"}, errmsg),
    van.bind(json, json => json ? ListItem({key: "", value: json}) : ""),
  )
}

Demo:

Try on jsfiddle

Textarea with Autocomplete


The code below implements a textarea with autocomplete support. This implementation leverages Stateful DOM binding to optimize the performance of DOM tree rendering:

The code was implemented in TypeScript to validate VanJS's TypeScript support.

interface SuggestionListProps {
  readonly candidates: readonly string[]
  readonly selectedIndex: number
}
const SuggestionList = ({candidates, selectedIndex}: SuggestionListProps) =>
  div({class: "suggestion"}, candidates.map((s, i) => pre({
    "data-index": i,
    class: i === selectedIndex ? "text-row selected" : "text-row",
  }, s)))

const lastWord = (text: string) => text.match(/\w+$/)?.[0] ?? ""

const AutoComplete = ({words}: {readonly words: readonly string[]}) => {
  const getCandidates = (prefix: string) => {
    const maxTotal = 10, result: string[] = []
    for (let word of words) {
      if (word.startsWith(prefix.toLowerCase())) result.push(word)
      if (result.length >= maxTotal) break
    }
    return result
  }

  const prefix = van.state("")
  const candidates = van.state(getCandidates(""))
  prefix.onnew(p => candidates.val = getCandidates(p))
  const selectedIndex = van.state(0)
  candidates.onnew(() => selectedIndex.val = 0)

  const suggestionList = van.bind(candidates, selectedIndex,
    (candidates, selectedIndex, dom, oldCandidates, oldSelectedIndex) => {
      if (dom && candidates === oldCandidates) {
        // If the candidate list doesn't change, we don't need to re-render the
        // suggestion list. Just need to change the selected candidate.
        dom.querySelector(`[data-index="${oldSelectedIndex}"]`)
          ?.classList?.remove("selected")
        dom.querySelector(`[data-index="${selectedIndex}"]`)
          ?.classList?.add("selected")
        return dom
      }
      return SuggestionList({candidates, selectedIndex})
    })

  const onkeydown = (e: KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      selectedIndex.val = selectedIndex.val + 1 < candidates.val.length ? selectedIndex.val + 1 : 0
      e.preventDefault()
    } else if (e.key === "ArrowUp") {
      selectedIndex.val = selectedIndex.val > 0 ? selectedIndex.val - 1 : candidates.val.length - 1
      e.preventDefault()
    } else if (e.key === "Enter") {
      const candidate = candidates.val[selectedIndex.val] ?? prefix.val
      const target = <HTMLTextAreaElement>e.target
      target.value += candidate.substring(prefix.val.length)
      target.setSelectionRange(target.value.length, target.value.length)
      prefix.val = lastWord(target.value)
      e.preventDefault()
    }
  }

  const oninput = (e: Event) => prefix.val = lastWord((<HTMLTextAreaElement>e.target).value)

  return div({class: "root"}, textarea({onkeydown, oninput}), suggestionList)
}

Demo:

Try on jsfiddle

Alternatively, we can implement the same app with State-derived properties:

The code was implemented in TypeScript to validate VanJS's TypeScript support.

const lastWord = (text: string) => text.match(/\w+$/)?.[0] ?? ""

const AutoComplete = ({words}: {readonly words: readonly string[]}) => {
  const maxTotalCandidates = 10

  const getCandidates = (prefix: string) => {
    const result: string[] = []
    for (let word of words) {
      if (word.startsWith(prefix.toLowerCase())) result.push(word)
      if (result.length >= maxTotalCandidates) break
    }
    return result
  }

  const prefix = van.state("")
  const candidates = van.state(getCandidates(""))
  prefix.onnew(p => candidates.val = getCandidates(p))
  const selectedIndex = van.state(0)
  candidates.onnew(() => selectedIndex.val = 0)

  const SuggestionListItem = ({index}: {index: number}) => pre(
    {class: {deps: [selectedIndex], f: s => index === s ? "text-row selected" : "text-row"}},
    van.bind(candidates, c => c[index] ?? ""),
  )

  const indices: number[] = []
  for (let i = 0; i < 10; ++i) indices.push(i)
  const suggestionList = div({class: "suggestion"},
    indices.map(index => SuggestionListItem({index})))

  const onkeydown = (e: KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      selectedIndex.val = selectedIndex.val + 1 < candidates.val.length ? selectedIndex.val + 1 : 0
      e.preventDefault()
    } else if (e.key === "ArrowUp") {
      selectedIndex.val = selectedIndex.val > 0 ? selectedIndex.val - 1 : candidates.val.length - 1
      e.preventDefault()
    } else if (e.key === "Enter") {
      const candidate = candidates.val[selectedIndex.val] ?? prefix.val
      const target = <HTMLTextAreaElement>e.target
      target.value += candidate.substring(prefix.val.length)
      target.setSelectionRange(target.value.length, target.value.length)
      prefix.val = lastWord(target.value)
      e.preventDefault()
    }
  }

  const oninput = (e: Event) => prefix.val = lastWord((<HTMLTextAreaElement>e.target).value)

  return div({class: "root"}, textarea({onkeydown, oninput}), suggestionList)
}

Demo:

Try on jsfiddle

HTML to VanJS Code Converter


The converter that converts HTML snippet to VanJS code, is also implemented with VanJS:

const quoteIfNeeded = key => /^[a-zA-Z_][a-zA-Z_0-9]+$/.test(key) ?
  key : `"${key}"`

const attrsToVanCode = dom => dom.attributes.length > 0 ?
  `{${[...dom.attributes].map(
    a => `${quoteIfNeeded(a.nodeName)}: ${JSON.stringify(a.nodeValue)}`)
    .join(", ")}}${dom.childNodes.length > 0 ? "," : ""}` : ""

const filterChild = (childNodes, {skipEmptyText}) =>
  [...childNodes].filter(c => (c.nodeType === 1 || c.nodeType === 3) &&
    (!skipEmptyText || c.nodeName !== "#text" || /\S/.test(c.nodeValue)))

const autoGrow = e => {
  e.style.height = "5px"
  e.style.height = (e.scrollHeight + 5) + "px"
}

const domToVanCode = (dom,
  {indent = 0, indentLevel, skipEmptyText, skipTrailingComma},
  tagsUsed) => {
  const prefix = " ".repeat(indent)
  const suffix = skipTrailingComma ? "" : ","
  if (dom.nodeName === "#text")
    return [`${prefix}${JSON.stringify(dom.nodeValue)}${suffix}`]
  const lowerCaseTagName = dom.nodeName.toLowerCase()
  tagsUsed.add(lowerCaseTagName)
  if (lowerCaseTagName === "pre") skipEmptyText = false
  return dom.childNodes.length > 0 ? [
    `${prefix}${lowerCaseTagName}(${attrsToVanCode(dom)}`,
    ...filterChild(dom.childNodes, {skipEmptyText}).flatMap(c =>
      domToVanCode(
        c, {indent: indent + indentLevel, indentLevel, skipEmptyText},
        tagsUsed)),
    `${prefix})${suffix}`,
  ] : [
    `${prefix}${lowerCaseTagName}(${attrsToVanCode(dom)})${suffix}`,
  ]
}

const Converter = ({initInput}) => {
  const oninput = () => {
    autoGrow(textareaDom)
    const containerDom = div()
    containerDom.innerHTML = textareaDom.value
    const tagsUsed = new Set;
    const childNodes = filterChild(containerDom.childNodes,
      {skipEmptyText: skipEmptyTextDom.checked})
    const lines = childNodes.flatMap(c =>
      domToVanCode(c, {
        indentLevel: parseInt(indentInputDom.value),
        skipEmptyText: skipEmptyTextDom.checked,
        skipTrailingComma: childNodes.length <= 1
      }, tagsUsed))
    const sortedTags = [...tagsUsed].sort()
    tagsCode.val = `const {${sortedTags.join(", ")}} = van.tags`
    domCode.val = lines.join("\n")
    setTimeout(() => Prism.highlightAll(), 5)
  }

  const textareaDom = textarea({oninput, style: "width: 100%;"}, initInput)
  const indentInputDom = input(
    {type: "number", min: 1, max: 8, value: 2, oninput})
  const skipEmptyTextDom = input({type: "checkbox", oninput})
  const tagsCode = van.state(""), domCode = van.state("")

  // Trigger the UI update after initialization
  setTimeout(() => textareaDom.dispatchEvent(new Event("input")))
  return div(
    h5("Paste your HTML snippet here:"),
    textareaDom,
    "Indent level: ", indentInputDom, " ",
    skipEmptyTextDom, "Skip empty text",
    h5("Declaring tag functions:"),
    van.bind(tagsCode, c => div(pre(code({class: "language-js"}, c)))),
    h5("Building the DOM tree:"),
    van.bind(domCode, c => div(pre(code({class: "language-js"}, c)))),
  )
}

You can try it out with this link.

Jupyter-like JavaScript Console


Next up, we're going to demonstrate a simplified Jupyter-like JavaScript console implemented in ~100 lines of code with VanJS. The JavaScript console supports drawing tables (with the technique similar to Table Viewer), inspecting objects in a tree view (with the technique similar to Json Inspector) and plotting (with the integration of Google Charts).

Here is the implementation:

const toDataArray = data => {
  const hasPrimitive = !data.every(r => typeof r === "object")
  const keys = [...new Set(
    data.flatMap(r => typeof r === "object" ? Object.keys(r) : []))]
  return [
    (hasPrimitive ? ["Value"] : []).concat(keys),
    ...data.map(r =>
      (typeof r === "object" ? (hasPrimitive ? [""] : []) : [r]).concat(
        keys.map(k => r[k] ?? "")
      )),
  ]
}

const table = data => {
  const dataArray = toDataArray(data)
  return van.tags.table(
    thead(tr(th("(index)"), dataArray[0].map(k => th(k)))),
    tbody(dataArray.slice(1).map((r, i) => tr(td(i), r.map(c => td(c))))),
  )
}

const plot = (data, chartType, options) => {
  if (data[0].constructor === Object) data = toDataArray(data)
  else if (typeof data[0] === "number")
    data = [["", "Value"], ...data.map((d, i) => [i + 1, d])]
  const dom = div({class: "chart"})
  setTimeout(() => new google.visualization[chartType](dom).draw(
    google.visualization.arrayToDataTable(data), options))
  return dom
}

const Tree = ({obj, indent = ""}) =>
  (indent ? div : pre)(Object.entries(obj).map(([k, v]) => {
    if (v?.constructor !== Object && !Array.isArray(v))
      return div(indent + "🟰 ", van.tags.b(k + ": "), v)
    const icon = van.state("βž• ")
    const suffix = van.state(" {…}")
    const show = () => {
      const treeDom = result.appendChild(Tree({obj: v, indent: indent + "  "}))
      icon.val = "βž– "
      suffix.val = ""
      onclick.val = () => {
        treeDom.remove()
        onclick.val = show
        icon.val = "βž• "
        suffix.val = " {…}"
      }
    }
    const onclick = van.state(show)
    const result = div(indent, van.tags.a({onclick}, icon, van.tags.b(k + ":"), suffix))
    return result
  }))

const ValueView = expr => {
  try {
    const value = eval(`(${expr})`)
    if (value instanceof Element) return value
    if (value?.constructor === Object || Array.isArray(value)) return Tree({obj: value})
    return pre(String(value))
  } catch (e) {
    return pre({class: "err"}, e.message + "\n" + e.stack)
  }
}

const Output = ({id, expr}) => div({class: "row"},
  pre({class: "left"}, `Out[${id}]:`),
  div({class: "break"}),
  div({class: "right"}, ValueView(expr)),
)

const autoGrow = e => {
  e.target.style.height = "5px"
  e.target.style.height = (e.target.scrollHeight + 5) + "px"
}

const Input = ({id}) => {
  const run = () => {
    textareaDom.setAttribute("readonly", true)
    runDom.disabled = true
    const newTextDom = van.add(textareaDom.closest(".console"), Output({id, expr: textareaDom.value}))
      .appendChild(Input({id: id + 1}))
      .querySelector("textarea")
    newTextDom.focus()
    setTimeout(() => newTextDom.scrollIntoView(), 10)
  }
  const runDom = button({class: "run", onclick: run}, "Run")
  const onkeydown = async e => {
    if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
      e.preventDefault()
      run()
    }
  }
  const textareaDom = textarea({id, type: "text", onkeydown, oninput: autoGrow,
    rows: 1, placeholder: 'Enter JS expression here:'})
  return div({class: "row"},
    pre({class: "left"}, `In[${id}]:`), runDom, div({class: "break"}),
    div({class: "right"}, textareaDom),
  )
}

const Console = () => div({class: "console"}, Input({id: 1}))

Demo:

Try on jsfiddle

You can also try out the JavaScript console in this standalone page.

An Improved Unix Shell


The final program we're going to demonstrate is a web-based Unix shell that connects to your local computer, with some notable improvements. This is to demonstrate that VanJS has the potential to become a great extension to commandline utilities. The program is heavily tested in macOS, and should in theory works in Linux, or in any environment that has /bin/sh.

Compare to ordinary Unix shell that you're familiar with, the web-based shell has 2 notable improvements:

  1. Command ps ... will render an HTML table instead of text output.
  2. Command tree (need the exact text match) will render an interactive tree-view of your current directory, like the one in the screenshot below:
    Tree-view of the current directory

Deployment Steps

1. To make the program work, we need to deploy the server first, which is implemented with Deno. If you don't have Deno in your environment, you can get it installed from deno.com.

2. Copy the code below, and save it into shell.ts, under the same directory of van-0.12.4.min.js. Alternatively, you can directly download the file with the link here: shell.ts.

import { getCookies, setCookie } from "https://deno.land/[email protected]/http/cookie.ts"
import { resolve, dirname, fromFileUrl, join } from "https://deno.land/[email protected]/path/mod.ts"

const moduleDir = resolve(dirname(fromFileUrl(import.meta.url)))

const port = Number(Deno.args[0] ?? 8000)

const encoder = new TextEncoder()
const decoder = new TextDecoder()
let cwd = Deno.cwd()

const genKey = () => {
  const buf = new Uint8Array(25)
  crypto.getRandomValues(buf)
  // Uint8Array.map doesn't support callbackFn that returns string in Deno
  const parts: string[] = []
  for (const b of buf) parts.push(b.toString(16))
  return parts.join("")
}

const key = genKey()
let sessionId = "to generate"

const tree = async (path: string) => {
  const result = {
    path,
    dirs: <string[]>[],
    files: <string[]>[],
  }
  for await (const f of Deno.readDir(path))
    if (!f.name.startsWith(".") && !f.isSymlink)
      (f.isFile ? result.files : result.dirs).push(f.name)
  return result
}

const readFileOrError = async (path: string) => {
  try {
    return await Deno.readTextFile(path)
  } catch (e) {
    return e.message + "\n" + e.stack
  }
}

const serveHttp = async (conn: Deno.Conn, req: Request) => {
  if ((<Deno.NetAddr>conn.remoteAddr).hostname !== "127.0.0.1")
    return new Response(
      "Only local requests are allowed for security purposes", {status: 403})
  const url = new URL(req.url)
  if (req.method === "GET") {
    if (getCookies(req.headers)["SessionId"] !== sessionId)
      if (url.pathname === "/cwd") return new Response("", {status: 200}); else
        return new Response(
          `<form action="/login" method="post">
    Paste the key from console: <input type="password", name="key" autofocus>
    <input type="submit" value="Log In"></form>`,
          {status: 200, headers: {"content-type": "text/html; charset=utf-8"}},
        )
    if (url.pathname === "/cwd") return new Response(cwd, {status: 200})
    if (url.pathname.startsWith("/open")) return new Response(
      await readFileOrError(url.pathname.slice(5)), {status: 200})
    if (url.pathname.endsWith(".js")) return new Response(
      await Deno.readTextFile(join(moduleDir, url.pathname)), {
        status: 200,
        headers: {"content-type": "application/javascript; charset=utf-8"},
      },
    )
    if (url.pathname !== "/shell.html") Response.redirect("/shell.html", 302)
    return new Response(await Deno.readTextFile(join(moduleDir, "shell.html")), {
      status: 200,
      headers: {"content-type": "text/html; charset=utf-8"},
    })
  }
  if (req.method === "POST") {
    if (url.pathname === "/login") {
      if ((await req.formData()).get("key") !== key)
        return new Response("Key mismatch", {status: 403})
      const response = new Response(
        "", {
          status: 303,
          headers: new Headers({"Location": "/shell.html"})
        }
      )
      setCookie(response.headers, {name: "SessionId", value: sessionId = genKey()})
      return response
    }
    if (getCookies(req.headers)["SessionId"] !== sessionId)
      return Response.json({
        stderr: "Expired session ID, please reload this page to relogin:"})

    const cmd = await req.text()
    try {
      if (cmd === "tree") return Response.json(
        {tree: await tree(url.searchParams.get("path") ?? cwd)})
      const p = new Deno.Command("sh", {
        cwd,
        stdin: "piped",
        stdout: "piped",
        stderr: "piped",
      }).spawn()
      const stdinWriter = p.stdin.getWriter()
      await stdinWriter.write(encoder.encode(cmd + ";echo;pwd"))
      await stdinWriter.close()
      const output = await p.output()
      const lines = decoder.decode(output.stdout).trim().split("\n")
      const stdout = lines.slice(0, -1).join("\n")
      cwd = lines[lines.length - 1].trim()

      return Response.json({stdout, stderr: decoder.decode(output.stderr)})
    } catch (e) {
      return Response.json({stderr: e.message + "\n" + e.stack})
    }
  }
  return new Response("Unsupported HTTP method", {status: 404})
}

const serveConn = async (conn: Deno.Conn) => {
  for await (const reqEvent of Deno.serveHttp(conn))
    (async () => reqEvent.respondWith(await serveHttp(conn, reqEvent.request)))()
}

console.log(`Visit http://localhost:${port} in your browser`)
console.log("When prompted, paste the key below:")
console.log(key + "\n")
console.log("%cFor security purposes, DO NOT share the key with anyone else",
  "color: red; font-weight: bold")
for await (const conn of Deno.listen({port})) serveConn(conn)

3. Copy the code below, and save it into shell.html, under the same directory of shell.ts. After this step, your working directory should have 3 files: shell.ts, shell.html and van-0.12.4.min.js. Alternatively, you can directly download the file with the link here: shell.html.

<!DOCTYPE html>
<html>
  <head>
    <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>πŸ’»</text></svg>">
    <title>A Simple Web-based Shell</title>
    <meta charset="UTF-8">
    <style>
      .row { display: flex; }

      .left {
        width: 60px;
        text-align: right;
      }

      .right { width: 800px; }

      .cwd {
        margin: 12px 0 0 13px;
        font-weight: bold;
      }

      .tree {
        margin: 12px;
        white-space: pre;
      }

      .tree a { cursor: pointer; }

      .right input, .right textarea, .right table {
        margin: 11px;
        border-width: 1px;
        box-sizing: border-box;
        font: 15px monospace;
        width: 100%;
      }

      .err, .warning { color: red; }

      .warning { font-weight: bold; }

      .hide { display: none; }

      table { border-collapse: collapse }

      th, td { border: 1px solid black; }
    </style>
  </head>
  <body>
    <script type="module">
      import van from "./van-0.12.4.min.js"

      const {a, div, i, input, pre, table, tbody, td, textarea, th, thead, tr} = van.tags

      const Text = (s, isErr = false) => {
        const dom = textarea({readonly: true, class: isErr ? "err" : ""}, s)
        setTimeout(() => dom.style.height = (dom.scrollHeight + 5) + "px")
        return dom
      }

      // Special handling for the output result of `ps ...` - displaying a table
      // instead of raw text.
      const Table = s => {
        const lines = s.trim().split("\n"), header = lines[0].split(/\s+/), nCols = header.length
        return table(
          thead(tr(header.map(h => th(h)))),
          tbody(lines.slice(1).map(row => {
            // The last column for the output of `ps ...` (which is COMMAND),
            // might contain spaces, thus we will split the row by whitespaces
            // first, and join all the trailing columns together.
            const cols = row.split(/\s+/)
            return tr(
              [...cols.slice(0, nCols - 1), cols.slice(nCols - 1).join(" ")].map(c => td(c)))
          })),
        )
      }

      // Special handling for command `tree` - displaying a tree view of the
      // current directory.
      const Tree = ({path, dirs, files, indent = ""}) => div(
        dirs.map(d => {
          const icon = van.state("πŸ“ ")
          const expand = async () => {
            icon.val = "πŸ“‚ "
            // No-op with clicking before subdirectory is fetched and rendered
            onclick.val = null
            const {tree, stderr} = await fetch(
              "/?path=" + encodeURIComponent(path + "/" + d),
              {method: "POST", body: "tree"}).then(r => r.json())
            const treeDom = result.appendChild(tree ?
              Tree({...tree, indent: indent + "    "}) : div({class: "err"}, indent + stderr))
            onclick.val = () => (treeDom.remove(), onclick.val = expand, icon.val = "πŸ“ ")
          }
          const onclick = van.state(expand)
          const result = div(indent, a({onclick}, icon, d))
          return result
        }),
        files.map(f => div(
          indent + "πŸ“„ ",
          a({href: "/open" + encodeURI(path + "/" + f), target: "_blank"}, f),
        )),
      )

      const Output = ({id, stdout, stderr, tree, isPsCmd}) => div({class: "row"},
        pre({class: "left"}, `Out[${id}]:`),
        div({class: "right"},
          stdout ? (isPsCmd ? Table(stdout) : Text(stdout)) : [],
          stderr ? Text(stderr, true) : [],
          tree ? div({class: "tree"},
            div(i("You can click folders to expand/collapse")),
            Tree(tree),
          ) : [],
        ),
      )

      const Input = ({id, cwd}) => {
        let historyId = id
        const onkeydown = async e => {
          if (e.key === "Enter") {
            e.preventDefault()
            e.target.setAttribute("readonly", true)
            const {stdout, stderr, tree} =
              await fetch("/", {method: "POST", body: e.target.value}).then(r => r.json())
            van.add(document.body, Output({id, stdout, stderr, tree,
              isPsCmd: e.target.value.trim().split(" ")[0] === "ps"}))
            van.add(document.body, Input({
              id: id + 1,
              cwd: await fetch("/cwd").then(r => r.text()),
            })).lastElementChild.querySelector("input").focus()
          } else if (e.key === "ArrowUp" && historyId > 1) {
            e.target.value = document.getElementById(--historyId).value
            const length = e.target.value.length
            setTimeout(() => e.target.setSelectionRange(length, length))
          } else if (e.key === "ArrowDown" && historyId < id) {
            e.target.value = document.getElementById(++historyId).value
            const length = e.target.value.length
            setTimeout(() => e.target.setSelectionRange(length, length))
          }
        }
        return [
          div({class: "row"},
            pre({class: "left"}),
            div({class: "right"}, pre({class: "cwd"}, cwd + "$")),
          ),
          div({class: "row"},
            pre({class: "left"}, `In[${id}]:`),
            div({class: "right"},
              input({id, type: "text",
                placeholder: 'Try "ls -l", "ps au", "tree", "cd <dir>", etc.', onkeydown}),
            ),
          ),
        ]
      }

      const Shell = ({cwd}) => div(
        div("⚠️ Please avoid commands that takes long time to execute."),
        div({class: "warning"}, "BE CAREFUL, commands will be executed on your computer and are IRREVERSIBLE."),
        div("Enter some shell command and press ↡ to execute (Use ↑ and ↓ to navigate through the command history):"),
        Input({id: 1, cwd})
      )

      fetch("/cwd").then(r => r.text()).then(cwd =>
        document.body.appendChild(Shell({cwd})).querySelector("input").focus())
    </script>
  </body>
</html>

4. Run the command below under your working directory:

You can append a custom port number to the end. By default, port 8000 will be chosen.

deno run --allow-net --allow-run --allow-read shell.ts

5. You can visit the web-based shell with the URL printed in the console output of deno run. In your first visit, it will ask you to login, you need to paste the random key printed from the console to proceed.

6. After login, you will be able to see and use the web-based shell.

Security Considerations

This program allows web access to your OS shell, which elevates the privilege to a level that you would not normally get with your browser. Here are the extra measures we're taking to ensure the security of your local computer:

  1. Only local connection to your shell.ts server is allowed.
  2. Before using the web-based shell in your browser, you need to login with the key printed in the console of shell.ts server first. The key is generated randomly every time the server restarts. You should never share the key to other people.
  3. You're advised to shut down the shell.ts server when you're not using the shell to further reduce the risk of unauthorized access to your shell with the leaked key. Next time, when the server restarts, any browser access needs the login with the new key generated randomly.
  4. Please be aware that any commands you run in the web-based shell are the real commands executed on your computer. Thus don't try dangerous stuff as they are IRREVERSIBLE.

Community Examples


Besides the official VanJS examples, there are also sample apps from the great VanJS community. Below is a curated list (contact [email protected] to add yours):