Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a75703d23
|
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user