diff --git a/docs/404.md b/docs/404.md
new file mode 100644
index 000000000..20948d579
--- /dev/null
+++ b/docs/404.md
@@ -0,0 +1,73 @@
+
+
+# Page not found
+
+Sorry, but we can’t find the page you requested.
+
+Here’s something inspired by [Bridget Riley](https://en.wikipedia.org/wiki/Bridget_Riley) instead.
+
+```js
+import * as d3 from "npm:d3";
+
+const height = Math.min(640, width);
+const point = (cx, cy, r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
+const circles = [];
+const random = d3.randomLcg(42);
+const n = 80;
+let a = 0.2;
+let x = width / 2;
+let y = height / 2;
+let r = Math.hypot(width, height) / 2;
+let dr = r / 6.5;
+
+while (r > 0) {
+ circles.push({x, y, r, a});
+ const t = random() * 2 * Math.PI;
+ const s = Math.sqrt((random() * dr * dr) / 4);
+ x += Math.cos(t) * s;
+ y += Math.sin(t) * s;
+ r -= dr;
+ a = -a;
+}
+
+const canvas = display(document.createElement("canvas"));
+canvas.width = width * devicePixelRatio;
+canvas.height = height * devicePixelRatio;
+canvas.style.width = `${width}px`;
+
+const context = canvas.getContext("2d");
+context.scale(devicePixelRatio, devicePixelRatio);
+
+(function frame(elapsed) {
+ context.save();
+ context.clearRect(0, 0, width, height);
+ context.translate(width / 2, height / 2);
+ context.rotate(Math.sin(elapsed / 50000));
+ context.translate(-width / 2, -height / 2);
+ context.beginPath();
+ for (let i = 0; i < n; ++i) {
+ let move = true;
+ d3.pairs(circles, ({x: x1, y: y1, r: r1, a: a1}, {x: x2, y: y2, r: r2, a: a2}) => {
+ const ai = ((i * 2) / n) * Math.PI;
+ context[move ? ((move = false), "moveTo") : "lineTo"](...point(x1, y1, r1, a1 + ai));
+ context.lineTo(...point(x2, y2, r2, a2 + ai));
+ });
+ d3.pairs(circles.slice().reverse(), ({x: x1, y: y1, r: r1, a: a1}, {x: x2, y: y2, r: r2, a: a2}) => {
+ const ai = ((i * 2 + 1) / n) * Math.PI;
+ context.lineTo(...point(x1, y1, r1, a1 + ai));
+ context.lineTo(...point(x2, y2, r2, a2 + ai));
+ });
+ context.closePath();
+ }
+ context.fillStyle = getComputedStyle(canvas).getPropertyValue("color");
+ context.fill();
+ context.restore();
+ if (canvas.isConnected) requestAnimationFrame(frame);
+})();
+```
diff --git a/src/navigation.ts b/src/navigation.ts
index 39fd270a2..5ade6c3f0 100644
--- a/src/navigation.ts
+++ b/src/navigation.ts
@@ -11,7 +11,7 @@ export async function readPages(root: string): Promise {
console.log(req.method, req.url);
+ let pages;
try {
const url = new URL(req.url!, "http://localhost");
let {pathname} = url;
@@ -124,7 +125,7 @@ class Server {
// Otherwise, serve the corresponding Markdown file, if it exists.
// Anything else should 404; static files should be matched above.
try {
- const pages = await readPages(this.root); // TODO cache? watcher?
+ pages = await readPages(this.root); // TODO cache? watcher?
const {html} = await renderPreview(await readFile(path + ".md", "utf-8"), {
root: this.root,
path: pathname,
@@ -141,6 +142,20 @@ class Server {
} catch (error) {
console.error(error);
res.statusCode = isHttpError(error) ? error.statusCode : 500;
+ if (req.method === "GET" && res.statusCode === 404) {
+ try {
+ const {html} = await renderPreview(await readFile(join(this.root, "404.md"), "utf-8"), {
+ root: this.root,
+ path: "/404",
+ pages,
+ resolver: this._resolver!
+ });
+ end(req, res, html, "text/html");
+ return;
+ } catch {
+ // ignore secondary error (e.g., no 404.md); show the original 404
+ }
+ }
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(error instanceof Error ? error.message : "Oops, an error occurred");
}
diff --git a/src/render.ts b/src/render.ts
index eb6f26bb4..ad09fa625 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -55,7 +55,7 @@ function render(
{path, pages, title, preview, hash, resolver}: RenderOptions & RenderInternalOptions
): string {
return `
-
+${path === "/404" ? `\n` : ""}
${
parseResult.title || title