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 ``. * 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( 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; }