Files
aia-website/www/tools/generate-redirects.mjs
T

96 lines
4.9 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
export const redirects = Object.freeze([
{ from: '/aba', to: '/aba-therapy', status: 301, reason: 'Brief legacy alias to preserved URL' },
{ from: '/autismevaluations', to: '/autism-evaluations', status: 301, reason: 'Brief legacy alias to preserved URL' },
{ from: '/learnersocialclub', to: '/learner-social-club', status: 301, reason: 'Brief legacy alias to preserved URL' },
{ from: '/library/page/1', to: '/library', status: 301, reason: 'Library first-page alias' },
{ from: '/es/library/page/1', to: '/es/library', status: 301, reason: 'Spanish Library first-page alias' },
{ from: '/ar/library/page/1', to: '/ar/library', status: 301, reason: 'Arabic Library first-page alias' },
{ from: '/library/author/rula-diab/page/1', to: '/library/author/rula-diab', status: 301, reason: 'Author first-page alias' },
{ from: '/es/library/author/rula-diab/page/1', to: '/es/library/author/rula-diab', status: 301, reason: 'Spanish author first-page alias' },
{ from: '/ar/library/author/rula-diab/page/1', to: '/ar/library/author/rula-diab', status: 301, reason: 'Arabic author first-page alias' }
]);
function normalizedPath(value) {
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || /[?#\\\s]/u.test(value)) {
throw new Error(`Invalid redirect path: ${value}`);
}
const trimmed = value.replace(/\/+$/, '') || '/';
let segments;
try {
segments = trimmed.slice(1).split('/').map((segment) => decodeURIComponent(segment));
} catch {
throw new Error(`Invalid redirect path: ${value}`);
}
if (trimmed !== '/' && segments.some((segment) => !segment || ['.', '..'].includes(segment) || /[/\\?#\s]/u.test(segment))) {
throw new Error(`Invalid redirect path: ${value}`);
}
const normalized = '/' + segments.map((segment) => encodeURIComponent(segment)).join('/');
if (normalized !== value) throw new Error(`Redirect path is not normalized: ${value}`);
return normalized;
}
/** Validate structural rules, plus generated-route targets when supplied. */
export function validateRedirects(records, generatedRoutes) {
if (!Array.isArray(records)) throw new Error('Redirect definitions must be an array.');
const sources = new Map();
for (const [index, record] of records.entries()) {
if (!record || typeof record !== 'object') throw new Error(`Invalid redirect definition at index ${index}.`);
const from = normalizedPath(record.from);
const to = normalizedPath(record.to);
if (record.status !== 301) throw new Error(`Redirect must use HTTP 301: ${from}`);
if (typeof record.reason !== 'string' || !record.reason.trim()) throw new Error(`Redirect reason is required: ${from}`);
if (from === to) throw new Error(`Self-redirect is not allowed: ${from}`);
if (sources.has(from)) throw new Error(`Duplicate redirect source: ${from}`);
const locale = from.match(/^\/(es|ar)(?:\/|$)/)?.[1] ?? 'en';
const destinationLocale = to.match(/^\/(es|ar)(?:\/|$)/)?.[1] ?? 'en';
if (locale !== destinationLocale) throw new Error(`Redirect changes locale: ${from} -> ${to}`);
sources.set(from, record);
}
for (const { from, to } of records) {
if (sources.has(to)) throw new Error(`Redirect chain or cycle is not allowed: ${from} -> ${to}`);
}
if (generatedRoutes !== undefined) {
const routes = new Set([...generatedRoutes].map(normalizedPath));
for (const { from, to } of records) {
if (routes.has(from)) throw new Error(`Redirect source collides with generated HTML: ${from}`);
if (!routes.has(to)) throw new Error(`Redirect target is not a generated route: ${from} -> ${to}`);
}
}
return records;
}
const csv = (value) => `"${String(value).replaceAll('"', '""')}"`;
const regex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export function redirectArtifacts(records = redirects) {
validateRedirects(records);
return {
json: `${JSON.stringify(records, null, 2)}\n`,
csv: `from,to,status,reason\n${records.map((record) =>
`${csv(record.from)},${csv(record.to)},${record.status},${csv(record.reason)}`).join('\n')}\n`,
nginx: `${records.map(({ from, to }) => `rewrite ^${regex(from)}$ ${to} permanent;`).join('\n')}\n`
};
}
export async function generateRedirectArtifacts(records = redirects) {
const artifacts = redirectArtifacts(records);
await Promise.all([
fs.writeFile(path.join(ROOT, 'src/data/redirects.json'), artifacts.json),
fs.writeFile(path.join(ROOT, 'reports/redirect-map.csv'), artifacts.csv),
fs.writeFile(path.join(ROOT, 'reports/nginx-rewrites.conf'), artifacts.nginx)
]);
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
generateRedirectArtifacts().catch((error) => {
console.error(`Redirect generation failed: ${error.message}`);
process.exitCode = 1;
});
}