fix(explorer): address review feedback on markdown viewer

This commit is contained in:
Sakshi Jain
2026-08-19 09:58:17 +05:30
parent 0f308b2078
commit 5a3bdc393d
3 changed files with 107 additions and 45 deletions
-2
View File
@@ -8,10 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
"test:markdown-viewer": "node --import tsx --test tests/markdownContentViewer.test.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
"dependencies": {
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from "react";
import { useState, useRef, useEffect, type CSSProperties } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
@@ -13,7 +13,9 @@ export interface MarkdownContentViewerProps {
export function isSafeUrl(url?: string): boolean {
if (!url) return false;
const trimmed = url.trim();
if (trimmed.startsWith("#") || trimmed.startsWith("/")) return true;
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("#")) return true;
if (trimmed.startsWith("/")) return true;
try {
const parsed = new URL(trimmed, "http://localhost");
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
@@ -29,6 +31,15 @@ export function MarkdownContentViewer({
}: MarkdownContentViewerProps) {
const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const rawContent = typeof content === "string" ? content : "";
const hasContent = rawContent.trim().length > 0;
@@ -37,8 +48,11 @@ export function MarkdownContentViewer({
if (!hasContent) return;
try {
await navigator.clipboard.writeText(rawContent);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard write unavailable
}
+90 -40
View File
@@ -1,8 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
import React from "react";
import { renderToString } from "react-dom/server";
test("isSafeUrl permits safe http, https, and mailto URLs", () => {
(globalThis as any).React = React;
import { isSafeUrl, MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
test("isSafeUrl permits safe http, https, and mailto URLs and relative paths", () => {
assert.equal(isSafeUrl("https://example.com"), true);
assert.equal(isSafeUrl("http://localhost:8000"), true);
assert.equal(isSafeUrl("mailto:user@example.com"), true);
@@ -10,7 +15,13 @@ test("isSafeUrl permits safe http, https, and mailto URLs", () => {
assert.equal(isSafeUrl("/relative/path"), true);
});
test("isSafeUrl rejects dangerous schemes like javascript:, data:, and vbscript:", () => {
test("isSafeUrl rejects protocol-relative URLs and dangerous schemes", () => {
// Protocol-relative URLs (must be blocked)
assert.equal(isSafeUrl("//evil.com"), false);
assert.equal(isSafeUrl("//localhost:8000"), false);
assert.equal(isSafeUrl("//"), false);
// Dangerous schemes
assert.equal(isSafeUrl("javascript:alert('xss')"), false);
assert.equal(isSafeUrl("JAVASCRIPT:alert(1)"), false);
assert.equal(isSafeUrl("data:text/html;base64,PHNjcmlwdD4="), false);
@@ -19,52 +30,91 @@ test("isSafeUrl rejects dangerous schemes like javascript:, data:, and vbscript:
assert.equal(isSafeUrl(undefined), false);
});
test("preserves exact unmodified content, whitespace, and Unicode in source format", () => {
const sampleMarkdown = `# Title with Unicode 🚀\n\n * Indented item 1\n * Indented item 2\n\n\`\`\`python\ndef test():\n return "α + β = γ"\n\`\`\``;
// Exact characters, newlines, and whitespace must remain unmodified
assert.equal(sampleMarkdown.includes(" * Indented item 1"), true);
assert.equal(sampleMarkdown.includes("🚀"), true);
assert.equal(sampleMarkdown.includes("α + β = γ"), true);
assert.equal(sampleMarkdown.includes(" return"), true);
test("renders Preview mode with formatted Markdown elements and tabs", () => {
const markdown = `# Main Title\n\n**Bold Statement**\n\n* Item A\n* Item B`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "preview" }));
// Tab buttons are present
assert.equal(html.includes("Preview"), true);
assert.equal(html.includes("Source"), true);
assert.equal(html.includes("Copy"), true);
// Formatted preview elements
assert.equal(html.includes("Main Title"), true);
assert.equal(html.includes("Bold Statement"), true);
assert.equal(html.includes("<strong>Bold Statement</strong>"), true);
assert.equal(html.includes("Item A"), true);
assert.equal(html.includes("Item B"), true);
});
test("handles empty, null, and whitespace content gracefully without errors", () => {
const emptyValues = ["", " \n\t ", null, undefined];
for (const val of emptyValues) {
const raw = typeof val === "string" ? val : "";
const hasContent = raw.trim().length > 0;
assert.equal(hasContent, false);
}
test("renders Source mode with exact unmodified text inside pre/code", () => {
const markdown = `# Title 🚀\n\n * Indented item\n\n\`\`\`python\ndef test():\n return "α + β"\n\`\`\``;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "source" }));
assert.equal(html.includes("<pre"), true);
assert.equal(html.includes("<code"), true);
assert.equal(html.includes("# Title 🚀"), true);
assert.equal(html.includes(" * Indented item"), true);
assert.equal(html.includes('return &quot;α + β&quot;'), true);
});
test("handles plain text without requiring Markdown syntax", () => {
const plainText = "Simple plain text summary of graph entity without any formatting.";
const raw = typeof plainText === "string" ? plainText : "";
const hasContent = raw.trim().length > 0;
assert.equal(hasContent, true);
assert.equal(raw, plainText);
test("renders raw HTML safely as escaped text without executing elements", () => {
const dangerousHtml = `<script>alert("XSS")</script><iframe src="https://evil.com"></iframe>`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content: dangerousHtml, defaultMode: "preview" }));
// Script and iframe tags must NOT be rendered as active DOM tags
assert.equal(html.includes("<script>"), false);
assert.equal(html.includes("<iframe"), false);
// Content is escaped as text
assert.equal(html.includes("&lt;script&gt;"), true);
});
test("handles very long content without truncation or performance failure", () => {
const longParagraph = "Semantica knowledge graph node content with structured facts. ".repeat(500);
const longMarkdown = `# Big Document\n\n${longParagraph}\n\n## Section 2\n\n${longParagraph}`;
assert.equal(longMarkdown.length > 50000, true);
const raw = typeof longMarkdown === "string" ? longMarkdown : "";
assert.equal(raw.length, longMarkdown.length);
test("renders safe links as <a> with target blank and unclickable span for unsafe links", () => {
const content = `[Safe Link](https://getsemantica.ai)\n\n[Unsafe Scheme](javascript:alert(1))\n\n[Protocol Relative](//evil.com)`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// Safe link renders as <a> with security attributes
assert.equal(html.includes('href="https://getsemantica.ai"'), true);
assert.equal(html.includes('target="_blank"'), true);
assert.equal(html.includes('rel="noopener noreferrer"'), true);
// Unsafe links do NOT render as <a> tags
assert.equal(html.includes('href="javascript:alert(1)"'), false);
assert.equal(html.includes('href="//evil.com"'), false);
assert.equal(html.includes("Unsafe Scheme"), true);
assert.equal(html.includes("Protocol Relative"), true);
});
test("handles raw HTML content safely as text", () => {
const dangerousHtml = `<script>alert("XSS")</script><img src="x" onerror="steal()"/><iframe src="evil.com"></iframe>`;
// In source mode, content is preserved literally without execution
assert.equal(dangerousHtml.includes("<script>"), true);
assert.equal(dangerousHtml.includes("onerror="), true);
test("renders remote images as safe placeholder badges instead of <img> tags", () => {
const content = `![System Diagram](https://example.com/diagram.png)`;
const html = renderToString(React.createElement(MarkdownContentViewer, { content, defaultMode: "preview" }));
// No <img> tag rendered
assert.equal(html.includes("<img"), false);
// Image placeholder badge rendered
assert.equal(html.includes("Image:"), true);
assert.equal(html.includes("System Diagram"), true);
});
test("preserves fenced code blocks with language identifiers and indentation", () => {
const codeBlockMarkdown = "```typescript\nfunction processGraph(id: string): boolean {\n return id.length > 0;\n}\n```";
assert.equal(codeBlockMarkdown.startsWith("```typescript"), true);
assert.equal(codeBlockMarkdown.includes(" return id.length > 0;"), true);
assert.equal(codeBlockMarkdown.endsWith("```"), true);
test("renders clear empty-state message when content is empty or null", () => {
const emptyHtml = renderToString(React.createElement(MarkdownContentViewer, { content: "" }));
assert.equal(emptyHtml.includes("No content available for this node."), true);
const nullHtml = renderToString(React.createElement(MarkdownContentViewer, { content: null }));
assert.equal(nullHtml.includes("No content available for this node."), true);
});
test("renders plain text cleanly without requiring Markdown formatting", () => {
const plainText = "Plain entity summary text without markdown formatting.";
const html = renderToString(React.createElement(MarkdownContentViewer, { content: plainText, defaultMode: "preview" }));
assert.equal(html.includes(plainText), true);
});
test("handles very large Markdown content without failure", () => {
const largeContent = `# Large Knowledge Node\n\n` + "Structured observation paragraph. ".repeat(400);
assert.equal(largeContent.length > 10000, true);
const html = renderToString(React.createElement(MarkdownContentViewer, { content: largeContent, defaultMode: "preview" }));
assert.equal(html.includes("Large Knowledge Node"), true);
});