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
This commit is contained in:
Jason Woltje
2026-01-29 17:42:49 -06:00
parent 95833fb4ea
commit 1e5fcd19a4
10 changed files with 2068 additions and 0 deletions

View File

@@ -0,0 +1,435 @@
import { describe, it, expect } from "vitest";
import { parseWikiLinks, WikiLink } from "./wiki-link-parser";
describe("Wiki Link Parser", () => {
describe("Basic Parsing", () => {
it("should parse a simple wiki link", () => {
const content = "This is a [[Page Name]] in text.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({
raw: "[[Page Name]]",
target: "Page Name",
displayText: "Page Name",
start: 10,
end: 23,
});
});
it("should parse multiple wiki links", () => {
const content = "Link to [[First Page]] and [[Second Page]].";
const links = parseWikiLinks(content);
expect(links).toHaveLength(2);
expect(links[0].target).toBe("First Page");
expect(links[0].start).toBe(8);
expect(links[0].end).toBe(22);
expect(links[1].target).toBe("Second Page");
expect(links[1].start).toBe(27);
expect(links[1].end).toBe(42);
});
it("should handle empty content", () => {
const links = parseWikiLinks("");
expect(links).toEqual([]);
});
it("should handle content without links", () => {
const content = "This is just plain text with no wiki links.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should parse link by slug (kebab-case)", () => {
const content = "Reference to [[page-slug-name]].";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("page-slug-name");
expect(links[0].displayText).toBe("page-slug-name");
});
});
describe("Display Text Variation", () => {
it("should parse link with custom display text", () => {
const content = "See [[Page Name|custom display]] for details.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0]).toEqual({
raw: "[[Page Name|custom display]]",
target: "Page Name",
displayText: "custom display",
start: 4,
end: 32,
});
});
it("should parse multiple links with display text", () => {
const content = "[[First|One]] and [[Second|Two]]";
const links = parseWikiLinks(content);
expect(links).toHaveLength(2);
expect(links[0].target).toBe("First");
expect(links[0].displayText).toBe("One");
expect(links[1].target).toBe("Second");
expect(links[1].displayText).toBe("Two");
});
it("should handle display text with special characters", () => {
const content = "[[Page|Click here! (details)]]";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].displayText).toBe("Click here! (details)");
});
it("should handle pipe character in target but default display", () => {
const content = "[[Page Name]]";
const links = parseWikiLinks(content);
expect(links[0].target).toBe("Page Name");
expect(links[0].displayText).toBe("Page Name");
});
});
describe("Edge Cases - Brackets", () => {
it("should not parse single brackets", () => {
const content = "This [is not] a wiki link.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should not parse three or more opening brackets", () => {
const content = "This [[[is not]]] a wiki link.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should not parse unmatched brackets", () => {
const content = "This [[is incomplete";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should not parse reversed brackets", () => {
const content = "This ]]not a link[[ text.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should handle nested brackets inside link text", () => {
const content = "[[Page [with] brackets]]";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("Page [with] brackets");
});
it("should handle nested double brackets", () => {
// This is tricky - we should parse the outer link
const content = "[[Outer [[inner]] link]]";
const links = parseWikiLinks(content);
// Should not parse nested double brackets - only the first valid one
expect(links).toHaveLength(1);
expect(links[0].raw).toBe("[[Outer [[inner]]");
});
});
describe("Edge Cases - Escaped Brackets", () => {
it("should not parse escaped opening brackets", () => {
const content = "This \\[[is not a link]] text.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should parse link after escaped brackets", () => {
const content = "Escaped \\[[not link]] but [[real link]] here.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("real link");
});
it("should handle backslash before brackets in various positions", () => {
const content = "Text \\[[ and [[valid link]] more \\]].";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("valid link");
});
});
describe("Edge Cases - Code Blocks", () => {
it("should not parse links in inline code", () => {
const content = "Use `[[WikiLink]]` syntax for linking.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should not parse links in fenced code blocks", () => {
const content = `
Here is some text.
\`\`\`
[[Link in code block]]
\`\`\`
End of text.
`;
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should not parse links in indented code blocks", () => {
const content = `
Normal text here.
[[Link in indented code]]
More code here
Normal text again.
`;
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should parse links outside code blocks but not inside", () => {
const content = `
[[Valid Link]]
\`\`\`
[[Invalid Link]]
\`\`\`
[[Another Valid Link]]
`;
const links = parseWikiLinks(content);
expect(links).toHaveLength(2);
expect(links[0].target).toBe("Valid Link");
expect(links[1].target).toBe("Another Valid Link");
});
it("should not parse links in code blocks with language", () => {
const content = `
\`\`\`typescript
const link = "[[Not A Link]]";
\`\`\`
`;
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should handle multiple inline code sections", () => {
const content = "Use `[[link1]]` or `[[link2]]` but [[real link]] works.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("real link");
});
it("should handle unclosed code backticks correctly", () => {
const content = "Start `code [[link1]] still in code [[link2]]";
const links = parseWikiLinks(content);
// If backtick is unclosed, we shouldn't parse any links after it
expect(links).toEqual([]);
});
it("should handle adjacent code blocks", () => {
const content = "`[[code1]]` text [[valid]] `[[code2]]`";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("valid");
});
});
describe("Edge Cases - Empty and Malformed", () => {
it("should not parse empty link brackets", () => {
const content = "Empty [[]] brackets.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should not parse whitespace-only links", () => {
const content = "Whitespace [[ ]] link.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should trim whitespace from link targets", () => {
const content = "Link [[ Page Name ]] here.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("Page Name");
expect(links[0].displayText).toBe("Page Name");
});
it("should trim whitespace from display text", () => {
const content = "Link [[Target| display text ]] here.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("Target");
expect(links[0].displayText).toBe("display text");
});
it("should not parse link with empty target but display text", () => {
const content = "Link [[|display only]] here.";
const links = parseWikiLinks(content);
expect(links).toEqual([]);
});
it("should handle link with empty display text", () => {
const content = "Link [[Target|]] here.";
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe("Target");
expect(links[0].displayText).toBe("Target");
});
it("should handle multiple pipes", () => {
const content = "Link [[Target|display|extra]] here.";
const links = parseWikiLinks(content);
// Should use first pipe as separator
expect(links).toHaveLength(1);
expect(links[0].target).toBe("Target");
expect(links[0].displayText).toBe("display|extra");
});
});
describe("Position Tracking", () => {
it("should track correct positions for single link", () => {
const content = "Start [[Link]] end";
const links = parseWikiLinks(content);
expect(links[0].start).toBe(6);
expect(links[0].end).toBe(14);
expect(content.substring(links[0].start, links[0].end)).toBe("[[Link]]");
});
it("should track correct positions for multiple links", () => {
const content = "[[First]] middle [[Second]] end";
const links = parseWikiLinks(content);
expect(links[0].start).toBe(0);
expect(links[0].end).toBe(9);
expect(links[1].start).toBe(17);
expect(links[1].end).toBe(27);
expect(content.substring(links[0].start, links[0].end)).toBe("[[First]]");
expect(content.substring(links[1].start, links[1].end)).toBe("[[Second]]");
});
it("should track positions with display text", () => {
const content = "Text [[Target|Display]] more";
const links = parseWikiLinks(content);
expect(links[0].start).toBe(5);
expect(links[0].end).toBe(23);
expect(content.substring(links[0].start, links[0].end)).toBe(
"[[Target|Display]]"
);
});
it("should track positions in multiline content", () => {
const content = `Line 1
Line 2 [[Link]]
Line 3`;
const links = parseWikiLinks(content);
expect(links[0].start).toBe(14);
expect(content.substring(links[0].start, links[0].end)).toBe("[[Link]]");
});
});
describe("Complex Scenarios", () => {
it("should handle realistic markdown content", () => {
const content = `
# Knowledge Base
This is a reference to [[Main Page]] and [[Getting Started|start here]].
You can also check [[FAQ]] for common questions.
\`\`\`typescript
// This [[should not parse]]
const link = "[[also not parsed]]";
\`\`\`
But [[this works]] after code block.
`;
const links = parseWikiLinks(content);
expect(links).toHaveLength(4);
expect(links[0].target).toBe("Main Page");
expect(links[1].target).toBe("Getting Started");
expect(links[1].displayText).toBe("start here");
expect(links[2].target).toBe("FAQ");
expect(links[3].target).toBe("this works");
});
it("should handle links at start and end of content", () => {
const content = "[[Start]] middle [[End]]";
const links = parseWikiLinks(content);
expect(links).toHaveLength(2);
expect(links[0].start).toBe(0);
expect(links[1].end).toBe(content.length);
});
it("should handle consecutive links", () => {
const content = "[[First]][[Second]][[Third]]";
const links = parseWikiLinks(content);
expect(links).toHaveLength(3);
expect(links[0].target).toBe("First");
expect(links[1].target).toBe("Second");
expect(links[2].target).toBe("Third");
});
it("should handle links with unicode characters", () => {
const content = "Link to [[日本語]] and [[Émojis 🚀]].";
const links = parseWikiLinks(content);
expect(links).toHaveLength(2);
expect(links[0].target).toBe("日本語");
expect(links[1].target).toBe("Émojis 🚀");
});
it("should handle very long link text", () => {
const longText = "A".repeat(1000);
const content = `Start [[${longText}]] end`;
const links = parseWikiLinks(content);
expect(links).toHaveLength(1);
expect(links[0].target).toBe(longText);
});
});
describe("Type Safety", () => {
it("should return correctly typed WikiLink objects", () => {
const content = "[[Test Link]]";
const links: WikiLink[] = parseWikiLinks(content);
expect(links[0]).toHaveProperty("raw");
expect(links[0]).toHaveProperty("target");
expect(links[0]).toHaveProperty("displayText");
expect(links[0]).toHaveProperty("start");
expect(links[0]).toHaveProperty("end");
expect(typeof links[0].raw).toBe("string");
expect(typeof links[0].target).toBe("string");
expect(typeof links[0].displayText).toBe("string");
expect(typeof links[0].start).toBe("number");
expect(typeof links[0].end).toBe("number");
});
});
});