feat(seo): reconcile URLs and author page-one redirects

This commit is contained in:
DeCentN2Madness committed 2026-10-11 00:43:45 +00:00
1 parent 8646792d46
commit 8192ea71c4
11 files changed
+689 -77

No files matched your search

+44 -10
View File
@@ -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);
+20 -1
View File
@@ -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 } },
+88 -8
View File
@@ -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
View File
@@ -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', () => {