Compare commits

...
Author SHA1 Message Date
grenadeandClaude Opus 5 7a75703d23 fix(web): /docs returned 403 — a prerendered directory shadows its route
CI / Classify changes (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Successful in 2m0s
CI / CUDA type-check (push) Has been skipped
CI / Format (push) Has been skipped
CI / Clippy (push) Has been skipped
CI / Test (push) Has been skipped
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Clicking the documentation link answered 403. Prerendering writes real
directories under the webroot, and a real directory shadows the
client-side route of the same name: nginx's `try_files $uri $uri/
/index.html` matches `$uri/`, looks for an index inside it, finds none,
and refuses. It never reaches the SPA shell, so the route that used to
work stopped working the moment its pages became files.

Only the leaves had indexes, so /docs/using/api served fine while /docs,
/docs/using and /docs/operating all 403'd. That is the worst arrangement
to notice: every link in the sidebar works, and only the addresses a
person reaches by trimming a URL — or by clicking the nav link — fail.

Every directory the prerenderer creates now gets an index, derived from
the page slugs rather than from the section list so a page nested deeper
than one level covers its intermediate directories too. They are worth
having in their own right: a crawler or a reader without JavaScript gets
a table of contents instead of a redirect.

A build-time check now fails the build if any directory under docs/ lacks
an index. Introducing this class of 403 costs a deploy and a bug report
to notice otherwise, and the condition is trivially checkable at the
point the files are written. Verified by adding a nested page and
confirming the check fires before it was generalised, then that the
generalised version covers it.

The SPA also treated a section path as a missing page, because no page
has that slug. It now redirects to the section's first page — those URLs
are reachable, both from the prerendered index and from someone trimming
a path back to its parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165r11RzqkMqWWXfJE8tAVU
2026-08-15 09:45:02 +03:00
3 changed files with 102 additions and 1 deletions
+37 -1
View File
@@ -58,7 +58,9 @@ if (!fs.existsSync(SSR_ENTRY)) {
fail(`${path.relative(ROOT, SSR_ENTRY)} is missing — run the SSR build first`);
}
const { renderDoc, slugs } = await import(url.pathToFileURL(SSR_ENTRY).href);
const { renderDoc, slugs, indexes } = await import(
url.pathToFileURL(SSR_ENTRY).href
);
const template = fs.readFileSync(path.join(DIST, "index.html"), "utf8");
// The shell must contain an empty #root for the markup to go into. If
@@ -117,6 +119,40 @@ for (const slug of slugs()) {
written.push({ slug, canonical });
}
// Index pages for every directory the loop above created. Without these
// nginx answers 403 for /docs and each section: the directory exists, so
// `try_files $uri/` matches, and with no index inside and autoindex off
// there is nothing to serve and no fallthrough to the SPA shell.
for (const idx of indexes()) {
const slugPath = idx.path ? `/docs/${idx.path}` : "/docs";
const html = withHead(
template.replace(ROOT_DIV, `<div id="root">${idx.html}</div>`),
idx.title,
undefined,
`${SITE}${slugPath}`,
);
const dir = idx.path
? path.join(DIST, "docs", ...idx.path.split("/"))
: path.join(DIST, "docs");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "index.html"), html);
}
// Nothing the prerenderer creates may be left without an index — that is
// the 403 above, and it is silent at build time.
const missing = [];
(function walk(dir) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
if (!entries.some((e) => e.isFile() && e.name === "index.html")) {
missing.push(path.relative(DIST, dir));
}
for (const e of entries) if (e.isDirectory()) walk(path.join(dir, e.name));
})(path.join(DIST, "docs"));
if (missing.length) {
fail(`directories with no index.html would 403: ${missing.join(", ")}`);
}
// A sitemap so the pages are discoverable without relying on a crawler
// walking the sidebar — only once they are meant to be found.
if (INDEXABLE) {
+9
View File
@@ -111,6 +111,15 @@ export default function Docs() {
return first ? <Navigate to={`/docs/${first}`} replace /> : null;
}
// A section on its own — `/docs/operating` — is a directory rather than
// a page. It is reachable: the prerenderer writes an index there, and
// trimming a URL back to its parent is a thing people do. Send it to
// the section's first page rather than claiming it does not exist.
const section = tree.find((s) => s.id === slug);
if (section?.pages.length) {
return <Navigate to={`/docs/${section.pages[0].slug}`} replace />;
}
if (!doc) {
return (
<main className="container py-5">
+56
View File
@@ -116,3 +116,59 @@ function flatten(node: React.ReactNode): string {
export function slugs(): string[] {
return allDocSlugs();
}
/**
* Index pages for the directories that pages live under.
*
* Prerendering creates real directories on disk, and a real directory
* shadows the client-side route of the same name: nginx's
* `try_files $uri $uri/ /index.html` matches `$uri/`, looks for an index
* inside it, finds none, and answers **403** — it never falls through to
* the SPA shell. So `/docs` and each section 403'd while the pages
* beneath them served fine.
*
* Every directory the prerenderer creates therefore needs an index. They
* are useful in their own right — a crawler or a reader without
* JavaScript gets a table of contents rather than a redirect — and the
* SPA replaces them on mount as it does every other prerendered page.
*/
export function indexes(): { path: string; title: string; html: string }[] {
const tree = docTree();
const pages = tree.flatMap((s) => s.pages);
// Every ancestor directory of every page, derived from the slugs rather
// than from the section list — a page nested deeper than one level
// creates intermediate directories too, and missing one of those is the
// same 403 with a less obvious cause.
const dirs = new Set<string>([""]);
for (const p of pages) {
const parts = p.slug.split("/");
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const labelFor = (dir: string): string => {
const section = tree.find((s) => s.id === dir);
if (section) return section.label;
const last = dir.split("/").pop() ?? dir;
return last.charAt(0).toUpperCase() + last.slice(1).replace(/[-_]/g, " ");
};
return [...dirs].sort().map((dir) => {
const prefix = dir ? `${dir}/` : "";
const children = pages.filter((p) => p.slug.startsWith(prefix));
const items = children
.map(
(p) =>
`<li><a href="/docs/${p.slug}">${escapeHtml(p.sidebarLabel)}</a></li>`,
)
.join("");
const title = dir ? labelFor(dir) : "Documentation";
return {
path: dir,
title,
html: `${navHtml("")}<article><h1>${escapeHtml(title)}</h1><ul>${items}</ul></article>`,
};
});
}