2024-08-27 Web Development

Building a Content Management System for Static MDX Files

By O Wolfson

Let's create a content management system (CMS) for our MDXBlog, where blog posts are stored as static files.

We'll cover and handling form submissions for new blog entries, saving posts to the file system, generating a cache for efficient data retrieval.

Note: this interface will only be relevant in your development environment as you are saving files to the local file system.

Our CMS will:

  1. Create and save blog posts as MDX files locally.
  2. Regenerate a cache of posts to improve performance.
  3. Handle form submissions for creating new posts.
  4. List blog post titles on the home page with edit and delete functionality.

Caching is crucial for enhancing performance and reducing the load on the file system. When dealing with static files, reading and parsing each file on every request can be inefficient, especially as the number of posts grows. By generating a cache, we can quickly access metadata and content without repeatedly accessing the file system.

Components Involved

  1. MDX File Handling: A function to save form data as MDX files.
  2. Cache Generation: A script to create a cache of the posts.
  3. API Endpoints: Endpoints to handle POST requests for saving, updating, and deleting files.
  4. Form Component: A React component to capture user input for new blog posts.
  5. Home Screen Component: A React component to list blog post titles.

1. Saving Form Data as MDX Files

We need a function to save the submitted form data as an MDX file. This function will:

  • Format the data properly.
  • Ensure unique filenames by checking for existing files.
  • Regenerate the posts cache after saving the file.

Here's the saveFileLocally function:

javascript
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const { generatePostsCache } = require("./posts-utils");

/**
 * Save form data as a local MDX file
 * @param {Object} data - The form data to save
 * @returns {string} - The generated slug for the new post
 */
const fs = require("node:fs");
const path = require("node:path");
const { exec } = require("node:child_process");
const { generatePostsCache } = require("./posts-utils");
const shortUUID = require("short-uuid");

