mirror of
https://github.com/quartz-community/alias-redirects.git
synced 2026-07-22 02:50:31 +00:00
feat: emit alias redirects matching the original filename casing on case-sensitive filesystems
This commit is contained in:
parent
39231fd8a3
commit
84a0a8533c
5 changed files with 469 additions and 38 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@
|
|||
"dependencies": [],
|
||||
"defaultOrder": 50,
|
||||
"defaultEnabled": true,
|
||||
"defaultOptions": {}
|
||||
"defaultOptions": {
|
||||
"enableCaseRedirects": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
|
|
|
|||
217
src/emitter.ts
217
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 `<!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);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
278
test/emitter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue