feat: emit alias redirects matching the original filename casing on case-sensitive filesystems

This commit is contained in:
saberzero1 2026-06-09 19:47:26 +02:00
parent 39231fd8a3
commit 84a0a8533c
No known key found for this signature in database
5 changed files with 469 additions and 38 deletions

View file

@ -1,6 +1,6 @@
# @quartz-community/alias-redirects
Generates HTML redirect pages for frontmatter aliases, so old URLs redirect to the canonical page.
Generates HTML redirect pages for frontmatter aliases and case-preserving URLs, so old URLs redirect to the canonical page.
## Installation
@ -18,7 +18,9 @@ plugins:
## Configuration
This plugin has no configuration options.
| Option | Type | Default | Description |
| --------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `enableCaseRedirects` | `boolean` | `true` | Automatically generate redirect pages for URLs that changed casing due to v5's lowercase normalization. |
## Documentation

View file

@ -76,7 +76,9 @@
"dependencies": [],
"defaultOrder": 50,
"defaultEnabled": true,
"defaultOptions": {}
"defaultOptions": {
"enableCaseRedirects": true
}
},
"engines": {
"node": ">=22",

View file

@ -2,9 +2,36 @@ import path from "node:path";
import fs from "node:fs/promises";
import type { QuartzEmitterPlugin, BuildCtx, FilePath, FullSlug } from "@quartz-community/types";
import { joinSegments } from "@quartz-community/types";
import { simplifySlug, resolveRelative, isRelativeURL } from "@quartz-community/utils";
import {
simplifySlug,
resolveRelative,
isRelativeURL,
getFileExtension,
stripSlashes,
endsWith,
} from "@quartz-community/utils";
import type { VFile } from "vfile";
interface Options {
/**
* When enabled, automatically generates redirect pages for the original
* (case-preserving) URL of any file whose path changed due to v5's
* lowercase slug normalization. This ensures previously indexed URLs
* (e.g. `/Diary/My-Note`) redirect to the new canonical lowercase URL
* (e.g. `/diary/my-note`) with proper SEO signals.
*
* Has no effect on case-insensitive filesystems (macOS, Windows) where
* the server already resolves either casing to the same file.
*
* @default true
*/
enableCaseRedirects: boolean;
}
const defaultOptions: Options = {
enableCaseRedirects: true,
};
const write = async (
ctx: BuildCtx,
slug: FullSlug,
@ -18,7 +45,109 @@ const write = async (
return pathToPage;
};
async function* processFile(ctx: BuildCtx, file: VFile) {
function redirectHtml(title: string, redirectUrl: string): string {
return `<!DOCTYPE html>
<html lang="en-us">
<head>
<title>${title}</title>
<link rel="canonical" href="${redirectUrl}">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=${redirectUrl}">
</head>
</html>
`;
}
/**
* Replicate the slug transforms from `slugifyPath` but preserve case.
* This matches `@quartz-community/utils` `slugifyPath()` exactly, minus
* the `.toLowerCase()` call.
*/
function slugifyPathPreserveCase(s: string): string {
return s
.split("/")
.map((segment) =>
segment
.replace(/\s/g, "-")
.replace(/&/g, "-and-")
.replace(/%/g, "-percent")
.replace(/\?/g, "")
.replace(/#/g, ""),
)
.join("/")
.replace(/\/$/, "");
}
/**
* Compute the slug that `slugifyFilePath` would produce, but without
* lowercasing. Used to find the original-case path for redirect generation.
*/
function slugifyFilePathPreserveCase(fp: FilePath): string {
fp = stripSlashes(fp) as FilePath;
const ext = getFileExtension(fp);
const withoutFileExt = fp.replace(new RegExp((ext ?? "") + "$"), "");
const finalExt = [".md", ".html", undefined].includes(ext) ? "" : (ext ?? "");
let slug = slugifyPathPreserveCase(withoutFileExt);
if (endsWith(slug, "_index")) {
slug = slug.replace(/_index$/, "index");
}
const segments = slug.split("/");
if (segments.length >= 2 && segments[segments.length - 1] === segments[segments.length - 2]) {
segments[segments.length - 1] = "index";
slug = segments.join("/");
}
return slug + finalExt;
}
/**
* Detect whether the filesystem is case-insensitive by attempting to
* stat the output directory with an altered-case path. Memoized per
* build since the filesystem doesn't change mid-build.
*/
let _fsCaseSensitive: boolean | undefined;
async function isFsCaseSensitive(outputDir: string): Promise<boolean> {
if (_fsCaseSensitive !== undefined) return _fsCaseSensitive;
try {
// Create a probe file with known casing, then check if the
// alternate casing resolves to the same inode.
const probeDir = path.join(outputDir, ".quartz-case-probe");
await fs.mkdir(probeDir, { recursive: true });
const probeLower = path.join(probeDir, "a");
const probeUpper = path.join(probeDir, "A");
await fs.writeFile(probeLower, "");
try {
await fs.access(probeUpper);
// If "A" is accessible after writing "a", the FS is case-insensitive
// But we need to check they're the same file (not two different files)
const statLower = await fs.stat(probeLower);
const statUpper = await fs.stat(probeUpper);
_fsCaseSensitive = statLower.ino !== statUpper.ino;
} catch {
// "A" not found → case-sensitive
_fsCaseSensitive = true;
}
// Clean up probe files
await fs.rm(probeDir, { recursive: true, force: true });
} catch {
// If detection fails, assume case-sensitive (safer default: generates
// redirect files even if they might not be needed)
_fsCaseSensitive = true;
}
return _fsCaseSensitive;
}
/** Reset filesystem detection cache (used in tests). */
export function _resetFsDetectionCache(): void {
_fsCaseSensitive = undefined;
}
async function* processAliases(ctx: BuildCtx, file: VFile, emittedPaths: Set<string>) {
const ogSlug = simplifySlug(file.data.slug! as FullSlug);
for (const aliasTarget of ((file.data as Record<string, unknown>).aliases as string[]) ?? []) {
@ -28,40 +157,60 @@ async function* processFile(ctx: BuildCtx, file: VFile) {
: aliasTarget
) as FullSlug;
emittedPaths.add(aliasTargetSlug);
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug);
yield write(
ctx,
aliasTargetSlug,
".html",
`
<!DOCTYPE html>
<html lang="en-us">
<head>
<title>${ogSlug}</title>
<link rel="canonical" href="${redirUrl}">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=${redirUrl}">
</head>
</html>
`,
);
yield write(ctx, aliasTargetSlug, ".html", redirectHtml(ogSlug, redirUrl));
}
}
export const AliasRedirects: QuartzEmitterPlugin = () => ({
name: "AliasRedirects",
async *emit(ctx, content) {
for (const [_tree, file] of content) {
yield* processFile(ctx, file);
}
},
async *partialEmit(ctx, _content, _resources, changeEvents) {
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue;
if (changeEvent.type === "add" || changeEvent.type === "change") {
yield* processFile(ctx, changeEvent.file);
async function* processCaseRedirects(ctx: BuildCtx, file: VFile, emittedPaths: Set<string>) {
const data = file.data as Record<string, unknown>;
const relativePath = data.relativePath as FilePath | undefined;
const slug = data.slug as FullSlug | undefined;
if (!relativePath || !slug) return;
const caseSensitive = await isFsCaseSensitive(ctx.argv.output);
if (!caseSensitive) return;
const casePreservedSlug = slugifyFilePathPreserveCase(relativePath);
if (casePreservedSlug === slug) return;
const simplifiedSlug = simplifySlug(slug);
const simplifiedCasePreserved = casePreservedSlug.replace(/\/index$/, "/");
if (emittedPaths.has(casePreservedSlug) || emittedPaths.has(simplifiedCasePreserved)) return;
emittedPaths.add(casePreservedSlug);
const redirUrl = resolveRelative(casePreservedSlug as FullSlug, simplifiedSlug);
yield write(ctx, casePreservedSlug as FullSlug, ".html", redirectHtml(simplifiedSlug, redirUrl));
}
export const AliasRedirects: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const options = { ...defaultOptions, ...opts };
return {
name: "AliasRedirects",
async *emit(ctx, content) {
const emittedPaths = new Set<string>();
for (const [_tree, file] of content) {
yield* processAliases(ctx, file, emittedPaths);
if (options.enableCaseRedirects) {
yield* processCaseRedirects(ctx, file, emittedPaths);
}
}
}
},
});
},
async *partialEmit(ctx, _content, _resources, changeEvents) {
const emittedPaths = new Set<string>();
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue;
if (changeEvent.type === "add" || changeEvent.type === "change") {
yield* processAliases(ctx, changeEvent.file, emittedPaths);
if (options.enableCaseRedirects) {
yield* processCaseRedirects(ctx, changeEvent.file, emittedPaths);
}
}
}
},
};
};

View file

@ -1,3 +1,3 @@
export { AliasRedirects } from "./emitter.js";
export { AliasRedirects, _resetFsDetectionCache } from "./emitter.js";
export type { QuartzEmitterPlugin } from "@quartz-community/types";

278
test/emitter.test.ts Normal file
View file

@ -0,0 +1,278 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { VFile } from "vfile";
import type { Root } from "hast";
import type { BuildCtx, FilePath, FullSlug, ProcessedContent } from "@quartz-community/types";
import { AliasRedirects, _resetFsDetectionCache } from "../src/index.js";
function makeBuildCtx(outputDir: string): BuildCtx {
return {
buildId: "test-build",
argv: {
directory: "content",
verbose: false,
output: outputDir,
serve: false,
watch: false,
port: 8080,
wsPort: 3001,
},
cfg: {
configuration: {},
plugins: { transformers: [], filters: [], emitters: [], pageTypes: [] },
},
allSlugs: [],
allFiles: [],
incremental: false,
} as unknown as BuildCtx;
}
function makeFile(opts: { slug: string; relativePath: string; aliases?: string[] }): VFile {
const file = new VFile("");
file.data.slug = opts.slug as FullSlug;
(file.data as Record<string, unknown>).relativePath = opts.relativePath as FilePath;
if (opts.aliases) {
(file.data as Record<string, unknown>).aliases = opts.aliases;
}
return file;
}
function makeContent(file: VFile): ProcessedContent {
const tree: Root = { type: "root", children: [] };
return [tree, file];
}
async function collectEmitted(
gen: AsyncGenerator<FilePath> | Promise<FilePath[]>,
): Promise<FilePath[]> {
if (Symbol.asyncIterator in (gen as object)) {
const results: FilePath[] = [];
for await (const fp of gen as AsyncGenerator<FilePath>) {
results.push(fp);
}
return results;
}
return gen as Promise<FilePath[]>;
}
describe("AliasRedirects", () => {
let tmpDir: string;
let ctx: BuildCtx;
beforeEach(async () => {
_resetFsDetectionCache();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "alias-redirects-test-"));
ctx = makeBuildCtx(tmpDir);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("frontmatter alias redirects", () => {
it("generates redirect pages for frontmatter aliases", async () => {
const file = makeFile({
slug: "notes/my-note",
relativePath: "notes/my-note.md",
aliases: ["old-url"],
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(1);
const html = await fs.readFile(emitted[0]!, "utf-8");
expect(html).toContain('<meta http-equiv="refresh"');
expect(html).toContain('<link rel="canonical"');
expect(html).toContain('<meta name="robots" content="noindex">');
});
it("generates multiple redirect pages for multiple aliases", async () => {
const file = makeFile({
slug: "notes/my-note",
relativePath: "notes/my-note.md",
aliases: ["old-url-1", "old-url-2"],
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(2);
});
it("generates no redirect pages when no aliases exist", async () => {
const file = makeFile({
slug: "notes/my-note",
relativePath: "notes/my-note.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(0);
});
});
describe("case redirects", () => {
it("generates a redirect when relativePath has uppercase characters", async () => {
const file = makeFile({
slug: "diary/2026-01-01",
relativePath: "Diary/2026-01-01.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(1);
const writtenPath = emitted[0]!;
expect(writtenPath).toContain("Diary/2026-01-01.html");
const html = await fs.readFile(writtenPath, "utf-8");
expect(html).toContain('<meta http-equiv="refresh"');
expect(html).toContain('<link rel="canonical"');
expect(html).toContain('<meta name="robots" content="noindex">');
});
it("does not generate a redirect when path is already lowercase", async () => {
const file = makeFile({
slug: "diary/2026-01-01",
relativePath: "diary/2026-01-01.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(0);
});
it("does not generate a redirect when enableCaseRedirects is false", async () => {
const file = makeFile({
slug: "diary/2026-01-01",
relativePath: "Diary/2026-01-01.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects({ enableCaseRedirects: false });
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(0);
});
it("handles uppercase in multiple path segments", async () => {
const file = makeFile({
slug: "my-folder/my-note",
relativePath: "My Folder/My Note.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(1);
const writtenPath = emitted[0]!;
expect(writtenPath).toContain("My-Folder/My-Note.html");
});
it("does not duplicate when alias already covers the case-preserved path", async () => {
const file = makeFile({
slug: "diary/note",
relativePath: "Diary/Note.md",
aliases: ["Diary/Note"],
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(1);
});
it("generates both alias and case redirect when they target different paths", async () => {
const file = makeFile({
slug: "diary/note",
relativePath: "Diary/Note.md",
aliases: ["old-diary-note"],
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(2);
});
it("handles special characters in path alongside case changes", async () => {
const file = makeFile({
slug: "notes/q-and-a",
relativePath: "Notes/Q&A.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
expect(emitted).toHaveLength(1);
const writtenPath = emitted[0]!;
expect(writtenPath).toContain("Notes/Q-and-A.html");
});
});
describe("redirect HTML content", () => {
it("contains all required SEO elements", async () => {
const file = makeFile({
slug: "diary/note",
relativePath: "Diary/Note.md",
});
const content = [makeContent(file)];
const plugin = AliasRedirects();
const emitted = await collectEmitted(plugin.emit(ctx, content, {} as never));
const html = await fs.readFile(emitted[0]!, "utf-8");
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain('<html lang="en-us">');
expect(html).toContain('<link rel="canonical"');
expect(html).toContain('<meta name="robots" content="noindex">');
expect(html).toContain('<meta charset="utf-8">');
expect(html).toContain('content="0; url=');
});
});
describe("partialEmit", () => {
it("generates case redirects for added files", async () => {
const file = makeFile({
slug: "diary/note",
relativePath: "Diary/Note.md",
});
const plugin = AliasRedirects();
const changeEvents = [{ type: "add" as const, path: "Diary/Note.md" as FilePath, file }];
const emitted = await collectEmitted(
plugin.partialEmit!(ctx, [], {} as never, changeEvents) as AsyncGenerator<FilePath>,
);
expect(emitted).toHaveLength(1);
});
it("generates case redirects for changed files", async () => {
const file = makeFile({
slug: "diary/note",
relativePath: "Diary/Note.md",
});
const plugin = AliasRedirects();
const changeEvents = [{ type: "change" as const, path: "Diary/Note.md" as FilePath, file }];
const emitted = await collectEmitted(
plugin.partialEmit!(ctx, [], {} as never, changeEvents) as AsyncGenerator<FilePath>,
);
expect(emitted).toHaveLength(1);
});
it("does not generate redirects for deleted files", async () => {
const plugin = AliasRedirects();
const changeEvents = [
{ type: "delete" as const, path: "Diary/Note.md" as FilePath, file: undefined },
];
const emitted = await collectEmitted(
plugin.partialEmit!(ctx, [], {} as never, changeEvents) as AsyncGenerator<FilePath>,
);
expect(emitted).toHaveLength(0);
});
});
});