Back to packages list

Vals using preact-render-to-string

Description from the NPM package:
Render JSX to an HTML string, with support for Preact components.
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
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
const HEXRE = /^[A-Fa-f0-9]{3,6}$/;
const toHex = (str) => HEXRE.test(str) ? "#" + str : str;
function SVG({
text = "Aa",
bg = "000",
color = "fff",
fontSize = 16,
}: {
text: string;
bg: string;
color: string;
fontSize: number;
}) {
// note: dominantBaseline does not work with resvg_wasm
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill={toHex(color)}
style={{
fontFamily: "sans-serif",
fontWeight: "bold",
fontSize,
}}
>
<rect
x="0"
y="0"
width="32"
height="32"
fill={toHex(bg)}
/>
<text
textAnchor="middle"
x="16"
y={16}
dominantBaseline="middle"
>
{text}
</text>
</svg>
);
}
export default function favicon(req: Request): Response {
const params = Object.fromEntries(new URL(req.url).searchParams);
const svg = render(<SVG {...params} />);
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
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
/** @jsxImportSource https://esm.sh/preact */
import { activeGTrainAlerts } from "https://esm.town/v/pbt/gtrainalerts?v=36";
import { render } from "npm:preact-render-to-string";
import Uwuifier from "npm:uwuifier";
export default async function(req: Request) {
const uwuifier = new Uwuifier();
const query = new URL(req.url).searchParams;
const timestamp = query.get("at");
const uwu = query.get("uwu");
const parsedTimestamp = timestamp ? Number.parseInt(timestamp, 10) : Date.now();
const entities = await activeGTrainAlerts(parsedTimestamp);
const fucked = entities.length > 0;
const soFucked = entities.length > 1;
const howFucked = soFucked ? `s${entities.map(e => "o").join("")} fucked` : "fucked";
const title = `${uwu ? "UwU! " : " "}the g train ${fucked ? "is" : "isn’t"} ${howFucked}`;
return new Response(
render(
<html class="dark:bg-zinc-800 dark:text-white min-h-screen">
<head>
<title>{title}</title>
<meta
property="og:title"
content={`${title} as of ${
new Date(parsedTimestamp).toLocaleString("en-US", {
timeZone: "America/New_York",
})
}`}
/>
<meta
property="og:description"
content={`${entities.length} active alert${!fucked || soFucked ? "s" : ""}`}
/>
<meta
property="og:image"
content={fucked
? "https://pbt-gtrainog.web.val.run/?status=notOk"
: "https://pbt-gtrainog.web.val.run/?status=ok"}
/>
<meta property="og:url" content="www.isthegtrainfucked.com" />
<meta name="viewport" content="widthdevice-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen flex flex-col justify-center items-center gap-4">
<header class="italic text-4xl mb-6">
{uwu ? "UwU!" : ""} the g train {fucked
? (
<span>
is{" "}
<strong>
{soFucked ? (uwu ? "so fucky-wucky" : "so fucked") : (uwu ? "fucky-wucky" : "fucked")}{" "}
{uwu ? "(˶˃ᆺ˂˶)" : ""}
</strong>
</span>
)
: <span>isn’t {uwu ? "fucky-wucky" : "fucked"} {uwu ? "⸜(。 ˃ ᵕ ˂ )⸝♡" : ""}</span>}
</header>
<h1
style="background: #6CBE45"
class={`${
uwu ? "text-7xl" : "text-9xl"
} font-bold text-white rounded-full p-10 h-64 w-64 flex flex-col justify-center items-center`}
>
{fucked ? (uwu ? "• ᴖ •。" : "💩") : (uwu ? "👉👈" : ":)")}
</h1>
<main class="bg-slate-100 dark:bg-stone-900 p-10 m-10 rounded">
{fucked
? (
<div>
<h2 class="font-bold pb-2">
{uwu ? uwuifier.uwuifyWords("how it’s fucked as of") : "how it’s fucked as of"}{" "}
{new Date(parsedTimestamp).toLocaleString("en-US", {
timeZone: "America/New_York",
})}:
</h2>
<ul>
{entities.map(({ alert }) => {
const { text: active } = (alert["transit_realtime.mercury_alert"]["human_readable_active_period"]
?? { translation: [{ language: "en", text: "right now" }] }).translation.find((
{ language },
) => language === "en");
const { text } = alert.header_text.translation.find(({ language }) => language === "en-html");
return (
<li>
<div dangerouslySetInnerHTML={{ __html: uwu ? uwuifier.uwuifySentence(text) : text }} />
<p class="text-xs pt-1 pb-3">{uwu ? uwuifier.uwuifySentence(active) : active}</p>
</li>
);
})}
</ul>
<p>
<a target="__blank" class="underline" href="https://new.mta.info/">
{uwu ? uwuifier.uwuifySentence("alerts on mta.info") : "alerts on mta.info"}
</a>
</p>
</div>
)
: (
<span class="font-bold">
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
/** @jsxImportSource https://esm.sh/preact */
import { activeGTrainAlerts } from "https://esm.town/v/pbt/gtrainalerts?v=36";
import { render } from "npm:preact-render-to-string";
import Uwuifier from "npm:uwuifier";
export default async function(req: Request) {
const uwuifier = new Uwuifier();
const query = new URL(req.url).searchParams;
const timestamp = query.get("at");
const uwu = query.get("uwu");
const parsedTimestamp = timestamp ? Number.parseInt(timestamp, 10) : Date.now();
const entities = await activeGTrainAlerts(parsedTimestamp);
const fucked = entities.length > 0;
const soFucked = entities.length > 1;
const howFucked = soFucked ? `s${entities.map(e => "o").join("")} fucked` : "fucked";
const title = `${uwu ? "UwU! " : " "}the g train ${fucked ? "is" : "isn’t"} ${howFucked}`;
return new Response(
render(
<html class="dark:bg-zinc-800 dark:text-white min-h-screen">
<head>
<title>{title}</title>
<meta
property="og:title"
content={`${title} as of ${
new Date(parsedTimestamp).toLocaleString("en-US", {
timeZone: "America/New_York",
})
}`}
/>
<meta
property="og:description"
content={`${entities.length} active alert${!fucked || soFucked ? "s" : ""}`}
/>
<meta
property="og:image"
content={fucked
? "https://pbt-gtrainog.web.val.run/?status=notOk"
: "https://pbt-gtrainog.web.val.run/?status=ok"}
/>
<meta property="og:url" content="www.isthegtrainfucked.com" />
<meta name="viewport" content="widthdevice-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen flex flex-col justify-center items-center gap-4 p-4">
<header class="italic text-4xl mb-6 text-center">
{uwu ? "UwU!" : ""} the g train {fucked
? (
<span>
is{" "}
<strong>
{soFucked ? (uwu ? "so fucky-wucky" : "so fucked") : (uwu ? "fucky-wucky" : "fucked")}{" "}
{uwu ? "(˶˃ᆺ˂˶)" : ""}
</strong>
</span>
)
: <span>isn’t {uwu ? "fucky-wucky" : "fucked"} {uwu ? "⸜(。 ˃ ᵕ ˂ )⸝♡" : ""}</span>}
</header>
<h1
style="background: #6CBE45"
class={`${
uwu ? "text-7xl" : "text-9xl"
} font-bold text-white rounded-full p-10 h-64 w-64 flex flex-col justify-center items-center`}
>
{fucked ? (uwu ? "• ᴖ •。" : "💩") : (uwu ? "👉👈" : ":)")}
</h1>
<main class="bg-slate-100 dark:bg-stone-900 p-6 md:p-10 md:m-6 rounded">
{fucked
? (
<div>
<h2 class="font-bold pb-2">
{uwu ? uwuifier.uwuifyWords("how it’s fucked as of") : "how it’s fucked as of"}{" "}
{new Date(parsedTimestamp).toLocaleString("en-US", {
timeZone: "America/New_York",
})}:
</h2>
<ul>
{entities.map(({ alert }) => {
const { text: active } = (alert["transit_realtime.mercury_alert"]["human_readable_active_period"]
?? { translation: [{ language: "en", text: "right now" }] }).translation.find((
{ language },
) => language === "en");
const { text } = alert.header_text.translation.find(({ language }) => language === "en-html");
return (
<li>
<div dangerouslySetInnerHTML={{ __html: uwu ? uwuifier.uwuifySentence(text) : text }} />
<p class="text-xs pt-1 pb-3">{uwu ? uwuifier.uwuifySentence(active) : active}</p>
</li>
);
})}
</ul>
<p>
<a target="__blank" class="underline" href="https://new.mta.info/">
{uwu ? uwuifier.uwuifySentence("alerts on mta.info") : "alerts on mta.info"}
</a>
</p>
</div>
)
: (
<span class="font-bold">
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
/** @jsxImportSource https://esm.sh/preact */
import { Color } from "https://deno.land/x/color/mod.ts";
import { render } from "https://deno.land/x/resvg_wasm/mod.ts";
import { render as preact } from "npm:preact-render-to-string";
const HEXRE = /^[A-Fa-f0-9]{3,6}$/;
const toHex = str => HEXRE.test(str) ? "#" + str : str;
export default async function(req: Request): Promise<Response> {
const url = new URL(req.url);
const [a, b] = url.pathname.split("/").slice(1);
const size = 256;
const fontSize = 64;
const color = !!a ? toHex(a) : "#fff";
const bg = !!b ? toHex(b) : "#000";
const data = {
color: Color.string(color),
bg: Color.string(bg),
};
data.contrast = data.color.contrast(data.bg);
const text = data.contrast.toFixed(2);
const svg = preact(
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill={color}
style={{
fontFamily: "sans-serif",
fontWeight: "bold",
fontSize,
}}
>
<rect
x="0"
y="0"
width={size}
height={size}
fill={bg}
/>
<text
x="50%"
y={size / 2 + fontSize * 0.25}
textAnchor="middle"
dominantBaseline="middle"
>
{text}
</text>
</svg>,
);
const png = await render(svg);
return new Response(png, {
headers: {
"Content-Type": "image/png",
},
});
}
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
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
const HEXRE = /^[A-Fa-f0-9]{3,6}$/;
const toHex = (str) => HEXRE.test(str) ? "#" + str : str;
export default function({
text = "Hello",
bg = "000",
color = "fff",
fontSize = 256,
}: {
text: string;
bg: string;
color: string;
fontSize: number;
}): string {
// note: dominantBaseline does not work with resvg_wasm
const svg = render(
<svg
xmlns="http://www.w3.org/2000/svg"
width="1280"
height="720"
viewBox="0 0 1280 720"
fill={toHex(color)}
style={{
fontFamily: "sans-serif",
fontWeight: "bold",
fontSize,
}}
>
<rect
x="0"
y="0"
width="1280"
height="720"
fill={toHex(bg)}
/>
<text
textAnchor="middle"
x="640"
y={360 + fontSize * 0.25}
>
{text}
</text>
</svg>,
);
return svg;
}
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
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
// import Quill from "npm:quill";
export default async function(req: Request) {
if (req.method === "POST") {
const name = (await req.formData()).get("name");
return new Response(render(<>Hello {name || "World"}!</>));
}
return new Response(
render(
<html>
<head>
<title>Title</title>
<style>{"html { font-family: sans-serif; }"}</style>
<script src="https://unpkg.com/htmx.org@1.9.9"></script>
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.0/dist/quill.core.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.0/dist/quill.core.js"></script>
</head>
<body>
<div id="editor" hx-on:load='let quill = new Quill("#editor")'>
<p>Hello World!</p>
<p>
Some initial <strong>bold</strong> text
</p>
<p>
<br />
</p>
</div>
</body>
</html>,
),
{
headers: {
"Content-Type": "text/html; charset=utf-8",
},
},
);
}

Exponential backoff middleware

If your server returns a 5xx error, it will wait 1, 2, 4, 8, 16, 32, 64, 128... seconds before retrying

Screenshot 2024-04-26 at 18.07.40.gif

Usage

Create valimport { exponentialBackoffMiddleware } from "https://esm.town/v/stevekrouse/exponentialBackoffMiddleware" export default exponentialBackoffMiddleware(() => { /* your normal http handler * / })

Example usage: https://www.val.town/v/stevekrouse/BIGweather?v=164#L114

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
/** @jsxImportSource https://esm.sh/preact */
import { html } from "https://esm.town/v/stevekrouse/html";
import { render } from "npm:preact-render-to-string";
/**
* Exponential backoff middleware
* If your server returns a 5xx error,
* it will wait 1, 2, 4, 8, 16, 32, 64, 128 seconds before retrying
* @param http handler
*/
export function exponentialBackoffMiddleware(
handler: (req: Request) => Response | Promise<Response>,
): (req: Request) => Promise<Response> {
return async (req: Request): Promise<Response> => {
const res = await handler(req);
if (res.status < 500) {
return res;
}
else {
const url = new URL(req.url);
const retryAfter = Number(url.searchParams.get("retryAfter")) || 1;
const nextRetryAfter = Math.min(retryAfter * 2, 128);
const nextURL = new URL(req.url);
nextURL.searchParams.set("retryAfter", nextRetryAfter.toString());
return html(render(
<html>
<head>
<meta http-equiv="refresh" content={retryAfter + "; url=" + nextURL} />
<title>{res.status} Error</title>
</head>
<body>
<h1>{res.status} Error</h1>
<p>
{res.statusText}
</p>
<p>Will retry in {retryAfter} seconds</p>
</body>
</html>,
));
}
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/** @jsxImportSource npm:preact **/
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
import { render } from "npm:preact-render-to-string";
type HandlerFunc = (req?: Request) => Response | Promise<Response>;
export function indieauth(handler: HandlerFunc, params: {
clientID: string;
}): HandlerFunc {
return (req) => {
return html(render(
<form action="https://indielogin.com/auth" method="get">
<label for="url">Web Address:</label>
<input id="url" type="text" name="me" placeholder="yourdomain.com" />
<p>
<button type="submit">Sign In</button>
</p>
<input type="hidden" name="client_id" value={params.clientID} />
<input type="hidden" name="redirect_uri" value={new URL("/redirect", params.clientID).toString()} />
<input type="hidden" name="state" value="jwiusuerujs" />
</form>,
));
};
}

Val Town Search

Search for vals using the Github API.

Either use the provided UI, or the query param:

https://val-town-search.pomdtr.me/search?q=fetchJSON

How does it work ?

I've wrote about it!

Todos

  • Embed the results in the UI
  • Refresh the vals on a cron using a github action
  • Improve layout on small screens
  • Support json Accept header
  • Add pagination params
  • Allow to filter by authors
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
/** @jsxImportSource npm:preact **/
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=50";
import { extractValInfo } from "https://esm.town/v/pomdtr/extractValInfo";
import { render } from "npm:preact-render-to-string";
const githubQuery = (query: string) => encodeURIComponent(`${query} repo:pomdtr/val-town-mirror path:vals/`);
async function handler(req: Request) {
const url = new URL(req.url);
if (url.pathname == "/opensearch.xml") {
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Val Town Search</ShortName>
<Description>Search for vals using the Github API</Description>
<Url type="text/html" method="get" template="https://val-town-search.pomdtr.me/search?q={searchTerms}"/>
<Url type="application/opensearchdescription+xml" template="https://val-town-search.pomdtr.me/opensearch.xml"/>
<Image height="16" width="16" type="image/png">https://pomdtr-favicons.web.val.run/val-town</Image>
<moz:SearchForm>https://val-town-search.pomdtr.me/search</moz:SearchForm>
</OpenSearchDescription>`,
{
headers: {
"Content-Type": "application/opensearchdescription+xml",
},
},
);
}
if (url.pathname == "/search") {
const query = url.searchParams.get("q");
const resp = await fetch(`https://api.github.com/search/code?q=${githubQuery(query)}`, {
headers: {
"Accept": "application/vnd.github.text-match+json",
"Authorization": `Bearer ${Deno.env.get("GH_TOKEN")}`,
},
});
if (!resp.ok) {
return new Response(null, {
status: 500,
});
}
const body = await resp.json();
const items = body.items.map(item => {
const [_dir, author, filename] = item.path.split("/");
const [name, _extension] = filename.split(".");
return {
val: {
author,
name,
},
match: item.text_matches[0].fragment,
};
});
const accept = req.headers.get("Accept");
if (accept == "text/json" || accept == "application/json") {
return Response.json(items);
}
return new Response(
render(
<html>
<head>
<title>Val Town Search</title>
<link rel="icon" href="https://pomdtr-favicons.web.val.run/val-town" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<link href="https://cdn.jsdelivr.net/npm/prismjs@v1.29.0/themes/prism.css" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<main class="container">
<h1>
<a href="/" class="contrast">Val Town Search</a>
</h1>
<section>
<form method="GET">
<fieldset role="search">
<input
type="search"
name="q"
value={query}
placeholder="Search for vals..."
autocomplete="off"
aria-label="Search"
/>
<input type="submit" value="Search" />
</fieldset>
</form>
</section>
<hr />
<section>
{items.map(item => (
<article>
<header>
<a href={`https://www.val.town/v/${item.val.author}/${item.val.name}`}>
{item.val.author}/{item.val.name}
1
2
3
4
5
6
7
8
9
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
export const preactExample = () =>
new Response(render(<div>Test {1 + 1}</div>), {
headers: {
"Content-Type": "text/html",
},
});
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
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
export default async function(req: Request) {
return new Response(
render(
<html class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Media Theme TailwindCSS Audio</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome@0.16/+esm"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/media-chrome@0.16/dist/media-theme-element.js/+esm">
</script>
</head>
<body class="max-w-screen-md h-5/6 flex flex-col items-center justify-center p-5 mx-auto">
<template id="media-theme-tailwind-audio">
<style>
{`
@import 'https://cb9871a3-39c1-4f12-8166-54e94f2a0e26-00-182rbb806m0to.spock.replit.dev/output.css';
`}
</style>
<svg class="hidden">
<symbol
id="backward"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 5L5 8M5 8L8 11M5 8H13.5C16.5376 8 19 10.4624 19 13.5C19 15.4826 18.148 17.2202 17 18.188">
</path>
<path d="M5 15V19"></path>
<path d="M8 18V16C8 15.4477 8.44772 15 9 15H10C10.5523 15 11 15.4477 11 16V18C11 18.5523
10.5523 19 10 19H9C8.44772 19 8 18.5523 8 18Z">
</path>
</symbol>
<symbol id="play" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0
3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z"
clip-rule="evenodd"
/>
</symbol>
<symbol id="pause" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0
01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0
01-.75.75H15a.75.75 0 01-.75-.75V5.25z"
clip-rule="evenodd"
/>
</symbol>
<symbol id="forward" viewBox="0 0 24 24">
<path
d="M16 5L19 8M19 8L16 11M19 8H10.5C7.46243 8 5 10.4624 5 13.5C5 15.4826 5.85204 17.2202 7 18.188"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
</path>
<path d="M13 15V19" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
<path
d="M16 18V16C16 15.4477 16.4477 15 17 15H18C18.5523 15 19 15.4477 19 16V18C19 18.5523 18.5523 19 18
19H17C16.4477 19 16 18.5523 16 18Z"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
</path>
</symbol>
<symbol id="high" viewBox="0 0 24 24">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0
001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276
2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06
8.25 8.25 0 000-11.668.75.75 0 010-1.06z">
</path>
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0
010-1.06z">
</path>
</symbol>
<symbol id="off" viewBox="0 0 24 24">
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0
001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276
2.561-1.06V4.06zM17.78 9.22a.75.75 0 10-1.06 1.06L18.44 12l-1.72 1.72a.75.75 0 001.06 1.06l1.72-1.72 1.72
1.72a.75.75 0 101.06-1.06L20.56 12l1.72-1.72a.75.75 0 00-1.06-1.06l-1.72 1.72-1.72-1.72z" />
</symbol>
</svg>
<media-controller
audio

Val Town email subscriptions: send test email

Cousin Val to @petermillspaugh/emailSubscription — see docs there.

When you're writing up an email to send to subscribers, it's helpful to send it to yourself ahead of time to proofread and see how it looks in different email clients etc.

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
/** @jsxImportSource https://esm.sh/preact */
import { generateNewsletterJsx } from "https://esm.town/v/petermillspaugh/generateNewsletterJsx";
import { getJanuary2024Newsletter } from "https://esm.town/v/petermillspaugh/january2024";
import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { render } from "npm:preact-render-to-string";
export function sendTestEmailNewsletter(interval: Interval) {
/*
* Since this is a public Val, anyone can run it.
* This early return prevents spamming me with test emails.
* Comment out the early return to actually test.
*/
if (interval.lastRunAt) {
return console.log("early return");
}
const { jsx: newsletterContent, subject, webUrl } = getJanuary2024Newsletter();
const jsx = generateNewsletterJsx({ webUrl, newsletterContent, emailAddress: "test" });
sendEmail({
subject,
html: render(jsx),
from: {
name: "Pete Millspaugh",
email: "petermillspaugh.sendTestEmailNewsletter@valtown.email",
},
replyTo: "pete@petemillspaugh.com",
});
}

Val Town email subscriptions: send email newsletter

Cousin Val to @petermillspaugh/emailSubscription — see docs there.

This Val has a few layers of protection to avoid double sending. Those mechanisms feel pretty hacky, so any suggestions are welcome! Feel free to comment on the Val or submit a PR.

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
/** @jsxImportSource https://esm.sh/preact */
import { generateNewsletterJsx } from "https://esm.town/v/petermillspaugh/generateNewsletterJsx";
import { newsletters } from "https://esm.town/v/petermillspaugh/newsletters";
import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { render } from "npm:preact-render-to-string";
type SubscriberRow = [
subscriberId: number,
emailAddress: string,
];
export async function sendEmailNewsletter(interval: Interval) {
const { jsx: newsletterContent, subject, webUrl, targetSendDate } = newsletters[newsletters.length - 1];
// no-op and alert if the current timestamp isn't within five minutes of the targetSendDate
const fiveMinutes = 5 * 60 * 1000;
if (Math.abs(new Date(targetSendDate).getTime() - Date.now()) > fiveMinutes) {
return await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: `Send attempt for newsletter_id=${newsletters.length} is not within 5 minutes of target send date`,
});
}
// no-op and alert if interval was run <28 days ago (enforce max one newsletter per month)
const twentyEightDaysAgo = Date.now() - (28 * 24 * 60 * 60 * 1000);
if (!interval.lastRunAt || interval.lastRunAt.getTime() > twentyEightDaysAgo) {
return await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: "Val fired twice in <28 days",
});
}
const { rows: newsletterEmailLogs } = await sqlite.execute({
sql: `SELECT * FROM email_logs WHERE newsletter_id = ?;`,
args: [newsletters.length],
});
// no-op and alert if there's already a log of the latest newsletter
if (newsletterEmailLogs.length > 0) {
return await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: `Duplicate send attempt for newsletter_id=${newsletters.length}`,
});
}
const { rows: subscribers } = await sqlite.execute(
`
SELECT id, email
FROM subscribers
WHERE verified = 1
AND subscribed_at IS NOT NULL;
`,
);
for (const [subscriberId, emailAddress] of subscribers as unknown as SubscriberRow[]) {
const { rows: subscriberEmailLogs } = await sqlite.execute({
sql: `
SELECT *
FROM email_logs
WHERE newsletter_id = ?
AND subscriber_id = ?;
`,
args: [newsletters.length, subscriberId],
});
// skip subscriber and alert if log exists for newsletter + subscriber
if (subscriberEmailLogs.length > 0) {
await sendEmail({
subject: "Alert! Unexpected error in sendEmailNewsletter",
html: `Duplicate send attempt for subscriber_id=${subscriberId} and newsletter_id=${newsletters.length}`,
});
continue;
}
const jsx = generateNewsletterJsx({ webUrl, newsletterContent, emailAddress });
await sendEmail({
subject,
html: render(jsx),
to: emailAddress,
from: {
name: "Pete Millspaugh",
email: "petermillspaugh.sendEmailNewsletter@valtown.email",
},
replyTo: "pete@petemillspaugh.com",
});
// log sent email
await sqlite.execute({
sql: `
INSERT INTO email_logs (newsletter_id, subscriber_id)
VALUES (?, ?);
`,
args: [newsletters.length, subscriberId],
});
}
}
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
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
const inputType = (type, name) => {
const displayName = name.replace("_", " ");
switch (type) {
case "INTEGER":
return (
<div className="form-element">
<label for={name}>{displayName}</label>
<input type="number" name={name} id={name} required={true} />
</div>
);
case "TIMESTAMP":
return (
<div className="form-element">
<label for={name}>{displayName}</label>
<input type="date" name={name} id={name} required={true} />
</div>
);
}
return (
<div className="form-element">
<label for={name}>{displayName}</label>
<input type="text" name={name} id={name} required={true} />
</div>
);
};
export default inputType;
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
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
export const remark = ({ title, content }) => async (req: Request) =>
new Response(
render(
<html>
<head>
<title>Title</title>
<style>
{`
@import url(https://fonts.googleapis.com/css?family=Droid+Serif);
@import url(https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz);
@import url(https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700,400italic);
body {
font-family: 'Droid Serif';
}
h1, h2, h3 {
font-family: 'Yanone Kaffeesatz';
font-weight: 400;
margin-bottom: 0;
}
.remark-slide-content h1 { font-size: 3em; }
.remark-slide-content h2 { font-size: 2em; }
.remark-slide-content h3 { font-size: 1.6em; }
.footnote {
position: absolute;
bottom: 3em;
}
li p { line-height: 1.25em; }
.red { color: #fa0000; }
.large { font-size: 2em; }
a, a > code {
color: rgb(249, 38, 114);
text-decoration: none;
}
code {
background: #e7e8e2;
border-radius: 5px;
}
.remark-code, .remark-inline-code { font-family: 'Ubuntu Mono'; }
.remark-code-line-highlighted { background-color: #373832; }
.pull-left {
float: left;
width: 47%;
}
.pull-right {
float: right;
width: 47%;
}
.pull-right ~ p {
clear: both;
}
#slideshow .slide .content code {
font-size: 0.8em;
}
#slideshow .slide .content pre code {
font-size: 0.9em;
padding: 15px;
}
.inverse {
background: #272822;
color: #777872;
text-shadow: 0 0 20px #333;
}
.inverse h1, .inverse h2 {
color: #f3f3f3;
line-height: 0.8em;
}
`}
</style>
</head>
<body>
<textarea id="source">
{content}
</textarea>
<script src="https://remarkjs.com/downloads/remark-latest.min.js">
</script>
<script>
{`
var slideshow = remark.create({
highlightStyle: 'monokai',
highlightLanguage: 'remark',
highlightLines: true
}) ;
`}
</script>
</body>
</html>,
),
{
headers: {
"Content-Type": "text/html",
},
},
);