Back to packages list

Vals using htm

Description from the NPM package:
The Tagged Template syntax for Virtual DOM. Only browser-compatible syntax.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
export let nearbyJonBo = async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const raw = (html) =>
vhtml(null, {
dangerouslySetInnerHTML: { __html: html },
});
res.send(html`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" style="display: grid; height: 100vh">
<head>
<title>anew.jon.bo</title>
<style>
* {
margin: 0;
padding: 0;
font: 1em sans-serif;
}
body {
color: white;
}
</style>
</head>
<body
style="place-self: center; height: fit-content; background-color: black"
>
<span>aasdfasdf</span>
<button onclick="fetchTest()">test</button>
  
<div><br /></div>
<div>
<br />
<table id="bikesTableBody">
<tbody></tbody>
</table>
</div>
<script>
${
raw(`
function fetchTest() {
console.log("hi");
fetch("https://api.val.town/v1/run/jonbo.fetchBcycleCounts")
.then((response) => {
if (!response.ok) {
console.log(response.status);
throw new Error("HTTP error! status:");
}
return response.json();
})
.then((data) => {
// console.log(data);
const filtered = data.filter(
(row) => row.id == 7305 || row.id == 1871
);
console.log("filtered", filtered);
var target = document.getElementById("bikesTableBody");
filtered.forEach((row) => {});
})
.catch((error) => console.error("Error:", error));
}
`)
}
</script>
</body>
</html>`);
};
// Forked from @tmcw.big_story_visualization
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
let { todos } = await import("https://esm.town/v/tmcw/todos?v=354");
export let todo_list = (async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const renderTodo = (todo) => {
return html`<li data-id=${todo.id}>
<label>
<input hx-delete="/" hx-target="[data-id='${todo.id}']" hx-swap="delete" style='display:inline;margin-right:0.5rem;' type='checkbox' name="id" value=${todo.id} />
${todo.name}
</label>
</li>`;
};
switch (req.method) {
case "POST": {
const name = req.body.name.substring(0, 256).trim();
if (!name)
return res.end();
const todo = {
id: crypto.randomUUID(),
name,
};
todos = [...todos, todo];
if (todos.length > 10) {
todos = todos.slice(-10);
res.set("HX-Trigger", JSON.stringify({ reload: true }));
}
return res.send(renderTodo(todo));
}
case "DELETE": {
todos = todos.filter((todo) =>
todo.id !== req.body.id
);
return res.end();
}
}
if (req.path === "/list") {
return res.send(html`<>${todos.map(renderTodo)}</>`);
}
return res.send(html`<html><head><title>TODO</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://the.missing.style" />
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>
<body>
<main>
<h2>Todo list</h2>
<ul style='list-style-type:none;padding-left:0;' hx-trigger="reload from:body" hx-get="/list">
${todos.map(renderTodo)}
</ul>
<form method="post" class='f-row' hx-post="/" hx-target="ul" hx-swap="beforeend settle:1s">
<input required name="name" type="text" value="" /><button type="submit">Add</button>
</form>
</main>
<footer>
<p>Powered by Val Town: <a href='https://www.val.town/v/tmcw.todo_list'>tmcw.todo_list</a></p>
</footer>
<style>
form.htmx-request { opacity: .5; transition: opacity 300ms linear; }
li { transition: background 1s ease-out; }
li.htmx-added { background: yellow; }
</style>
</body>
</html>`);
});
// Forked from @tmcw.todo_list
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import { runVal } from "https://esm.town/v/std/runVal";
export let graffitiWebsite = async (req: express.Request, res: express.Response) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const head = html`<head>
<title>Graffiti Wall</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>`;
const formClass =
"p-2 pt-16 group min-h-screen animate-all bg-emerald-50 w-full max-w-2xl mx-auto";
const inputClass = "p-2 border rounded w-full";
const labelClass = "w-full flex flex-col gap-2";
const inputNameClass = "pl-0.5 text-sm font-bold uppercase text-emerald-800";
const inputDescriptionClass = "pl-0.5 text-xs text-emerald-700";
if (req.method === "POST") {
const value = req.body;
if (
!("x" in value) ||
!value.x ||
!("y" in value) ||
!value.y ||
!("color" in value) ||
!value.color
) {
return res.end("Bad input");
}
await runVal(
"andreterron.paintGraffitiPixel",
value.x,
value.y,
value.color,
);
await new Promise<void>((resolve) => setTimeout(() => resolve(), 150));
}
const code = `await api(
@andreterron.paintGraffitiPixel,
${Math.min(Math.floor(Math.random() * 64), 64)}, // x
${Math.min(Math.floor(Math.random() * 32), 32)}, // y
"#ffffff", // color
);`;
const query = new URLSearchParams({ code });
const url = `https://www.val.town/embed/new?${query.toString()}`;
return res.end(html`<html>
${head}
<body class="bg-emerald-50">
<form hx-post="/" class=${formClass}>
<h1 class="text-3xl text-center font-bold text-emerald-800">
Graffiti Wall
</h1>
<img class="mx-auto my-2 bg-black w-[512px] h-[256px]" src="https://andreterron-graffitiwall.express.val.run/" />
<div class="flex flex-col gap-6 items-start p-4 max-w-2xl">
<label class=${labelClass} for="x">
<span class=${inputNameClass}>X</span>
<small class=${inputDescriptionClass}>0 ≤ x ${"<"} 64</small>
<input required class=${inputClass} id="x" name="x" type="number" placeholder="0" autocomplete="off" />
</label>
<label class=${labelClass} for="y">
<span class=${inputNameClass}>Y</span>
<small class=${inputDescriptionClass}>0 ≤ y ${"<"} 32</small>
<input required class=${inputClass} id="y" name="y" type="number" placeholder="0" autocomplete="off" />
</label>
<label class=${labelClass} for="color">
<span class=${inputNameClass}>Color</span>
<small class=${inputDescriptionClass}>"#ff0000" or "rgb(255,0,0)"</small>
<input required class=${inputClass} id="color" name="color" type="text" placeholder="#000000" autocomplete="off" />
</label>
<button class="p-2 bg-emerald-500 text-white rounded" type="submit">Paint!</button>
<div class="flex my-6 w-full items-center gap-4">
<hr class="block flex-1 w-full h-px bg-gray-600 opacity-100" />
<span class="shrink-0 grow-0 text-gray-600 opacity-80">or</span>
<hr class="block flex-1 w-full h-px bg-gray-600 opacity-100" />
</div>
<label class=${labelClass}>
<span class=${inputNameClass}>Create a val</span>
<small class=${inputDescriptionClass}>reload the page after running the code</small>
</label>
<iframe class="w-full h-[480px] bg-emerald-50" src=${url} />
</div>
</form>
</body>
</html>`);
};
// Forked from @andreterron.genval
// Forked from @tmcw.poll

