docs: emit redirect stubs at both <from>/index.html and <from>.html (#38389)

active-redirects.json entries whose `from` lives outside the docs
CloudFront function's frozen legacy-Sphinx prefix set currently 404 in
production. The stub file exists in S3 at <from>/index.html but the CFF
(mattermost-iac-docs-prod -> 05-cdn -> clean-url-rewrite.js) only serves
that key for the frozen legacy prefixes; for any other first path
segment it falls back to <from>.html, which doesn't exist for
plugin-client-redirects stubs.

Add a postbuild step that duplicates every stub at the sibling flat
key. The legacy branch keeps hitting <from>/index.html unchanged; the
fallback branch now finds <from>.html. Real content pages are never
overwritten (the script skips any path where <from>.html already
exists), .html-suffixed from-values are skipped (CFF Phase A
canonicalizes them before Phase B routes them), and .. traversal in a
malformed from is rejected before any filesystem call.

Smoke-tested against a local build: 741 stubs duplicated, 0 real
content pages touched, byte-identical meta-refresh content.
This commit is contained in:
Eva Sarafianou
2026-09-08 09:41:56 +03:00
committed by GitHub
parent 03a30e9183
commit 904ee27f26
2 changed files with 68 additions and 0 deletions
+1
View File
@@ -6,6 +6,7 @@
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"postbuild": "node scripts/duplicate-redirect-stubs.mjs",
"stage:agents-docs": "node scripts/stage-agents-docs.mjs",
"build:sidebars": "node scripts/gen-documentation-sidebar.mjs && node scripts/gen-developer-sidebar.mjs",
"build:openapi:spec": "node scripts/build-openapi.mjs",
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Copy every plugin-client-redirects stub from `build/<from>/index.html` to
// `build/<from>.html` so the docs CloudFront function's `<uri>.html` fallback
// branch finds it (see mattermost-iac-docs-prod → 05-cdn →
// clean-url-rewrite.js).
import {readFileSync, writeFileSync, existsSync, statSync} from 'node:fs';
import {join, resolve, dirname, sep} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..');
const BUILD_DIR = join(SITE_ROOT, 'build');
const REDIRECTS_JSON = join(SITE_ROOT, 'sidebars', 'active-redirects.json');
if (!existsSync(BUILD_DIR)) {
console.error(`[duplicate-redirect-stubs] ${BUILD_DIR} not found; run \`docusaurus build\` first.`);
process.exit(1);
}
const {redirects} = JSON.parse(readFileSync(REDIRECTS_JSON, 'utf8'));
const BUILD_DIR_PREFIX = BUILD_DIR + sep;
let duplicated = 0;
let skippedExisting = 0;
let skippedMissingStub = 0;
let skippedHtmlSuffix = 0;
let skippedOutsideBuild = 0;
for (const {from} of redirects) {
// CFF Phase A already 301s `.html` to the extensionless form.
if (from.endsWith('.html')) {
skippedHtmlSuffix++;
continue;
}
const rel = from.replace(/^\/+/, '');
const stubPath = resolve(BUILD_DIR, rel, 'index.html');
const flatPath = resolve(BUILD_DIR, `${rel}.html`);
if (!stubPath.startsWith(BUILD_DIR_PREFIX) || !flatPath.startsWith(BUILD_DIR_PREFIX)) {
skippedOutsideBuild++;
continue;
}
if (!existsSync(stubPath) || !statSync(stubPath).isFile()) {
skippedMissingStub++;
continue;
}
// Never shadow a real content page at the flat key.
if (existsSync(flatPath)) {
skippedExisting++;
continue;
}
writeFileSync(flatPath, readFileSync(stubPath));
duplicated++;
}
console.log(
`[duplicate-redirect-stubs] duplicated=${duplicated} ` +
`skipped-existing=${skippedExisting} ` +
`skipped-missing-stub=${skippedMissingStub} ` +
`skipped-html-suffix=${skippedHtmlSuffix} ` +
`skipped-outside-build=${skippedOutsideBuild} ` +
`total=${redirects.length}`,
);