diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 8b600be9..059d5d9a 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -4,8 +4,8 @@ ARG GIT_SHA=dev
WORKDIR /src
COPY scripts/build-site.mjs scripts/
COPY site site
-# _headers and _redirects are for Cloudflare Workers; nginx.conf does the same.
-RUN node scripts/build-site.mjs && rm dist/_headers dist/_redirects && echo "$GIT_SHA" > dist/version.txt
+# _headers is for Cloudflare Workers; nginx.conf sends the same headers.
+RUN node scripts/build-site.mjs && rm dist/_headers && echo "$GIT_SHA" > dist/version.txt
FROM nginxinc/nginx-unprivileged:1.28-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
index e35c790b..6fcc9ee5 100644
--- a/deploy/nginx.conf
+++ b/deploy/nginx.conf
@@ -28,8 +28,8 @@ server {
location = /booking/404.html { internal; }
location = /404.html { internal; }
- # Planner's landing page is exactly /planner; the trailing-slash form redirects to it.
- location = /planner/ { return 301 /planner; }
+ # Every page lives at its address with no trailing slash; the slash form redirects to it.
+ location ~ ^/(apps|download|pricing|security|planner|notes|studio|engineering)/$ { return 301 /$1; }
location / { try_files $uri $uri/index.html $uri/ =404; }
error_page 404 /404.html;
diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs
index 6d4b3ebc..4e14dc54 100755
--- a/scripts/build-site.mjs
+++ b/scripts/build-site.mjs
@@ -151,7 +151,7 @@ const PREVIEWS = {
const esc = (s) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const building = 'Building';
const planned = 'Planned';
-const NAV = [["Apps", "/apps/"], ["Pricing", "/pricing/"], ["Security", "/security/"]];
+const NAV = [["Apps", "/apps"], ["Pricing", "/pricing"], ["Security", "/security"]];
// Theme: the button and menu from orbyn.dev Designs "Theme switch".
const THEMES = [["system", "System", "i-laptop", "Follows this device"], ["light", "Light", "i-sun", ""], ["dark", "Dark", "i-moon", ""], ["dim", "Dim", "i-moon", "Softer dark"]];
@@ -178,9 +178,9 @@ function nav(current) {
`
`;
}
-// The Planner landing page lives at exactly https://orbyn.dev/planner (no trailing slash).
-// /planner/ redirects there; every link and the canonical address use the exact form.
-const appPath = (key) => (key === "planner" ? "/planner" : `/${key}/`);
+// Every page lives at its address with no trailing slash (orbyn.dev/planner, orbyn.dev/apps).
+// The trailing-slash form redirects there: nginx.conf, and Workers' drop-trailing-slash setting.
+const appPath = (key) => `/${key}`;
const appIcon = (a, sm = true) => `${ic(a.icon)}`;
function appCard(a, { withPreview = true } = {}) {
@@ -236,7 +236,7 @@ pages.push({ file: "index.html", path: "/", title: "Orbyn", current: "",
One account · four apps
Private apps that work offline and that agents can work in safely.
Orbyn is a family of local-first apps that sync end to end under one account. Plan your week, keep your notes, make films and build software.A family of local-first apps under one account.
-
+
Nothing is out yet. Every app is still being built.
@@ -254,7 +254,7 @@ ${APPS.map((a) => `
${appIcon(
${promises()}` });
-pages.push({ file: "apps/index.html", path: "/apps/", title: "Apps · Orbyn", current: "Apps",
+pages.push({ file: "apps/index.html", path: "/apps", title: "Apps · Orbyn", current: "Apps",
description: "Four apps on one account: Orbyn Planner, Notes, Studio and Engineering. Every app is still being built.",
body: `
Apps
@@ -265,7 +265,7 @@ pages.push({ file: "apps/index.html", path: "/apps/", title: "Apps · Orbyn", cu
${APPS.map((a) => appCard(a)).join("")}
${building}Being built, not out yet${planned}On the roadmap, not started
` });
-pages.push({ file: "download/index.html", path: "/download/", title: "Download · Orbyn", current: "",
+pages.push({ file: "download/index.html", path: "/download", title: "Download · Orbyn", current: "",
description: "Nothing to download yet. Every Orbyn app is still being built.",
body: `
Nothing to download yet
@@ -274,7 +274,7 @@ pages.push({ file: "download/index.html", path: "/download/", title: "Download
${APPS.map((a) => `
${appIcon(a)}${a.name}
Planned for ${PLATFORMS[a.key]}${building}
`).join("")}
` });
-pages.push({ file: "pricing/index.html", path: "/pricing/", title: "Pricing · Orbyn", current: "Pricing",
+pages.push({ file: "pricing/index.html", path: "/pricing", title: "Pricing · Orbyn", current: "Pricing",
description: "Nothing is for sale yet. These are the plans Orbyn intends to launch with: every app free on one device, and one subscription to sync.",
body: `
${planned}
@@ -300,7 +300,7 @@ const SEC_ROWS = [
["Shared lists and projects", "Standard", "End-to-end when everyone sharing uses Advanced"],
["Calendars other apps use", "Standard", "Standard, so calendar apps and invitations keep working"],
];
-pages.push({ file: "security/index.html", path: "/security/", title: "Security · Orbyn", current: "Security",
+pages.push({ file: "security/index.html", path: "/security", title: "Security · Orbyn", current: "Security",
description: "How Orbyn protects your data. This is how Orbyn is being built. Nothing has launched yet, and details may change before it does.",
body: `
${building}How Orbyn protects your data
This is how Orbyn is being built. Nothing has launched yet, and details may change before it does.
@@ -365,7 +365,7 @@ ${promises()}
const notFound = (line, button, path, file) => ({ file, path, title: "Not found · Orbyn", current: "",
description: "This page doesn’t exist.",
body: `
404This page doesn’t exist
${line}
-
` });
+
` });
pages.push(notFound("The link may be wrong, or the page may have moved.", "See the apps", "/404", "404.html"));
pages.push(notFound("Booking lives inside Orbyn Planner. It’s planned, not built yet.", "See all apps", "/booking", "booking/404.html"));
diff --git a/scripts/serve-site.mjs b/scripts/serve-site.mjs
index d56729be..a882492e 100755
--- a/scripts/serve-site.mjs
+++ b/scripts/serve-site.mjs
@@ -17,10 +17,8 @@ async function file(p) {
createServer(async (req, res) => {
const path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, "");
- if (path === "/planner/") { res.writeHead(301, { Location: "/planner" }); return res.end(); }
- if (/^\/(apps|download|pricing|security|notes|studio|engineering)$/.test(path)) {
- res.writeHead(301, { Location: path + "/" }); return res.end();
- }
+ const slash = path.match(/^(\/(?:apps|download|pricing|security|planner|notes|studio|engineering))\/$/);
+ if (slash) { res.writeHead(301, { Location: slash[1] }); return res.end(); }
let found = path.startsWith("/_headers") ? null : await file(join(dist, path));
let status = 200;
if (!found) {
diff --git a/site/public/_redirects b/site/public/_redirects
deleted file mode 100644
index fc82e1ec..00000000
--- a/site/public/_redirects
+++ /dev/null
@@ -1,2 +0,0 @@
-# Cloudflare Workers static assets. nginx.conf has the same rule.
-/planner/ /planner 301
diff --git a/wrangler.jsonc b/wrangler.jsonc
index f033a510..b99133ba 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -8,6 +8,6 @@
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
- "html_handling": "auto-trailing-slash"
+ "html_handling": "drop-trailing-slash"
}
}