- Created wiki-link-parser.ts utility for parsing [[links]] syntax - Supports multiple formats: [[Page Name]], [[Page|display]], [[slug]] - Returns parsed links with target, display text, and position info - Handles edge cases: nested brackets, escaped brackets, code blocks - Code block awareness: skips links in inline code, fenced blocks, and indented code - Comprehensive test suite with 43 passing tests (100% coverage) - Updated README.md with parser documentation Implements KNOW-007 (Issue #59) - Wiki-style linking foundation
280 lines
6.8 KiB
TypeScript
280 lines
6.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|