feat(seo): reconcile URLs and author page-one redirects
This commit is contained in:
1 parent
8646792d46
commit
8192ea71c4
11 files changed
+689
-77
No files matched your search
+44
-10
@@ -7,6 +7,7 @@ import { load } from 'cheerio';
|
||||
import fg from 'fast-glob';
|
||||
import matter from 'gray-matter';
|
||||
import { createPublicationManifest } from '../src/utils/publication-policy.ts';
|
||||
import { validateRedirects } from './generate-redirects.mjs';
|
||||
|
||||
export const ORIGIN = 'https://www.azinstitute4autism.com';
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -56,15 +57,30 @@ export function addDiscovery(records, value, source, observedAt, base = ORIGIN)
|
||||
export function classify(local, production) {
|
||||
const status = production?.http?.status;
|
||||
const hasLocal = Boolean(local?.generated);
|
||||
if (status >= 300 && status < 400) return hasLocal ? 'local-and-production-redirect' : 'production-redirect-only';
|
||||
const redirect = local?.redirect;
|
||||
if (status >= 300 && status < 400) {
|
||||
if (redirect) {
|
||||
const target = production?.http?.location ? normalizeReference(production.http.location)?.route : null;
|
||||
return !target || target === redirect.to ? 'local-and-production-redirect' : 'local-production-redirect-conflict';
|
||||
}
|
||||
return hasLocal ? 'local-generated-production-redirect' : 'production-redirect-only';
|
||||
}
|
||||
if (status === 404 || status === 410) {
|
||||
if (redirect) return 'local-redirect-production-absent';
|
||||
return hasLocal ? (local.placeholder ? 'local-placeholder-production-absent' : 'local-only-production-absent')
|
||||
: 'discovered-production-absent';
|
||||
}
|
||||
if (status >= 200 && status < 300) return hasLocal ? 'both-route-present-content-unverified' : 'live-only-verified';
|
||||
if (status >= 200 && status < 300) {
|
||||
if (redirect) return 'local-redirect-production-live';
|
||||
return hasLocal ? 'both-route-present-content-unverified' : 'live-only-verified';
|
||||
}
|
||||
const discovered = production?.sources?.some((s) => /^(sitemap|link|form|alternate):/.test(s));
|
||||
if (discovered) return hasLocal ? 'both-discovered-http-unverified' : 'live-discovered-local-missing';
|
||||
if (local) return local.generated ? 'local-only-production-unverified' : 'local-source-not-generated';
|
||||
if (discovered) {
|
||||
if (redirect) return 'local-redirect-production-unverified';
|
||||
return hasLocal ? 'both-discovered-http-unverified' : 'live-discovered-local-missing';
|
||||
}
|
||||
if (local) return redirect ? 'local-redirect-production-unverified'
|
||||
: local.generated ? 'local-only-production-unverified' : 'local-source-not-generated';
|
||||
return 'verification-inconclusive';
|
||||
}
|
||||
|
||||
@@ -89,6 +105,7 @@ export function summarize(routes) {
|
||||
return {
|
||||
routes: routes.length,
|
||||
localGenerated: routes.filter(generated).length,
|
||||
localRedirects: routes.filter((r) => r.local?.redirect).length,
|
||||
sitemap: routes.filter(hasSitemap).length,
|
||||
sitemapLocalOverlap: routes.filter((r) => hasSitemap(r) && generated(r)).length,
|
||||
sitemapLocalMissing: routes.filter((r) => hasSitemap(r) && !generated(r)).length,
|
||||
@@ -194,12 +211,25 @@ async function localInventory() {
|
||||
const route = file.replace(/^dist/, '').replace(/\/index\.html$/, '/').replace(/\.html$/, '').replace(/\/$/, '') || '/';
|
||||
records.set(route, { ...records.get(route), route, generated: true, ...metadata(await fs.readFile(path.join(ROOT, file), 'utf8')) });
|
||||
}
|
||||
const redirects = validateRedirects(
|
||||
JSON.parse(await fs.readFile(path.join(ROOT, 'src/data/redirects.json'), 'utf8')),
|
||||
[...records.values()].filter(({ generated }) => generated).map(({ route }) => route)
|
||||
);
|
||||
for (const { from, to, status, reason } of redirects) {
|
||||
records.set(from, {
|
||||
...records.get(from), route: from, generated: false, eligible: false,
|
||||
sitemapEligible: false, redirect: { to, status, reason }
|
||||
});
|
||||
}
|
||||
return [...records.values()].sort((a, b) => compare(a.route, b.route));
|
||||
}
|
||||
|
||||
async function sourceFingerprint() {
|
||||
const hash = createHash('sha256');
|
||||
for (const file of (await fg(['src/**/*', 'public/**/*', 'astro.config.mjs', 'package-lock.json'], { cwd: ROOT, onlyFiles: true })).sort(compare)) {
|
||||
for (const file of (await fg([
|
||||
'src/**/*', 'public/**/*', 'astro.config.mjs', 'package.json', 'package-lock.json',
|
||||
'tools/audit-routes.mjs', 'tools/generate-redirects.mjs'
|
||||
], { cwd: ROOT, onlyFiles: true })).sort(compare)) {
|
||||
hash.update(file + '\0'); hash.update(await fs.readFile(path.join(ROOT, file))); hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
@@ -342,12 +372,14 @@ async function main() {
|
||||
if (check || offline) {
|
||||
evidenceFile ??= await latestEvidence();
|
||||
const saved = JSON.parse(await fs.readFile(evidenceFile, 'utf8'));
|
||||
if (saved.schemaVersion !== 1 || saved.baselineSha256 !== baselineSha256) throw new Error('Saved evidence schema/baseline does not match.');
|
||||
if (![1, 2].includes(saved.schemaVersion) || saved.baselineSha256 !== baselineSha256) throw new Error('Saved evidence schema/baseline does not match.');
|
||||
discovery = { ...saved.discovery, production: saved.routes.flatMap((r) => r.production ? [r.production] : []) };
|
||||
const routes = reconcile(local, discovery.production);
|
||||
if (check) {
|
||||
if (saved.schemaVersion !== 2) throw new Error('Saved evidence predates redirect-aware reconciliation. Run --offline to produce a schema 2 report.');
|
||||
if (fingerprint !== saved.sourceFingerprint) throw new Error('Application source/assets changed since evidence capture. Build, then run --offline to produce a new reconciliation.');
|
||||
for (const [key, expected] of Object.entries({ routes, summary: summarize(routes), baselineChanges: baselineChanges(baseline, routes) })) {
|
||||
const localRedirects = local.filter(({ redirect }) => redirect).map(({ route, redirect }) => ({ route, ...redirect }));
|
||||
for (const [key, expected] of Object.entries({ routes, localRedirects, summary: summarize(routes), baselineChanges: baselineChanges(baseline, routes) })) {
|
||||
if (JSON.stringify(saved[key]) !== JSON.stringify(expected)) throw new Error(`Offline ${key} mismatch. Rebuild/reconcile; saved evidence was not changed.`);
|
||||
}
|
||||
if (discovery.warnings.length) throw new Error(`Saved production discovery is incomplete: ${discovery.warnings.join('; ')}`);
|
||||
@@ -360,12 +392,14 @@ async function main() {
|
||||
const generatedAt = now();
|
||||
const { production: _production, ...discoveryDetails } = discovery;
|
||||
const report = {
|
||||
schemaVersion: 1, generatedAt, mode: offline ? 'offline-reconciliation' : 'fresh-discovery',
|
||||
schemaVersion: 2, generatedAt, mode: offline ? 'offline-reconciliation' : 'fresh-discovery',
|
||||
gitHead: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(),
|
||||
sourceFingerprint: fingerprint, baselineFile: BASELINE, baselineSha256,
|
||||
limitations: ['Route presence is not content or visual parity.', 'Unrequested sitemap URLs are not HTTP-verified.', 'Existing dist required; rebuild before capture if application files changed.', 'Fragments are preserved references, not separately fetched documents.', 'Query variants are retained; automatic requests do not submit search terms.', 'Fresh requests record redirects without following them; inherited observations generally recorded final responses. A status difference alone is not evidence of a newly introduced redirect.'],
|
||||
limitations: ['Route presence is not content or visual parity.', 'Unrequested sitemap URLs are not HTTP-verified.', 'Existing dist required; rebuild before capture if application files changed.', 'Fragments are preserved references, not separately fetched documents.', 'Query variants are retained; automatic requests do not submit search terms.', 'Fresh requests record redirects without following them; inherited observations generally recorded final responses. A status difference alone is not evidence of a newly introduced redirect.', 'Local redirects are repository definitions; this report does not establish activation on a deployed host.'],
|
||||
localUtilities: (await fg(['dist/robots.txt', 'dist/sitemap.xml'], { cwd: ROOT })).map((f) => '/' + f.slice(5)).sort(compare),
|
||||
discovery: discoveryDetails, summary: summarize(routes), baselineChanges: baselineChanges(baseline, routes), routes
|
||||
discovery: discoveryDetails,
|
||||
localRedirects: local.filter(({ redirect }) => redirect).map(({ route, redirect }) => ({ route, ...redirect })),
|
||||
summary: summarize(routes), baselineChanges: baselineChanges(baseline, routes), routes
|
||||
};
|
||||
const name = `route-reconciliation-${generatedAt.slice(0, 10)}${offline ? '-offline' : ''}.json`;
|
||||
let output = path.join(REPORTS, name);
|
||||
|
||||
@@ -70,7 +70,13 @@ test('a generated placeholder is not misrepresented as already unpublished', ()
|
||||
test('external canonicals remain live pages; redirects remain redirects', () => {
|
||||
assert.equal(classify({ generated: true }, { http: { status: 200, canonical: 'https://publisher.example/original' } }), 'both-route-present-content-unverified');
|
||||
assert.equal(classify(null, { http: { status: 301, location: ORIGIN + '/aba-therapy' } }), 'production-redirect-only');
|
||||
assert.equal(classify({ generated: true }, { http: { status: 302 } }), 'local-and-production-redirect');
|
||||
assert.equal(classify({ generated: true }, { http: { status: 302 } }), 'local-generated-production-redirect');
|
||||
const redirect = { redirect: { to: '/aba-therapy', status: 301 } };
|
||||
assert.equal(classify(redirect, { http: { status: 301, location: ORIGIN + '/aba-therapy' } }), 'local-and-production-redirect');
|
||||
assert.equal(classify(redirect, { http: { status: 301, location: ORIGIN + '/other' } }), 'local-production-redirect-conflict');
|
||||
assert.equal(classify(redirect, { http: { status: 200 } }), 'local-redirect-production-live');
|
||||
assert.equal(classify(redirect, { http: { status: 404 } }), 'local-redirect-production-absent');
|
||||
assert.equal(classify(redirect, null), 'local-redirect-production-unverified');
|
||||
});
|
||||
|
||||
test('reconciliation is stable and distinguishes listed, verified and generated counts', () => {
|
||||
@@ -84,6 +90,7 @@ test('reconciliation is stable and distinguishes listed, verified and generated
|
||||
assert.deepEqual(routes, reconcile([...local].reverse(), [...production].reverse()));
|
||||
const summary = summarize(routes);
|
||||
assert.equal(summary.localGenerated, 2);
|
||||
assert.equal(summary.localRedirects, 0);
|
||||
assert.equal(summary.sitemap, 2);
|
||||
assert.equal(summary.sitemapLocalOverlap, 1);
|
||||
assert.equal(summary.sitemapLocalMissing, 1);
|
||||
@@ -91,6 +98,18 @@ test('reconciliation is stable and distinguishes listed, verified and generated
|
||||
assert.deepEqual(JSON.parse(compactJson({ routes, summary })), { routes, summary });
|
||||
});
|
||||
|
||||
test('redirect definitions receive reproducible local dispositions without becoming generated pages', () => {
|
||||
const redirect = { route: '/library/page/1', generated: false, redirect: { to: '/library', status: 301 } };
|
||||
const routes = reconcile([redirect, { route: '/library', generated: true }], [
|
||||
{ route: '/library/page/1', sources: ['link:/library/page/2'], http: null }
|
||||
]);
|
||||
const alias = routes.find(({ route }) => route === '/library/page/1');
|
||||
assert.equal(alias.local.redirect.to, '/library');
|
||||
assert.equal(alias.disposition, 'local-redirect-production-unverified');
|
||||
assert.equal(summarize(routes).localRedirects, 1);
|
||||
assert.equal(summarize(routes).localGenerated, 1);
|
||||
});
|
||||
|
||||
test('baseline changes preserve original observations and never compare unknown values', () => {
|
||||
const baseline = { production: [
|
||||
{ route: '/old', sources: ['sitemap:/sitemap.xml'], http: { status: 200, title: 'Old title', canonical: null } },
|
||||
|
||||
@@ -1,15 +1,95 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const redirects = [
|
||||
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' }
|
||||
];
|
||||
await fs.writeFile(new URL('../src/data/redirects.json', import.meta.url), `${JSON.stringify(redirects, null, 2)}\n`);
|
||||
await fs.writeFile(new URL('../reports/redirect-map.csv', import.meta.url),
|
||||
`from,to,status,reason\n${redirects.map((r) => `"${r.from}","${r.to}",${r.status},"${r.reason}"`).join('\n')}\n`);
|
||||
await fs.writeFile(new URL('../reports/nginx-rewrites.conf', import.meta.url),
|
||||
`${redirects.map((r) => `rewrite ^${r.from}$ ${r.to} permanent;`).join('\n')}\n`);
|
||||
{ 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;
|
||||
});
|
||||
}
|
||||
+42
-10
@@ -15,6 +15,7 @@ import { createPublicationManifest, renderSitemap, robotsFor } from '../src/util
|
||||
import { createLibrarySearchPublication } from '../src/utils/search-publication.ts';
|
||||
import { createAuthorResolver } from '../src/utils/authors.ts';
|
||||
import { authorPageRoute, createAuthorArchivePublications } from '../src/utils/author-archives.ts';
|
||||
import { redirectArtifacts, redirects as approvedRedirects, validateRedirects } from './generate-redirects.mjs';
|
||||
|
||||
const project = fileURLToPath(new URL('../', import.meta.url));
|
||||
const site = JSON.parse(await readFile(path.join(project, 'src/data/site.json'), 'utf8')).url;
|
||||
@@ -422,21 +423,52 @@ test('numbered pages keep full-locale search indexes and hide cards plus pager t
|
||||
}
|
||||
});
|
||||
|
||||
test('page-1 redirect registry preserves service aliases and defines only three Library aliases', async () => {
|
||||
test('redirect registry preserves service aliases and defines all six page-one aliases', async () => {
|
||||
const redirects = JSON.parse(await readFile(path.join(project, 'src/data/redirects.json'), 'utf8'));
|
||||
assert.deepEqual(redirects.slice(0, 3).map(({ from, to, status }) => ({ from, to, status })), [
|
||||
const expected = [
|
||||
{ from: '/aba', to: '/aba-therapy', status: 301 },
|
||||
{ from: '/autismevaluations', to: '/autism-evaluations', status: 301 },
|
||||
{ from: '/learnersocialclub', to: '/learner-social-club', status: 301 }
|
||||
]);
|
||||
assert.deepEqual(redirects.slice(3).map(({ from, to, status }) => ({ from, to, status })), [
|
||||
{ from: '/learnersocialclub', to: '/learner-social-club', status: 301 },
|
||||
{ from: '/library/page/1', to: '/library', status: 301 },
|
||||
{ from: '/es/library/page/1', to: '/es/library', status: 301 },
|
||||
{ from: '/ar/library/page/1', to: '/ar/library', status: 301 }
|
||||
]);
|
||||
assert.equal(new Set(redirects.map(({ from }) => from)).size, redirects.length);
|
||||
const rewrites = await readFile(path.join(project, 'reports/nginx-rewrites.conf'), 'utf8');
|
||||
for (const { from, to } of redirects.slice(3)) assert.ok(rewrites.includes(`rewrite ^${from}$ ${to} permanent;`));
|
||||
{ from: '/ar/library/page/1', to: '/ar/library', status: 301 },
|
||||
{ from: '/library/author/rula-diab/page/1', to: '/library/author/rula-diab', status: 301 },
|
||||
{ from: '/es/library/author/rula-diab/page/1', to: '/es/library/author/rula-diab', status: 301 },
|
||||
{ from: '/ar/library/author/rula-diab/page/1', to: '/ar/library/author/rula-diab', status: 301 }
|
||||
];
|
||||
assert.deepEqual(redirects.map(({ from, to, status }) => ({ from, to, status })), expected);
|
||||
assert.deepEqual(redirects, approvedRedirects);
|
||||
const generatedRoutes = routeManifest.filter(({ eligible }) => eligible).map(({ route }) => route).concat('/search');
|
||||
assert.doesNotThrow(() => validateRedirects(redirects, generatedRoutes));
|
||||
const rendered = redirectArtifacts(redirects);
|
||||
assert.equal(await readFile(path.join(project, 'src/data/redirects.json'), 'utf8'), rendered.json);
|
||||
assert.equal(await readFile(path.join(project, 'reports/redirect-map.csv'), 'utf8'), rendered.csv);
|
||||
assert.equal(await readFile(path.join(project, 'reports/nginx-rewrites.conf'), 'utf8'), rendered.nginx);
|
||||
assert.ok(!redirects.some(({ from }) => /\/page\/[2-9]\d*$/.test(from)));
|
||||
});
|
||||
|
||||
test('redirect validation rejects duplicate, unsafe, chained, cyclic and non-generated routes', () => {
|
||||
const redirect = (from, to, overrides = {}) => ({ from, to, status: 301, reason: 'Fixture', ...overrides });
|
||||
assert.throws(() => validateRedirects([redirect('/old', '/target'), redirect('/old', '/other')]), /Duplicate redirect source/);
|
||||
assert.throws(() => validateRedirects([redirect('/same', '/same')]), /Self-redirect/);
|
||||
assert.throws(() => validateRedirects([redirect('/one', '/two'), redirect('/two', '/three')]), /chain or cycle/);
|
||||
assert.throws(() => validateRedirects([redirect('/one', '/two'), redirect('/two', '/one')]), /chain or cycle/);
|
||||
assert.throws(() => validateRedirects([redirect('/old/', '/target')]), /not normalized/);
|
||||
assert.throws(() => validateRedirects([redirect('/es/old', '/target')]), /changes locale/);
|
||||
assert.throws(() => validateRedirects([redirect('/old', '/target', { status: 302 })]), /HTTP 301/);
|
||||
assert.throws(() => validateRedirects([redirect('/old', '/missing')], ['/target']), /target is not a generated route/);
|
||||
assert.throws(() => validateRedirects([redirect('/old', '/target')], ['/old', '/target']), /source collides/);
|
||||
});
|
||||
|
||||
test('redirect-only aliases generate no HTML, canonical, sitemap or hreflang entry', () => {
|
||||
for (const mode of ['staging', 'indexing']) {
|
||||
const { html, sitemap } = artifacts.get(mode);
|
||||
for (const { from, to } of approvedRedirects) {
|
||||
assert.ok(!html.has(from), from);
|
||||
assert.ok(html.has(to), to);
|
||||
assert.ok(!sitemap.includes(`<loc>${site}${from}</loc>`), from);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('/search is a noindex static Library-only compatibility route in both build modes', () => {
|
||||
|
||||
Reference in new issue
Block a user