TODO List

Every web project needs a todo list, right? This is val.town's first (maybe?) With a healthy dose of htmx and web fundamentals, we're packing the essentials of a TODO list with server persistence and animations into about 60 lines. The data is stored in the @tmcw.todos val, for now. Try it out.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
let { todos } = await import("https://esm.town/v/tmcw/todos");
export let todo_list = (async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const renderTodo = (todo) => {
return html`<li data-id=${todo.id}>
<label>
<input hx-delete="/" hx-target="[data-id='${todo.id}']" hx-swap="delete" style='display:inline;margin-right:0.5rem;' type='checkbox' name="id" value=${todo.id} />
${todo.name}
</label>
</li>`;
};
switch (req.method) {
case "POST": {
const name = req.body.name.substring(0, 256).trim();
if (!name)
return res.end();
const todo = {
id: crypto.randomUUID(),
name,
};
todos = [...todos, todo];
if (todos.length > 10) {
todos = todos.slice(-10);
res.set("HX-Trigger", JSON.stringify({ reload: true }));
}
return res.send(renderTodo(todo));
}
case "DELETE": {
todos = todos.filter((todo) =>
todo.id !== req.body.id
);
return res.end();
}
}
if (req.path === "/list") {
return res.send(html`<>${todos.map(renderTodo)}</>`);
}
return res.send(html`<html><head><title>TODO</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://the.missing.style" />
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>
<body>
<main>
<h2>Todo list</h2>
<ul style='list-style-type:none;padding-left:0;' hx-trigger="reload from:body" hx-get="/list">
${todos.map(renderTodo)}
</ul>
<form method="post" class='f-row' hx-post="/" hx-target="ul" hx-swap="beforeend settle:1s">
<input required name="name" type="text" value="" /><button type="submit">Add</button>
</form>
</main>
<footer>
<p>Powered by Val Town: <a href='https://www.val.town/v/tmcw.todo_list'>tmcw.todo_list</a></p>
</footer>
<style>
form.htmx-request { opacity: .5; transition: opacity 300ms linear; }
li { transition: background 1s ease-out; }
li.htmx-added { background: yellow; }
</style>
</body>
</html>`);
});

Generate a Val

Uses the OpenAI API to generate code for a val based on the description given by the user.

