Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active July 26, 2023 19:55
Show Gist options
  • Save ryanflorence/cb8252438c8b88292bc171452ac33f61 to your computer and use it in GitHub Desktop.
Save ryanflorence/cb8252438c8b88292bc171452ac33f61 to your computer and use it in GitHub Desktop.
import { riff, route, html, indexRoute } from "../riff/riff.mjs";
import { serve } from "../riff/riff-express.mjs";
import {
deleteTask,
getProjects,
getTasks,
getUser,
sendEmail,
updateTask,
createTask,
} from "./data.mjs";
let app = riff([
route("/", rootRoute, [
route("about", aboutRoute),
route("contact", contactRoute),
route("projects", projectsRoute, [
indexRoute(projectsIndexRoute),
route(":projectId", projectRoute),
]),
]),
]);
async function rootRoute() {
let user = await getUser();
return outlet => html`
<!DOCTYPE html>
<html lang="en">
<head>
<title>Riff Demo</title>
<script type="module" src="/riff-client.mjs"></script>
</head>
<body>
<h1>Riff: ${user.name}</h1>
<ul>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
<li><a href="/projects">Projects</a></li>
</ul>
${outlet}
</body>
</html>
`;
}
function aboutRoute() {
return html`
<div>
<h2>About</h2>
<p>Blah blah blah</p>
</div>
`;
}
async function contactRoute({ request }) {
if (request.method === "post") {
await sendEmail(await request.formData());
return html`<div><h2>Thank you!</h2></div>`;
}
return html`
<div>
<h2>Contact us</h2>
<form method="post">
<input type="email" name="email" />
<textarea name="content"></textarea>
</form>
</div>
`;
}
async function projectsRoute() {
let projects = await getProjects();
return outlet => {
return html`
<div data-route="projects">
<h2>Projects</h2>
<ul>
${projects
.map(
({ id, name }) => html`
<li>
<a href="/projects/${id}">${name}</a>
</li>
`,
)
.join("")}
</ul>
<main>${outlet}</main>
</div>
`;
};
}
function projectsIndexRoute() {
return html` <h2>Select a project</h2>`;
}
async function projectRoute({ request, params }) {
if (request.method === "POST") {
let { intent, ...data } = Object.fromEntries(await request.formData());
switch (intent) {
case "DELETE": {
await deleteTask(data.taskId);
break;
}
case "UPDATE": {
let { taskId, done } = data;
await updateTask(taskId, { done: done === "1" });
break;
}
case "CREATE": {
await createTask({
name: data.taskName,
projectId: params.projectId,
});
break;
}
default: {
return new Response("", { status: 400 });
}
}
}
let tasks = await getTasks(params.projectId);
return html`
<h2>Tasks</h2>
<x-new-task-form>
<form method="post" autocomplete="off">
<label>New task: <input autofocus type="text" name="taskName" /></label>
<button type="submit" name="intent" value="CREATE" value="1">
Create
</button>
</form>
</x-new-task-form>
<ul>
${tasks
.map(
task => html`
<li id="task-${task.id}">
<div>${task.name}</div>
<x-update-task-form>
<form method="post">
<input type="hidden" name="taskId" value=${task.id} />
<input
type="hidden"
name="done"
value=${task.done ? "0" : "1"}
/>
<button
id="update:${task.id}"
type="submit"
name="intent"
value="UPDATE"
>
<span ${task.done ? "hidden" : ""}>Mark complete</span>
<span ${!task.done ? "hidden" : ""}>Mark incomplete</span>
</button>
<button
id="delete:${task.id}"
type="submit"
name="intent"
value="DELETE"
>
Delete
</button>
</form>
</x-update-task-form>
</li>
`,
)
.join("")}
</ul>
<script type="module">
import { addListener } from "/riff-client.mjs";
window.customElements.define(
"x-new-task-form",
class extends HTMLElement {
connectedCallback() {
let button = this.querySelector("button[type=submit]");
let form = this.querySelector("form");
let input = this.querySelector("input[type=text]");
this.unlisten = addListener(event => {
switch (event.reason) {
case "nav:start": {
if (this.contains(event.form)) {
button.disabled = true;
}
break;
}
case "nav:end": {
button.disabled = false;
if (
this.contains(event.form) &&
document.activeElement === input
) {
input.select();
}
break;
}
}
});
}
disconnectedCallback() {
this.unlisten();
}
},
);
window.customElements.define(
"x-update-task-form",
class extends HTMLElement {
connectedCallback() {
let del = this.querySelector("button[name=intent][value=DELETE]");
let done = this.querySelector("button[name=intent][value=UPDATE]");
this.unlisten = addListener(({ reason, form, formData }) => {
let submitting = reason === "nav:start" && this.contains(form);
del.disabled = submitting && formData.get("intent") == "DELETE";
done.disabled = submitting && formData.get("intent") == "UPDATE";
});
}
disconnectedCallback() {
this.unlisten();
}
},
);
</script>
`;
}
serve(app, process.env.PORT);
import morph from "https://unpkg.com/[email protected]/dist/morphdom-esm.js";
let emitter = new EventTarget();
async function getHtml(href, formData) {
let init = formData ? { method: "POST", body: formData } : {};
let response = await fetch(href, init);
let html = await response.text();
return html.replace("<!DOCTYPE html>", "");
}
async function navigate(href, anchor) {
emitter.dispatchEvent(
new CustomEvent("change", {
reason: "nav:start",
detail: { href, anchor },
}),
);
let html = await getHtml(href);
morph(document.documentElement, html);
window.history.pushState(null, null, href);
emitter.dispatchEvent(
new CustomEvent("change", { detail: { reason: "nav:end", anchor } }),
);
}
/**
* @param {HTMLFormElement} form
*/
async function submit(form, submitter) {
// emitter.removeEventListener("my-event", console.log);
let href = form.action;
let formData = new FormData(form);
if (submitter) formData.append(submitter.name, submitter.value);
emitter.dispatchEvent(
new CustomEvent("change", {
detail: { reason: "nav:start", form, formData },
}),
);
let html = await getHtml(href, formData);
morph(document.documentElement, html);
let replace = new URL(href).pathname === window.location.pathname;
window.history[replace ? "replaceState" : "pushState"](null, null, href);
emitter.dispatchEvent(
new CustomEvent("change", {
detail: { reason: "nav:end", form, formData },
}),
);
}
window.addEventListener("popstate", async () => {
let { pathname, search } = window.location;
morph(document.documentElement, await getHtml(pathname + search));
});
document.body.addEventListener("click", event => {
let a = event.target.closest("a");
if (
a &&
event.button === 0 &&
(!a.target || a.target === "_self") &&
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
) {
event.preventDefault();
navigate(a.href, a);
}
});
document.body.addEventListener("submit", event => {
event.preventDefault();
let form = event.target;
let submitter = event.submitter;
submit(form, submitter);
});
function addListener(handler) {
let fn = event => handler(event.detail);
emitter.addEventListener("change", fn);
return () => emitter.removeEventListener("change", fn);
}
export { navigate, submit, addListener };
import { matchRoutes } from "./util.mjs";
import { minify } from "html-minifier";
export let html = String.raw;
/**
* @typedef {Object} Route
* @property {string?} path
* @property {routeHandler} handler
*/
/**
* Handles route requests
*
* @callback routeHandler
* @param {RouteHandlerContext} context
* @returns {string | parentRender}
*/
/**
* @callback parentRender
* @param {string} outlet
*/
/**
* @typedef {Record<string,string>} Params
*/
/**
* @typedef {Object} RouteHandlerContext
* @property {Request} request
* @property {Params} params
*/
/**
* Create a route.
*
* @param {string} path
* @param {routeHandler} handler
* @param {children?} Route[]
*/
export function route(path, handler, children) {
return { path, handler, children };
}
/**
* Create an index route.
*
* @param {routeHandler} handler
*/
export function indexRoute(handler) {
return { index: true, handler };
}
/**
* @param {Route[]} routes
*/
export function riff(routes) {
/**
* @param {Request} request
*/
async function handleRequest(request) {
let url = new URL(request.url);
let matches = matchRoutes(routes, url.pathname);
if (!matches) {
return new Response("Not Found", { status: 404 });
}
if (url.searchParams.has("_riff")) {
return handleClientNavigation();
} else {
return handleDocument();
}
async function handleDocument() {
let results = await Promise.all(
matches.map(match => {
/** @type {Route} */
let route = match.route;
/** @type {Params} */
let params = match.params;
return route.handler({
request: request.clone(),
params,
});
}),
);
let html = results.reduceRight((outlet, result, index) => {
if (typeof result === "function") {
return result(
`<!-- riff-outlet:${index} -->${outlet}<!-- /riff-outlet:${index} -->`,
);
} else {
return result;
}
}, "");
return new Response(
minify(html, {
collapseWhitespace: true,
}),
{
headers: {
"Content-Type": "text/html; utf-8",
},
},
);
}
}
return async request => {
try {
return handleRequest(request);
} catch (e) {
if (e instanceof Response) {
return e;
} else {
console.error(e);
return new Response("Unexpected Error", { status: 500 });
}
}
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment