Files
stack/apps/api/src/knowledge/utils/wiki-link-parser.ts
T
Jason Woltje 1e5fcd19a4 feat(#59): implement wiki-link parser
- 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
2026-01-29 17:42:49 -06:00

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;
}