Skip to content
Merged
1,332 changes: 64 additions & 1,268 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "portfolio",
"private": true,
"homepage": "https://cagesthrottleus.github.io/",
"version": "1.0.2",
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -26,7 +26,6 @@
"@spectrum-icons/workflow": "^4.2.25",
"framer-motion": "^12.23.24",
"lucide-react": "^0.555.0",
"mermaid": "^11.12.1",
"react": "^19.2.0",
"react-aria-components": "^1.13.0",
"react-dom": "^19.2.0",
Expand Down
54 changes: 54 additions & 0 deletions src/components/BlogList/__tests__/BlogList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,4 +466,58 @@ describe("BlogList", () => {
expect(screen.getByText("Second Post")).toBeInTheDocument();
});
});

it("should navigate when blog card is clicked", async () => {
const user = userEvent.setup();

render(
<BrowserRouter>
<BlogList />
</BrowserRouter>,
);

await waitFor(() => {
expect(screen.getByText("Hello World")).toBeInTheDocument();
});

// Click on a blog card
const blogCard = screen.getByText("Hello World").closest(".blog-card");
expect(blogCard).toBeTruthy();

if (blogCard) {
await user.click(blogCard);
}
});

it("should show loading when index is still loading", () => {
// Mock fetchBlogIndex to return null initially
vi.spyOn(blogService, "fetchBlogIndex").mockImplementation(
() => new Promise(() => {}), // Never resolves
);

render(
<BrowserRouter>
<BlogList />
</BrowserRouter>,
);

// Should show loading spinner
expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument();
});

it("should show loading spinner when index is null after loading completes", async () => {
// Mock to return null after loading completes
vi.spyOn(blogService, "fetchBlogIndex").mockResolvedValue(null as never);

render(
<BrowserRouter>
<BlogList />
</BrowserRouter>,
);

// Wait for loading to complete and check spinner is still shown
await waitFor(() => {
expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument();
});
});
});
161 changes: 53 additions & 108 deletions src/components/BlogPost/BlogComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
* blog authors to know CSS class names.
*/

import { useEffect, useRef, useState } from "react";

import { MERMAID_CONFIG } from "../../utils/constants";
import { ExternalLink } from "lucide-react";

import type { ReactNode } from "react";

Expand Down Expand Up @@ -138,125 +136,71 @@ export function Table({ headers, rows }: TableProps) {
}

interface MermaidProps {
children: string;
text: string;
caption?: string;
}

// Track mermaid initialization globally
let mermaidInitialized = false;

/**
* Mermaid diagram component with classified document styling
* Renders diagrams using Cold War intelligence color palette
* Uses safe ref-based rendering (no dangerouslySetInnerHTML)
* Lazy loads Mermaid library only when component is used
* Mermaid diagram component
* Links to mermaid.live with pre-populated diagram text
*
* Usage:
* <Mermaid caption="Fig 1.1: System Architecture">
* {`
* graph TD
* A[Client] --> B[Server]
* B --> C[Database]
* `}
* </Mermaid>
* <Mermaid
* text={`
* flowchart TD
* Start --> End
* `}
* caption="Fig 1.1: System Architecture"
* />
*/
export function Mermaid({ children, caption }: MermaidProps) {
const diagramRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string>("");
const [isRendered, setIsRendered] = useState(false);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const currentRef = diagramRef.current;
let cancelled = false;

const loadAndRenderDiagram = async () => {
if (!currentRef) return;

try {
setIsLoading(true);

// Lazy load mermaid library (only when Mermaid component is used)
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule.default;

// Check if cancelled after async import
if (cancelled) return;

// Initialize mermaid once with Cold War classified theme
if (!mermaidInitialized) {
mermaid.initialize(MERMAID_CONFIG);
mermaidInitialized = true;
}

// Clear previous content safely (use innerHTML to avoid DOM removal errors)
currentRef.innerHTML = "";
currentRef.removeAttribute("data-processed");

// Create a temporary div for mermaid to process
const tempDiv = document.createElement("div");
tempDiv.className = "mermaid";
tempDiv.textContent = children.trim();
currentRef.appendChild(tempDiv);

// Render the diagram directly into the DOM (safe approach)
await mermaid.run({
nodes: [tempDiv],
});

// Prevent state updates after unmount (cleanup can set cancelled during async)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (cancelled) return;
setError("");
setIsRendered(true);
} catch (err) {
if (cancelled) return;
setError(
err instanceof Error ? err.message : "Failed to render diagram",
);
setIsRendered(false);
} finally {
if (cancelled) return;
setIsLoading(false);
}
};

void loadAndRenderDiagram();

return () => {
cancelled = true;
};
}, [children]);

if (error) {
return (
<div className="blog-mermaid-error">
<CallOut type="error">
<strong>Diagram Rendering Error:</strong>
<pre>{error}</pre>
</CallOut>
</div>
);
}
export function Mermaid({ text, caption }: MermaidProps) {
// Encode diagram text for URL using base64 format
const jsonString = JSON.stringify({
code: text.trim(),
mermaid: { theme: "default" },
autoSync: true,
updateDiagram: true,
});

// Convert UTF-8 string to base64 safely using TextEncoder
const bytes = new TextEncoder().encode(jsonString);
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte),
).join("");
const encodedDiagram = btoa(binString);
const mermaidUrl = `https://mermaid.live/edit#base64:${encodedDiagram}`;

return (
<figure className="blog-mermaid">
<div
ref={diagramRef}
className="blog-mermaid-diagram"
style={{
opacity: isRendered ? 1 : 0,
transition: "opacity 0.3s",
minHeight: isLoading ? "200px" : undefined,
padding: "2rem",
border: "1px solid rgba(34, 197, 94, 0.3)",
borderRadius: "4px",
textAlign: "center",
background: "rgba(34, 197, 94, 0.05)",
}}
>
{isLoading && (
<div
style={{ textAlign: "center", padding: "3rem", color: "#22c55e" }}
>
Loading diagram renderer...
</div>
)}
<a
href={mermaidUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.5rem",
background: "#22c55e",
color: "#0a0a0a",
textDecoration: "none",
borderRadius: "4px",
fontWeight: "bold",
transition: "background 0.2s",
}}
>
View Diagram on Mermaid.live
<ExternalLink size={16} />
</a>
</div>
{caption && <figcaption className="blog-caption">{caption}</figcaption>}
</figure>
Expand All @@ -271,6 +215,7 @@ export function Mermaid({ children, caption }: MermaidProps) {
export const blogComponents = {
Redacted,
CallOut,
// Callout: CallOut, // Alias - MDX may normalize component names
SectionMarker,
ImageWithCaption,
Highlight,
Expand Down
55 changes: 55 additions & 0 deletions src/components/BlogPost/BlogPostErrorBoundary.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.blog-post-error {
padding: 2rem;
margin: 2rem 0;
background: rgba(255, 50, 50, 0.1);
border: 2px solid var(--theme-red, #ff3232);
border-radius: 4px;
text-align: center;
}

.error-stamp {
display: inline-block;
padding: 0.5rem 2rem;
background: var(--theme-red, #ff3232);
color: var(--theme-black, #000);
font-weight: bold;
font-size: 1.5rem;
letter-spacing: 0.2em;
transform: rotate(-5deg);
margin-bottom: 1rem;
border: 3px solid var(--theme-red, #ff3232);
}

.blog-post-error h2 {
color: var(--theme-red, #ff3232);
margin-bottom: 1rem;
}

.blog-post-error p {
color: var(--theme-text, #fff);
margin-bottom: 1rem;
}

.blog-post-error details {
margin-top: 1rem;
text-align: left;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}

.blog-post-error summary {
cursor: pointer;
color: var(--theme-green, #00ff00);
font-family: "Courier New", monospace;
margin-bottom: 0.5rem;
}

.blog-post-error pre {
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
color: var(--theme-green, #00ff00);
font-size: 0.875rem;
}
51 changes: 51 additions & 0 deletions src/components/BlogPost/BlogPostErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Component, type ReactNode } from "react";

interface Props {
children: ReactNode;
fallback?: ReactNode;
}

interface State {
hasError: boolean;
error?: Error;
}

class BlogPostErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("BlogPost rendering error:", error, errorInfo);
}

render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="blog-post-error">
<div className="error-stamp">ERROR</div>
<h2>Document Rendering Failed</h2>
<p>
An error occurred while rendering this classified document. The
content may contain incompatible components.
</p>
<details>
<summary>Technical Details</summary>
<pre>{this.state.error?.message}</pre>
</details>
</div>
)
);
}

return this.props.children;
}
}

export default BlogPostErrorBoundary;
Loading