diff --git a/README.md b/README.md
index d68568a..5bce5a6 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
index 20daa7b..16d85f3 100644
--- a/package.json
+++ b/package.json
@@ -76,7 +76,9 @@
"dependencies": [],
"defaultOrder": 50,
"defaultEnabled": true,
- "defaultOptions": {}
+ "defaultOptions": {
+ "enableCaseRedirects": true
+ }
},
"engines": {
"node": ">=22",
diff --git a/src/emitter.ts b/src/emitter.ts
index d79af7e..85bc61c 100644
--- a/src/emitter.ts
+++ b/src/emitter.ts
@@ -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 `
+
+
+${title}
+
+
+
+
+
+
+`;
+}
+
+/**
+ * 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 {
+ 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) {
const ogSlug = simplifySlug(file.data.slug! as FullSlug);
for (const aliasTarget of ((file.data as Record).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",
- `
-
-
-
- ${ogSlug}
-
-
-
-
-
-
- `,
- );
+ 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) {
+ const data = file.data as Record;
+ 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> = (opts) => {
+ const options = { ...defaultOptions, ...opts };
+
+ return {
+ name: "AliasRedirects",
+ async *emit(ctx, content) {
+ const emittedPaths = new Set();
+ 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();
+ 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);
+ }
+ }
+ }
+ },
+ };
+};
diff --git a/src/index.ts b/src/index.ts
index 2aed171..34d51c3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,3 @@
-export { AliasRedirects } from "./emitter.js";
+export { AliasRedirects, _resetFsDetectionCache } from "./emitter.js";
export type { QuartzEmitterPlugin } from "@quartz-community/types";
diff --git a/test/emitter.test.ts b/test/emitter.test.ts
new file mode 100644
index 0000000..153a59c
--- /dev/null
+++ b/test/emitter.test.ts
@@ -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).relativePath = opts.relativePath as FilePath;
+ if (opts.aliases) {
+ (file.data as Record).aliases = opts.aliases;
+ }
+ return file;
+}
+
+function makeContent(file: VFile): ProcessedContent {
+ const tree: Root = { type: "root", children: [] };
+ return [tree, file];
+}
+
+async function collectEmitted(
+ gen: AsyncGenerator | Promise,
+): Promise {
+ if (Symbol.asyncIterator in (gen as object)) {
+ const results: FilePath[] = [];
+ for await (const fp of gen as AsyncGenerator) {
+ results.push(fp);
+ }
+ return results;
+ }
+ return gen as Promise;
+}
+
+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('');
+ });
+
+ 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('');
+ });
+
+ 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("");
+ expect(html).toContain('');
+ expect(html).toContain('');
+ expect(html).toContain('');
+ 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,
+ );
+
+ 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,
+ );
+
+ 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,
+ );
+
+ expect(emitted).toHaveLength(0);
+ });
+ });
+});