mirror of
https://github.com/quartz-community/utils.git
synced 2026-07-22 02:50:27 +00:00
isFolderPath() was checked on the raw input before slugifyFilePath() rewrote folder notes (e.g. "My Folder/My Folder" -> "my-folder/index"). Now checks isFolderPath on both the raw input and the slugified result, so the trailing slash is correctly emitted for folder note links.
619 lines
20 KiB
TypeScript
619 lines
20 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
simplifySlug,
|
|
joinSegments,
|
|
resolvePath,
|
|
resolveBasePath,
|
|
endsWith,
|
|
trimSuffix,
|
|
stripSlashes,
|
|
isFolderPath,
|
|
getAllSegmentPrefixes,
|
|
slugifyFilePath,
|
|
slugifyPath,
|
|
transformInternalLink,
|
|
transformLink,
|
|
normalizeHastElement,
|
|
} from "../src/path.js";
|
|
import type { FilePath, FullSlug, TransformOptions } from "../src/path.js";
|
|
import type { Element as HastElement } from "hast";
|
|
|
|
describe("simplifySlug", () => {
|
|
it("removes /index suffix", () => {
|
|
expect(simplifySlug("folder/index")).toBe("folder/");
|
|
});
|
|
|
|
it("returns / for index only", () => {
|
|
expect(simplifySlug("index")).toBe("/");
|
|
});
|
|
|
|
it("keeps non-index slugs unchanged", () => {
|
|
expect(simplifySlug("page")).toBe("page");
|
|
});
|
|
});
|
|
|
|
describe("joinSegments", () => {
|
|
it("joins simple segments", () => {
|
|
expect(joinSegments("a", "b", "c")).toBe("a/b/c");
|
|
});
|
|
|
|
it("handles leading slash", () => {
|
|
expect(joinSegments("/a", "b")).toBe("/a/b");
|
|
});
|
|
|
|
it("handles trailing slash", () => {
|
|
expect(joinSegments("a", "b/")).toBe("a/b/");
|
|
});
|
|
|
|
it("filters empty segments", () => {
|
|
expect(joinSegments("a", "", "c")).toBe("a/c");
|
|
});
|
|
});
|
|
|
|
describe("resolvePath", () => {
|
|
it("adds leading slash if missing", () => {
|
|
expect(resolvePath("blog/post")).toBe("/blog/post");
|
|
});
|
|
|
|
it("keeps existing leading slash", () => {
|
|
expect(resolvePath("/already")).toBe("/already");
|
|
});
|
|
});
|
|
|
|
describe("endsWith", () => {
|
|
it("matches exact suffix", () => {
|
|
expect(endsWith("folder/index", "index")).toBe(true);
|
|
});
|
|
|
|
it("matches exact string", () => {
|
|
expect(endsWith("index", "index")).toBe(true);
|
|
});
|
|
|
|
it("does not match partial suffix", () => {
|
|
expect(endsWith("myindex", "index")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("trimSuffix", () => {
|
|
it("removes matching suffix", () => {
|
|
expect(trimSuffix("folder/index", "index")).toBe("folder/");
|
|
});
|
|
|
|
it("keeps non-matching string", () => {
|
|
expect(trimSuffix("page", "index")).toBe("page");
|
|
});
|
|
});
|
|
|
|
describe("stripSlashes", () => {
|
|
it("removes leading and trailing slashes", () => {
|
|
expect(stripSlashes("/path/to/file/")).toBe("path/to/file");
|
|
});
|
|
|
|
it("removes only leading slash when specified", () => {
|
|
expect(stripSlashes("/path/", true)).toBe("path/");
|
|
});
|
|
});
|
|
|
|
describe("slugifyFilePath", () => {
|
|
const slugify = (fp: string) => slugifyFilePath(fp as FilePath);
|
|
|
|
describe("basic path handling", () => {
|
|
it("strips .md extension from markdown files", () => {
|
|
expect(slugify("content/post.md")).toBe("content/post");
|
|
});
|
|
|
|
it("strips .html extension", () => {
|
|
expect(slugify("content/post.html")).toBe("content/post");
|
|
});
|
|
|
|
it("preserves non-markdown extensions", () => {
|
|
expect(slugify("content/image.png")).toBe("content/image.png");
|
|
});
|
|
|
|
it("strips leading slash", () => {
|
|
expect(slugify("/content/post.md")).toBe("content/post");
|
|
});
|
|
|
|
it("handles top-level index.md", () => {
|
|
expect(slugify("index.md")).toBe("index");
|
|
});
|
|
|
|
it("handles nested index.md", () => {
|
|
expect(slugify("content/index.md")).toBe("content/index");
|
|
});
|
|
});
|
|
|
|
describe("Hugo _index convention", () => {
|
|
it("rewrites trailing _index to index", () => {
|
|
expect(slugify("content/_index.md")).toBe("content/index");
|
|
});
|
|
|
|
it("rewrites _index inside nested path", () => {
|
|
expect(slugify("a/b/_index.md")).toBe("a/b/index");
|
|
});
|
|
});
|
|
|
|
describe("Obsidian Folder Notes convention", () => {
|
|
it("rewrites folder/folder.md to folder/index", () => {
|
|
expect(slugify("characters/characters.md")).toBe("characters/index");
|
|
});
|
|
|
|
it("rewrites nested folder/folder.md to folder/index", () => {
|
|
expect(slugify("fiction/books/books.md")).toBe("fiction/books/index");
|
|
});
|
|
|
|
it("rewrites deeply nested folder/folder.md", () => {
|
|
expect(slugify("a/b/c/d/d.md")).toBe("a/b/c/d/index");
|
|
});
|
|
|
|
it("does NOT rewrite single-segment slugs (parentFolder storage is out of scope)", () => {
|
|
expect(slugify("characters.md")).toBe("characters");
|
|
});
|
|
|
|
it("does NOT rewrite when last two segments differ", () => {
|
|
expect(slugify("characters/alice.md")).toBe("characters/alice");
|
|
});
|
|
|
|
it("does NOT rewrite when basename matches a deeper ancestor but not direct parent", () => {
|
|
expect(slugify("characters/sub/characters.md")).toBe("characters/sub/characters");
|
|
});
|
|
|
|
it("rewrites even when the inner folder is inside a same-named outer folder", () => {
|
|
expect(slugify("a/a/a.md")).toBe("a/a/index");
|
|
});
|
|
|
|
it("leaves index.md as the canonical form unchanged", () => {
|
|
expect(slugify("characters/index.md")).toBe("characters/index");
|
|
});
|
|
|
|
it("does not double-apply when folder literally named 'index' has index.md", () => {
|
|
expect(slugify("index/index.md")).toBe("index/index");
|
|
});
|
|
|
|
it("handles nested folder literally named 'index'", () => {
|
|
expect(slugify("docs/index/index.md")).toBe("docs/index/index");
|
|
});
|
|
|
|
it("rewrites slugified paths with special characters", () => {
|
|
expect(slugify("my folder/my folder.md")).toBe("my-folder/index");
|
|
});
|
|
});
|
|
|
|
describe("interaction with simplifySlug (end-to-end canonicalization)", () => {
|
|
it("folder/folder.md routes to canonical folder URL via simplifySlug", () => {
|
|
expect(simplifySlug(slugify("characters/characters.md"))).toBe("characters/");
|
|
});
|
|
|
|
it("folder/index.md and folder/folder.md produce identical simplified URLs", () => {
|
|
expect(simplifySlug(slugify("characters/characters.md"))).toBe(
|
|
simplifySlug(slugify("characters/index.md")),
|
|
);
|
|
});
|
|
|
|
it("top-level index.md simplifies to root", () => {
|
|
expect(simplifySlug(slugify("index.md"))).toBe("/");
|
|
});
|
|
});
|
|
|
|
describe("interaction with isFolderPath", () => {
|
|
it("classifies folder/folder.md output as a folder path", () => {
|
|
expect(isFolderPath(slugify("characters/characters.md"))).toBe(true);
|
|
});
|
|
|
|
it("classifies regular file as non-folder", () => {
|
|
expect(isFolderPath(slugify("characters/alice.md"))).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isFolderPath", () => {
|
|
it("detects trailing slash", () => {
|
|
expect(isFolderPath("folder/")).toBe(true);
|
|
});
|
|
|
|
it("detects index suffix", () => {
|
|
expect(isFolderPath("folder/index")).toBe(true);
|
|
});
|
|
|
|
it("returns false for files", () => {
|
|
expect(isFolderPath("file.md")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getAllSegmentPrefixes", () => {
|
|
it("returns all prefixes", () => {
|
|
expect(getAllSegmentPrefixes("a/b/c")).toEqual(["a", "a/b", "a/b/c"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveBasePath", () => {
|
|
it("prepends explicit basePath to slug", () => {
|
|
expect(resolveBasePath("features/Callouts", "/repository")).toBe(
|
|
"/repository/features/Callouts",
|
|
);
|
|
});
|
|
|
|
it("prepends explicit basePath to slug with leading slash", () => {
|
|
expect(resolveBasePath("/features/Callouts", "/repository")).toBe(
|
|
"/repository/features/Callouts",
|
|
);
|
|
});
|
|
|
|
it("returns just /slug when basePath is empty (root deployment)", () => {
|
|
expect(resolveBasePath("features/Callouts", "")).toBe("/features/Callouts");
|
|
});
|
|
|
|
it("handles empty slug with basePath", () => {
|
|
expect(resolveBasePath("", "/repository")).toBe("/repository/");
|
|
});
|
|
|
|
it("handles empty slug with empty basePath", () => {
|
|
expect(resolveBasePath("", "")).toBe("/");
|
|
});
|
|
|
|
it("falls back to getBasePath() when no basePath argument (server-side returns empty)", () => {
|
|
expect(resolveBasePath("page")).toBe("/page");
|
|
});
|
|
});
|
|
|
|
describe("slugifyPath", () => {
|
|
it("replaces spaces with hyphens", () => {
|
|
expect(slugifyPath("Arcanist's Folly")).toBe("arcanist's-folly");
|
|
});
|
|
|
|
it("replaces ampersands with -and-", () => {
|
|
expect(slugifyPath("Arts & Crafts")).toBe("arts--and--crafts");
|
|
});
|
|
|
|
it("replaces percent with -percent", () => {
|
|
expect(slugifyPath("100%")).toBe("100-percent");
|
|
});
|
|
|
|
it("removes question marks and hash signs", () => {
|
|
expect(slugifyPath("What?#Section")).toBe("whatsection");
|
|
});
|
|
|
|
it("handles multi-segment paths", () => {
|
|
expect(slugifyPath("Compendium/Species/Ratkin/Deific Exaltation")).toBe(
|
|
"compendium/species/ratkin/deific-exaltation",
|
|
);
|
|
});
|
|
|
|
it("strips trailing slashes", () => {
|
|
expect(slugifyPath("folder/")).toBe("folder");
|
|
});
|
|
|
|
it("lowercases for case-insensitive matching (Obsidian parity)", () => {
|
|
expect(slugifyPath("Compendium/Species/Dryad/Apple")).toBe("compendium/species/dryad/apple");
|
|
});
|
|
});
|
|
|
|
describe("transformInternalLink", () => {
|
|
it("returns folder path for folder note convention (same-name parent)", () => {
|
|
expect(transformInternalLink("characters/characters")).toBe("./characters/");
|
|
});
|
|
|
|
it("returns folder path for folder note with spaces", () => {
|
|
expect(transformInternalLink("My Folder/My Folder")).toBe("./my-folder/");
|
|
});
|
|
|
|
it("returns folder path for deep folder note", () => {
|
|
expect(transformInternalLink("a/b/c/d/d")).toBe("./a/b/c/d/");
|
|
});
|
|
|
|
it("preserves explicit trailing slash", () => {
|
|
expect(transformInternalLink("tags/")).toBe("./tags/");
|
|
});
|
|
|
|
it("returns folder path for explicit index", () => {
|
|
expect(transformInternalLink("content/index")).toBe("./content/");
|
|
});
|
|
|
|
it("does not add trailing slash for regular nested links", () => {
|
|
expect(transformInternalLink("My Folder/My Note")).toBe("./my-folder/my-note");
|
|
});
|
|
|
|
it("handles spaces in nested paths", () => {
|
|
expect(transformInternalLink("content/with spaces")).toBe("./content/with-spaces");
|
|
});
|
|
|
|
it("handles anchor on folder note link", () => {
|
|
expect(transformInternalLink("My Folder/My Folder#heading")).toBe("./my-folder/#heading");
|
|
});
|
|
|
|
it("handles percent-encoded spaces", () => {
|
|
expect(transformInternalLink("My%20Folder/My%20Note")).toBe("./my-folder/my-note");
|
|
});
|
|
|
|
it("handles simple single-segment link", () => {
|
|
expect(transformInternalLink("My Note")).toBe("./my-note");
|
|
});
|
|
|
|
it("returns dot for empty input", () => {
|
|
expect(transformInternalLink("")).toBe(".");
|
|
});
|
|
});
|
|
|
|
describe("transformLink", () => {
|
|
const allSlugs = [
|
|
"a/b/c",
|
|
"a/b/d",
|
|
"a/b/index",
|
|
"e/f",
|
|
"e/g/h",
|
|
"index",
|
|
"a/test.png",
|
|
] as FullSlug[];
|
|
|
|
describe("absolute", () => {
|
|
const opts: TransformOptions = { strategy: "absolute", allSlugs };
|
|
|
|
it("resolves from a/b/c", () => {
|
|
const cur = "a/b/c" as FullSlug;
|
|
expect(transformLink(cur, "a/b/d", opts)).toBe("../../a/b/d");
|
|
expect(transformLink(cur, "a/b/index", opts)).toBe("../../a/b/");
|
|
expect(transformLink(cur, "e/f", opts)).toBe("../../e/f");
|
|
expect(transformLink(cur, "index", opts)).toBe("../../");
|
|
});
|
|
|
|
it("resolves from a/b/index (explicit index slug)", () => {
|
|
const cur = "a/b/index" as FullSlug;
|
|
expect(transformLink(cur, "a/b/d", opts)).toBe("../../a/b/d");
|
|
expect(transformLink(cur, "a/b", opts)).toBe("../../a/b");
|
|
expect(transformLink(cur, "index", opts)).toBe("../../");
|
|
});
|
|
|
|
it("resolves from a/b (folder page without /index suffix) same as a/b/index", () => {
|
|
const cur = "a/b" as FullSlug;
|
|
expect(transformLink(cur, "a/b/d", opts)).toBe("../../a/b/d");
|
|
expect(transformLink(cur, "index", opts)).toBe("../../");
|
|
});
|
|
|
|
it("resolves from index", () => {
|
|
const cur = "index" as FullSlug;
|
|
expect(transformLink(cur, "index", opts)).toBe("./");
|
|
expect(transformLink(cur, "a/b/c", opts)).toBe("./a/b/c");
|
|
expect(transformLink(cur, "a/b/index", opts)).toBe("./a/b/");
|
|
});
|
|
});
|
|
|
|
describe("shortest", () => {
|
|
const opts: TransformOptions = { strategy: "shortest", allSlugs };
|
|
|
|
it("resolves unique filenames from a/b/c", () => {
|
|
const cur = "a/b/c" as FullSlug;
|
|
expect(transformLink(cur, "d", opts)).toBe("../../a/b/d");
|
|
expect(transformLink(cur, "h", opts)).toBe("../../e/g/h");
|
|
expect(transformLink(cur, "index", opts)).toBe("../../");
|
|
});
|
|
|
|
it("resolves from a/b/index (explicit index slug)", () => {
|
|
const cur = "a/b/index" as FullSlug;
|
|
expect(transformLink(cur, "d", opts)).toBe("../../a/b/d");
|
|
expect(transformLink(cur, "h", opts)).toBe("../../e/g/h");
|
|
expect(transformLink(cur, "index", opts)).toBe("../../");
|
|
});
|
|
|
|
it("resolves from a/b (folder page without /index suffix) same as a/b/index", () => {
|
|
const cur = "a/b" as FullSlug;
|
|
expect(transformLink(cur, "d", opts)).toBe("../../a/b/d");
|
|
expect(transformLink(cur, "h", opts)).toBe("../../e/g/h");
|
|
expect(transformLink(cur, "index", opts)).toBe("../../");
|
|
});
|
|
|
|
it("resolves from index", () => {
|
|
const cur = "index" as FullSlug;
|
|
expect(transformLink(cur, "d", opts)).toBe("./a/b/d");
|
|
expect(transformLink(cur, "h", opts)).toBe("./e/g/h");
|
|
expect(transformLink(cur, "index", opts)).toBe("./");
|
|
});
|
|
});
|
|
|
|
describe("multi-segment partial path (shortest)", () => {
|
|
const multiSegSlugs = [
|
|
"compendium/species/elf/wood",
|
|
"compendium/species/elf/index",
|
|
"compendium/species/elf/eladrin",
|
|
"compendium/spells/index",
|
|
"compendium/spells/bane",
|
|
"campaigns/unnamed/people/index",
|
|
"index",
|
|
] as FullSlug[];
|
|
|
|
const opts: TransformOptions = { strategy: "shortest", allSlugs: multiSegSlugs };
|
|
|
|
it("resolves multi-segment path species/elf/wood to unique match", () => {
|
|
const cur = "campaigns/unnamed/people/index" as FullSlug;
|
|
expect(transformLink(cur, "species/elf/wood", opts)).toBe(
|
|
"../../../compendium/species/elf/wood",
|
|
);
|
|
});
|
|
|
|
it("resolves multi-segment path species/elf/index to folder page", () => {
|
|
const cur = "campaigns/unnamed/people/index" as FullSlug;
|
|
expect(transformLink(cur, "species/elf/index", opts)).toBe(
|
|
"../../../compendium/species/elf/",
|
|
);
|
|
});
|
|
|
|
it("falls back to absolute when multi-segment path has no match", () => {
|
|
const cur = "index" as FullSlug;
|
|
expect(transformLink(cur, "species/elf/nosuchpage", opts)).toBe("./species/elf/nosuchpage");
|
|
});
|
|
|
|
it("resolves single-segment name that is unique", () => {
|
|
const cur = "index" as FullSlug;
|
|
expect(transformLink(cur, "bane", opts)).toBe("./compendium/spells/bane");
|
|
});
|
|
|
|
it("resolves exact full path as multi-segment match", () => {
|
|
const cur = "index" as FullSlug;
|
|
expect(transformLink(cur, "compendium/species/elf/eladrin", opts)).toBe(
|
|
"./compendium/species/elf/eladrin",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("folder page bug regression", () => {
|
|
const spellSlugs = [
|
|
"compendium/spells/index",
|
|
"compendium/spells/bane",
|
|
"compendium/spells/absorb-elements",
|
|
"compendium/species/elf/eladrin",
|
|
"index",
|
|
] as FullSlug[];
|
|
|
|
it("does not double the folder segment for absolute strategy", () => {
|
|
const opts: TransformOptions = { strategy: "absolute", allSlugs: spellSlugs };
|
|
const cur = "compendium/spells" as FullSlug;
|
|
expect(transformLink(cur, "compendium/spells/bane", opts)).toBe(
|
|
"../../compendium/spells/bane",
|
|
);
|
|
expect(transformLink(cur, "compendium/spells/absorb-elements", opts)).toBe(
|
|
"../../compendium/spells/absorb-elements",
|
|
);
|
|
});
|
|
|
|
it("does not double the folder segment for shortest strategy", () => {
|
|
const opts: TransformOptions = { strategy: "shortest", allSlugs: spellSlugs };
|
|
const cur = "compendium/spells" as FullSlug;
|
|
expect(transformLink(cur, "bane", opts)).toBe("../../compendium/spells/bane");
|
|
expect(transformLink(cur, "absorb-elements", opts)).toBe(
|
|
"../../compendium/spells/absorb-elements",
|
|
);
|
|
});
|
|
|
|
it("matches explicit index slug behavior", () => {
|
|
const opts: TransformOptions = { strategy: "absolute", allSlugs: spellSlugs };
|
|
const fromFolder = "compendium/spells" as FullSlug;
|
|
const fromIndex = "compendium/spells/index" as FullSlug;
|
|
expect(transformLink(fromFolder, "compendium/spells/bane", opts)).toBe(
|
|
transformLink(fromIndex, "compendium/spells/bane", opts),
|
|
);
|
|
});
|
|
|
|
it("does not affect non-folder pages", () => {
|
|
const opts: TransformOptions = { strategy: "absolute", allSlugs: spellSlugs };
|
|
const cur = "compendium/spells/bane" as FullSlug;
|
|
expect(transformLink(cur, "compendium/spells/absorb-elements", opts)).toBe(
|
|
"../../compendium/spells/absorb-elements",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("normalizeHastElement", () => {
|
|
const makeEl = (href: string, tagName = "a"): HastElement => ({
|
|
type: "element",
|
|
tagName,
|
|
properties: { href },
|
|
children: [{ type: "text", value: "link" }],
|
|
});
|
|
|
|
it("rebases a relative href when embedding deeper content into shallower context", () => {
|
|
const el = makeEl("../layout#page-frames");
|
|
const out = normalizeHastElement(
|
|
el,
|
|
"canvas.canvas" as FullSlug,
|
|
"plugins/canvaspage" as FullSlug,
|
|
);
|
|
const href = out.properties!.href as string;
|
|
expect(
|
|
new URL(href, "https://example.com/canvas.canvas").pathname +
|
|
new URL(href, "https://example.com/canvas.canvas").hash,
|
|
).toBe("/layout#page-frames");
|
|
});
|
|
|
|
it("rebases a relative href when embedding shallower content into deeper context", () => {
|
|
const el = makeEl("./sibling");
|
|
const out = normalizeHastElement(
|
|
el,
|
|
"features/canvas" as FullSlug,
|
|
"canvas.canvas" as FullSlug,
|
|
);
|
|
const href = out.properties!.href as string;
|
|
expect(new URL(href, "https://example.com/features/canvas").pathname).toBe("/sibling");
|
|
});
|
|
|
|
it("produces a href that resolves identically when curBase equals newBase", () => {
|
|
const el = makeEl("./foo");
|
|
const out = normalizeHastElement(el, "index" as FullSlug, "index" as FullSlug);
|
|
const href = out.properties!.href as string;
|
|
expect(new URL(href, "https://example.com/").pathname).toBe("/foo");
|
|
});
|
|
|
|
it("preserves anchor fragments", () => {
|
|
const el = makeEl("../target#section-two");
|
|
const out = normalizeHastElement(el, "a/b" as FullSlug, "a/b/c" as FullSlug);
|
|
const href = out.properties!.href as string;
|
|
const url = new URL(href, "https://example.com/a/b");
|
|
expect(url.hash).toBe("#section-two");
|
|
});
|
|
|
|
it("passes absolute URLs through unchanged", () => {
|
|
const el = makeEl("https://example.com/external");
|
|
const out = normalizeHastElement(el, "index" as FullSlug, "other" as FullSlug);
|
|
expect(out.properties!.href).toBe("https://example.com/external");
|
|
});
|
|
|
|
it("passes root-relative URLs through unchanged", () => {
|
|
const el = makeEl("/absolute/path");
|
|
const out = normalizeHastElement(el, "index" as FullSlug, "other" as FullSlug);
|
|
expect(out.properties!.href).toBe("/absolute/path");
|
|
});
|
|
|
|
it("rebases src attributes as well as href", () => {
|
|
const el: HastElement = {
|
|
type: "element",
|
|
tagName: "img",
|
|
properties: { src: "../image.png" },
|
|
children: [],
|
|
};
|
|
const out = normalizeHastElement(el, "index" as FullSlug, "deep/page" as FullSlug);
|
|
const src = out.properties!.src as string;
|
|
expect(new URL(src, "https://example.com/").pathname).toBe("/image.png");
|
|
});
|
|
|
|
it("leaves elements without href or src unchanged", () => {
|
|
const el: HastElement = {
|
|
type: "element",
|
|
tagName: "p",
|
|
properties: {},
|
|
children: [{ type: "text", value: "plain text" }],
|
|
};
|
|
const out = normalizeHastElement(el, "a" as FullSlug, "b" as FullSlug);
|
|
expect(out).toEqual(el);
|
|
});
|
|
|
|
it("recurses into children to rebase nested elements", () => {
|
|
const el: HastElement = {
|
|
type: "element",
|
|
tagName: "p",
|
|
properties: {},
|
|
children: [
|
|
{ type: "text", value: "see " },
|
|
{
|
|
type: "element",
|
|
tagName: "a",
|
|
properties: { href: "../layout" },
|
|
children: [{ type: "text", value: "layout" }],
|
|
},
|
|
],
|
|
};
|
|
const out = normalizeHastElement(
|
|
el,
|
|
"canvas.canvas" as FullSlug,
|
|
"plugins/canvaspage" as FullSlug,
|
|
);
|
|
const innerAnchor = out.children[1] as HastElement;
|
|
const href = innerAnchor.properties!.href as string;
|
|
expect(new URL(href, "https://example.com/canvas.canvas").pathname).toBe("/layout");
|
|
});
|
|
|
|
it("does not mutate the input element", () => {
|
|
const el = makeEl("../layout");
|
|
const originalHref = el.properties!.href;
|
|
normalizeHastElement(el, "canvas.canvas" as FullSlug, "plugins/canvaspage" as FullSlug);
|
|
expect(el.properties!.href).toBe(originalHref);
|
|
});
|
|
});
|