Skip to content

Commit 3cd6ff8

Browse files
committed
fix(seo): redirect legacy comparison URLs to canonical pages
Google Search Console reported 404s for previously-indexed comparison URLs that no longer match the canonical shape: inverse order, family-name aliases (svelte->svelte5, vue->vue3, angular->angularRenaissance, etc.), and missing trailing slashes. The redirect generator only covered inverse pairs with a trailing slash, so those forms dropped to the 404 fallback. Generate the full alias x order x slash matrix (686 rules) so every legacy form 301s to its canonical page, extracted into a testable buildRedirectLines().
1 parent ef97107 commit 3cd6ff8

3 files changed

Lines changed: 125 additions & 17 deletions

File tree

build/generateRedirectsVitePlugin.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import type { Plugin, ResolvedConfig } from "vite";
4-
import { frameworkVersions } from "../frameworks.ts";
5-
6-
// canonicalPairs() uses the @frameworks alias which resolves at runtime via
7-
// the alias configured in svelte.config.js. For the Vite plugin (Node context)
8-
// we resolve it directly from the raw frameworks import, mirroring the same logic.
9-
function buildCanonicalPairs(): [string, string][] {
10-
const ids = frameworkVersions.map((f) => f.id);
11-
const pairs: [string, string][] = [];
12-
for (let i = 0; i < ids.length; i++) {
13-
for (let j = i + 1; j < ids.length; j++) {
14-
pairs.push([ids[i], ids[j]]);
15-
}
16-
}
17-
return pairs;
18-
}
4+
import { buildRedirectLines } from "./redirects.ts";
195

