/** * Link parsing and normalization utilities. * * Parses wiki-style [[links]] and markdown [text](path.md) links. * Handles anchors, collection prefixes, and display text aliases. * * @module src/core/links */ // node:path/posix for POSIX paths (relPaths are always POSIX in gno) import { posix as pathPosix } from "node:path"; import type { ExcludedRange } from "../ingestion/strip"; import { buildLineOffsets, offsetToPosition } from "../ingestion/position"; import { rangeIntersectsExcluded } from "../ingestion/strip"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export type LinkKind = "wiki" | "markdown"; export interface ParsedLink { /** Link type */ kind: LinkKind; /** Original text including brackets */ raw: string; /** Path/name WITHOUT anchor or collection prefix */ targetRef: string; /** Fragment without # */ targetAnchor?: string; /** Explicit collection prefix */ targetCollection?: string; /** Display text if different (truncated 256 graphemes) */ displayText?: string; /** 1-based line number (in original doc) */ startLine: number; /** 1-based column (in original doc) */ startCol: number; /** 1-based end line */ endLine: number; /** 1-based end column */ endCol: number; } export interface TargetParts { /** Reference (name or path) without anchor */ ref: string; /** Anchor/fragment without # */ anchor?: string; /** Collection prefix */ collection?: string; } // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── /** Max graphemes for display text before truncation */ const MAX_DISPLAY_TEXT_GRAPHEMES = 256; /** Safe percent-encoded chars to decode */ const SAFE_PERCENT_DECODE: Record = { "%20": " ", "%28": "(", "%29": ")", }; /** Chars that should never be decoded (security) */ const UNSAFE_PERCENT_CODES = new Set(["%2F", "%5C", "%00", "%2f", "%5c"]); // ───────────────────────────────────────────────────────────────────────────── // Regex Patterns // ───────────────────────────────────────────────────────────────────────────── /** * Wiki link: [[target]] or [[target|alias]] or [[target#anchor]] or [[collection:target]] * Captures: 1=content inside brackets */ const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g; /** Logseq embed: {{embed [[Page]]}} or {{embed ((block-id))}} */ const LOGSEQ_EMBED_REGEX = /\{\{\s*embed\s+(\[\[[^\]]+\]\]|\(\([^)]+\)\))\s*\}\}/gi; /** * Markdown inline link: [text](url) * Captures: 1=text, 2=url (path and optional anchor) * Negative lookbehind to avoid image links ![]() * * SCOPE LIMITATIONS: * - Only matches simple inline links [text](url) * - Does NOT match reference-style links [text][ref] or [text] * - Does NOT match autolinks or bare URLs * - Parens in URLs not supported (use %28 %29 encoding) */ const MARKDOWN_LINK_REGEX = /(?= 0 ? remaining.slice(0, hashIndex) : remaining; const prefixMatch = COLLECTION_PREFIX_REGEX.exec(textBeforeHash); if (prefixMatch?.[1] && prefixMatch[2]) { collection = prefixMatch[1].toLowerCase(); // Normalize to lowercase for consistency // Reconstruct remaining without the collection prefix remaining = prefixMatch[2] + (hashIndex >= 0 ? remaining.slice(hashIndex) : ""); } // Split on # for anchor const parts = remaining.split("#"); const ref = (parts[0] ?? "").trim(); const anchor = parts[1]?.trim(); return { ref, anchor: anchor && anchor.length > 0 ? anchor : undefined, collection, }; } // ───────────────────────────────────────────────────────────────────────────── // Link Parsing // ───────────────────────────────────────────────────────────────────────────── /** * Parse all links from markdown content. * Skips links inside excluded ranges (code blocks, frontmatter, etc.). * * @param markdown - Original markdown content * @param lineOffsets - Precomputed line offsets from buildLineOffsets() * @param excludedRanges - Ranges to skip from getExcludedRanges() */ export function parseLinks( markdown: string, lineOffsets: number[], excludedRanges: ExcludedRange[] ): ParsedLink[] { const links: ParsedLink[] = []; const pushWikiLink = ( raw: string, target: string, startOffset: number, endOffset: number, displayText?: string ): void => { const trimmedTarget = target.trim(); const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedTarget) || trimmedTarget.startsWith("mailto:"); if (hasScheme || trimmedTarget.startsWith("//")) { return; } const parts = parseTargetParts(trimmedTarget); if (!parts.ref) { return; } const startPos = offsetToPosition(startOffset, lineOffsets); const endPos = offsetToPosition(endOffset, lineOffsets); links.push({ kind: "wiki", raw, targetRef: parts.ref, targetAnchor: parts.anchor, targetCollection: parts.collection, displayText, startLine: startPos.line, startCol: startPos.col, endLine: endPos.line, endCol: endPos.col, }); }; // Parse wiki links WIKI_LINK_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = WIKI_LINK_REGEX.exec(markdown)) !== null) { const startOffset = match.index; const endOffset = startOffset + match[0].length; // Skip [[target]] nested in Logseq alias syntax: [Display]([[target]]) if ( markdown.slice(Math.max(0, startOffset - 2), startOffset) === "](" && markdown.slice(endOffset, endOffset + 1) === ")" ) { continue; } // Skip if inside excluded range if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) { continue; } const content = match[1]; if (!content) continue; // Parse [[target|alias]] format const pipeIndex = content.indexOf("|"); let targetPart: string; let displayText: string | undefined; if (pipeIndex >= 0) { targetPart = content.slice(0, pipeIndex); const aliasText = content.slice(pipeIndex + 1); // Only set displayText if different from target displayText = aliasText !== targetPart ? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES) : undefined; } else { targetPart = content; } const trimmedTarget = targetPart.trim(); if (!trimmedTarget) { continue; } pushWikiLink(match[0], trimmedTarget, startOffset, endOffset, displayText); } // Parse Logseq embeds as links LOGSEQ_EMBED_REGEX.lastIndex = 0; while ((match = LOGSEQ_EMBED_REGEX.exec(markdown)) !== null) { const startOffset = match.index; const endOffset = startOffset + match[0].length; if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) { continue; } const embedTarget = match[1]?.trim(); if (!embedTarget) { continue; } if (embedTarget.startsWith("((") && embedTarget.endsWith("))")) { const blockId = embedTarget.slice(2, -2).trim(); if (blockId.length === 0) { continue; } pushWikiLink(match[0], blockId, startOffset, endOffset); } } // Parse markdown links MARKDOWN_LINK_REGEX.lastIndex = 0; while ((match = MARKDOWN_LINK_REGEX.exec(markdown)) !== null) { const startOffset = match.index; const endOffset = startOffset + match[0].length; // Skip if inside excluded range if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) { continue; } const linkText = match[1] ?? ""; const url = match[2]; if (!url) continue; // Logseq alias syntax: [Display]([[Target]]) if (url.startsWith("[[") && url.endsWith("]]")) { const innerTarget = url.slice(2, -2).trim(); if (innerTarget.length > 0) { const displayText = linkText && linkText !== innerTarget ? truncateText(linkText, MAX_DISPLAY_TEXT_GRAPHEMES) : undefined; pushWikiLink( match[0], innerTarget, startOffset, endOffset, displayText ); } continue; } // Skip external URLs if (EXTERNAL_URL_REGEX.test(url)) { continue; } // Skip URLs that look like protocol-relative (//example.com) if (url.startsWith("//")) { continue; } // Parse URL and anchor const hashIndex = url.indexOf("#"); let path: string; let anchor: string | undefined; if (hashIndex >= 0) { path = url.slice(0, hashIndex); const anchorPart = url.slice(hashIndex + 1); anchor = anchorPart.length > 0 ? anchorPart : undefined; } else { path = url; } // Skip empty paths (anchor-only links like #section) if (!path) { continue; } // Check for collection prefix in path const parts = parseTargetParts(path); if (parts.collection) { // Markdown cross-collection links are not supported continue; } const startPos = offsetToPosition(startOffset, lineOffsets); const endPos = offsetToPosition(endOffset, lineOffsets); // Display text is the link text if different from path const displayText = linkText && linkText !== parts.ref ? truncateText(linkText, MAX_DISPLAY_TEXT_GRAPHEMES) : undefined; links.push({ kind: "markdown", raw: match[0], targetRef: parts.ref, targetAnchor: anchor ?? parts.anchor, targetCollection: parts.collection, displayText, startLine: startPos.line, startCol: startPos.col, endLine: endPos.line, endCol: endPos.col, }); } // Sort by position for consistent ordering links.sort((a, b) => { if (a.startLine !== b.startLine) return a.startLine - b.startLine; return a.startCol - b.startCol; }); return links; } /** * Convenience function to parse links with automatic line offset computation. */ export function parseLinksFromContent( markdown: string, excludedRanges: ExcludedRange[] ): ParsedLink[] { const lineOffsets = buildLineOffsets(markdown); return parseLinks(markdown, lineOffsets, excludedRanges); }