export function saveFileLocally(data) {
  return new Promise((resolve, reject) => {
    const { date, savedFilename, title, categories, tags, ...rest } = data;
    const projectRoot = process.cwd();

    // Sanitize the title to remove special characters and replace spaces with hyphens
    let filename = `${title
      .toLowerCase()
      .replace(/[^a-z0-9\s-]/g, "")
      .replace(/\s+/g, "-")}.mdx`;
    const slug = `${title
      .toLowerCase()
      .replace(/[^a-z0-9\s-]/g, "")
      .replace(/\s+/g, "-")}`;

    let filePath = path.join(projectRoot, "content/posts", filename);

    // Check if the file already exists and create a unique filename
    let counter = 1;
    while (fs.existsSync(filePath)) {
      filename = `${title
        .toLowerCase()
        .replace(/[^a-z0-9\s-]/g, "")
        .replace(/\s+/g, "-")}-${counter}.mdx`;
      filePath = path.join(projectRoot, "content/posts", filename); // Update filePath
      counter++;
    }

    const currentDate = new Date(data.date);
    const formattedDate = `${currentDate.getFullYear()}-${String(
      currentDate.getMonth() + 1
    ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;

    // Generate a short UUID for the id field
    const id = shortUUID.generate();

    // Format tags and categories for metadata
    const formattedTags = tags
      .split(", ")
      .map((tag) => `"${tag.trim()}"`)
      .join(", ");

    const formattedCategories = categories
      .map((category) => `"${category}"`)
      .join(", ");

    // Construct the file content
    const fileContent = [
      "export const metadata = {",
      `  id: "${id}",`, // Added id field
      `  type: "blog",`,
      `  title: "${title}",`,
      `  author: "${data.author}",`,
      `  publishDate: "${formattedDate}",`,
      `  description: "${data.description}",`,
      `  categories: [${formattedCategories}],`,
      `  tags: [${formattedTags}]`,
      "};",
      "",
      `${data.content}`,
    ].join("\n");

    // Write the file
    fs.writeFile(filePath, fileContent, (err) => {
      if (err) {
        console.error("Error writing file:", err);
        reject(err);
      } else {
        console.log("File saved to", filePath);

        // Regenerate posts cache
        generatePostsCache();

        // Open the file in VS Code
        exec(`code "${filePath}"`, (error, stdout, stderr) => {
          if (error) {
            console.error(`exec error: ${error}`);
            return;
          }
          console.log(`stdout: ${stdout}`);
          console.error(`stderr: ${stderr}`);
        });

        resolve(slug);
      }
    });
  });
}

2. Generating the Cache

The cache script reads all MDX files, extracts the necessary metadata, and writes it to a JSON file. This cached data can then be quickly accessed, improving performance.

Here's the cachePosts.js script:

javascript
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { startOfDay } from "date-fns";

/**
 * Generate a cache of all posts
 * @returns {Array} - An array of post metadata
 */
export function generatePostsCache() {
  const postsDirectory = path.join(process.cwd(), "data/posts");
  const fileNames = fs
    .readdirSync(postsDirectory)
    .filter(
      (fileName) => !fileName.startsWith(".") && fileName.endsWith(".mdx")
    );

  const currentDate = startOfDay(new Date()); // Get the start of the current day

  const posts = fileNames
    .map((fileName) => {
      const fullPath = path.join(postsDirectory, fileName);
      const fileContents = fs.readFileSync(fullPath, "utf8");
      const { data: frontMatter } = matter(fileContents);

      const postDate = startOfDay(new Date(frontMatter.date)); // Get the start of the post's date

      // Skip future-dated posts and include posts for the current day
      if (postDate > currentDate) {
        return null;
      }

      return {
        slug: fileName.replace(".mdx", ""),
        ...frontMatter,
      };
    })
    .filter(Boolean); // Filter out null values representing future-dated posts

  const cachePath = path.join(process.cwd(), "cache/posts.json");
  fs.writeFileSync(cachePath, JSON.stringify(posts, null, 2));

  return posts;
}

3. Handling POST Requests

We need an API endpoint to handle the POST requests from our form. This endpoint will call the saveFileLocally function and respond with the path of the saved file or an error message.

Here's the implementation of the POST handler:

javascript
import { saveFileLocally } from "@/lib/save-file-locally";

/*
 * Handle POST requests to save form data as an MDX file
 * @param {Request} req - The request object
 * @returns {Response} - The response object
 */

export async function POST(req) {
  if (req.method === "POST") {
    try {
      const data = await req.json();
      const filePath = saveFileLocally(data); // Save the file and regenerate the cache
      return new Response(JSON.stringify({ filePath }), {
        headers: { "content-type": "application/json" },
      });
    } catch (error) {
      console.error("Error:", error);
      return new Response(
        JSON.stringify({ message: "Error processing request" }),
        {
          status: 500,
          headers: { "content-type": "application/json" },
        }
      );
    }
  } else {
    return new Response(
      JSON.stringify({ message: "Only POST requests are accepted" }),
      {
        headers: { "content-type": "application/json" },
      }
    );
  }
}

4. Creating the Form Component

We'll create a form component that captures the user's input and sends it to our API endpoint. We'll use react-hook-form for form handling and validation, and zod for schema validation. Additionally, we'll add functionality to update and delete posts. FYI we are using Tailwind CSS for styling and shadcn/ui for most of the UI components.

Here's the CreatePostForm component code:

jsx
"use client";

import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { v4 as uuidv4 } from "uuid";
import { useRouter } from "next/navigation";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import DatePickerField from "@/components/date-picker";
import { MultiSelect } from "@/components/rs-multi-select";

// Define the schema for form validation using Zod
const formSchema = z.object({
  date: z.date(),
  type: z.string().optional(),
  title: z.string().min(3, { message: "Title must be at least 3 characters." }),
  description: z
    .string()
    .min(15, { message: "Description must be at least 15 characters." }),
  content: z
    .string()
    .min(2, { message: "Content must be at least 2 characters." }),
  categories: z.array(z.string()).nonempty(),
  tags: z.string().optional(),
});

export function CreatePostForm({ post }) {
  const [selectedValue, setSelectedValue] = useState("blog");
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: post || {
      date: new Date(),
      type: "blog",
      title: "",
      description: "",
      content: "",
      categories: ["Web Development"],
      tags: "",
    },
  });
  const authorName = "O Wolfson";
  const router = useRouter();

  // Handle form submission
  async function onSubmit(values) {
    const endpoint = post ? "/api/update-file" : "/api/save-file-locally";
    const submissionData = {
      ...values,
      author: authorName,
      id: post ? post.id : uuidv4(),
    };

    try {
      const response = await fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(submissionData),
      });
      if (!response.ok) throw new Error("Network response was not ok");
      const result = await response.json();
      console.log("Success:", result);
      form.reset();
      router.push(`/blog/${result.filePath}`);
    } catch (error) {
      console.error("Error:", error);
    }
  }

  // Handle file deletion
  async function handleDelete() {
    try {
      const response = await fetch("/api/delete-file", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ path: post.path }),
      });
      if (!response.ok) throw new Error("Network response was not ok");
      console.log("File deleted successfully");
      router.push("/home");
    } catch (error) {
      console.error("Error deleting file:", error);
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {/* Post Type Field */}
        <FormField
          control={form.control}
          name="type"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Post Type</FormLabel>
              <Select
                onValueChange={field.onChange}
                defaultValue={field.value}
                value={selectedValue}
              >
                <FormControl>
                  <SelectTrigger className="w-[180px]">
                    <SelectValue placeholder="Select post type" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="blog">Blog</SelectItem>
                  <SelectItem value="project">Project</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* Date Field */}
        <FormField
          control={form.control}
          name="date"
          render={({ field }) => (
            <FormItem className="flex flex-col">
              <FormLabel className="font-semibold text-md">Date</FormLabel>
              <DatePickerField field={field} />
              <FormMessage />
            </FormItem>
          )}
        />
        {/* Title Field */}
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Post Title</FormLabel>
              <FormControl>
                <Input placeholder="Title" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* Description Field */}
        <FormField
          control={form.control}
          name="description"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Description</FormLabel>
              <FormControl>
                <Textarea placeholder="Description" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* Content Field */}
        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Content</FormLabel>
              <FormControl>
                <Textarea
                  id="content"
                  className="h-[300px]"
                  placeholder="Content"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* Categories Field */}
        <FormField
          control={form.control}
          name="categories"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Categories</FormLabel>
              <FormControl>
                <MultiSelect
                  selectedCategories={field.value}
                  setSelectedCategories={field.onChange}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* Tags Field */}
        <FormField
          control={form.control}
          name="tags"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Tags</FormLabel>
              <FormControl>
                <Input placeholder="Enter tags (comma separated)" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">{post ? "Update" : "Create"}</Button>
        {post && <Button onClick={handleDelete}>Delete</Button>}
      </form>
    </Form>
  );
}

5. Creating the Home Screen Component

We'll create a home screen component that fetches the cached posts and displays their titles. Clicking on a title will open the post in the CreatePostForm.

HomeScreen.jsx

jsx
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";

export default function HomeScreen() {
  const [posts, setPosts] = useState([]);
  const router = useRouter();

  useEffect(() => {
    async function fetchPosts() {
      const res = await fetch("/cache/posts.json");
      const data = await res.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);

  return (
    <div className="space-y-4">
      <h1 className="text-2xl font-bold">Blog Posts</h1>
      <ul className="space-y-2">
        {posts.map((post) => (
          <li key={post.slug} className="flex justify-between items-center">
            <Link href={`/edit/${post.slug}`}>
              <a className="text-blue-500 hover:underline">{post.title}</a>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

6. Handling Delete Requests

We'll create an API endpoint to handle the deletion of posts.

deleteFile.js

javascript
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const { generatePostsCache } = require("./posts-utils");

export async function POST(req) {
  if (req.method === "POST") {
    try {
      const { path: filePath } = await req.json();
      const fullPath = path.join(process.cwd(), "data/posts", filePath);

      exec(`rm "${fullPath}"`, (error, stdout, stderr) => {
        if (error) {
          console.error(`exec error: ${error}`);
          return new Response(
            JSON.stringify({ message: "Error deleting file" }),
            {
              status: 500,
              headers: { "content-type": "application/json" },
            }
          );
        }
        console.log("File deleted successfully");
        generatePostsCache();
        return new Response(JSON.stringify({ message: "File deleted" }), {
          headers: { "content-type": "application/json" },
        });
      });
    } catch (error) {
      console.error("Error:", error);
      return new Response(
        JSON.stringify({ message: "Error processing request" }),
        {
          status: 500,
          headers: { "content-type": "application/json" },
        }
      );
    }
  } else {
    return new Response(
      JSON.stringify({ message: "Only POST requests are accepted" }),
      {
        headers: { "content-type": "application/json" },
      }
    );
  }
}

Conclusion

By following these steps, we've built a content management system for our blog. Users can submit new blog posts through a form, which are then saved as MDX files on the server. The posts cache is regenerated to ensure quick access to the latest posts, enhancing performance. Additionally, we added a home screen to list the titles of the blog posts, along with functionality to update and delete posts. This setup leverages the power of Next.js, MDX, and React to create a seamless and dynamic content creation experience in our local development environment.

If you have any questions or need further assistance, feel free to reach out!