Last active
July 26, 2023 19:55
-
-
Save ryanflorence/cb8252438c8b88292bc171452ac33f61 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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