diff --git a/app/tailwind.css b/app/tailwind.css
index c2ab555..f0fa8ee 100644
--- a/app/tailwind.css
+++ b/app/tailwind.css
@@ -19,15 +19,12 @@
--color-*: initial;
/* Font families */
- --font-sans:
- var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI",
- Roboto, "Helvetica Neue", Arial, sans-serif;
- --font-display:
- var(--font-bricolage-grotesque), ui-sans-serif, system-ui, -apple-system,
+ --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- --font-mono:
- var(--font-space-grotesk), ui-monospace, SFMono-Regular, "SF Mono",
- Consolas, "Liberation Mono", Menlo, monospace;
+ --font-display: var(--font-bricolage-grotesque), ui-sans-serif, system-ui,
+ -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: var(--font-space-grotesk), ui-monospace, SFMono-Regular,
+ "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
/* Dimension */
--spacing-scale-000: 0px;
@@ -370,6 +367,7 @@
--color-content-inverse-brand-accent: var(--color-yellow-yellow700);
--color-content-inverse-brand-primary: var(--color-yellow-yellow900);
--color-content-inverse-brand-secondary: var(--color-rust-rust800);
+ --color-content-inverse-brand-royal: var(--color-royal-blue-royal-blue1000);
--color-content-inverse-primary: var(--color-gray-1000);
--color-content-inverse-secondary: var(--color-gray-800);
--color-content-inverse-tertiary: var(--color-gray-700);
diff --git a/app/test-thumbnail/page.js b/app/test-thumbnail/page.js
new file mode 100644
index 0000000..7ab9b8e
--- /dev/null
+++ b/app/test-thumbnail/page.js
@@ -0,0 +1,153 @@
+import ContentThumbnailTemplate from "../../components/ContentThumbnailTemplate";
+
+// Mock blog post data for testing
+const mockPost1 = {
+ slug: "test-post-1",
+ frontmatter: {
+ title: "Resolving Active Conflicts",
+ description:
+ "Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals",
+ author: "Author name",
+ date: "2025-04-15",
+ tags: ["conflict-resolution", "governance", "community"],
+ },
+ wordCount: 467,
+ readingTime: 3,
+};
+
+const mockPost2 = {
+ slug: "test-post-2",
+ frontmatter: {
+ title: "Operational Security for Mutual Aid",
+ description:
+ "Tactics to protect members, secure communication, and prevent Infiltration",
+ author: "Author name",
+ date: "2025-04-10",
+ tags: ["community-building", "sustainability", "structure"],
+ },
+ wordCount: 523,
+ readingTime: 4,
+};
+
+const mockPost3 = {
+ slug: "test-post-3",
+ frontmatter: {
+ title: "Making decisions without hierarchy",
+ description:
+ "A brief guide to collaborative nonhierarchical decision making",
+ author: "Author name",
+ date: "2025-04-05",
+ tags: ["communication", "remote-work", "collaboration"],
+ },
+ wordCount: 389,
+ readingTime: 2,
+};
+
+export default function TestThumbnailPage() {
+ return (
+
+
+
+ ContentThumbnailTemplate Test
+
+
+
+ {/* Vertical Variant */}
+
+
+ Vertical Variant
+
+
+
+
+
+
+
+
+ {/* Horizontal Variant */}
+
+
+ Horizontal Variant
+
+
+
+
+
+
+
+
+ {/* Component Props */}
+
+
+ Component Props
+
+
+
+
+ Required Props:
+
+
+ -
+
post - Blog post object with frontmatter
+
+
+
+
+
+ Optional Props:
+
+
+ -
+
variant - "vertical" (default) or "horizontal"
+
+ -
+
className - Additional CSS classes
+
+ -
+
showTags - Show/hide tags (default: true)
+
+ -
+
showReadingTime - Show/hide reading time
+ (default: true)
+
+
+
+
+
+
+ {/* Mock Data */}
+
+
+ Mock Data Structure
+
+
+ {JSON.stringify(mockPost1, null, 2)}
+
+
+
+
+
+ );
+}
diff --git a/components/ContentContainer.js b/components/ContentContainer.js
new file mode 100644
index 0000000..40867f7
--- /dev/null
+++ b/components/ContentContainer.js
@@ -0,0 +1,115 @@
+"use client";
+
+import React from "react";
+
+const ContentContainer = ({ post, width = "200px", variant = "vertical" }) => {
+ // Get the corresponding icon based on the same logic as background images
+ const getIconImage = (slug, variant) => {
+ const verticalIcons = [
+ "/assets/Content_Thumbnail/Icon_1.svg",
+ "/assets/Content_Thumbnail/Icon_2.svg",
+ "/assets/Content_Thumbnail/Icon_3.svg",
+ ];
+
+ const horizontalIcons = [
+ "/assets/Content_Thumbnail/Icon_1.svg",
+ "/assets/Content_Thumbnail/Icon_2.svg",
+ "/assets/Content_Thumbnail/Icon_3.svg",
+ ];
+
+ const icons = variant === "vertical" ? verticalIcons : horizontalIcons;
+
+ if (!slug) return icons[0];
+
+ // Use the same hash logic as background images to ensure matching
+ const hash = slug.split("").reduce((a, b) => {
+ a = (a << 5) - a + b.charCodeAt(0);
+ return a & a;
+ }, 0);
+
+ return icons[Math.abs(hash) % icons.length];
+ };
+
+ const iconImage = getIconImage(post.slug, variant);
+
+ return (
+
+ {/* Content Container - 8px gap between icon and text */}
+
+ {/* Icon */}
+
+

+
+
+ {/* Text Container */}
+
+ {/* Title */}
+
+ {post.frontmatter.title}
+
+
+ {/* Description */}
+
+ {post.frontmatter.description}
+
+
+
+
+ {/* Metadata Container - horizontal with 8px gap */}
+
+ {/* Author Name */}
+
+ {post.frontmatter.author}
+
+
+ {/* Date */}
+
+ {new Date(post.frontmatter.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ })}
+
+
+
+ );
+};
+
+export default ContentContainer;
diff --git a/components/ContentThumbnailTemplate.js b/components/ContentThumbnailTemplate.js
new file mode 100644
index 0000000..4ad22dd
--- /dev/null
+++ b/components/ContentThumbnailTemplate.js
@@ -0,0 +1,114 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import ContentContainer from "./ContentContainer";
+
+/**
+ * ContentThumbnailTemplate component for displaying blog post previews
+ * Simplified version to debug infinite loop
+ */
+const ContentThumbnailTemplate = ({
+ post,
+ variant = "vertical",
+ className = "",
+ showReadingTime = true,
+}) => {
+ // Post-specific background selection - different SVG for each post
+ const getBackgroundImage = (slug, variant) => {
+ const verticalImages = [
+ "/assets/Content_Thumbnail/Vertical_1.svg",
+ "/assets/Content_Thumbnail/Vertical_2.svg",
+ "/assets/Content_Thumbnail/Vertical_3.svg",
+ ];
+
+ const horizontalImages = [
+ "/assets/Content_Thumbnail/Horizontal_1.svg",
+ "/assets/Content_Thumbnail/Horizontal_2.svg",
+ "/assets/Content_Thumbnail/Horizontal_3.svg",
+ ];
+
+ const images = variant === "vertical" ? verticalImages : horizontalImages;
+
+ if (!slug) return images[0];
+
+ // Use the slug to deterministically select an image
+ const hash = slug.split("").reduce((a, b) => {
+ a = (a << 5) - a + b.charCodeAt(0);
+ return a & a;
+ }, 0);
+
+ return images[Math.abs(hash) % images.length];
+ };
+
+ const backgroundImage = getBackgroundImage(post.slug, variant);
+
+ if (variant === "vertical") {
+ return (
+
+
+ {/* Background SVG - sized to fit the 260x390 container exactly */}
+
+

+ {/* Gradient overlay for better text readability */}
+
+
+
+ {/* Content Section - positioned within the padding constraints */}
+
+
+
+ );
+ }
+
+ // Horizontal variant
+ return (
+
+
+ {/* Background SVG - sized to fit the 320x225.5 container exactly */}
+
+

+ {/* Gradient overlay */}
+
+
+
+ {/* Content - positioned within the padding constraints */}
+
+
+
+ );
+};
+
+export default ContentThumbnailTemplate;
diff --git a/components/ImagePlaceholder.js b/components/ImagePlaceholder.js
new file mode 100644
index 0000000..c762aaa
--- /dev/null
+++ b/components/ImagePlaceholder.js
@@ -0,0 +1,37 @@
+"use client";
+
+import React from "react";
+
+/**
+ * Simple image placeholder component for testing
+ * Generates colored backgrounds with text overlays
+ */
+const ImagePlaceholder = ({
+ width = 260,
+ height = 390,
+ text = "Blog Image",
+ color = "blue",
+ className = "",
+}) => {
+ const colors = {
+ blue: "bg-blue-500",
+ green: "bg-green-500",
+ purple: "bg-purple-500",
+ red: "bg-red-500",
+ orange: "bg-orange-500",
+ teal: "bg-teal-500",
+ };
+
+ const bgColor = colors[color] || colors.blue;
+
+ return (
+
+ {text}
+
+ );
+};
+
+export default ImagePlaceholder;
diff --git a/lib/contentProcessor.js b/lib/contentProcessor.js
index 260345e..5801d6b 100644
--- a/lib/contentProcessor.js
+++ b/lib/contentProcessor.js
@@ -6,6 +6,7 @@ import {
processMarkdown,
generateTableOfContents,
processFrontmatter,
+ formatDate,
} from "./mdx.js";
import { validateBlogPost, sanitizeBlogPost } from "./validation.js";
import {
diff --git a/lib/mdx.js b/lib/mdx.js
index 0178b25..9049e26 100644
--- a/lib/mdx.js
+++ b/lib/mdx.js
@@ -3,9 +3,21 @@
*/
/**
- * Process markdown content with enhanced features
+ * Format date consistently across the markdown pipeline
+ * Uses "Month Year" format (e.g., "April 2025")
+ */
+export function formatDate(dateString) {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ });
+}
+
+/**
+ * Process markdown content and extract metadata
* @param {string} markdown - Raw markdown content
- * @returns {Object} Processed content with metadata
+ * @returns {object} Processed content with metadata
*/
export function processMarkdown(markdown) {
if (!markdown) {
@@ -33,9 +45,12 @@ export function processMarkdown(markdown) {
const wordCount = calculateWordCount(markdown);
const readingTime = calculateReadingTime(wordCount);
+ // Convert markdown to HTML
+ const htmlContent = markdownToHtml(markdown);
+
return {
content: markdown,
- htmlContent: markdownToHtml(markdown),
+ htmlContent,
wordCount,
readingTime,
headings,
diff --git a/public/assets/Content_Thumbnail/Horizontal_1.svg b/public/assets/Content_Thumbnail/Horizontal_1.svg
new file mode 100644
index 0000000..838dafd
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Horizontal_1.svg
@@ -0,0 +1,31 @@
+
diff --git a/public/assets/Content_Thumbnail/Horizontal_2.svg b/public/assets/Content_Thumbnail/Horizontal_2.svg
new file mode 100644
index 0000000..ff6736d
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Horizontal_2.svg
@@ -0,0 +1,28 @@
+
diff --git a/public/assets/Content_Thumbnail/Horizontal_3.svg b/public/assets/Content_Thumbnail/Horizontal_3.svg
new file mode 100644
index 0000000..d1a81fa
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Horizontal_3.svg
@@ -0,0 +1,25 @@
+
diff --git a/public/assets/Content_Thumbnail/Icon_1.svg b/public/assets/Content_Thumbnail/Icon_1.svg
new file mode 100644
index 0000000..b8fd584
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Icon_1.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/assets/Content_Thumbnail/Icon_2.svg b/public/assets/Content_Thumbnail/Icon_2.svg
new file mode 100644
index 0000000..873505c
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Icon_2.svg
@@ -0,0 +1,18 @@
+
diff --git a/public/assets/Content_Thumbnail/Icon_3.svg b/public/assets/Content_Thumbnail/Icon_3.svg
new file mode 100644
index 0000000..0bf598d
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Icon_3.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/assets/Content_Thumbnail/Vertical_1.svg b/public/assets/Content_Thumbnail/Vertical_1.svg
new file mode 100644
index 0000000..ff5f25f
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Vertical_1.svg
@@ -0,0 +1,31 @@
+
diff --git a/public/assets/Content_Thumbnail/Vertical_2.svg b/public/assets/Content_Thumbnail/Vertical_2.svg
new file mode 100644
index 0000000..0b9aa36
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Vertical_2.svg
@@ -0,0 +1,34 @@
+
diff --git a/public/assets/Content_Thumbnail/Vertical_3.svg b/public/assets/Content_Thumbnail/Vertical_3.svg
new file mode 100644
index 0000000..bc3df93
--- /dev/null
+++ b/public/assets/Content_Thumbnail/Vertical_3.svg
@@ -0,0 +1,35 @@
+
diff --git a/tests/unit/ContentThumbnailTemplate.test.jsx b/tests/unit/ContentThumbnailTemplate.test.jsx
new file mode 100644
index 0000000..c89a480
--- /dev/null
+++ b/tests/unit/ContentThumbnailTemplate.test.jsx
@@ -0,0 +1,218 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import ContentThumbnailTemplate from "../../components/ContentThumbnailTemplate";
+
+// Mock Next.js components
+vi.mock("next/link", () => {
+ return {
+ default: ({ children, href, ...props }) => (
+
+ {children}
+
+ ),
+ };
+});
+
+vi.mock("next/image", () => {
+ return {
+ default: ({ src, alt, ...props }) =>
,
+ };
+});
+
+// Mock blog post data
+const mockPost = {
+ slug: "test-post",
+ frontmatter: {
+ title: "Test Blog Post Title",
+ description:
+ "This is a test description for the blog post that should be long enough to test truncation.",
+ author: "Test Author",
+ date: "2025-04-15",
+ tags: ["test", "blog", "example"],
+ backgroundImages: ["/test-image-1.jpg", "/test-image-2.jpg"],
+ },
+ wordCount: 500,
+ readingTime: 3,
+};
+
+describe("ContentThumbnailTemplate", () => {
+ describe("Vertical Variant", () => {
+ it("should render vertical variant with correct dimensions", () => {
+ render();
+
+ const container = screen.getByRole("link");
+ expect(container).toBeInTheDocument();
+
+ // Check that the component has the correct classes for dimensions
+ const thumbnailDiv = container.querySelector("div");
+ expect(thumbnailDiv).toHaveClass("w-[260px]", "h-[390px]");
+ });
+
+ it("should display post title and description", () => {
+ render();
+
+ expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument();
+ expect(
+ screen.getByText(/This is a test description/)
+ ).toBeInTheDocument();
+ });
+
+ it("should display tags when showTags is true", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("test")).toBeInTheDocument();
+ expect(screen.getByText("blog")).toBeInTheDocument();
+ });
+
+ it("should hide tags when showTags is false", () => {
+ render(
+
+ );
+
+ expect(screen.queryByText("test")).not.toBeInTheDocument();
+ expect(screen.queryByText("blog")).not.toBeInTheDocument();
+ });
+
+ it("should display reading time when showReadingTime is true", () => {
+ render(
+
+ );
+
+ expect(screen.getByText(/3 min read/)).toBeInTheDocument();
+ });
+
+ it("should hide reading time when showReadingTime is false", () => {
+ render(
+
+ );
+
+ expect(screen.queryByText(/min read/)).not.toBeInTheDocument();
+ });
+
+ it("should display author and date", () => {
+ render();
+
+ expect(screen.getByText("Test Author")).toBeInTheDocument();
+ // Check for "Month Year" format (e.g., "April 2025")
+ expect(screen.getByText("April 2025")).toBeInTheDocument();
+ });
+ });
+
+ describe("Horizontal Variant", () => {
+ it("should render horizontal variant", () => {
+ render();
+
+ const container = screen.getByRole("link");
+ expect(container).toBeInTheDocument();
+
+ // Check that the component has the correct classes for horizontal layout
+ const thumbnailDiv = container.querySelector("div");
+ expect(thumbnailDiv).toHaveClass("h-[226px]");
+ });
+
+ it("should display post information in horizontal layout", () => {
+ render();
+
+ expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument();
+ expect(
+ screen.getByText(/This is a test description/)
+ ).toBeInTheDocument();
+ expect(screen.getByText("Test Author")).toBeInTheDocument();
+ });
+ });
+
+ describe("Props and Customization", () => {
+ it("should apply custom className", () => {
+ render(
+
+ );
+
+ const container = screen.getByRole("link");
+ expect(container).toHaveClass("custom-class");
+ });
+
+ it("should generate correct link href", () => {
+ render();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/blog/test-post");
+ });
+
+ it("should handle posts without tags gracefully", () => {
+ const postWithoutTags = {
+ ...mockPost,
+ frontmatter: {
+ ...mockPost.frontmatter,
+ tags: [],
+ },
+ };
+
+ render(
+
+ );
+
+ // Should still render without errors
+ expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument();
+ });
+
+ it("should handle posts without background images", () => {
+ const postWithoutImages = {
+ ...mockPost,
+ frontmatter: {
+ ...mockPost.frontmatter,
+ backgroundImages: undefined,
+ },
+ };
+
+ render(
+
+ );
+
+ // Should still render without errors
+ expect(screen.getByText("Test Blog Post Title")).toBeInTheDocument();
+ });
+ });
+
+ describe("Default Behavior", () => {
+ it("should default to vertical variant when no variant specified", () => {
+ render();
+
+ const thumbnailDiv = screen.getByRole("link").querySelector("div");
+ expect(thumbnailDiv).toHaveClass("w-[260px]", "h-[390px]");
+ });
+
+ it("should show tags by default", () => {
+ render();
+
+ expect(screen.getByText("test")).toBeInTheDocument();
+ });
+
+ it("should show reading time by default", () => {
+ render();
+
+ expect(screen.getByText(/min read/)).toBeInTheDocument();
+ });
+ });
+});