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
112 lines
3.1 KiB
TypeScript
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,
|
|
};
|
|
}
|