mirror of
https://github.com/quartz-community/alias-redirects.git
synced 2026-07-22 02:50:31 +00:00
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
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,
|
|
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,
|
|
ext: string,
|
|
content: string,
|
|
): Promise<FilePath> => {
|
|
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath;
|
|
const dir = path.dirname(pathToPage);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(pathToPage, content);
|
|
return pathToPage;
|
|
};
|
|
|
|
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[]) ?? []) {
|
|
const aliasTargetSlug = (
|
|
isRelativeURL(aliasTarget)
|
|
? path.normalize(path.join(ogSlug, "..", aliasTarget))
|
|
: aliasTarget
|
|
) as FullSlug;
|
|
|
|
emittedPaths.add(aliasTargetSlug);
|
|
const redirUrl = resolveRelative(aliasTargetSlug, ogSlug);
|
|
yield write(ctx, aliasTargetSlug, ".html", redirectHtml(ogSlug, redirUrl));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
};
|