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:
@@ -8,7 +8,14 @@ import { EntryViewer } from "@/components/knowledge/EntryViewer";
|
|||||||
import { EntryEditor } from "@/components/knowledge/EntryEditor";
|
import { EntryEditor } from "@/components/knowledge/EntryEditor";
|
||||||
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
|
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
|
||||||
import { VersionHistory } from "@/components/knowledge/VersionHistory";
|
import { VersionHistory } from "@/components/knowledge/VersionHistory";
|
||||||
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
|
import { BacklinksList } from "@/components/knowledge/BacklinksList";
|
||||||
|
import {
|
||||||
|
fetchEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
fetchTags,
|
||||||
|
fetchBacklinks,
|
||||||
|
} from "@/lib/api/knowledge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Knowledge Entry Detail/Editor Page
|
* Knowledge Entry Detail/Editor Page
|
||||||
@@ -25,6 +32,11 @@ export default function EntryPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Backlinks state
|
||||||
|
const [backlinks, setBacklinks] = useState<any[]>([]);
|
||||||
|
const [backlinksLoading, setBacklinksLoading] = useState(false);
|
||||||
|
const [backlinksError, setBacklinksError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
const [editContent, setEditContent] = useState("");
|
const [editContent, setEditContent] = useState("");
|
||||||
@@ -56,6 +68,25 @@ export default function EntryPage() {
|
|||||||
void loadEntry();
|
void loadEntry();
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
|
// Load backlinks
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadBacklinks(): Promise<void> {
|
||||||
|
try {
|
||||||
|
setBacklinksLoading(true);
|
||||||
|
setBacklinksError(null);
|
||||||
|
const data = await fetchBacklinks(slug);
|
||||||
|
setBacklinks(data.backlinks);
|
||||||
|
} catch (err) {
|
||||||
|
setBacklinksError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load backlinks"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBacklinksLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadBacklinks();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
// Load available tags
|
// Load available tags
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadTags(): Promise<void> {
|
async function loadTags(): Promise<void> {
|
||||||
@@ -324,7 +355,18 @@ export default function EntryPage() {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<EntryEditor content={editContent} onChange={setEditContent} />
|
<EntryEditor content={editContent} onChange={setEditContent} />
|
||||||
) : activeTab === "content" ? (
|
) : activeTab === "content" ? (
|
||||||
<EntryViewer entry={entry} />
|
<>
|
||||||
|
<EntryViewer entry={entry} />
|
||||||
|
|
||||||
|
{/* Backlinks Section */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<BacklinksList
|
||||||
|
backlinks={backlinks}
|
||||||
|
isLoading={backlinksLoading}
|
||||||
|
error={backlinksError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<VersionHistory slug={slug} onRestore={handleVersionRestore} />
|
<VersionHistory slug={slug} onRestore={handleVersionRestore} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
160
apps/web/src/components/knowledge/BacklinksList.tsx
Normal file
160
apps/web/src/components/knowledge/BacklinksList.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { KnowledgeBacklink } from "@mosaic/shared";
|
||||||
|
import { Clock, Link2, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
interface BacklinksListProps {
|
||||||
|
/** Array of backlinks to display */
|
||||||
|
backlinks: KnowledgeBacklink[];
|
||||||
|
/** Loading state */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Error message if loading failed */
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BacklinksList - Displays entries that link to the current entry
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Shows entry title, summary, and link count
|
||||||
|
* - Click to navigate to linking entry
|
||||||
|
* - Empty state when no backlinks exist
|
||||||
|
* - Loading skeleton
|
||||||
|
*/
|
||||||
|
export function BacklinksList({
|
||||||
|
backlinks,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
}: BacklinksListProps): React.ReactElement {
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5" />
|
||||||
|
Backlinks
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 animate-pulse">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="h-5 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5" />
|
||||||
|
Backlinks
|
||||||
|
</h3>
|
||||||
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (backlinks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5" />
|
||||||
|
Backlinks
|
||||||
|
</h3>
|
||||||
|
<div className="p-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-400 dark:text-gray-600" />
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
No other entries link to this page yet.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
Use <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded">[[slug]]</code> to create links
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backlinks list
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5" />
|
||||||
|
Backlinks
|
||||||
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
({backlinks.length})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{backlinks.map((backlink) => (
|
||||||
|
<Link
|
||||||
|
key={backlink.id}
|
||||||
|
href={`/knowledge/${backlink.source.slug}`}
|
||||||
|
className="block p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{backlink.source.title}
|
||||||
|
</h4>
|
||||||
|
{backlink.source.summary && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{backlink.source.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{backlink.context && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500 italic bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded">
|
||||||
|
“{backlink.context}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>{formatDate(backlink.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for display
|
||||||
|
*/
|
||||||
|
function formatDate(date: Date | string): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return "Today";
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return "Yesterday";
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
} else if (diffDays < 30) {
|
||||||
|
return `${Math.floor(diffDays / 7)}w ago`;
|
||||||
|
} else {
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,22 +2,23 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||||
|
import { WikiLinkRenderer } from "./WikiLinkRenderer";
|
||||||
|
|
||||||
interface EntryViewerProps {
|
interface EntryViewerProps {
|
||||||
entry: KnowledgeEntryWithTags;
|
entry: KnowledgeEntryWithTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntryViewer - Displays rendered markdown content
|
* EntryViewer - Displays rendered markdown content with wiki-link support
|
||||||
*/
|
*/
|
||||||
export function EntryViewer({ entry }: EntryViewerProps) {
|
export function EntryViewer({ entry }: EntryViewerProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="entry-viewer">
|
<div className="entry-viewer">
|
||||||
<div className="entry-content">
|
<div className="entry-content">
|
||||||
{entry.contentHtml ? (
|
{entry.contentHtml ? (
|
||||||
<div
|
<WikiLinkRenderer
|
||||||
|
html={entry.contentHtml}
|
||||||
className="prose prose-sm max-w-none dark:prose-invert"
|
className="prose prose-sm max-w-none dark:prose-invert"
|
||||||
dangerouslySetInnerHTML={{ __html: entry.contentHtml }}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
<div className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||||
|
|||||||
111
apps/web/src/components/knowledge/WikiLinkRenderer.tsx
Normal file
111
apps/web/src/components/knowledge/WikiLinkRenderer.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* Knowledge module components
|
* Knowledge module components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export { BacklinksList } from "./BacklinksList";
|
||||||
export { EntryViewer } from "./EntryViewer";
|
export { EntryViewer } from "./EntryViewer";
|
||||||
export { EntryEditor } from "./EntryEditor";
|
export { EntryEditor } from "./EntryEditor";
|
||||||
export { EntryMetadata } from "./EntryMetadata";
|
export { EntryMetadata } from "./EntryMetadata";
|
||||||
|
|||||||
@@ -181,6 +181,55 @@ export async function restoreVersion(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch backlinks for an entry (entries that link to this entry)
|
||||||
|
*/
|
||||||
|
export async function fetchBacklinks(slug: string): Promise<{
|
||||||
|
entry: { id: string; slug: string; title: string };
|
||||||
|
backlinks: Array<{
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
targetId: string;
|
||||||
|
linkText: string;
|
||||||
|
displayText: string;
|
||||||
|
positionStart: number;
|
||||||
|
positionEnd: number;
|
||||||
|
resolved: boolean;
|
||||||
|
context: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
source: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
count: number;
|
||||||
|
}> {
|
||||||
|
return apiGet<{
|
||||||
|
entry: { id: string; slug: string; title: string };
|
||||||
|
backlinks: Array<{
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
targetId: string;
|
||||||
|
linkText: string;
|
||||||
|
displayText: string;
|
||||||
|
positionStart: number;
|
||||||
|
positionEnd: number;
|
||||||
|
resolved: boolean;
|
||||||
|
context: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
source: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
count: number;
|
||||||
|
}>(`/api/knowledge/entries/${slug}/backlinks`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock entries for development (until backend endpoints are ready)
|
* Mock entries for development (until backend endpoints are ready)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -211,6 +211,29 @@ export interface KnowledgeEntryVersionWithAuthor extends KnowledgeEntryVersion {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowledge entry backlink
|
||||||
|
* Represents an entry that links to another entry
|
||||||
|
*/
|
||||||
|
export interface KnowledgeBacklink {
|
||||||
|
readonly id: string;
|
||||||
|
sourceId: string;
|
||||||
|
targetId: string;
|
||||||
|
linkText: string;
|
||||||
|
displayText: string;
|
||||||
|
positionStart: number;
|
||||||
|
positionEnd: number;
|
||||||
|
resolved: boolean;
|
||||||
|
context: string | null;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
source: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain entity
|
* Domain entity
|
||||||
*/
|
*/
|
||||||
|
|||||||
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
@@ -132,6 +132,9 @@ importers:
|
|||||||
'@types/highlight.js':
|
'@types/highlight.js':
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
|
'@types/ioredis':
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.4
|
specifier: ^22.13.4
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
@@ -1167,6 +1170,9 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.0':
|
||||||
|
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
|
||||||
|
|
||||||
'@isaacs/balanced-match@4.0.1':
|
'@isaacs/balanced-match@4.0.1':
|
||||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -1837,6 +1843,10 @@ packages:
|
|||||||
'@types/http-errors@2.0.5':
|
'@types/http-errors@2.0.5':
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||||
|
|
||||||
|
'@types/ioredis@5.0.0':
|
||||||
|
resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==}
|
||||||
|
deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -2448,6 +2458,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2:
|
||||||
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -2772,6 +2786,10 @@ packages:
|
|||||||
delaunator@5.0.1:
|
delaunator@5.0.1:
|
||||||
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
|
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3339,6 +3357,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ioredis@5.9.2:
|
||||||
|
resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -3541,6 +3563,12 @@ packages:
|
|||||||
lodash-es@4.17.23:
|
lodash-es@4.17.23:
|
||||||
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0:
|
||||||
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0:
|
||||||
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@@ -4108,6 +4136,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
redis-errors@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
reflect-metadata@0.2.2:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
@@ -4304,6 +4340,9 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -5813,6 +5852,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.0': {}
|
||||||
|
|
||||||
'@isaacs/balanced-match@4.0.1': {}
|
'@isaacs/balanced-match@4.0.1': {}
|
||||||
|
|
||||||
'@isaacs/brace-expansion@5.0.0':
|
'@isaacs/brace-expansion@5.0.0':
|
||||||
@@ -6452,6 +6493,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/http-errors@2.0.5': {}
|
'@types/http-errors@2.0.5': {}
|
||||||
|
|
||||||
|
'@types/ioredis@5.0.0':
|
||||||
|
dependencies:
|
||||||
|
ioredis: 5.9.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/marked@6.0.0':
|
'@types/marked@6.0.0':
|
||||||
@@ -7225,6 +7272,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -7543,6 +7592,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
robust-predicates: 3.0.2
|
robust-predicates: 3.0.2
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
@@ -8094,6 +8145,20 @@ snapshots:
|
|||||||
|
|
||||||
internmap@2.0.3: {}
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
|
ioredis@5.9.2:
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 1.5.0
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
denque: 2.1.0
|
||||||
|
lodash.defaults: 4.2.0
|
||||||
|
lodash.isarguments: 3.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
redis-parser: 3.0.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
@@ -8275,6 +8340,10 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.23: {}
|
lodash-es@4.17.23: {}
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
@@ -8843,6 +8912,12 @@ snapshots:
|
|||||||
indent-string: 4.0.0
|
indent-string: 4.0.0
|
||||||
strip-indent: 3.0.0
|
strip-indent: 3.0.0
|
||||||
|
|
||||||
|
redis-errors@1.2.0: {}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
|
|
||||||
regexp-to-ast@0.5.0: {}
|
regexp-to-ast@0.5.0: {}
|
||||||
@@ -9135,6 +9210,8 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user