Infrastructure setup
This commit is contained in:
@@ -1 +0,0 @@
|
||||
10574
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: "Resolving Active Conflicts"
|
||||
description: "Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals"
|
||||
author: "Community Organizer"
|
||||
date: "2025-04-15"
|
||||
tags: ["conflict-resolution", "governance", "community"]
|
||||
related: ["operational-security", "making-decisions-without-hierarchy"]
|
||||
---
|
||||
|
||||
# Resolving Active Conflicts
|
||||
|
||||
Practical steps for resolving conflicts while maintaining trust, cooperation, and shared goals.
|
||||
|
||||
## Understanding Conflict in Communities
|
||||
|
||||
Conflict is a natural part of any community's growth and evolution. When people come together with different perspectives, experiences, and goals, disagreements are inevitable. The key is not to avoid conflict, but to handle it constructively.
|
||||
|
||||
## The Foundation: Trust and Communication
|
||||
|
||||
Before any conflict resolution process can be effective, there must be a foundation of trust and open communication. Community members need to feel safe expressing their concerns and confident that their voices will be heard.
|
||||
|
||||
### Building Trust
|
||||
|
||||
- Regular check-ins and community meetings
|
||||
- Transparent decision-making processes
|
||||
- Clear communication channels
|
||||
- Consistent follow-through on commitments
|
||||
|
||||
## A Structured Approach to Resolution
|
||||
|
||||
### 1. Acknowledge the Conflict
|
||||
|
||||
The first step is recognizing that a conflict exists and needs attention. Ignoring conflicts often makes them worse.
|
||||
|
||||
### 2. Create Safe Space for Discussion
|
||||
|
||||
- Choose a neutral location
|
||||
- Set ground rules for respectful communication
|
||||
- Ensure all parties have equal time to speak
|
||||
- Use a facilitator if needed
|
||||
|
||||
### 3. Identify Root Causes
|
||||
|
||||
Look beyond surface-level disagreements to understand the underlying issues:
|
||||
|
||||
- Unmet needs or expectations
|
||||
- Misunderstandings or miscommunications
|
||||
- Competing priorities or values
|
||||
- Resource constraints
|
||||
|
||||
### 4. Generate Solutions Together
|
||||
|
||||
- Brainstorm multiple options
|
||||
- Focus on interests, not positions
|
||||
- Look for win-win solutions
|
||||
- Consider creative alternatives
|
||||
|
||||
### 5. Agree on Next Steps
|
||||
|
||||
- Document the agreed-upon solution
|
||||
- Assign responsibilities and timelines
|
||||
- Set up follow-up meetings
|
||||
- Establish how to handle future conflicts
|
||||
|
||||
## Tools and Techniques
|
||||
|
||||
### Active Listening
|
||||
|
||||
- Give full attention to the speaker
|
||||
- Reflect back what you heard
|
||||
- Ask clarifying questions
|
||||
- Avoid interrupting or planning your response
|
||||
|
||||
### Nonviolent Communication
|
||||
|
||||
- Observe without judgment
|
||||
- Express feelings and needs clearly
|
||||
- Make specific, actionable requests
|
||||
- Show empathy for others' perspectives
|
||||
|
||||
### Consensus Building
|
||||
|
||||
- Seek solutions that work for everyone
|
||||
- Build on areas of agreement
|
||||
- Address concerns and objections
|
||||
- Work toward genuine consensus, not just majority rule
|
||||
|
||||
## When to Seek External Help
|
||||
|
||||
Some conflicts may require outside assistance:
|
||||
|
||||
- When emotions are running very high
|
||||
- When there's a power imbalance
|
||||
- When the conflict involves legal issues
|
||||
- When previous attempts at resolution have failed
|
||||
|
||||
## Prevention and Maintenance
|
||||
|
||||
The best conflict resolution is prevention:
|
||||
|
||||
- Regular community health checks
|
||||
- Clear policies and procedures
|
||||
- Ongoing relationship building
|
||||
- Early intervention when tensions arise
|
||||
|
||||
## Conclusion
|
||||
|
||||
Conflict resolution is not about eliminating disagreements, but about creating a community where conflicts can be addressed constructively and lead to growth and understanding. With the right tools, processes, and commitment, conflicts can become opportunities for strengthening relationships and improving community governance.
|
||||
|
||||
Remember: the goal is not to win arguments, but to build stronger, more resilient communities where everyone can thrive.
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import { validateBlogPost, sanitizeBlogPost } from "./validation.js";
|
||||
|
||||
/**
|
||||
* Content processing utilities for blog posts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all blog post files from the content directory
|
||||
* @returns {Array} Array of file paths
|
||||
*/
|
||||
export function getBlogPostFiles() {
|
||||
const contentDirectory = path.join(process.cwd(), "content/blog");
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(contentDirectory);
|
||||
return files.filter(
|
||||
(file) => file.endsWith(".md") || file.endsWith(".mdx")
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error reading blog content directory:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single blog post file
|
||||
* @param {string} filePath - Path to the markdown file
|
||||
* @returns {Object|null} Parsed blog post data or null if invalid
|
||||
*/
|
||||
export function parseBlogPost(filePath) {
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), "content/blog", filePath);
|
||||
const fileContents = fs.readFileSync(fullPath, "utf8");
|
||||
const { data: frontmatter, content } = matter(fileContents);
|
||||
|
||||
// Validate frontmatter
|
||||
const validation = validateBlogPost(frontmatter);
|
||||
if (!validation.isValid) {
|
||||
console.error(`Validation failed for ${filePath}:`, validation.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize frontmatter
|
||||
const sanitized = sanitizeBlogPost(frontmatter);
|
||||
|
||||
// Generate slug from filename
|
||||
const slug = filePath.replace(/\.(md|mdx)$/, "");
|
||||
|
||||
return {
|
||||
slug,
|
||||
frontmatter: sanitized,
|
||||
content,
|
||||
filePath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing blog post ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blog posts with parsed data
|
||||
* @returns {Array} Array of parsed blog post objects
|
||||
*/
|
||||
export function getAllBlogPosts() {
|
||||
const files = getBlogPostFiles();
|
||||
const posts = files
|
||||
.map((file) => parseBlogPost(file))
|
||||
.filter((post) => post !== null)
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
|
||||
);
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single blog post by slug
|
||||
* @param {string} slug - The post slug
|
||||
* @returns {Object|null} Parsed blog post or null if not found
|
||||
*/
|
||||
export function getBlogPostBySlug(slug) {
|
||||
const files = getBlogPostFiles();
|
||||
const file = files.find((f) => f.replace(/\.(md|mdx)$/, "") === slug);
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseBlogPost(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related blog posts
|
||||
* @param {string} currentSlug - Current post slug
|
||||
* @param {Array} relatedSlugs - Array of related post slugs
|
||||
* @param {number} limit - Maximum number of related posts to return
|
||||
* @returns {Array} Array of related blog posts
|
||||
*/
|
||||
export function getRelatedBlogPosts(currentSlug, relatedSlugs = [], limit = 3) {
|
||||
if (!relatedSlugs || relatedSlugs.length === 0) {
|
||||
// Fallback: get posts with similar tags or recent posts
|
||||
const allPosts = getAllBlogPosts();
|
||||
return allPosts.filter((post) => post.slug !== currentSlug).slice(0, limit);
|
||||
}
|
||||
|
||||
const allPosts = getAllBlogPosts();
|
||||
const related = allPosts
|
||||
.filter((post) => relatedSlugs.includes(post.slug))
|
||||
.slice(0, limit);
|
||||
|
||||
// If we don't have enough related posts, fill with recent ones
|
||||
if (related.length < limit) {
|
||||
const recent = allPosts
|
||||
.filter(
|
||||
(post) => post.slug !== currentSlug && !relatedSlugs.includes(post.slug)
|
||||
)
|
||||
.slice(0, limit - related.length);
|
||||
return [...related, ...recent];
|
||||
}
|
||||
|
||||
return related;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique tags from blog posts
|
||||
* @returns {Array} Array of unique tags
|
||||
*/
|
||||
export function getAllTags() {
|
||||
const posts = getAllBlogPosts();
|
||||
const tags = new Set();
|
||||
|
||||
posts.forEach((post) => {
|
||||
if (post.frontmatter.tags) {
|
||||
post.frontmatter.tags.forEach((tag) => tags.add(tag));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tags).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blog posts by tag
|
||||
* @param {string} tag - Tag to filter by
|
||||
* @returns {Array} Array of blog posts with the specified tag
|
||||
*/
|
||||
export function getBlogPostsByTag(tag) {
|
||||
const posts = getAllBlogPosts();
|
||||
return posts.filter(
|
||||
(post) => post.frontmatter.tags && post.frontmatter.tags.includes(tag)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Content validation utilities for blog posts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Blog post frontmatter schema
|
||||
*/
|
||||
export const BLOG_POST_SCHEMA = {
|
||||
title: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 10,
|
||||
maxLength: 200,
|
||||
},
|
||||
author: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
},
|
||||
date: {
|
||||
type: "string",
|
||||
required: true,
|
||||
pattern: /^\d{4}-\d{2}-\d{2}$/, // YYYY-MM-DD format
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
required: false,
|
||||
default: [],
|
||||
items: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 20,
|
||||
},
|
||||
},
|
||||
related: {
|
||||
type: "array",
|
||||
required: false,
|
||||
default: [],
|
||||
items: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a blog post's frontmatter
|
||||
* @param {Object} frontmatter - The frontmatter object to validate
|
||||
* @returns {Object} Validation result with isValid boolean and errors array
|
||||
*/
|
||||
export function validateBlogPost(frontmatter) {
|
||||
const errors = [];
|
||||
|
||||
// Check required fields first
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
if (config.required && !frontmatter[field]) {
|
||||
errors.push(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have missing required fields, don't continue with other validations
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Now validate field types and constraints
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
if (frontmatter[field] !== undefined) {
|
||||
// Type validation
|
||||
if (config.type === "string" && typeof frontmatter[field] !== "string") {
|
||||
errors.push(`Field ${field} must be a string`);
|
||||
} else if (
|
||||
config.type === "array" &&
|
||||
!Array.isArray(frontmatter[field])
|
||||
) {
|
||||
errors.push(`Field ${field} must be an array`);
|
||||
}
|
||||
|
||||
// Length validation for strings
|
||||
if (config.type === "string" && typeof frontmatter[field] === "string") {
|
||||
if (config.minLength && frontmatter[field].length < config.minLength) {
|
||||
errors.push(
|
||||
`Field ${field} must be at least ${config.minLength} characters`
|
||||
);
|
||||
}
|
||||
if (config.maxLength && frontmatter[field].length > config.maxLength) {
|
||||
errors.push(
|
||||
`Field ${field} must be no more than ${config.maxLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (config.pattern && !config.pattern.test(frontmatter[field])) {
|
||||
errors.push(`Field ${field} format is invalid`);
|
||||
}
|
||||
|
||||
// Array item validation
|
||||
if (config.type === "array" && Array.isArray(frontmatter[field])) {
|
||||
for (let i = 0; i < frontmatter[field].length; i++) {
|
||||
const item = frontmatter[field][i];
|
||||
if (config.items.type === "string" && typeof item !== "string") {
|
||||
errors.push(`Item ${i} in ${field} must be a string`);
|
||||
}
|
||||
if (config.items.minLength && item.length < config.items.minLength) {
|
||||
errors.push(
|
||||
`Item ${i} in ${field} must be at least ${config.items.minLength} characters`
|
||||
);
|
||||
}
|
||||
if (config.items.maxLength && item.length > config.items.maxLength) {
|
||||
errors.push(
|
||||
`Item ${i} in ${field} must be no more than ${config.items.maxLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and normalize frontmatter data
|
||||
* @param {Object} frontmatter - Raw frontmatter data
|
||||
* @returns {Object} Sanitized frontmatter
|
||||
*/
|
||||
export function sanitizeBlogPost(frontmatter) {
|
||||
const sanitized = {};
|
||||
|
||||
for (const [field, config] of Object.entries(BLOG_POST_SCHEMA)) {
|
||||
if (frontmatter[field] !== undefined) {
|
||||
sanitized[field] = frontmatter[field];
|
||||
} else if (config.default !== undefined) {
|
||||
sanitized[field] = config.default;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
+10
-1
@@ -1,3 +1,5 @@
|
||||
import createMDX from "@next/mdx";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
@@ -13,4 +15,11 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
const withMDX = createMDX({
|
||||
options: {
|
||||
remarkPlugins: [],
|
||||
rehypePlugins: [],
|
||||
},
|
||||
});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
|
||||
Generated
+1799
-10
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,10 @@
|
||||
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^15.5.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "15.2.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
@@ -62,6 +66,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/react": "19.1.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||
"@typescript-eslint/parser": "^8.41.0",
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
getBlogPostFiles,
|
||||
parseBlogPost,
|
||||
getAllBlogPosts,
|
||||
getBlogPostBySlug,
|
||||
getRelatedBlogPosts,
|
||||
getAllTags,
|
||||
getBlogPostsByTag,
|
||||
} from "../../lib/content.js";
|
||||
|
||||
// Mock fs and path modules
|
||||
vi.mock("fs", () => ({
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("path", () => ({
|
||||
join: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Content Processing", () => {
|
||||
let mockReaddirSync, mockReadFileSync, mockPathJoin;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Get references to the mocked functions
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
mockReaddirSync = fs.readdirSync;
|
||||
mockReadFileSync = fs.readFileSync;
|
||||
mockPathJoin = path.join;
|
||||
|
||||
// Mock process.cwd to return a predictable path
|
||||
vi.spyOn(process, "cwd").mockReturnValue("/mock/project/root");
|
||||
|
||||
// Mock path.join to return predictable paths
|
||||
mockPathJoin.mockImplementation((...args) => args.join("/"));
|
||||
});
|
||||
|
||||
describe("getBlogPostFiles", () => {
|
||||
it("should return markdown files from content directory", () => {
|
||||
const mockFiles = ["post1.md", "post2.mdx", "image.png", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const result = getBlogPostFiles();
|
||||
expect(result).toEqual(["post1.md", "post2.mdx", "post3.md"]);
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle directory read errors gracefully", () => {
|
||||
mockReaddirSync.mockImplementation(() => {
|
||||
throw new Error("Directory not found");
|
||||
});
|
||||
|
||||
const result = getBlogPostFiles();
|
||||
expect(result).toEqual([]);
|
||||
expect(mockReaddirSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlogPost", () => {
|
||||
it("should parse a valid markdown file", () => {
|
||||
const mockContent = `---
|
||||
title: "Test Post"
|
||||
description: "A test description that meets the minimum length requirement"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
tags: ["test"]
|
||||
related: []
|
||||
---
|
||||
# Test Content
|
||||
This is the content.`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = parseBlogPost("test-post.md");
|
||||
expect(result).toMatchObject({
|
||||
slug: "test-post",
|
||||
frontmatter: {
|
||||
title: "Test Post",
|
||||
description:
|
||||
"A test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["test"],
|
||||
related: [],
|
||||
},
|
||||
content: "\n# Test Content\nThis is the content.",
|
||||
filePath: "test-post.md",
|
||||
});
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(
|
||||
"/mock/project/root/content/blog/test-post.md",
|
||||
"utf8"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid frontmatter", () => {
|
||||
const mockContent = `---
|
||||
title: "" # Invalid title
|
||||
description: "A test description"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
---
|
||||
# Test Content`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = parseBlogPost("invalid-post.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle file read errors gracefully", () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("File not found");
|
||||
});
|
||||
|
||||
const result = parseBlogPost("non-existent-post.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllBlogPosts", () => {
|
||||
it("should return all valid blog posts sorted by date", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
// Mock fs.readFileSync for each post
|
||||
mockReadFileSync.mockReturnValueOnce(`---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
---
|
||||
# Content 1`).mockReturnValueOnce(`---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
---
|
||||
# Content 2`).mockReturnValueOnce(`---
|
||||
title: "Post 3"
|
||||
description: "Desc 3"
|
||||
author: "Author 3"
|
||||
date: "2025-04-05"
|
||||
---
|
||||
# Content 3`);
|
||||
|
||||
const result = getAllBlogPosts();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].slug).toBe("post2"); // Latest date
|
||||
expect(result[1].slug).toBe("post1");
|
||||
expect(result[2].slug).toBe("post3"); // Oldest date
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlogPostBySlug", () => {
|
||||
it("should return blog post for valid slug", () => {
|
||||
const mockFiles = ["test-post.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent = `---
|
||||
title: "Test Post"
|
||||
description: "A test description that meets the minimum length requirement"
|
||||
author: "Test Author"
|
||||
date: "2025-04-15"
|
||||
---
|
||||
# Test Content`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = getBlogPostBySlug("test-post");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.slug).toBe("test-post");
|
||||
});
|
||||
|
||||
it("should return null for invalid slug", () => {
|
||||
const mockFiles = ["test-post.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const result = getBlogPostBySlug("invalid-slug");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRelatedBlogPosts", () => {
|
||||
it("should return related posts when slugs are provided", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
// Mock content for all posts
|
||||
mockReadFileSync.mockReturnValueOnce(`---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
related: ["post2"]
|
||||
---
|
||||
# Content 1`).mockReturnValueOnce(`---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
---
|
||||
# Content 2`).mockReturnValueOnce(`---
|
||||
title: "Post 3"
|
||||
description: "Desc 3"
|
||||
author: "Author 3"
|
||||
date: "2025-04-05"
|
||||
---
|
||||
# Content 3`);
|
||||
|
||||
const result = getRelatedBlogPosts("post1", ["post2", "post3"], 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].slug).toBe("post2");
|
||||
expect(result[1].slug).toBe("post3");
|
||||
});
|
||||
|
||||
it("should fallback to recent posts when no related slugs provided", () => {
|
||||
const mockFiles = ["post1.md", "post2.md", "post3.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
---
|
||||
# Content 1`;
|
||||
|
||||
mockReadFileSync.mockReturnValue(mockContent);
|
||||
|
||||
const result = getRelatedBlogPosts("post1", [], 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].slug).toBe("post2"); // Should be the most recent after excluding 'post1'
|
||||
expect(result[1].slug).toBe("post3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllTags", () => {
|
||||
it("should return unique tags from all posts", () => {
|
||||
const mockFiles = ["post1.md", "post2.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent1 = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
tags: ["tagA", "tagB"]
|
||||
---
|
||||
# Content 1`;
|
||||
const mockContent2 = `---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
tags: ["tagB", "tagC"]
|
||||
---
|
||||
# Content 2`;
|
||||
|
||||
mockReadFileSync
|
||||
.mockReturnValueOnce(mockContent1)
|
||||
.mockReturnValueOnce(mockContent2);
|
||||
|
||||
const result = getAllTags();
|
||||
expect(result).toEqual(expect.arrayContaining(["tagA", "tagB", "tagC"]));
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlogPostsByTag", () => {
|
||||
it("should return posts with matching tag", () => {
|
||||
const mockFiles = ["post1.md", "post2.md"];
|
||||
mockReaddirSync.mockReturnValue(mockFiles);
|
||||
|
||||
const mockContent1 = `---
|
||||
title: "Post 1"
|
||||
description: "Desc 1"
|
||||
author: "Author 1"
|
||||
date: "2025-04-10"
|
||||
tags: ["tagA", "tagB"]
|
||||
---
|
||||
# Content 1`;
|
||||
const mockContent2 = `---
|
||||
title: "Post 2"
|
||||
description: "Desc 2"
|
||||
author: "Author 2"
|
||||
date: "2025-04-20"
|
||||
tags: ["tagB", "tagC"]
|
||||
---
|
||||
# Content 2`;
|
||||
|
||||
mockReadFileSync
|
||||
.mockReturnValueOnce(mockContent1)
|
||||
.mockReturnValueOnce(mockContent2);
|
||||
|
||||
const result = getBlogPostsByTag("tagA");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].slug).toBe("post1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validateBlogPost,
|
||||
sanitizeBlogPost,
|
||||
BLOG_POST_SCHEMA,
|
||||
} from "../../lib/validation.js";
|
||||
|
||||
describe("Blog Post Validation", () => {
|
||||
describe("validateBlogPost", () => {
|
||||
it("should validate a correct blog post", () => {
|
||||
const validPost = {
|
||||
title: "Test Title",
|
||||
description:
|
||||
"This is a test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["test", "blog"],
|
||||
related: ["post-1", "post-2"],
|
||||
};
|
||||
|
||||
const result = validateBlogPost(validPost);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should reject missing required fields", () => {
|
||||
const invalidPost = {
|
||||
title: "Test Title",
|
||||
// Missing description, author, date
|
||||
tags: ["test"],
|
||||
};
|
||||
|
||||
const result = validateBlogPost(invalidPost);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain("Missing required field: description");
|
||||
expect(result.errors).toContain("Missing required field: author");
|
||||
expect(result.errors).toContain("Missing required field: date");
|
||||
});
|
||||
|
||||
it("should validate title length constraints", () => {
|
||||
const shortTitle = {
|
||||
title: "", // Empty string (less than 1 character minimum)
|
||||
description:
|
||||
"This is a test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
};
|
||||
|
||||
const result = validateBlogPost(shortTitle);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain("Missing required field: title");
|
||||
});
|
||||
|
||||
it("should validate date format", () => {
|
||||
const invalidDate = {
|
||||
title: "Test Title",
|
||||
description:
|
||||
"This is a test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025/04/15", // Wrong format
|
||||
};
|
||||
|
||||
const result = validateBlogPost(invalidDate);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain("Field date format is invalid");
|
||||
});
|
||||
|
||||
it("should validate tags array", () => {
|
||||
const invalidTags = {
|
||||
title: "Test Title",
|
||||
description:
|
||||
"This is a test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: "not-an-array", // Should be array
|
||||
};
|
||||
|
||||
const result = validateBlogPost(invalidTags);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain("Field tags must be an array");
|
||||
});
|
||||
|
||||
it("should validate tag item lengths", () => {
|
||||
const invalidTagItems = {
|
||||
title: "Test Title",
|
||||
description:
|
||||
"This is a test description that meets the minimum length requirement",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["", "very-long-tag-name-that-exceeds-maximum-length"],
|
||||
};
|
||||
|
||||
const result = validateBlogPost(invalidTagItems);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Item 0 in tags must be at least 1 characters"
|
||||
);
|
||||
expect(result.errors).toContain(
|
||||
"Item 1 in tags must be no more than 20 characters"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeBlogPost", () => {
|
||||
it("should return original data when all fields are present", () => {
|
||||
const post = {
|
||||
title: "Test Title",
|
||||
description: "Test description",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["test"],
|
||||
related: ["post-1"],
|
||||
};
|
||||
|
||||
const sanitized = sanitizeBlogPost(post);
|
||||
expect(sanitized).toEqual(post);
|
||||
});
|
||||
|
||||
it("should add default values for missing optional fields", () => {
|
||||
const post = {
|
||||
title: "Test Title",
|
||||
description: "Test description",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
// Missing tags and related
|
||||
};
|
||||
|
||||
const sanitized = sanitizeBlogPost(post);
|
||||
expect(sanitized.tags).toEqual([]);
|
||||
expect(sanitized.related).toEqual([]);
|
||||
});
|
||||
|
||||
it("should preserve existing optional fields", () => {
|
||||
const post = {
|
||||
title: "Test Title",
|
||||
description: "Test description",
|
||||
author: "Test Author",
|
||||
date: "2025-04-15",
|
||||
tags: ["custom-tag"],
|
||||
related: ["custom-post"],
|
||||
};
|
||||
|
||||
const sanitized = sanitizeBlogPost(post);
|
||||
expect(sanitized.tags).toEqual(["custom-tag"]);
|
||||
expect(sanitized.related).toEqual(["custom-post"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BLOG_POST_SCHEMA", () => {
|
||||
it("should have correct structure", () => {
|
||||
expect(BLOG_POST_SCHEMA).toHaveProperty("title");
|
||||
expect(BLOG_POST_SCHEMA).toHaveProperty("description");
|
||||
expect(BLOG_POST_SCHEMA).toHaveProperty("author");
|
||||
expect(BLOG_POST_SCHEMA).toHaveProperty("date");
|
||||
expect(BLOG_POST_SCHEMA).toHaveProperty("tags");
|
||||
expect(BLOG_POST_SCHEMA).toHaveProperty("related");
|
||||
});
|
||||
|
||||
it("should have correct required field configuration", () => {
|
||||
expect(BLOG_POST_SCHEMA.title.required).toBe(true);
|
||||
expect(BLOG_POST_SCHEMA.description.required).toBe(true);
|
||||
expect(BLOG_POST_SCHEMA.author.required).toBe(true);
|
||||
expect(BLOG_POST_SCHEMA.date.required).toBe(true);
|
||||
expect(BLOG_POST_SCHEMA.tags.required).toBe(false);
|
||||
expect(BLOG_POST_SCHEMA.related.required).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user