TODO:

  • Improve code detection on GPT response
  • Give more context on val town exclusive features like console.email or @references
  • Enable the AI to search val town to find other vals to use
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import process from "node:process";
import { generateValCode } from "https://esm.town/v/andreterron/generateValCode";
export let genval = async (req: express.Request, res: express.Response) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const cookies = await import("https://deno.land/std@0.193.0/http/cookie.ts");
const html = htm.bind(vhtml);
const head = html`<head>
<title>Gen Val</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>`;
const formClass =
"p-2 pt-16 group min-h-screen animate-all bg-emerald-50 w-full max-w-2xl mx-auto";
const inputClass = "p-2 border rounded w-full";
const labelClass =
"w-full text-sm font-bold uppercase text-emerald-800 [&>span]:pl-0.5 flex flex-col gap-2";
function parseCookies(cookie: string) {
const out: Record<string, string> = {};
const c = cookie.split(";");
for (const kv of c) {
const [cookieKey, ...cookieVal] = kv.split("=");
const key = cookieKey.trim();
out[key] = cookieVal.join("=");
}
return out;
}
switch (req.method) {
case "POST": {
const value = req.body;
if (
!("description" in value) ||
!value.description
) {
return res.end("Bad input");
}
const code = await generateValCode(
process.env.VT_OPENAI_KEY,
value.description,
);
const query = new URLSearchParams({ code });
const url = `https://www.val.town/embed/new?${query.toString()}`;
res.end(html`<html>
${head}
<body class="bg-emerald-50">
<div class="w-full max-w-2xl mx-auto flex flex-col gap-4 p-6 ">
<h1 class="text-3xl text-center font-bold text-emerald-800">
Generated Val:
</h1>
<iframe class="w-full h-[480px]" src=${url} />
<a class="p-2 bg-emerald-500 text-white rounded" href="/">Generate another</a>
</div>
</body>
</html>`);
return;
}
default: {
return res.end(html`<html>
${head}
<body class="bg-emerald-50">
<form hx-post="/" class=${formClass}>
<div class='flex justify-between items-center'>
<div class='group-[&.htmx-request]:block hidden uppercase text-emerald-500 text-sm'>
Loading…
</div>
</div>
<h1 class="text-3xl text-center font-bold text-emerald-800">
Generate a Val!
</h1>
<div class="flex flex-col gap-6 items-start p-4 max-w-2xl">
<label class=${labelClass} for="description">
<span>Description</span>
<input required class=${inputClass} id="description" name="description" type="text" placeholder="Function to return a random number" autocomplete="off" />
</label>
<button class="p-2 bg-emerald-500 text-white rounded" type="submit">Generate!</button>
</div>
</form>
<script>
</script>
</body>
</html>`);
}
}
};
// Forked from @tmcw.poll

The Big Story

This val, along with @tmcw.big_story, which requests from the New York Times API, and @tmcw.big_stories_ranks, which contains the data, generates a visualization of top stories on the NYTimes homepage.

This is here just to ask the question – what happens to cover stories over time? Do they slowly drop down the page, or just get replaced by a fully new lede? So far it doesn't have quite enough data to answer that question.

But also, it might be neat because it'll show which kinds of stories make the front page - is it climate, war, politics, or something else?

