mirror of
https://github.com/quartz-community/utils.git
synced 2026-07-22 02:50:27 +00:00
feat(path): add normalizeHastElement for cross-slug HAST rebasing
Moved from Quartz core (quartz/util/path.ts) into the shared utils package so that plugins performing cross-slug HAST embedding can re-use the same rebase helper as Quartz's internal transclude expansion, rather than reimplementing or skipping the rebase. The primary consumer outside of Quartz core is canvas-page, whose buildEmbeddedContent() pulls vfile.data.htmlAst from arbitrary target pages and serializes it verbatim into canvas pages - producing broken relative hrefs that survive accidentally on root deployments but fail under non-trivial basePath configurations. Exposing the helper here lets canvas-page (and any future embedding plugin) rebase correctly without duplicating the logic. Implementation details: - Deep-clones the input via structuredClone (Node >=22 builtin) so callers don't need to bring their own clone function - Absolute URLs (http://, https://, /root-relative) pass through untouched; only relative URLs are rebased - Recurses into children so nested anchors inside a rebased subtree are also normalized Backed by 10 new tests covering same-slug, cross-slug (both directions), nested depth, anchor preservation, absolute URL passthrough, root-relative passthrough, src-attribute rebasing, elements without href/src, nested children recursion, and input-immutability.
This commit is contained in:
parent
4dda81bc82
commit
bb5c136296
7 changed files with 206 additions and 3 deletions
2
dist/index.d.ts
vendored
2
dist/index.d.ts
vendored
|
|
@ -37,5 +37,5 @@ export { formatDate } from "./date.js";
|
|||
export { getIconCode } from "./emoji.js";
|
||||
export { byDateAndAlphabetical, getDate } from "./sort.js";
|
||||
export { FilePath, FullSlug } from "@quartz-community/types";
|
||||
import "hast-util-to-jsx-runtime";
|
||||
import "hast";
|
||||
import "hast-util-to-jsx-runtime";
|
||||
|
|
|
|||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
20
dist/path.d.ts
vendored
20
dist/path.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
|||
import { Element } from "hast";
|
||||
import { FullSlug, FilePath } from "@quartz-community/types";
|
||||
export { FilePath, FullSlug } from "@quartz-community/types";
|
||||
|
||||
|
|
@ -50,6 +51,24 @@ declare function transformLink(src: FullSlug, target: string, opts: TransformOpt
|
|||
* separators are preserved.
|
||||
*/
|
||||
declare function slugifyPath(s: string): string;
|
||||
/**
|
||||
* Re-base the relative `href` and `src` attributes of a HAST element so that
|
||||
* links inside content originally resolved relative to `newBase` remain valid
|
||||
* when the element is embedded at a page with slug `curBase`.
|
||||
*
|
||||
* Used for transclusions and cross-slug HAST embedding (Quartz core's
|
||||
* `renderPage` transclude expansion, canvas-page's embedded file-nodes, and
|
||||
* any plugin that takes `vfile.data.htmlAst` from one page and renders it
|
||||
* inside another). Absolute URLs pass through unchanged.
|
||||
*
|
||||
* The element (and its children) are deep-cloned via `structuredClone`, so
|
||||
* the original HAST tree is never mutated.
|
||||
*/
|
||||
declare function normalizeHastElement<T extends Element>(
|
||||
rawEl: T,
|
||||
curBase: FullSlug,
|
||||
newBase: FullSlug,
|
||||
): T;
|
||||
|
||||
export {
|
||||
type RelativeURL,
|
||||
|
|
@ -68,6 +87,7 @@ export {
|
|||
isRelativeURL,
|
||||
isSimpleSlug,
|
||||
joinSegments,
|
||||
normalizeHastElement,
|
||||
pathToRoot,
|
||||
resolveBasePath,
|
||||
resolvePath,
|
||||
|
|
|
|||
19
dist/path.js
vendored
19
dist/path.js
vendored
|
|
@ -219,6 +219,24 @@ function slugifyPath(s) {
|
|||
.join("/")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
function normalizeHastElement(rawEl, curBase, newBase) {
|
||||
const el = structuredClone(rawEl);
|
||||
_rebaseHastElement(el, "src", curBase, newBase);
|
||||
_rebaseHastElement(el, "href", curBase, newBase);
|
||||
if (el.children) {
|
||||
el.children = el.children.map((child) =>
|
||||
child.type === "element" ? normalizeHastElement(child, curBase, newBase) : child,
|
||||
);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
function _rebaseHastElement(el, attr, curBase, newBase) {
|
||||
const value = el.properties?.[attr];
|
||||
if (value === void 0 || value === null) return;
|
||||
const href = String(value);
|
||||
if (!isRelativeURL(href)) return;
|
||||
el.properties[attr] = joinSegments(resolveRelative(curBase, newBase), "..", href);
|
||||
}
|
||||
function _sluggify(s) {
|
||||
return slugifyPath(s);
|
||||
}
|
||||
|
|
@ -255,6 +273,7 @@ export {
|
|||
isRelativeURL,
|
||||
isSimpleSlug,
|
||||
joinSegments,
|
||||
normalizeHastElement,
|
||||
pathToRoot,
|
||||
resolveBasePath,
|
||||
resolvePath,
|
||||
|
|
|
|||
2
dist/path.js.map
vendored
2
dist/path.js.map
vendored
File diff suppressed because one or more lines are too long
46
src/path.ts
46
src/path.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { slug as slugAnchor } from "github-slugger";
|
||||
import type { Element as HastElement } from "hast";
|
||||
import type { FilePath, FullSlug } from "@quartz-community/types";
|
||||
|
||||
export type { FilePath, FullSlug };
|
||||
|
|
@ -303,6 +304,51 @@ export function slugifyPath(s: string): string {
|
|||
.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-base the relative `href` and `src` attributes of a HAST element so that
|
||||
* links inside content originally resolved relative to `newBase` remain valid
|
||||
* when the element is embedded at a page with slug `curBase`.
|
||||
*
|
||||
* Used for transclusions and cross-slug HAST embedding (Quartz core's
|
||||
* `renderPage` transclude expansion, canvas-page's embedded file-nodes, and
|
||||
* any plugin that takes `vfile.data.htmlAst` from one page and renders it
|
||||
* inside another). Absolute URLs pass through unchanged.
|
||||
*
|
||||
* The element (and its children) are deep-cloned via `structuredClone`, so
|
||||
* the original HAST tree is never mutated.
|
||||
*/
|
||||
export function normalizeHastElement<T extends HastElement>(
|
||||
rawEl: T,
|
||||
curBase: FullSlug,
|
||||
newBase: FullSlug,
|
||||
): T {
|
||||
const el = structuredClone(rawEl);
|
||||
_rebaseHastElement(el, "src", curBase, newBase);
|
||||
_rebaseHastElement(el, "href", curBase, newBase);
|
||||
if (el.children) {
|
||||
el.children = el.children.map((child) =>
|
||||
child.type === "element"
|
||||
? (normalizeHastElement(child as HastElement, curBase, newBase) as typeof child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function _rebaseHastElement(
|
||||
el: HastElement,
|
||||
attr: "href" | "src",
|
||||
curBase: FullSlug,
|
||||
newBase: FullSlug,
|
||||
): void {
|
||||
const value = el.properties?.[attr];
|
||||
if (value === undefined || value === null) return;
|
||||
const href = String(value);
|
||||
if (!isRelativeURL(href)) return;
|
||||
el.properties![attr] = joinSegments(resolveRelative(curBase, newBase), "..", href);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function _sluggify(s: string): string {
|
||||
return slugifyPath(s);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import {
|
|||
slugifyFilePath,
|
||||
slugifyPath,
|
||||
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", () => {
|
||||
|
|
@ -452,3 +454,119 @@ describe("transformLink", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue