@@ -46,7 +84,9 @@ function BlogPostLayout() {
{/* Post Content */}
-
+
+
+
{/* Footer Navigation */}
diff --git a/src/components/BlogPost/__tests__/BlogComponents.test.tsx b/src/components/BlogPost/__tests__/BlogComponents.test.tsx
index 467f91f..69bd7db 100644
--- a/src/components/BlogPost/__tests__/BlogComponents.test.tsx
+++ b/src/components/BlogPost/__tests__/BlogComponents.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import {
@@ -195,46 +195,50 @@ describe("BlogComponents", () => {
});
describe("Mermaid", () => {
- it("should render mermaid diagram", async () => {
+ it("should render mermaid link", () => {
const diagramCode = `
graph TD
A[Start] --> B[End]
`;
- const { container } = render(
{diagramCode});
+ render(
);
- await waitFor(() => {
- const mermaidDiv = container.querySelector(".blog-mermaid-diagram");
- expect(mermaidDiv).toBeInTheDocument();
- });
+ expect(
+ screen.getByText("View Diagram on Mermaid.live"),
+ ).toBeInTheDocument();
});
- it("should render caption when provided", async () => {
- const diagramCode = "graph TD\nA-->B";
+ it("should render caption when provided", () => {
+ const diagramCode = "graph TD\nA-->B";
- render(
{diagramCode});
+ render(
);
- await waitFor(() => {
- expect(screen.getByText("Fig 1.1: System Flow")).toBeInTheDocument();
- });
+ expect(screen.getByText("Fig 1.1: System Flow")).toBeInTheDocument();
});
- it("should apply blog-mermaid class", async () => {
- const { container } = render(
{"graph TD\nA-->B"});
+ it("should apply blog-mermaid class", () => {
+ const { container } = render(
);
- await waitFor(() => {
- const figure = container.querySelector(".blog-mermaid");
- expect(figure).toBeInTheDocument();
- });
+ const figure = container.querySelector(".blog-mermaid");
+ expect(figure).toBeInTheDocument();
});
- it("should render without caption", async () => {
- const { container } = render(
{"graph TD\nA-->B"});
+ it("should render without caption", () => {
+ const { container } = render(
);
+
+ const caption = container.querySelector(".blog-caption");
+ expect(caption).not.toBeInTheDocument();
+ });
+
+ it("should render link with correct href", () => {
+ const diagramCode = "graph TD\nA-->B";
+
+ render(
);
- await waitFor(() => {
- const caption = container.querySelector(".blog-caption");
- expect(caption).not.toBeInTheDocument();
- });
+ const link = screen.getByText("View Diagram on Mermaid.live");
+ expect(link).toHaveAttribute("href");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
});
diff --git a/src/components/BlogPost/__tests__/BlogPostErrorBoundary.test.tsx b/src/components/BlogPost/__tests__/BlogPostErrorBoundary.test.tsx
new file mode 100644
index 0000000..33aeb05
--- /dev/null
+++ b/src/components/BlogPost/__tests__/BlogPostErrorBoundary.test.tsx
@@ -0,0 +1,114 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import BlogPostErrorBoundary from "../BlogPostErrorBoundary";
+
+// Component that throws an error
+const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
+ if (shouldThrow) {
+ throw new Error("Test rendering error");
+ }
+ return
Normal content
;
+};
+
+describe("BlogPostErrorBoundary", () => {
+ beforeEach(() => {
+ // Suppress console.error in tests
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should render children when no error occurs", () => {
+ render(
+
+ Test content
+ ,
+ );
+
+ expect(screen.getByText("Test content")).toBeInTheDocument();
+ });
+
+ it("should render error UI when child component throws", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Document Rendering Failed")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /An error occurred while rendering this classified document/,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("should display error message in technical details", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Technical Details")).toBeInTheDocument();
+ expect(screen.getByText("Test rendering error")).toBeInTheDocument();
+ });
+
+ it("should render custom fallback when provided", () => {
+ const customFallback =
Custom error message
;
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Custom error message")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Document Rendering Failed"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should log error to console via componentDidCatch", () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ render(
+
+
+ ,
+ );
+
+ // React Error Boundary logs errors - just verify it was called
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+
+ it("should have error stamp in default error UI", () => {
+ render(
+
+
+ ,
+ );
+
+ const errorStamp = screen.getByText("ERROR");
+ expect(errorStamp).toBeInTheDocument();
+ expect(errorStamp).toHaveClass("error-stamp");
+ });
+
+ it("should not render error UI when error is not thrown", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Normal content")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Document Rendering Failed"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx b/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx
index 93aa15c..aeff666 100644
--- a/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx
+++ b/src/components/BlogPost/__tests__/BlogPostLayout.test.tsx
@@ -41,7 +41,9 @@ describe("BlogPostLayout", () => {
expect(screen.getByText("CLASSIFIED TRANSMISSION")).toBeInTheDocument();
});
- it("should render not found component on error", () => {
+ it("should render error UI on error", async () => {
+ const user = userEvent.setup();
+
vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({
MDXContent: null,
metadata: null,
@@ -55,7 +57,15 @@ describe("BlogPostLayout", () => {
,
);
- expect(screen.getByText("PAGE NOT FOUND")).toBeInTheDocument();
+ expect(screen.getByText("Blog Post Error")).toBeInTheDocument();
+ expect(screen.getByText("COMPILATION FAILED")).toBeInTheDocument();
+ expect(screen.getByText("Post not found")).toBeInTheDocument();
+
+ // Click return to archive button
+ const returnButton = screen.getByText("RETURN TO ARCHIVE");
+ await user.click(returnButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith("/blog");
});
it("should render blog post with metadata", () => {
@@ -245,4 +255,47 @@ describe("BlogPostLayout", () => {
screen.getByText("UNAUTHORIZED DISCLOSURE SUBJECT TO CRIMINAL SANCTIONS"),
).toBeInTheDocument();
});
+
+ it("should render not found when metadata is missing but no error", () => {
+ vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({
+ MDXContent: null,
+ metadata: null,
+ loading: false,
+ error: null,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("PAGE NOT FOUND")).toBeInTheDocument();
+ });
+
+ it("should render not found when MDXContent is missing but no error", () => {
+ const mockMetadata = {
+ slug: "test-post",
+ title: "Test Post",
+ classification: "UNCLASSIFIED",
+ abstract: "Test abstract",
+ publishDate: "2025-11-29",
+ version: "1.0",
+ };
+
+ vi.spyOn(useBlogPostHook, "useBlogPost").mockReturnValue({
+ MDXContent: null,
+ metadata: mockMetadata,
+ loading: false,
+ error: null,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("PAGE NOT FOUND")).toBeInTheDocument();
+ });
});
diff --git a/src/components/CursorTracker/__tests__/CursorTracker.test.tsx b/src/components/CursorTracker/__tests__/CursorTracker.test.tsx
new file mode 100644
index 0000000..22d1b87
--- /dev/null
+++ b/src/components/CursorTracker/__tests__/CursorTracker.test.tsx
@@ -0,0 +1,158 @@
+import { render } from "@testing-library/react";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+import CursorTracker from "../CursorTracker";
+
+// Helper to create and dispatch mouse events
+const dispatchMouseMove = (target: HTMLElement, x: number, y: number) => {
+ const event = new MouseEvent("mousemove", {
+ bubbles: true,
+ cancelable: true,
+ clientX: x,
+ clientY: y,
+ });
+ target.dispatchEvent(event);
+};
+
+describe("CursorTracker", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it("should render cursor tracker element", () => {
+ const { container } = render(
);
+ const cursor = container.querySelector(".cursor-tracker");
+ expect(cursor).toBeInTheDocument();
+ });
+
+ it("should update cursor position on mouse move", () => {
+ const { container } = render(
+
,
+ );
+ const cursor = container.querySelector(".cursor-tracker") as HTMLElement;
+ const testArea = container.querySelector(".test-area") as HTMLElement;
+
+ dispatchMouseMove(testArea, 100, 200);
+
+ // Flush requestAnimationFrame
+ vi.runAllTimers();
+
+ expect(cursor.style.getPropertyValue("--cursor-x")).toBe("100px");
+ expect(cursor.style.getPropertyValue("--cursor-y")).toBe("200px");
+ });
+
+ it("should set opacity to 0 when hovering over no-cursor-track elements", () => {
+ const { container } = render(
+
,
+ );
+
+ const cursor = container.querySelector(".cursor-tracker") as HTMLElement;
+ const noTrackElement = container.querySelector(
+ ".no-cursor-track",
+ ) as HTMLElement;
+
+ dispatchMouseMove(noTrackElement, 50, 50);
+
+ vi.runAllTimers();
+
+ expect(cursor.style.opacity).toBe("0");
+ });
+
+ it("should set opacity to 1 when not hovering over no-cursor-track elements", () => {
+ const { container } = render(
+
,
+ );
+ const cursor = container.querySelector(".cursor-tracker") as HTMLElement;
+ const testArea = container.querySelector(".test-area") as HTMLElement;
+
+ dispatchMouseMove(testArea, 100, 200);
+
+ vi.runAllTimers();
+
+ expect(cursor.style.opacity).toBe("1");
+ });
+
+ it("should cleanup event listeners on unmount", () => {
+ const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
+
+ const { unmount } = render(
+
+
+
,
+ );
+
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ "mousemove",
+ expect.any(Function),
+ );
+ });
+
+ it("should cancel animation frame on unmount", () => {
+ const cancelAnimationFrameSpy = vi.spyOn(window, "cancelAnimationFrame");
+
+ const { container, unmount } = render(
+
,
+ );
+
+ const testArea = container.querySelector(".test-area") as HTMLElement;
+
+ // Trigger a mouse move to start an animation frame
+ dispatchMouseMove(testArea, 100, 200);
+
+ unmount();
+
+ // Should have called cancelAnimationFrame during cleanup
+ expect(cancelAnimationFrameSpy).toHaveBeenCalled();
+ });
+
+ it("should handle rapid mouse movements by canceling previous frames", () => {
+ const { container } = render(
+
,
+ );
+ const cursor = container.querySelector(".cursor-tracker") as HTMLElement;
+ const testArea = container.querySelector(".test-area") as HTMLElement;
+
+ // Trigger multiple rapid movements
+ dispatchMouseMove(testArea, 10, 10);
+ dispatchMouseMove(testArea, 20, 20);
+ dispatchMouseMove(testArea, 30, 30);
+
+ vi.runAllTimers();
+
+ // Should only process the last position
+ expect(cursor.style.getPropertyValue("--cursor-x")).toBe("30px");
+ expect(cursor.style.getPropertyValue("--cursor-y")).toBe("30px");
+ });
+
+ it("should return early if cursor ref is null", () => {
+ const { container } = render(
);
+
+ // Manually set ref to null to test early return
+ const cursor = container.querySelector(".cursor-tracker");
+
+ // Should not throw error even if cursor element is not found
+ expect(cursor).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ReadingProgress/__tests__/ReadingProgress.test.tsx b/src/components/ReadingProgress/__tests__/ReadingProgress.test.tsx
new file mode 100644
index 0000000..50be87a
--- /dev/null
+++ b/src/components/ReadingProgress/__tests__/ReadingProgress.test.tsx
@@ -0,0 +1,127 @@
+import { render } from "@testing-library/react";
+import * as framerMotion from "framer-motion";
+import { describe, it, expect, vi } from "vitest";
+
+import ReadingProgress from "../ReadingProgress";
+
+// Mock framer-motion
+vi.mock("framer-motion", () => ({
+ motion: {
+ div: vi.fn(
+ ({
+ children,
+ className,
+ style,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+ [key: string]: unknown;
+ }) => (
+
+ {children}
+
+ ),
+ ),
+ },
+ useScroll: vi.fn(() => ({
+ scrollYProgress: {
+ get: () => 0,
+ on: vi.fn((event: string, handler: (value: number) => void) => {
+ // Immediately call handler with test value
+ if (event === "change") {
+ handler(0.5);
+ }
+ return () => {}; // Return unsubscribe function
+ }),
+ },
+ })),
+ useSpring: vi.fn((value: unknown) => value),
+}));
+
+describe("ReadingProgress", () => {
+ it("should render reading progress container", () => {
+ const { container } = render(
);
+ const progressContainer = container.querySelector(
+ ".reading-progress-container",
+ );
+ expect(progressContainer).toBeInTheDocument();
+ });
+
+ it("should render progress bar", () => {
+ const { container } = render(
);
+ const progressBar = container.querySelector(".reading-progress-bar");
+ expect(progressBar).toBeInTheDocument();
+ });
+
+ it("should show progress bar when scroll position is greater than 10%", () => {
+ const mockOn = vi.fn((event: string, handler: (value: number) => void) => {
+ if (event === "change") {
+ handler(0.15); // 15% scroll
+ }
+ return () => {};
+ });
+
+ vi.mocked(framerMotion).useScroll.mockReturnValue({
+ scrollYProgress: {
+ get: () => 0.15,
+ on: mockOn,
+ },
+ } as never);
+
+ render(
);
+
+ expect(mockOn).toHaveBeenCalledWith("change", expect.any(Function));
+ });
+
+ it("should hide progress bar when scroll position is less than 10%", () => {
+ const mockOn = vi.fn((event: string, handler: (value: number) => void) => {
+ if (event === "change") {
+ handler(0.05); // 5% scroll
+ }
+ return () => {};
+ });
+
+ vi.mocked(framerMotion).useScroll.mockReturnValue({
+ scrollYProgress: {
+ get: () => 0.05,
+ on: mockOn,
+ },
+ } as never);
+
+ render(
);
+
+ expect(mockOn).toHaveBeenCalledWith("change", expect.any(Function));
+ });
+
+ it("should unsubscribe from scroll progress on unmount", () => {
+ const mockUnsubscribe = vi.fn();
+ const mockOn = vi.fn(() => mockUnsubscribe);
+
+ vi.mocked(framerMotion).useScroll.mockReturnValue({
+ scrollYProgress: {
+ get: () => 0,
+ on: mockOn,
+ },
+ } as never);
+
+ const { unmount } = render(
);
+
+ unmount();
+
+ expect(mockUnsubscribe).toHaveBeenCalled();
+ });
+
+ it("should apply correct spring configuration", () => {
+ const { useSpring } = vi.mocked(framerMotion);
+
+ render(
);
+
+ expect(useSpring).toHaveBeenCalledWith(expect.anything(), {
+ stiffness: 100,
+ damping: 30,
+ restDelta: 0.001,
+ });
+ });
+});
diff --git a/src/hooks/__tests__/useBlogPost.test.ts b/src/hooks/__tests__/useBlogPost.test.ts
index 756adbb..a963120 100644
--- a/src/hooks/__tests__/useBlogPost.test.ts
+++ b/src/hooks/__tests__/useBlogPost.test.ts
@@ -93,7 +93,7 @@ title: Test Post
expect(result.current.loading).toBe(false);
});
- expect(result.current.error).toBe("Failed to load post");
+ expect(result.current.error).toBe("Network error");
expect(result.current.MDXContent).toBeNull();
consoleErrorSpy.mockRestore();
@@ -162,4 +162,114 @@ classification: UNCLASSIFIED
expect(compiledContent).not.toContain("---");
expect(compiledContent).toContain("# Actual Content");
});
+
+ it("should handle MDX syntax errors with helpful message", async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue({
+ slug: "test-post",
+ title: "Test Post",
+ classification: "UNCLASSIFIED",
+ abstract: "Test abstract",
+ publishDate: "2025-11-29",
+ version: "1.0",
+ });
+ vi.spyOn(blogService, "fetchPostContent").mockResolvedValue("# Content");
+ vi.spyOn(mdx, "compile").mockRejectedValue(
+ new Error("Could not parse expression"),
+ );
+
+ const { result } = renderHook(() => useBlogPost("test-post"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.error).toContain("MDX Syntax Error");
+ expect(result.current.error).toContain("Could not parse expression");
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle unexpected character errors", async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue({
+ slug: "test-post",
+ title: "Test Post",
+ classification: "UNCLASSIFIED",
+ abstract: "Test abstract",
+ publishDate: "2025-11-29",
+ version: "1.0",
+ });
+ vi.spyOn(blogService, "fetchPostContent").mockResolvedValue("# Content");
+ vi.spyOn(mdx, "compile").mockRejectedValue(
+ new Error("Unexpected character found"),
+ );
+
+ const { result } = renderHook(() => useBlogPost("test-post"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.error).toContain("MDX Parsing Error");
+ expect(result.current.error).toContain("Unexpected character");
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle network fetch errors", async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ vi.spyOn(blogService, "fetchPostMetadata").mockRejectedValue(
+ new Error("Failed to fetch post content"),
+ );
+
+ const { result } = renderHook(() => useBlogPost("test-post"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.error).toContain("Network Error");
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("should handle component errors", async () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ vi.spyOn(blogService, "fetchPostMetadata").mockResolvedValue({
+ slug: "test-post",
+ title: "Test Post",
+ classification: "UNCLASSIFIED",
+ abstract: "Test abstract",
+ publishDate: "2025-11-29",
+ version: "1.0",
+ });
+ vi.spyOn(blogService, "fetchPostContent").mockResolvedValue("# Content");
+ vi.spyOn(mdx, "compile").mockRejectedValue(
+ new Error("Expected component 'CustomComponent' not found"),
+ );
+
+ const { result } = renderHook(() => useBlogPost("test-post"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.error).toContain("Component Error");
+ expect(result.current.error).toContain("Expected component");
+
+ consoleErrorSpy.mockRestore();
+ });
});
diff --git a/src/hooks/useBlogPost.ts b/src/hooks/useBlogPost.ts
index 5468944..108204f 100644
--- a/src/hooks/useBlogPost.ts
+++ b/src/hooks/useBlogPost.ts
@@ -65,7 +65,8 @@ export function useBlogPost(slug: string | undefined) {
const code = String(
await compile(contentWithoutFrontmatter, {
outputFormat: "function-body",
- development: false,
+ development: false, // Disable dev mode to skip component validation
+ jsxImportSource: "react",
}),
);
@@ -85,7 +86,24 @@ export function useBlogPost(slug: string | undefined) {
} catch (err: unknown) {
if (!cancelled) {
console.error("Error loading post:", err);
- setError("Failed to load post");
+
+ // Provide detailed error messages
+ let errorMessage = "Failed to load post";
+ if (err instanceof Error) {
+ if (err.message.includes("Could not parse")) {
+ errorMessage = `MDX Syntax Error:\n\n${err.message}\n\nTip: Check for emojis, special characters, or invalid syntax in template expressions.`;
+ } else if (err.message.includes("Unexpected character")) {
+ errorMessage = `MDX Parsing Error:\n\n${err.message}\n\nTip: Emojis and special characters must be wrapped in markdown text syntax ["\`...\`"] or removed from code blocks.`;
+ } else if (err.message.includes("Failed to fetch")) {
+ errorMessage = `Network Error: Could not fetch blog post content.`;
+ } else if (err.message.includes("Expected component")) {
+ errorMessage = `Component Error:\n\n${err.message}\n\nTip: Ensure all custom components are defined in BlogComponents.tsx`;
+ } else {
+ errorMessage = err.message;
+ }
+ }
+
+ setError(errorMessage);
setLoading(false);
}
}
diff --git a/src/services/__tests__/blogService.test.ts b/src/services/__tests__/blogService.test.ts
index db6f83d..a8ebbb3 100644
--- a/src/services/__tests__/blogService.test.ts
+++ b/src/services/__tests__/blogService.test.ts
@@ -5,8 +5,6 @@ import {
fetchPage,
fetchPostMetadata,
fetchPostContent,
- clearBlogCache,
- getCacheStats,
} from "../blogService";
// Mock localStorage
@@ -61,7 +59,7 @@ describe("blogService", () => {
});
describe("fetchBlogIndex", () => {
- it("should fetch and cache blog index", async () => {
+ it("should fetch blog index", async () => {
const mockIndex = {
version: "2025-11-29",
totalPosts: 2,
@@ -81,37 +79,8 @@ describe("blogService", () => {
expect(result).toEqual(mockIndex);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/manifests/index.json"),
- { cache: "no-cache" },
+ { cache: "no-store" },
);
-
- // Verify caching
- const cached = localStorageMock.getItem("blog-index-v1");
- expect(cached).toBeTruthy();
- });
-
- it("should return cached data if available", async () => {
- const mockIndex = {
- version: "2025-11-29",
- totalPosts: 2,
- totalPages: 1,
- postsPerPage: 50,
- latestPosts: [],
- pages: {},
- };
-
- // Set cache
- localStorageMock.setItem(
- "blog-index-v1",
- JSON.stringify({
- data: mockIndex,
- timestamp: Date.now(),
- }),
- );
-
- const result = await fetchBlogIndex();
-
- expect(result).toEqual(mockIndex);
- expect(mockFetch).not.toHaveBeenCalled();
});
it("should throw error on failed fetch", async () => {
@@ -127,7 +96,7 @@ describe("blogService", () => {
});
describe("fetchPage", () => {
- it("should fetch and cache page data", async () => {
+ it("should fetch page data", async () => {
const mockPage = {
page: 1,
posts: [],
@@ -143,33 +112,22 @@ describe("blogService", () => {
expect(result).toEqual(mockPage);
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/manifests/page-1.json"),
- { cache: "no-cache" },
+ { cache: "no-store" },
);
});
- it("should return cached page data", async () => {
- const mockPage = {
- page: 1,
- posts: [],
- };
-
- localStorageMock.setItem(
- "blog-page-1-v1",
- JSON.stringify({
- data: mockPage,
- timestamp: Date.now(),
- }),
- );
-
- const result = await fetchPage(1);
+ it("should throw error on failed page fetch", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ statusText: "Not Found",
+ });
- expect(result).toEqual(mockPage);
- expect(mockFetch).not.toHaveBeenCalled();
+ await expect(fetchPage(1)).rejects.toThrow("Failed to fetch page 1");
});
});
describe("fetchPostMetadata", () => {
- it("should fetch and cache post metadata", async () => {
+ it("should fetch post metadata", async () => {
const mockMetadata = {
slug: "test-post",
title: "Test Post",
@@ -215,7 +173,7 @@ describe("blogService", () => {
});
describe("fetchPostContent", () => {
- it("should fetch and cache post content", async () => {
+ it("should fetch post content", async () => {
const mockContent = "# Test Post\n\nContent here";
mockFetch.mockResolvedValueOnce({
@@ -239,133 +197,4 @@ describe("blogService", () => {
);
});
});
-
- describe("clearBlogCache", () => {
- it("should remove all blog-related cache entries", () => {
- localStorageMock.setItem("blog-index-v1", "test");
- localStorageMock.setItem("blog-page-1-v1", "test");
- localStorageMock.setItem("other-key", "test");
-
- clearBlogCache();
-
- expect(localStorageMock.getItem("blog-index-v1")).toBeNull();
- expect(localStorageMock.getItem("blog-page-1-v1")).toBeNull();
- expect(localStorageMock.getItem("other-key")).toBe("test");
- });
- });
-
- describe("getCacheStats", () => {
- it("should return cache statistics", () => {
- localStorageMock.setItem("blog-index-v1", "test1");
- localStorageMock.setItem("blog-page-1-v1", "test2");
- localStorageMock.setItem("other-key", "test3");
-
- const stats = getCacheStats();
-
- expect(stats.totalEntries).toBe(2);
- expect(stats.entries).toEqual(["blog-index-v1", "blog-page-1-v1"]);
- expect(stats.totalSize).toBeGreaterThan(0);
- });
- });
-
- describe("cache expiration", () => {
- it("should ignore expired cache entries", async () => {
- const mockIndex = {
- version: "2025-11-29",
- totalPosts: 2,
- totalPages: 1,
- postsPerPage: 50,
- latestPosts: [],
- pages: {},
- };
-
- // Set expired cache (6 minutes ago)
- const sixMinutesAgo = Date.now() - 6 * 60 * 1000;
- localStorageMock.setItem(
- "blog-index-v1",
- JSON.stringify({
- data: mockIndex,
- timestamp: sixMinutesAgo,
- }),
- );
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockIndex),
- });
-
- await fetchBlogIndex();
-
- // Should fetch fresh data
- expect(mockFetch).toHaveBeenCalled();
- });
- });
-
- describe("cache error handling", () => {
- it("should handle corrupted cache data gracefully", async () => {
- const consoleErrorSpy = vi
- .spyOn(console, "error")
- .mockImplementation(() => {});
-
- // Set corrupted cache
- localStorageMock.setItem("blog-index-v1", "invalid json{");
-
- const mockIndex = {
- version: "2025-11-29",
- totalPosts: 2,
- totalPages: 1,
- postsPerPage: 50,
- latestPosts: [],
- pages: {},
- };
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockIndex),
- });
-
- const result = await fetchBlogIndex();
-
- expect(result).toEqual(mockIndex);
- expect(consoleErrorSpy).toHaveBeenCalled();
-
- consoleErrorSpy.mockRestore();
- });
-
- it("should handle localStorage write errors gracefully", async () => {
- const consoleErrorSpy = vi
- .spyOn(console, "error")
- .mockImplementation(() => {});
-
- // Mock setItem to throw error
- const originalSetItem = localStorageMock.setItem;
- localStorageMock.setItem = () => {
- throw new Error("QuotaExceededError");
- };
-
- const mockIndex = {
- version: "2025-11-29",
- totalPosts: 2,
- totalPages: 1,
- postsPerPage: 50,
- latestPosts: [],
- pages: {},
- };
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve(mockIndex),
- });
-
- const result = await fetchBlogIndex();
-
- // Should still return data even if caching fails
- expect(result).toEqual(mockIndex);
- expect(consoleErrorSpy).toHaveBeenCalled();
-
- // Restore
- localStorageMock.setItem = originalSetItem;
- consoleErrorSpy.mockRestore();
- });
- });
});
diff --git a/src/services/blogService.ts b/src/services/blogService.ts
index c31545e..0e345e9 100644
--- a/src/services/blogService.ts
+++ b/src/services/blogService.ts
@@ -2,106 +2,37 @@
* Blog Service
*
* Fetches blog content from the GitHub blog branch via raw content API.
- * Implements localStorage caching with configurable TTL.
* No authentication required (public repository).
*/
-import { BLOG_BASE_URL, BLOG_CACHE_DURATION } from "../utils/constants";
+import { BLOG_BASE_URL } from "../utils/constants";
-import type {
- BlogIndex,
- BlogMetadata,
- PageManifest,
- CacheEntry,
-} from "../types/blog";
-
-/**
- * Get cached data from localStorage
- * Type parameter only used for return type inference but necessary for type safety
- */
-// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
-function getCached
(key: string): T | null {
- try {
- const cached = localStorage.getItem(key);
- if (!cached) return null;
-
- const entry = JSON.parse(cached) as CacheEntry;
-
- // Check if cache is expired
- if (Date.now() - entry.timestamp > BLOG_CACHE_DURATION) {
- localStorage.removeItem(key);
- return null;
- }
-
- return entry.data;
- } catch (error) {
- console.error(`Error reading cache for ${key}:`, error);
- return null;
- }
-}
-
-/**
- * Set data in localStorage cache
- * Type parameter only used for input type but necessary for type safety with getCached
- */
-// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
-function setCache(key: string, data: T): void {
- try {
- const entry: CacheEntry = {
- data,
- timestamp: Date.now(),
- };
- localStorage.setItem(key, JSON.stringify(entry));
- } catch (error) {
- console.error(`Error setting cache for ${key}:`, error);
- // Don't throw - caching is optional
- }
-}
+import type { BlogIndex, BlogMetadata, PageManifest } from "../types/blog";
/**
* Fetch blog index manifest
* Contains metadata about all posts, total count, and page links
*/
export async function fetchBlogIndex(): Promise {
- const cacheKey = "blog-index-v1";
-
- // Try cache first
- const cached = getCached(cacheKey);
- if (cached) {
- return cached;
- }
-
- // Fetch from GitHub
const response = await fetch(`${BLOG_BASE_URL}/manifests/index.json`, {
- cache: "no-cache", // Bypass browser cache, use our localStorage
+ cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to fetch blog index: ${response.statusText}`);
}
- const data = (await response.json()) as BlogIndex;
- setCache(cacheKey, data);
- return data;
+ return (await response.json()) as BlogIndex;
}
/**
* Fetch a specific page of blog posts
*/
export async function fetchPage(pageNum: number): Promise {
- const cacheKey = `blog-page-${String(pageNum)}-v1`;
-
- // Try cache first
- const cached = getCached(cacheKey);
- if (cached) {
- return cached;
- }
-
- // Fetch from GitHub
const response = await fetch(
`${BLOG_BASE_URL}/manifests/page-${String(pageNum)}.json`,
{
- cache: "no-cache",
+ cache: "no-store",
},
);
@@ -111,9 +42,7 @@ export async function fetchPage(pageNum: number): Promise {
);
}
- const data = (await response.json()) as PageManifest;
- setCache(cacheKey, data);
- return data;
+ return (await response.json()) as PageManifest;
}
/**
@@ -122,30 +51,22 @@ export async function fetchPage(pageNum: number): Promise {
export async function fetchPostMetadata(
slug: string,
): Promise {
- const cacheKey = `blog-metadata-${slug}-v1`;
-
- // Try cache first
- const cached = getCached(cacheKey);
- if (cached) {
- return cached;
- }
-
- // Fetch from GitHub
try {
- const response = await fetch(
- `${BLOG_BASE_URL}/manifests/metadata/${slug}.json`,
- {
- cache: "no-cache",
- },
- );
+ // In dev, add timestamp to URL to bust cache (avoids CORS preflight issues)
+ const isDev = import.meta.env.DEV;
+ const url = isDev
+ ? `${BLOG_BASE_URL}/manifests/metadata/${slug}.json?t=${Date.now().toString()}`
+ : `${BLOG_BASE_URL}/manifests/metadata/${slug}.json`;
+
+ const response = await fetch(url, {
+ cache: "no-store",
+ });
if (!response.ok) {
return null;
}
- const data = (await response.json()) as BlogMetadata;
- setCache(cacheKey, data);
- return data;
+ return (await response.json()) as BlogMetadata;
} catch (error) {
console.error(`Error fetching metadata for ${slug}:`, error);
return null;
@@ -156,63 +77,19 @@ export async function fetchPostMetadata(
* Fetch MDX content for a specific post
*/
export async function fetchPostContent(slug: string): Promise {
- const cacheKey = `blog-content-${slug}-v1`;
-
- // Try cache first
- const cached = getCached(cacheKey);
- if (cached) {
- return cached;
- }
-
- // Fetch from GitHub
- const response = await fetch(`${BLOG_BASE_URL}/posts/${slug}.mdx`, {
- cache: "no-cache",
+ // In dev, add timestamp to URL to bust cache (avoids CORS preflight issues)
+ const isDev = import.meta.env.DEV;
+ const url = isDev
+ ? `${BLOG_BASE_URL}/posts/${slug}.mdx?t=${Date.now().toString()}`
+ : `${BLOG_BASE_URL}/posts/${slug}.mdx`;
+
+ const response = await fetch(url, {
+ cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to fetch post ${slug}: ${response.statusText}`);
}
- const content = await response.text();
- setCache(cacheKey, content);
- return content;
-}
-
-/**
- * Clear all blog-related caches
- * Useful for forcing a refresh
- */
-export function clearBlogCache(): void {
- const keys = Object.keys(localStorage);
- keys.forEach((key) => {
- if (key.startsWith("blog-")) {
- localStorage.removeItem(key);
- }
- });
-}
-
-/**
- * Get cache statistics for debugging
- */
-export function getCacheStats(): {
- totalEntries: number;
- totalSize: number;
- entries: string[];
-} {
- const keys = Object.keys(localStorage);
- const blogKeys = keys.filter((key) => key.startsWith("blog-"));
-
- let totalSize = 0;
- blogKeys.forEach((key) => {
- const item = localStorage.getItem(key);
- if (item) {
- totalSize += item.length;
- }
- });
-
- return {
- totalEntries: blogKeys.length,
- totalSize,
- entries: blogKeys,
- };
+ return await response.text();
}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 650477f..30eee9e 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -1,5 +1,3 @@
-import type { MermaidConfig } from "mermaid";
-
export const URL_RESUME = "/";
export const URL_BLOG = "/blog";
@@ -10,67 +8,3 @@ export const BLOG_REPO = "cagesthrottleus/cagesthrottleus.github.io";
export const BLOG_BRANCH = "blog";
export const BLOG_BASE_URL = `https://raw.githubusercontent.com/${BLOG_REPO}/${BLOG_BRANCH}`;
export const BLOG_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
-
-/**
- * Mermaid diagram configuration
- * Cold War classified document theme
- */
-export const MERMAID_CONFIG: MermaidConfig = {
- startOnLoad: false,
- theme: "dark",
- themeVariables: {
- // Cold War Classified Color Palette
- primaryColor: "#dc2626", // classified-500 red
- primaryTextColor: "#ffffff",
- primaryBorderColor: "#dc2626",
- lineColor: "#22c55e", // terminal-500 green
- secondaryColor: "#3b82f6", // steel-500 blue
- tertiaryColor: "#f59e0b", // warning-500 amber
- background: "#0a0a0a", // dark-primary
- mainBkg: "rgba(220, 38, 38, 0.05)",
- secondBkg: "rgba(59, 130, 246, 0.05)",
- border1: "#dc2626",
- border2: "#22c55e",
- arrowheadColor: "#22c55e",
- fontFamily: "JetBrains Mono, monospace",
- fontSize: "14px",
- textColor: "#e8e8e8", // gray-200
- nodeBorder: "#dc2626",
- clusterBkg: "rgba(34, 197, 94, 0.05)",
- clusterBorder: "#22c55e",
- defaultLinkColor: "#22c55e",
- titleColor: "#ffffff",
- edgeLabelBackground: "#0a0a0a",
- nodeTextColor: "#ffffff",
- // Flowchart specific
- nodeBackground: "rgba(220, 38, 38, 0.1)",
- nodeForeground: "#ffffff",
- // Sequence diagram specific
- actorBorder: "#dc2626",
- actorBkg: "rgba(220, 38, 38, 0.1)",
- actorTextColor: "#ffffff",
- actorLineColor: "#22c55e",
- signalColor: "#e8e8e8",
- signalTextColor: "#e8e8e8",
- labelBoxBkgColor: "rgba(59, 130, 246, 0.1)",
- labelBoxBorderColor: "#3b82f6",
- labelTextColor: "#ffffff",
- // Git graph specific
- git0: "#dc2626",
- git1: "#22c55e",
- git2: "#3b82f6",
- git3: "#f59e0b",
- git4: "#60a5fa",
- git5: "#fbbf24",
- git6: "#4ade80",
- git7: "#ff3838",
- commitLabelColor: "#ffffff",
- commitLabelBackground: "rgba(220, 38, 38, 0.2)",
- },
- securityLevel: "loose",
- flowchart: {
- htmlLabels: true,
- curve: "basis",
- padding: 15,
- },
-};