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); + }); + }); +});