Files
stack/apps/web/src/components/knowledge/WikiLinkRenderer.tsx
Jason Woltje ee9663a1f6 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
2026-01-30 00:06:48 -06:00

112 lines
3.1 KiB
TypeScript

"use client";
import React from "react";
import Link from "next/link";
interface WikiLinkRendererProps {
/** HTML content with wiki-links to parse */
html: string;
/** Additional CSS classes */
className?: string;
}
/**
* WikiLinkRenderer - Parses and renders wiki-links in HTML content
*
* Converts:
* - [[slug]] → clickable link to /knowledge/slug
* - [[slug|display text]] → clickable link with custom text
*
* Features:
* - Distinct styling for wiki-links (blue color, underline)
* - Graceful handling of broken links (gray out)
* - Preserves all other HTML formatting
*/
export function WikiLinkRenderer({
html,
className = "",
}: WikiLinkRendererProps): React.ReactElement {
const processedHtml = React.useMemo(() => {
return parseWikiLinks(html);
}, [html]);
return (
<div
className={`wiki-link-content ${className}`}
dangerouslySetInnerHTML={{ __html: processedHtml }}
onClick={handleWikiLinkClick}
/>
);
}
/**
* Parse wiki-links in HTML and convert to anchor tags
*
* Supports:
* - [[slug]] - basic link
* - [[slug|display text]] - link with custom display text
*/
function parseWikiLinks(html: string): string {
// Match [[...]] patterns
// Group 1: target slug
// Group 2: optional display text after |
const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
return html.replace(wikiLinkRegex, (match, slug: string, displayText?: string) => {
const trimmedSlug = slug.trim();
const text = displayText?.trim() || trimmedSlug;
// Create a styled link
// Using data-wiki-link attribute for styling and click handling
return `<a
href="/knowledge/${encodeURIComponent(trimmedSlug)}"
data-wiki-link="true"
data-slug="${encodeURIComponent(trimmedSlug)}"
class="wiki-link text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline decoration-dotted hover:decoration-solid transition-colors"
title="Go to ${trimmedSlug}"
>${escapeHtml(text)}</a>`;
});
}
/**
* Handle wiki-link clicks
* Intercepts clicks on wiki-links to use Next.js navigation
*/
function handleWikiLinkClick(e: React.MouseEvent<HTMLDivElement>): void {
const target = e.target as HTMLElement;
// Check if the clicked element is a wiki-link
if (target.tagName === "A" && target.dataset.wikiLink === "true") {
const href = target.getAttribute("href");
if (href && href.startsWith("/knowledge/")) {
// Let Next.js Link handle navigation naturally
// No need to preventDefault - the href will work
}
}
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
/**
* Custom hook to check if a wiki-link target exists
* (For future enhancement - mark broken links differently)
*/
export function useWikiLinkValidation(slug: string): {
isValid: boolean;
isLoading: boolean;
} {
// Placeholder for future implementation
// Could fetch from /api/knowledge/entries/:slug to check existence
return {
isValid: true,
isLoading: false,
};
}