feat: add backlinks display and wiki-link rendering (closes #62, #64)

Implements two key knowledge module features:

**#62 - Backlinks Display:**
- Added BacklinksList component to show entries that link to current entry
- Fetches backlinks from /api/knowledge/entries/:slug/backlinks
- Displays entry title, summary, and link context
- Clickable links to navigate to linking entries
- Loading, error, and empty states

**#64 - Wiki-Link Rendering:**
- Added WikiLinkRenderer component to parse and render wiki-links
- Supports [[slug]] and [[slug|display text]] syntax
- Converts wiki-links to clickable navigation links
- Distinct styling (blue color, dotted underline)
- XSS protection via HTML escaping
- Memoized HTML processing for performance

**Components:**
- BacklinksList.tsx - Backlinks display with empty/loading/error states
- WikiLinkRenderer.tsx - Wiki-link parser and renderer
- Updated EntryViewer.tsx to use WikiLinkRenderer
- Integrated BacklinksList into entry detail page

**API:**
- Added fetchBacklinks() function in knowledge.ts
- Added KnowledgeBacklink type to shared types

**Tests:**
- Comprehensive tests for BacklinksList (8 tests)
- Comprehensive tests for WikiLinkRenderer (14 tests)
- All tests passing with Vitest

**Type Safety:**
- Strict TypeScript compliance
- No 'any' types
- Proper error handling
This commit is contained in:
Jason Woltje
2026-01-30 00:06:48 -06:00
parent 806a518467
commit ee9663a1f6
10 changed files with 849 additions and 6 deletions

View File

@@ -0,0 +1,194 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { BacklinksList } from "../BacklinksList";
import type { KnowledgeBacklink } from "@mosaic/shared";
// Mock Next.js Link component
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
},
}));
describe("BacklinksList", () => {
const mockBacklinks: KnowledgeBacklink[] = [
{
id: "link-1",
sourceId: "entry-1",
targetId: "entry-2",
linkText: "target-entry",
displayText: "Target Entry",
positionStart: 0,
positionEnd: 15,
resolved: true,
context: "This is a link to [[target-entry]]",
createdAt: new Date("2026-01-29T10:00:00Z"),
source: {
id: "entry-1",
title: "Source Entry One",
slug: "source-entry-one",
summary: "This entry links to the target",
},
},
{
id: "link-2",
sourceId: "entry-3",
targetId: "entry-2",
linkText: "target-entry",
displayText: "Another Link",
positionStart: 10,
positionEnd: 25,
resolved: true,
context: null,
createdAt: new Date("2026-01-28T15:30:00Z"),
source: {
id: "entry-3",
title: "Source Entry Two",
slug: "source-entry-two",
summary: null,
},
},
];
it("renders loading state correctly", () => {
render(<BacklinksList backlinks={[]} isLoading={true} />);
expect(screen.getByText("Backlinks")).toBeInTheDocument();
// Should show skeleton loaders
const skeletons = document.querySelectorAll(".animate-pulse");
expect(skeletons.length).toBeGreaterThan(0);
});
it("renders error state correctly", () => {
render(
<BacklinksList
backlinks={[]}
isLoading={false}
error="Failed to load backlinks"
/>
);
expect(screen.getByText("Backlinks")).toBeInTheDocument();
expect(screen.getByText("Failed to load backlinks")).toBeInTheDocument();
});
it("renders empty state when no backlinks exist", () => {
render(<BacklinksList backlinks={[]} isLoading={false} />);
expect(screen.getByText("Backlinks")).toBeInTheDocument();
expect(
screen.getByText("No other entries link to this page yet.")
).toBeInTheDocument();
});
it("renders backlinks list correctly", () => {
render(<BacklinksList backlinks={mockBacklinks} isLoading={false} />);
// Should show title with count
expect(screen.getByText("Backlinks")).toBeInTheDocument();
expect(screen.getByText("(2)")).toBeInTheDocument();
// Should show both backlink titles
expect(screen.getByText("Source Entry One")).toBeInTheDocument();
expect(screen.getByText("Source Entry Two")).toBeInTheDocument();
// Should show summary for first entry
expect(
screen.getByText("This entry links to the target")
).toBeInTheDocument();
// Should show context for first entry
expect(
screen.getByText(/This is a link to \[\[target-entry\]\]/)
).toBeInTheDocument();
});
it("generates correct links for backlinks", () => {
render(<BacklinksList backlinks={mockBacklinks} isLoading={false} />);
const links = screen.getAllByRole("link");
// Should have links to source entries
expect(links[0]).toHaveAttribute("href", "/knowledge/source-entry-one");
expect(links[1]).toHaveAttribute("href", "/knowledge/source-entry-two");
});
it("displays date information correctly", () => {
render(<BacklinksList backlinks={mockBacklinks} isLoading={false} />);
// Should display relative dates (implementation depends on current date)
// Just verify date elements are present
const timeElements = document.querySelectorAll('[class*="text-xs"]');
expect(timeElements.length).toBeGreaterThan(0);
});
it("handles backlinks without summaries", () => {
const sourceBacklink = mockBacklinks[1];
if (!sourceBacklink) {
throw new Error("Test setup error: mockBacklinks[1] is undefined");
}
const backlinksWithoutSummary: KnowledgeBacklink[] = [
{
id: sourceBacklink.id,
sourceId: sourceBacklink.sourceId,
targetId: sourceBacklink.targetId,
linkText: sourceBacklink.linkText,
displayText: sourceBacklink.displayText,
positionStart: sourceBacklink.positionStart,
positionEnd: sourceBacklink.positionEnd,
resolved: sourceBacklink.resolved,
context: sourceBacklink.context,
createdAt: sourceBacklink.createdAt,
source: {
id: sourceBacklink.source.id,
title: sourceBacklink.source.title,
slug: sourceBacklink.source.slug,
summary: null,
},
},
];
render(
<BacklinksList backlinks={backlinksWithoutSummary} isLoading={false} />
);
expect(screen.getByText("Source Entry Two")).toBeInTheDocument();
// Summary should not be rendered
expect(screen.queryByText("This entry links to the target")).not.toBeInTheDocument();
});
it("handles backlinks without context", () => {
const sourceBacklink = mockBacklinks[0];
if (!sourceBacklink) {
throw new Error("Test setup error: mockBacklinks[0] is undefined");
}
const backlinksWithoutContext: KnowledgeBacklink[] = [
{
id: sourceBacklink.id,
sourceId: sourceBacklink.sourceId,
targetId: sourceBacklink.targetId,
linkText: sourceBacklink.linkText,
displayText: sourceBacklink.displayText,
positionStart: sourceBacklink.positionStart,
positionEnd: sourceBacklink.positionEnd,
resolved: sourceBacklink.resolved,
context: null,
createdAt: sourceBacklink.createdAt,
source: sourceBacklink.source,
},
];
render(
<BacklinksList backlinks={backlinksWithoutContext} isLoading={false} />
);
expect(screen.getByText("Source Entry One")).toBeInTheDocument();
// Context should not be rendered
expect(
screen.queryByText(/This is a link to \[\[target-entry\]\]/)
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,185 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { WikiLinkRenderer } from "../WikiLinkRenderer";
// Mock Next.js Link component
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
},
}));
describe("WikiLinkRenderer", () => {
it("renders plain HTML without wiki-links", () => {
const html = "<p>This is plain <strong>HTML</strong> content.</p>";
render(<WikiLinkRenderer html={html} />);
expect(screen.getByText(/This is plain/)).toBeInTheDocument();
expect(screen.getByText("HTML")).toBeInTheDocument();
});
it("converts basic wiki-links [[slug]] to anchor tags", () => {
const html = "<p>Check out [[my-entry]] for more info.</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/knowledge/my-entry");
expect(link).toHaveAttribute("data-slug", "my-entry");
expect(link).toHaveTextContent("my-entry");
});
it("converts wiki-links with display text [[slug|text]]", () => {
const html = "<p>Read the [[architecture|Architecture Guide]] please.</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/knowledge/architecture");
expect(link).toHaveAttribute("data-slug", "architecture");
expect(link).toHaveTextContent("Architecture Guide");
});
it("handles multiple wiki-links in the same content", () => {
const html =
"<p>See [[page-one]] and [[page-two|Page Two]] for details.</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const links = container.querySelectorAll('a[data-wiki-link="true"]');
expect(links.length).toBe(2);
expect(links[0]).toHaveAttribute("href", "/knowledge/page-one");
expect(links[0]).toHaveTextContent("page-one");
expect(links[1]).toHaveAttribute("href", "/knowledge/page-two");
expect(links[1]).toHaveTextContent("Page Two");
});
it("handles wiki-links with whitespace", () => {
const html = "<p>Check [[ my-entry ]] and [[ other-entry | Other Entry ]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const links = container.querySelectorAll('a[data-wiki-link="true"]');
expect(links.length).toBe(2);
// Whitespace should be trimmed
expect(links[0]).toHaveAttribute("href", "/knowledge/my-entry");
expect(links[1]).toHaveAttribute("href", "/knowledge/other-entry");
expect(links[1]).toHaveTextContent("Other Entry");
});
it("escapes HTML in link text to prevent XSS", () => {
const html = "<p>[[entry|<script>alert('xss')</script>]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toBeInTheDocument();
// Script tags should be escaped
const linkHtml = link?.innerHTML || "";
expect(linkHtml).not.toContain("<script>");
expect(linkHtml).toContain("&lt;script&gt;");
});
it("preserves other HTML structure while converting wiki-links", () => {
const html = `
<h2>Title</h2>
<p>Paragraph with [[link-one|Link One]].</p>
<ul>
<li>Item with [[link-two]]</li>
</ul>
`;
const { container } = render(<WikiLinkRenderer html={html} />);
// Should preserve HTML structure
expect(container.querySelector("h2")).toBeInTheDocument();
expect(container.querySelector("ul")).toBeInTheDocument();
expect(container.querySelector("li")).toBeInTheDocument();
// Should have converted wiki-links
const links = container.querySelectorAll('a[data-wiki-link="true"]');
expect(links.length).toBe(2);
});
it("applies custom className to wrapper div", () => {
const html = "<p>Content</p>";
const { container } = render(
<WikiLinkRenderer html={html} className="custom-class" />
);
const wrapper = container.querySelector(".wiki-link-content");
expect(wrapper).toHaveClass("custom-class");
});
it("applies wiki-link styling classes", () => {
const html = "<p>[[test-link]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toHaveClass("wiki-link");
expect(link).toHaveClass("text-blue-600");
expect(link).toHaveClass("dark:text-blue-400");
expect(link).toHaveClass("underline");
});
it("handles encoded special characters in slugs", () => {
const html = "<p>[[hello-world-2026]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toHaveAttribute("href", "/knowledge/hello-world-2026");
});
it("does not convert malformed wiki-links", () => {
const html = "<p>[[incomplete and [mismatched] brackets</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// Should not create wiki-links for malformed patterns
const links = container.querySelectorAll('a[data-wiki-link="true"]');
expect(links.length).toBe(0);
// Original text should be preserved
expect(container.textContent).toContain("[[incomplete");
});
it("handles nested HTML within paragraphs containing wiki-links", () => {
const html = "<p>Text with <em>emphasis</em> and [[my-link|My Link]].</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// Should preserve nested HTML
expect(container.querySelector("em")).toBeInTheDocument();
// Should still convert wiki-link
const link = container.querySelector('a[data-wiki-link="true"]');
expect(link).toHaveAttribute("href", "/knowledge/my-link");
});
it("handles empty wiki-links gracefully", () => {
const html = "<p>Empty link: [[]]</p>";
const { container } = render(<WikiLinkRenderer html={html} />);
// Should handle empty slugs (though they're not valid)
// The regex should match but create a link with empty slug
const links = container.querySelectorAll('a[data-wiki-link="true"]');
// Depending on implementation, this might create a link or skip it
// Either way, it shouldn't crash
expect(container.textContent).toContain("Empty link:");
});
it("memoizes processed HTML to avoid unnecessary re-parsing", () => {
const html = "<p>[[test-link]]</p>";
const { rerender, container } = render(<WikiLinkRenderer html={html} />);
const firstLink = container.querySelector('a[data-wiki-link="true"]');
expect(firstLink).toBeInTheDocument();
// Re-render with same HTML
rerender(<WikiLinkRenderer html={html} />);
// Should still have the link (memoization test is implicit)
const secondLink = container.querySelector('a[data-wiki-link="true"]');
expect(secondLink).toBeInTheDocument();
});
});