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:
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:
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:
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:
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:
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:
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))
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,
)
}
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)),
),
)
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:
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:
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))),
),
)
}
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:
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:
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:
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:
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:
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 App | React-based App | |
---|---|---|
# of files: | 2 | 16 |
# of lines: | 156 | 616 |
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:
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:
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:
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:
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:
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:
- Command
ps ...
will render an HTML table instead of text output. - Command
tree
(need the exact text match) will render an interactive tree-view of your current directory, like the one in the screenshot below:
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:
- Only local connection to your
shell.ts
server is allowed. - 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. - 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. - 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):