206
export default function generateRedirectsVitePlugin(): Plugin {
217
let resolvedOutDir = "dist";
@@ -32,8 +18,7 @@ export default function generateRedirectsVitePlugin(): Plugin {
3218
// Skip the SSR environment; write only once after the client build.
3319
if (this.environment?.name !== "client") return;
3420

35-
const pairs = buildCanonicalPairs();
36-
const lines = pairs.map(([a, b]) => `/compare/${b}-vs-${a}/ /compare/${a}-vs-${b}/ 301`);
21+
const lines = buildRedirectLines();
3722
const outPath = path.join(resolvedOutDir, "_redirects");
3823
try {
3924
await fs.mkdir(resolvedOutDir, { recursive: true });

build/redirects.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect } from "vitest";
2+
import { buildRedirectLines } from "./redirects";
3+
import { canonicalPairs } from "../src/lib/comparisonPairs";
4+
5+
const lines = buildRedirectLines();
6+
7+
/** source path -> redirect target, parsed from "<source> <target> 301". */
8+
const targetBySource = new Map(
9+
lines.map((line) => {
10+
const [source, target] = line.split(" ");
11+
return [source, target] as const;
12+
}),
13+
);
14+
15+
describe("buildRedirectLines", () => {
16+
// The exact URLs Google Search Console reported as 404 (Introuvable).
17+
it.each([
18+
["/compare/react-vs-svelte5", "/compare/svelte5-vs-react/"], // inverse, no slash
19+
["/compare/svelte4-vs-svelte5", "/compare/svelte5-vs-svelte4/"], // inverse, no slash
20+
["/compare/aurelia1-vs-aurelia2", "/compare/aurelia2-vs-aurelia1/"], // inverse, no slash
21+
["/compare/emberOctane-vs-emberPolaris", "/compare/emberPolaris-vs-emberOctane/"], // inverse, no slash
22+
["/compare/react-vs-vue", "/compare/react-vs-vue3/"], // family alias, no slash
23+
["/compare/angular-vs-solid", "/compare/angularRenaissance-vs-solid/"], // family alias, no slash
24+
["/compare/react-vs-svelte", "/compare/svelte5-vs-react/"], // clean form of react-vs-svelte;
25+
])("301s the legacy URL %s → %s", (source, target) => {
26+
expect(targetBySource.get(source)).toBe(target);
27+
});
28+
29+
it("still covers the original inverse-with-slash redirects", () => {
30+
expect(targetBySource.get("/compare/react-vs-svelte5/")).toBe("/compare/svelte5-vs-react/");
31+
});
32+
33+
it("emits both a slashed and unslashed source for every redirect target", () => {
34+
// react-vs-vue (family) should exist with and without a trailing slash.
35+
expect(targetBySource.has("/compare/react-vs-vue")).toBe(true);
36+
expect(targetBySource.has("/compare/react-vs-vue/")).toBe(true);
37+
});
38+
39+
it("never redirects a canonical page onto itself (would shadow the real asset)", () => {
40+
const canonicalSources = new Set(
41+
canonicalPairs().flatMap(([a, b]) => [`/compare/${a}-vs-${b}/`, `/compare/${a}-vs-${b}`]),
42+
);
43+
for (const source of targetBySource.keys()) {
44+
expect(canonicalSources.has(source)).toBe(false);
45+
}
46+
});
47+
48+
it("has no duplicate source paths", () => {
49+
const sources = lines.map((l) => l.split(" ")[0]);
50+
expect(new Set(sources).size).toBe(sources.length);
51+
});
52+
53+
it("every target is a real canonical comparison page", () => {
54+
const canonicalTargets = new Set(canonicalPairs().map(([a, b]) => `/compare/${a}-vs-${b}/`));
55+
for (const target of targetBySource.values()) {
56+
expect(canonicalTargets.has(target)).toBe(true);
57+
}
58+
});
59+
60+
it("stays well under Cloudflare's 2,100 static-redirect limit", () => {
61+
expect(lines.length).toBeLessThan(2000);
62+
});
63+
});

build/redirects.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { frameworkVersions, frameworks } from "../frameworks.ts";
2+
3+
/**
4+
* Every non-canonical URL form that should 301 to a canonical comparison page.
5+
*
6+
* The canonical page for a pair is `/compare/{a}-vs-{b}/`, where `a` precedes
7+
* `b` in `frameworkVersions` order and both are concrete version ids. Google has
8+
* historically indexed URLs that no longer match that exact shape:
9+
* - inverse order (`b-vs-a`),
10+
* - family-name aliases (`svelte`→`svelte5`, `vue`→`vue3`, `angular`→
11+
* `angularRenaissance`, `ember`→`emberPolaris`, `aurelia`→`aurelia2`),
12+
* - and any of the above without the trailing slash.
13+
* Each of those 404s today because the redirect file only covered inverse pairs
14+
* with a trailing slash. This enumerates the full alias × order × slash matrix so
15+
* those legacy URLs 301 to the live page instead of dropping to the 404 fallback.
16+
*
17+
* Canonical URLs themselves are never emitted as a source: a `_redirects` rule
18+
* shadows the matching static asset, so redirecting a real page would break it.
19+
* The bare (no-slash) canonical form is left to Cloudflare's own 308 handling.
20+
*/
21+
export function buildRedirectLines(): string[] {
22+
const versions = frameworkVersions.map((f) => f.id);
23+
24+
// A version id plus any family whose latest stable release is that version,
25+
// e.g. svelte5 → ["svelte5", "svelte"]; react → ["react"] (family id === version id).
26+
const aliasesFor = (versionId: string): string[] => {
27+
const set = new Set<string>([versionId]);
28+
for (const family of frameworks) {
29+
if (family.latestStable === versionId) set.add(family.id);
30+
}
31+
return [...set];
32+
};
33+
34+
const lines: string[] = [];
35+
const seenSources = new Set<string>();
36+
37+
for (let i = 0; i < versions.length; i++) {
38+
for (let j = i + 1; j < versions.length; j++) {
39+
const a = versions[i];
40+
const b = versions[j];
41+
const canonicalSlug = `${a}-vs-${b}`;
42+
const canonicalPath = `/compare/${canonicalSlug}/`;
43+
44+
for (const x of aliasesFor(a)) {
45+
for (const y of aliasesFor(b)) {
46+
for (const slug of [`${x}-vs-${y}`, `${y}-vs-${x}`]) {
47+
if (slug === canonicalSlug) continue; // never shadow the real page
48+
for (const source of [`/compare/${slug}/`, `/compare/${slug}`]) {
49+
if (seenSources.has(source)) continue;
50+
seenSources.add(source);
51+
lines.push(`${source} ${canonicalPath} 301`);
52+
}
53+
}
54+
}
55+
}
56+
}
57+
}
58+
59+
return lines;
60+
}

0 commit comments

Comments
 (0)