/** * expandEmbeds.ts * * This module provides a recursive function to expand Obsidian-style embedded links (`![[file]]` and `![[file#heading]]`) * by inlining their referenced content. It supports: * * - Resolving full-note and section-level embeds (`![[file#subsection]]`) * - Expanding embedded content recursively while preventing infinite loops * - Handling multiple instances of the same embedded section within the same note * - Skipping the expansion of images (`png, jpg, gif, etc.`) and PDFs while preserving their original links * * The function maintains a call stack to track active expansions, ensuring that duplicate references in the same * recursion chain are ignored (to prevent loops), while allowing repeated embeds elsewhere in the note. */ import { App, TFile, CachedMetadata } from "obsidian"; /** * Recursively expands embedded content (including subpath references), * allowing the same (file+subpath) to appear multiple times if it's *not* * in the same immediate recursion stack. */ export async function expandEmbeds( app: App, file: TFile, stack: string[] = [], subpath?: string ): Promise { const sp = subpath ?? ""; const currentKey = `${file.path}::${sp}`; // If it's already on the current expansion stack, we have a cycle => skip if (stack.includes(currentKey)) { return ""; } // Push it on stack stack.push(currentKey); // Now do the usual reading const raw = await app.vault.read(file); const embedRegex = /!\[\[([^\]]+)\]\]/g; // We'll do a standard async replacement const expandedWholeFile = await replaceAsync(raw, embedRegex, async (fullMatch, link) => { let [filePart] = link.split("|"); let sub: string | undefined; const hashIndex = filePart.indexOf("#"); if (hashIndex >= 0) { sub = filePart.substring(hashIndex + 1).trim(); filePart = filePart.substring(0, hashIndex).trim(); } const linkedTFile = app.metadataCache.getFirstLinkpathDest(filePart, file.path); if (!linkedTFile) { // The file doesn't exist return ""; } // If it's an image, keep the link if (isIgnoredFile(linkedTFile)) { return fullMatch; } // Recursively expand that subpath return expandEmbeds(app, linkedTFile, stack, sub); }); // Pop it from stack stack.pop(); // If subpath was specified, slice out that portion if (subpath) { return sliceSubpathContent(app, file, expandedWholeFile, subpath); } return expandedWholeFile; } /** * If the user references a heading or a block (e.g. "#Heading" or "#^blockID"), * we slice out just that portion from the fully expanded content. */ function sliceSubpathContent( app: App, tfile: TFile, fileContent: string, subpath: string ): string { const fileCache = app.metadataCache.getFileCache(tfile); if (!fileCache) return fileContent; // Block reference => if subpath starts with '^' if (subpath.startsWith("^")) { const blockId = subpath.slice(1); const block = fileCache.blocks?.[blockId]; if (!block) { return ""; } const { start, end } = block.position; if (!end) { // Goes to EOF if no explicit end return fileContent.substring(start.offset); } else { return fileContent.substring(start.offset, end.offset); } } // Otherwise treat it as a heading return sliceHeading(fileContent, fileCache, subpath); } /** * Finds a heading by case-insensitive match and returns everything until * the next heading of the same or shallower level. */ function sliceHeading(content: string, fileCache: CachedMetadata, headingName: string): string { if (!fileCache.headings) return content; const target = headingName.toLowerCase(); // Step 1: find the heading let foundHeadingIndex = -1; for (let i = 0; i < fileCache.headings.length; i++) { if (fileCache.headings[i].heading.toLowerCase() === target) { foundHeadingIndex = i; break; } } if (foundHeadingIndex === -1) { return ""; // no heading matched } // Step 2: find the end offset for that heading's section const heading = fileCache.headings[foundHeadingIndex]; const startOffset = heading.position.start.offset; const thisLevel = heading.level; // We'll search forward for the next heading of the same or shallower level let endOffset = content.length; for (let j = foundHeadingIndex + 1; j < fileCache.headings.length; j++) { const h = fileCache.headings[j]; if (h.level <= thisLevel) { endOffset = h.position.start.offset; break; } } console.log(`"Sliceheading for ${heading}, level ${thisLevel}, offsets ${startOffset} and ${endOffset}."`) return content.substring(startOffset, endOffset).trim(); } /** * Checks if a TFile is an image or PDF by extension */ function isIgnoredFile(file: TFile): boolean { const imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "pdf"]; return imageExtensions.includes(file.extension.toLowerCase()); } /** * A helper for asynchronous regex replacements */ async function replaceAsync( str: string, regex: RegExp, asyncFn: (match: string, ...args: any[]) => Promise ): Promise { const matches: Array<{ match: string; args: any[]; index: number }> = []; let m: RegExpExecArray | null; while ((m = regex.exec(str)) !== null) { matches.push({ match: m[0], args: m.slice(1), index: m.index }); } let result = ""; let lastIndex = str.length; for (let i = matches.length - 1; i >= 0; i--) { const { match, args, index } = matches[i]; const afterMatchIndex = index + match.length; const replacement = await asyncFn(match, ...args); result = str.substring(afterMatchIndex, lastIndex) + result; result = replacement + result; lastIndex = index; } result = str.substring(0, lastIndex) + result; return result; }