👉 The Big Story (visualization)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
export let big_story_visualization = async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const raw = (html) =>
vhtml(null, {
dangerouslySetInnerHTML: { __html: html },
});
res.send(html`
<html>
<body style="font-family: sans-serif;padding:30px;">
<h1>Big stories on the New York Times</h1>
<p>Below is a chart of headlines from the New York Times homepage, ordered
by their rank over time. Most stories start near the top and then get lower-ranked
when other news breaks. Some stories pop back up the ranks.
</p>
<p>Check out <a href='https://www.val.town/v/tmcw.big_story_visualization'>the code that makes this work</a> on <a href='https://val.town/'>val.town</a>.
</p>
<p>Hover over a line to see the story title, click on a line to view the story.</p>
<div style='padding-top:40px' id='chart'></div>
<script type="module">
${
raw(`
import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.9/+esm";
import {debounce} from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm";
const ranks = await (await fetch('https://api.val.town/v1/run/tmcw.big_stories_ranks')).json();
let allDates = new Set();
for (let rank of Object.values(ranks)) {
for (let [date] of rank.ranks) {
allDates.add(date);
}
}
allDates = [...allDates.values()].sort((a, b) => a - b);
const normalized = Object.entries(ranks).flatMap(([url, data]) => {
return allDates.map(date => {
return {
...data,
ranks: undefined,
url: url,
rank: data.ranks.find(rank => rank[0] === date)?.[1],
date: new Date(date),
};
});
});
function render() {
const width = window.innerWidth;
const output = Plot.plot({
grid: true,
width,
height: 800,
color: {
legend: true,
},
y: {
reverse: true,
},
x: {
type: 'time'
},
marginLeft: 20,
marginRight: 10,
marks: [
Plot.lineY(normalized, {
x: "date",
y: "rank",
stroke: 'section',
strokeLinecap: 'butt',
strokeLinejoin: 'miter-clip',
z: 'url',
href: 'url',
strokeWidth: 10,
}),
Plot.tip(normalized, Plot.pointer({
x: "date",
y: "rank",
href: 'url',
frameAnchor: "top-left",
title: (d) => d.title + ' ' + d.section
}))
]});
chart.innerHTML = '';
output.addEventListener('click', (e) => {
if (e.target instanceof SVGPathElement && e.target.ariaDescription) {
window.open(e.target.ariaDescription);
}
});
chart.append(output);
}
render();
window.addEventListener('resize', debounce(() => {
render();

Quick poll

https://tmcw-poll.express.val.run

This val, along with a val called poll_results and a val called poll_options, lets you conduct a little poll using just Val Town! With the express API, this exposes a web interface in which people can click an option and vote. We make sure everyone only votes once by setting a cookie in their browser.

This uses htmx to make the voting experience slick, Tailwind to style the UI, and htm to generate HTML for the pages.

If you want to use this yourself, fork this val, the poll_results val, and the poll_options val, and customize the options list. You can delete the existing results as well to clear the data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { poll_results } from "https://esm.town/v/tmcw/poll_results";
import { poll_options } from "https://esm.town/v/tmcw/poll_options";
export let poll = async (req: express.Request, res: express.Response) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const cookies = await import("https://deno.land/std@0.193.0/http/cookie.ts");
const html = htm.bind(vhtml);
const head = html`<head>
<title>Poll</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>`;
const ChartPie =
html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4">
<path d="M12 9a1 1 0 01-1-1V3c0-.553.45-1.008.997-.93a7.004 7.004 0 015.933 5.933c.078.547-.378.997-.93.997h-5z" />
<path d="M8.003 4.07C8.55 3.992 9 4.447 9 5v5a1 1 0 001 1h5c.552 0 1.008.45.93.997A7.001 7.001 0 012 11a7.002 7.002 0 016.003-6.93z" />
</svg>
`;
const formClass = "p-2 group min-h-screen animate-all bg-pink-50";
function parseCookies(cookie: string) {
const out: Record<string, string> = {};
const c = cookie.split(";");
for (const kv of c) {
const [cookieKey, ...cookieVal] = kv.split("=");
const key = cookieKey.trim();
out[key] = cookieVal.join("=");
}
return out;
}
function getResults(hasVoted: boolean) {
return html`<html>
${head}
<body>
<form hx-post="/" class=${formClass}>
<h1 class="text-sm font-bold uppercase text-pink-900 pb-2 flex items-center gap-x-2">
${ChartPie}
poll results
</h1>
<div class='space-y-2'>
${
poll_options.map((option, i) => {
const count = poll_results.filter((result) => {
return result.value === option;
}).length;
const percentage = (count / poll_results.length) *
100;
return html`<div class='p-2 rounded-md block text-sm
bg-pink-100 border border-pink-500
text-pink-700 overflow-hidden
relative flex justify-between'>
<div class='absolute top-0 left-0 bottom-0 bg-pink-200 z-0'
style=${`width: ${percentage}%`} />
<div class='z-1 relative'>${option}</div>
<div class='z-1 relative'>${count.toLocaleString("en-US")} / ${
(percentage).toFixed(0)
}%</div>
</div>`;
})
}</div>
<div class='text-center pt-2'>
${
hasVoted
? html`<div class='text-pink-300 cursor-not-allowed text-xs'>You've voted</div>`
: html`<a href='/' class='text-pink-500 underline hover:text-pink-700 text-xs'>Vote</a>`
}</div>
</form>
</body>
</html>`;
}
switch (req.method) {
case "POST": {
const value = req.body;
if (!("choice" in value)) {
return res.end("Bad input");
}
poll_results.push({
value: poll_options[value.choice],
time: (new Date()).toISOString(),
});
res.set("Set-Cookie", "voted=true; Path=/; HttpOnly");
res.end(getResults(true));
return;
}
default: {
const cookies = parseCookies(req.get("cookie"));
const hasVoted = "voted" in cookies;
if ("results" in req.query || hasVoted) {
return res.end(getResults(hasVoted));
}
else {
return res.end(html`<html>
${head}
<body>
<form hx-post="/"
class=${formClass}
1
2
3
4
5
6
7
8
9
10
11
12
export async function renderHtm(req: express.Request, res: express.Response) {
try {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
res.send(html`<h1 id=hello>Hello!</h1>`);
}
catch (e) {
return `An error occured: ${e}`;
}
}
// '<h1 id="hello">Hello world!</h1>'
1
Next