/** * Names, slugs and wikilinks. * * Every note is addressed three ways: its file name in the vault (what * Obsidian wikilinks resolve), its slug (what the URL uses), and its type. * These helpers are the only place those rules live. */ /** Characters Obsidian or common file systems refuse in a file name. */ const UNSAFE_FILE_CHARS = /[\\/:*?"<>|#^[\]]/g; /** A file name (without `.md`) safe for Obsidian on every platform. */ export function safeFileName(title: string): string { return title .replace(UNSAFE_FILE_CHARS, " ") .replace(/\s+/g, " ") .trim() .replace(/[. ]+$/, ""); } /** URL slug from a title: lowercase ASCII words joined by hyphens. */ export function slugify(text: string): string { return text .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/&/g, " and ") .replace(/['’]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } const WIKILINK = /^\[\[([^[\]|#]+)(?:#[^[\]|]*)?(?:\|[^[\]]*)?\]\]$/; const WIKILINK_GLOBAL = /\[\[([^[\]|#]+)(?:#[^[\]|]*)?(?:\|[^[\]]*)?\]\]/g; /** `Name` → `[[Name]]`. */ export function toWikilink(target: string): string { return `[[${target}]]`; } /** `[[Name|alias]]` → `Name`, or null when the value is not a wikilink. */ export function wikilinkTarget(value: string): string | null { const m = WIKILINK.exec(value.trim()); return m ? m[1]!.trim() : null; } /** Every wikilink target in a block of Markdown, in order, without repeats. */ export function wikilinksIn(markdown: string): string[] { const seen = new Set(); for (const m of markdown.matchAll(WIKILINK_GLOBAL)) seen.add(m[1]!.trim()); return [...seen]; } /** * An unquoted `[[Name]]` in YAML parses as a nested list (`[["Name"]]`). * Obsidian quotes them, but hand edits may not, so accept both shapes. */ export function normalizeWikilinkInput(value: unknown): unknown { if (Array.isArray(value) && value.length === 1 && Array.isArray(value[0]) && value[0].length === 1) { const inner = value[0][0]; if (typeof inner === "string") return toWikilink(inner); } return value; }