feat(gateway): clean URLs — serve <path>.html + strip .html from hrefs

Two symmetric changes so a site published as flat .html files (sirius.x
has tld.html, portal.html, market.html, register.html, studio.html)
reads with clean URLs to the visitor.

1. S3 handler fallback order for extensionless requests:
     /tld  →  tld.html         (new — first)
     /tld  →  tld/index.html   (existing — second)
     /...  →  index.html       (existing — SPA last-resort)
   Requests with a real file extension (.js, .svg, .json) are never
   rescued — those must never be routed by a client-side router and
   swallowing them would hide real bugs.

2. transformHtml() gains stripHtmlExt(): rewrites href="foo.html"
   (relative) to href="foo", preserving anchor and query. Absolute
   URLs (https://silentmode.st/tlds/) are left alone — external
   references are none of our business.

Backwards-compatible: existing /bns/<name>/foo.html links still 200.
Verified end-to-end on sirius.x: /tld, /portal, /market, /register,
/studio all serve; assets under js/, assets/ unchanged; direct .html
requests still work.
This commit is contained in:
Local Dev 2026-09-20 19:34:42 +02:00
parent 6ebdb98936
commit e130596584

View file

@ -317,11 +317,27 @@ function rewriteBnsLinks(html) {
}
);
}
// Apply both transforms (base first, then link rewrite) — order doesn't
// matter because they touch disjoint constructs, but grouping them here
// means every text/html response goes through the same path.
// Strip `.html` from relative link targets in served HTML — so a link
// written as href="tld.html" reads as href="tld" in the browser bar.
// Only touches non-absolute URLs (leaves `https://…/x.html` alone —
// those are external and often out of our control). Preserves anchor
// and query, and skips the ".html" that's part of a filename like
// "foo.htmlx" (regex requires end/? / #).
function stripHtmlExt(html) {
return html.replace(
/\b(href\s*=\s*")([^"#?][^"#?]*)\.html(?=["#?])/gi,
(m, prefix, path) => {
if (/^\s*[a-z][a-z0-9+.-]*:/i.test(path)) return m; // absolute URL — leave alone
return prefix + path;
}
);
}
// Apply the three transforms (base first, link rewrite, extension strip).
// Order doesn't matter across them because they touch disjoint constructs,
// but grouping them here means every text/html response goes through the
// same path.
function transformHtml(html, baseHref) {
return rewriteBnsLinks(injectBase(html, baseHref));
return stripHtmlExt(rewriteBnsLinks(injectBase(html, baseHref)));
}
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
@ -487,15 +503,25 @@ async function serve(res, name, reqPath, baseHref) {
// NOT rescued — those should never be routed by a client-side
// router and swallowing them would hide real bugs.
if (up.status === 404 && !/\.[a-z0-9]+$/i.test(reqPath) && reqPath !== "/") {
// Try directory-index first
const cleanBase = base.endsWith("/") ? base : base + "/";
const cleanPath = reqPath.slice(1).replace(/\/+$/, "");
// 1) Extensionless-URL support: /tld -> tld.html. Lets sites
// publish clean URLs without keeping mirror files around.
if (!reqPath.endsWith("/")) {
const dirKey = (base.endsWith("/") ? base : base + "/") + reqPath.slice(1).replace(/\/+$/, '') + "/index.html";
const htmlKey = cleanBase + cleanPath + ".html";
const htmlHit = await aws.fetch(`${S3_ENDPOINT}/${htmlKey}`);
if (htmlHit.ok) { up = htmlHit; }
}
// 2) Directory-index: /docs -> docs/index.html
if (up.status === 404 && !reqPath.endsWith("/")) {
const dirKey = cleanBase + cleanPath + "/index.html";
const dir = await aws.fetch(`${S3_ENDPOINT}/${dirKey}`);
if (dir.ok) { up = dir; }
}
// Then SPA root fallback
// 3) SPA root fallback — last resort so client-side routers
// can pick up /katalogos, /anthemus, etc.
if (up.status === 404) {
const fallbackKey = (base.endsWith("/") ? base : base + "/") + "index.html";
const fallbackKey = cleanBase + "index.html";
const fb = await aws.fetch(`${S3_ENDPOINT}/${fallbackKey}`);
if (fb.ok) { up = fb; }
}