/** * Represents a parsed wiki-style link from markdown content */ export interface WikiLink { /** The raw matched text including brackets (e.g., "[[Page Name]]") */ raw: string; /** The target page name or slug */ target: string; /** The display text (may differ from target if using | syntax) */ displayText: string; /** Start position of the link in the original content */ start: number; /** End position of the link in the original content */ end: number; } /** * Represents a region in the content that should be excluded from parsing */ interface ExcludedRegion { start: number; end: number; } /** * Parse wiki-style [[links]] from markdown content. * * Supports: * - [[Page Name]] - link by title * - [[Page Name|display text]] - link with custom display * - [[page-slug]] - link by slug * * Handles edge cases: * - Nested brackets within link text * - Links in code blocks (excluded from parsing) * - Escaped brackets (excluded from parsing) * * @param content - The markdown content to parse * @returns Array of parsed wiki links with position information */ export function parseWikiLinks(content: string): WikiLink[] { if (!content || content.length === 0) { return []; } const excludedRegions = findExcludedRegions(content); const links: WikiLink[] = []; // Manual parsing to handle complex bracket scenarios let i = 0; while (i < content.length) { // Look for [[ if (i < content.length - 1 && content[i] === "[" && content[i + 1] === "[") { // Check if preceded by escape character if (i > 0 && content[i - 1] === "\\") { i++; continue; } // Check if preceded by another [ (would make [[[) if (i > 0 && content[i - 1] === "[") { i++; continue; } // Check if followed by another [ (would make [[[) if (i + 2 < content.length && content[i + 2] === "[") { i++; continue; } const start = i; i += 2; // Skip past [[ // Find the closing ]] let innerContent = ""; let foundClosing = false; while (i < content.length - 1) { // Check for ]] if (content[i] === "]" && content[i + 1] === "]") { foundClosing = true; break; } innerContent += content[i]; i++; } if (!foundClosing) { // No closing brackets found, continue searching continue; } const end = i + 2; // Include the ]] const raw = content.substring(start, end); // Skip if this link is in an excluded region if (isInExcludedRegion(start, end, excludedRegions)) { i += 2; // Move past the ]] continue; } // Parse the inner content to extract target and display text const parsed = parseInnerContent(innerContent); if (!parsed) { i += 2; // Move past the ]] continue; } links.push({ raw, target: parsed.target, displayText: parsed.displayText, start, end, }); i += 2; // Move past the ]] } else { i++; } } return links; } /** * Parse the inner content of a wiki link to extract target and display text */ function parseInnerContent( content: string ): { target: string; displayText: string } | null { // Check for pipe separator const pipeIndex = content.indexOf("|"); let target: string; let displayText: string; if (pipeIndex !== -1) { // Has display text target = content.substring(0, pipeIndex).trim(); displayText = content.substring(pipeIndex + 1).trim(); // If display text is empty after trim, use target if (displayText === "") { displayText = target; } } else { // No display text, target and display are the same target = content.trim(); displayText = target; } // Reject if target is empty or whitespace-only if (target === "") { return null; } return { target, displayText }; } /** * Find all regions that should be excluded from wiki link parsing * (code blocks, inline code, etc.) */ function findExcludedRegions(content: string): ExcludedRegion[] { const regions: ExcludedRegion[] = []; // Find fenced code blocks (``` ... ```) const fencedCodePattern = /```[\s\S]*?```/g; let match: RegExpExecArray | null; while ((match = fencedCodePattern.exec(content)) !== null) { regions.push({ start: match.index, end: match.index + match[0].length, }); } // Find indented code blocks (4 spaces or 1 tab at line start) const lines = content.split("\n"); let currentIndex = 0; let inIndentedBlock = false; let blockStart = 0; for (const line of lines) { const lineStart = currentIndex; const lineEnd = currentIndex + line.length; // Check if line is indented (4 spaces or tab) const isIndented = line.startsWith(" ") || line.startsWith("\t"); const isEmpty = line.trim() === ""; if (isIndented && !inIndentedBlock) { // Start of indented block inIndentedBlock = true; blockStart = lineStart; } else if (!isIndented && !isEmpty && inIndentedBlock) { // End of indented block (non-empty, non-indented line) regions.push({ start: blockStart, end: lineStart, }); inIndentedBlock = false; } currentIndex = lineEnd + 1; // +1 for newline character } // Handle case where indented block extends to end of content if (inIndentedBlock) { regions.push({ start: blockStart, end: content.length, }); } // Find inline code (` ... `) // This is tricky because we need to track state let inInlineCode = false; let inlineStart = 0; for (let i = 0; i < content.length; i++) { if (content[i] === "`") { // Check if it's escaped if (i > 0 && content[i - 1] === "\\") { continue; } // Check if we're already in a fenced code block or indented block if (isInExcludedRegion(i, i + 1, regions)) { continue; } if (!inInlineCode) { inInlineCode = true; inlineStart = i; } else { // End of inline code regions.push({ start: inlineStart, end: i + 1, }); inInlineCode = false; } } } // Handle unclosed inline code (extends to end of content) if (inInlineCode) { regions.push({ start: inlineStart, end: content.length, }); } // Sort regions by start position for efficient checking regions.sort((a, b) => a.start - b.start); return regions; } /** * Check if a position range is within any excluded region */ function isInExcludedRegion( start: number, end: number, regions: ExcludedRegion[] ): boolean { for (const region of regions) { // Check if the range overlaps with this excluded region if (start < region.end && end > region.start) { return true; } } return false; }