mirror of
https://github.com/quartz-community/utils.git
synced 2026-07-22 02:50:27 +00:00
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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 };
|
|
|
|
/** No '/index' ending, no file extension, can have trailing slash for folders. */
|
|
export type SimpleSlug = string & { _brand: "SimpleSlug" };
|
|
|
|
/** Starts with './' or '../', used for navigation. */
|
|
export type RelativeURL = string & { _brand: "RelativeURL" };
|
|
|
|
export interface TransformOptions {
|
|
strategy: "absolute" | "relative" | "shortest";
|
|
allSlugs: FullSlug[];
|
|
}
|
|
|
|
export function isFilePath(s: string): s is FilePath {
|
|
const validStart = !s.startsWith(".");
|
|
return validStart && _hasFileExtension(s);
|
|
}
|
|
|
|
export function isFullSlug(s: string): s is FullSlug {
|
|
const validStart = !(s.startsWith(".") || s.startsWith("/"));
|
|
const validEnding = !s.endsWith("/");
|
|
return validStart && validEnding && !_containsForbiddenCharacters(s);
|
|
}
|
|
|
|
export function isSimpleSlug(s: string): s is SimpleSlug {
|
|
const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/")));
|
|
const validEnding = !endsWith(s, "index");
|
|
return validStart && !_containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s);
|
|
}
|
|
|
|
export function isRelativeURL(s: string): s is RelativeURL {
|
|
const validStart = /^\.{1,2}/.test(s);
|
|
const validEnding = !endsWith(s, "index");
|
|
return validStart && validEnding && ![".md", ".html"].includes(getFileExtension(s) ?? "");
|
|
}
|
|
|
|
export function isAbsoluteURL(s: string): boolean {
|
|
try {
|
|
new URL(s);
|
|
} catch {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function simplifySlug(fp: FullSlug | string): SimpleSlug {
|
|
const res = stripSlashes(trimSuffix(fp, "index"), true);
|
|
return (res.length === 0 ? "/" : res) as SimpleSlug;
|
|
}
|
|
|
|
export function getFullSlug(window: Window): FullSlug {
|
|
const res = window.document.body.dataset.slug! as FullSlug;
|
|
return res;
|
|
}
|
|
|
|
export function getFullSlugFromUrl(): FullSlug {
|
|
let rawSlug = window.location.pathname;
|
|
if (rawSlug.endsWith("/")) rawSlug = rawSlug.slice(0, -1);
|
|
if (rawSlug.startsWith("/")) rawSlug = rawSlug.slice(1);
|
|
return rawSlug as FullSlug;
|
|
}
|
|
|
|
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
|
fp = stripSlashes(fp) as FilePath;
|
|
const ext = getFileExtension(fp);
|
|
const withoutFileExt = fp.replace(new RegExp(ext + "$"), "");
|
|
const finalExt = excludeExt || [".md", ".html", undefined].includes(ext) ? "" : ext;
|
|
|
|
let slug = _sluggify(withoutFileExt);
|
|
|
|
if (endsWith(slug, "_index")) {
|
|
slug = slug.replace(/_index$/, "index");
|
|
}
|
|
|
|
// Obsidian "Folder Notes" convention (insideFolder storage): a file named the
|
|
// same as its parent folder is treated as the folder's landing page. Rewrite
|
|
// `folder/folder` → `folder/index` so every downstream consumer (trie, link
|
|
// resolver, simplifySlug, isFolderPath) sees the canonical Quartz slug shape
|
|
// and Just Works. A top-level single-segment slug is NOT rewritten — that
|
|
// represents Obsidian's `parentFolder` storage mode, which Quartz already
|
|
// routes as a regular top-level page.
|
|
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 ?? "")) as FullSlug;
|
|
}
|
|
|
|
export function joinSegments(...args: string[]): string {
|
|
if (args.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
let joined = args
|
|
.filter((segment) => segment !== "" && segment !== "/")
|
|
.map((segment) => stripSlashes(segment))
|
|
.join("/");
|
|
|
|
const first = args[0];
|
|
const last = args[args.length - 1];
|
|
|
|
if (first?.startsWith("/")) {
|
|
joined = "/" + joined;
|
|
}
|
|
|
|
if (last?.endsWith("/")) {
|
|
joined = joined + "/";
|
|
}
|
|
|
|
return joined;
|
|
}
|
|
|
|
export function resolvePath(to: string): string {
|
|
if (to.startsWith("/")) return to;
|
|
return "/" + to;
|
|
}
|
|
|
|
/** Read the base path injected at build time via `data-basepath` on `<body>`.
|
|
* Returns `""` for root deployments, e.g. `"/repository"` for subdirectory. */
|
|
export function getBasePath(): string {
|
|
if (typeof document === "undefined") return "";
|
|
return document.body?.dataset?.basepath ?? "";
|
|
}
|
|
|
|
/** Resolve a slug to an absolute URL path, prepending the site's base path.
|
|
* e.g. `resolveBasePath("features/Callouts")` → `"/repository/features/Callouts"` */
|
|
export function resolveBasePath(to: string, basePath?: string): string {
|
|
const base = basePath ?? getBasePath();
|
|
const slug = to.startsWith("/") ? to : "/" + to;
|
|
return base + slug;
|
|
}
|
|
|
|
export function endsWith(s: string, suffix: string): boolean {
|
|
return s === suffix || s.endsWith("/" + suffix);
|
|
}
|
|
|
|
export function trimSuffix(s: string, suffix: string): string {
|
|
if (endsWith(s, suffix)) {
|
|
s = s.slice(0, -suffix.length);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export function stripSlashes(s: string, onlyStripPrefix?: boolean): string {
|
|
if (s.startsWith("/")) {
|
|
s = s.substring(1);
|
|
}
|
|
|
|
if (!onlyStripPrefix && s.endsWith("/")) {
|
|
s = s.slice(0, -1);
|
|
}
|
|
|
|
return s;
|
|
}
|
|
|
|
export function getFileExtension(s: string): string | undefined {
|
|
return s.match(/\.[A-Za-z0-9]+$/)?.[0];
|
|
}
|
|
|
|
export function isFolderPath(fplike: string): boolean {
|
|
return (
|
|
fplike.endsWith("/") ||
|
|
endsWith(fplike, "index") ||
|
|
endsWith(fplike, "index.md") ||
|
|
endsWith(fplike, "index.html")
|
|
);
|
|
}
|
|
|
|
export function getAllSegmentPrefixes(path: string): string[] {
|
|
const segments = path.split("/");
|
|
const results: string[] = [];
|
|
for (let i = 0; i < segments.length; i++) {
|
|
results.push(segments.slice(0, i + 1).join("/"));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export function pathToRoot(slug: FullSlug): RelativeURL {
|
|
let rootPath = slug
|
|
.split("/")
|
|
.filter((x) => x !== "")
|
|
.slice(0, -1)
|
|
.map((_) => "..")
|
|
.join("/");
|
|
|
|
if (rootPath.length === 0) {
|
|
rootPath = ".";
|
|
}
|
|
|
|
return rootPath as RelativeURL;
|
|
}
|
|
|
|
export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL {
|
|
const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL;
|
|
return res;
|
|
}
|
|
|
|
export function splitAnchor(link: string): [string, string] {
|
|
const [fp, anchor] = link.split("#", 2);
|
|
if (fp!.endsWith(".pdf")) {
|
|
return [fp!, anchor === undefined ? "" : `#${anchor}`];
|
|
}
|
|
if (anchor === undefined) {
|
|
return [fp!, ""];
|
|
}
|
|
const bare = anchor.startsWith("^") ? anchor.slice(1) : anchor;
|
|
const slugged = "#" + slugAnchor(bare);
|
|
return [fp!, slugged];
|
|
}
|
|
|
|
export function slugTag(tag: string): string {
|
|
return tag
|
|
.split("/")
|
|
.map((tagSegment) => _sluggify(tagSegment))
|
|
.join("/");
|
|
}
|
|
|
|
export function transformInternalLink(link: string): RelativeURL {
|
|
const [fplike, anchor] = splitAnchor(decodeURI(link));
|
|
|
|
const segments = fplike.split("/").filter((x) => x.length > 0);
|
|
const prefix = segments.filter(_isRelativeSegment).join("/");
|
|
const fp = segments.filter((seg) => !_isRelativeSegment(seg) && seg !== "").join("/");
|
|
|
|
const slugged = slugifyFilePath(fp as FilePath);
|
|
const simpleSlug = simplifySlug(slugged);
|
|
const folderPath = isFolderPath(fplike) || isFolderPath(slugged);
|
|
const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug));
|
|
const trail = folderPath ? "/" : "";
|
|
const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL;
|
|
return res;
|
|
}
|
|
|
|
export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL {
|
|
const targetSlug = transformInternalLink(target);
|
|
|
|
if (opts.strategy === "relative") {
|
|
return targetSlug as RelativeURL;
|
|
} else {
|
|
// When src is a folder page (e.g. "Compendium/Spells" backed by index.md),
|
|
// the browser serves it at "/Compendium/Spells/" with a trailing slash.
|
|
// pathToRoot needs the "/index" suffix to compute the correct depth.
|
|
const effectiveSrc =
|
|
!endsWith(src, "index") && opts.allSlugs.includes(`${src}/index` as FullSlug)
|
|
? (`${src}/index` as FullSlug)
|
|
: src;
|
|
|
|
const folderTail = isFolderPath(targetSlug) ? "/" : "";
|
|
const canonicalSlug = stripSlashes(targetSlug.slice(".".length));
|
|
const [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug);
|
|
|
|
if (opts.strategy === "shortest") {
|
|
const isMultiSegment = targetCanonical.includes("/");
|
|
const isFolderTarget = isFolderPath(targetSlug);
|
|
const matchingFileNames = opts.allSlugs.filter((slug) => {
|
|
if (isMultiSegment) {
|
|
// Multi-segment partial path: match by suffix
|
|
if (slug === targetCanonical || slug.endsWith("/" + targetCanonical)) {
|
|
return true;
|
|
}
|
|
// Folder targets have "/index" stripped — also match the "/index" variant
|
|
if (isFolderTarget) {
|
|
const withIndex = targetCanonical + "/index";
|
|
return slug === withIndex || slug.endsWith("/" + withIndex);
|
|
}
|
|
return false;
|
|
}
|
|
const parts = slug.split("/");
|
|
const fileName = parts.at(-1);
|
|
return targetCanonical === fileName;
|
|
});
|
|
|
|
if (matchingFileNames.length === 1) {
|
|
const matchedSlug = matchingFileNames[0]!;
|
|
return (resolveRelative(effectiveSrc, matchedSlug) + targetAnchor) as RelativeURL;
|
|
}
|
|
}
|
|
|
|
return (joinSegments(pathToRoot(effectiveSrc), canonicalSlug) + folderTail) as RelativeURL;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Slugify a file path for use as an href.
|
|
* Replaces whitespace→hyphens, &→-and-, %→-percent, removes ? and #, and
|
|
* lowercases the result so that link matching is case-insensitive (matching
|
|
* Obsidian's link-resolution semantics). Operates per segment so directory
|
|
* separators are preserved.
|
|
*/
|
|
export function slugifyPath(s: string): string {
|
|
return s
|
|
.split("/")
|
|
.map((segment) =>
|
|
segment
|
|
.replace(/\s/g, "-")
|
|
.replace(/&/g, "-and-")
|
|
.replace(/%/g, "-percent")
|
|
.replace(/\?/g, "")
|
|
.replace(/#/g, "")
|
|
.toLowerCase(),
|
|
)
|
|
.join("/")
|
|
.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);
|
|
}
|
|
|
|
function _containsForbiddenCharacters(s: string): boolean {
|
|
return s.includes(" ") || s.includes("#") || s.includes("?") || s.includes("&");
|
|
}
|
|
|
|
function _hasFileExtension(s: string): boolean {
|
|
return getFileExtension(s) !== undefined;
|
|
}
|
|
|
|
function _isRelativeSegment(s: string): boolean {
|
|
return /^\.{0,2}$/.test(s);
|
|
}
|
|
|
|
function _addRelativeToStart(s: string): string {
|
|
if (s === "") {
|
|
s = ".";
|
|
}
|
|
|
|
if (!s.startsWith(".")) {
|
|
s = joinSegments(".", s);
|
|
}
|
|
|
|
return s;
|
|
}
|