ravenscott-blog/markdown/Building a Feature-Rich Blog Platform with Node.js, Express, and Markdown.md
2024-09-20 01:56:49 -04:00

24 KiB
Raw Blame History

How I built this blog, A Deep Dive into Modern Web Development.

A blog is one of the most powerful tools for sharing information, building authority, and engaging with an audience. When I decided to build a blog platform using Node.js, I wanted to go beyond the typical setup. I aimed for a feature-rich platform that dynamically serves content from Markdown files, supports pagination, integrates syntax highlighting for code snippets, offers a functional contact form with reCAPTCHA validation, and generates RSS feeds and sitemaps for better SEO.

In this in-depth technical breakdown, Ill walk you through every aspect of the platforms architecture and code, discussing why I chose each technology and how the different parts work together. If you're looking to create your own blog platform or simply want to dive deeper into building dynamic web applications with Node.js, this post will cover everything in great detail.

Why Node.js and Express?

Before we get into the nitty-gritty, let's talk about the choice of technologies. I chose Node.js as the runtime because of its event-driven, non-blocking I/O model, which is great for building scalable and performant web applications. Express.js, a minimalist web framework for Node, simplifies the process of setting up a web server, routing requests, and serving static files.

Heres why these choices make sense for this project:

  • Node.js: Handles high-concurrency applications well, meaning it can efficiently serve multiple blog readers without performance bottlenecks.
  • Express.js: Provides a straightforward way to build a RESTful architecture for managing routes, handling form submissions, and rendering views dynamically.

Source

https://git.ssh.surf/snxraven/ravenscott-blog

Folder Structure: Organizing the Blog Platform

One of the first things you need to think about when building a project is its structure. Here's a breakdown of the folder structure I used for this blog platform:

/blog-platform
│
├── /markdown            # Contains all blog posts written in Markdown
│   └── post-1.md        # Example Markdown blog post
│
├── /public              # Public assets (CSS, images, etc.)
│   └── /css
│       └── styles.css   # Custom styles for the blog
│
├── /views               # EJS templates (HTML views rendered by the server)
│   ├── index.ejs        # Homepage template showing a list of blog posts
│   ├── blog-post.ejs    # Template for individual blog posts
│   ├── about.ejs        # "About Me" page
│   └── contact.ejs      # Contact form page
│
├── app.js               # Main server file, handles all backend logic
├── package.json         # Project dependencies and scripts
├── .env                 # Environment variables (API keys, credentials, etc.)
└── README.md            # Documentation

This structure provides a clear separation of concerns:

  • Markdown files are stored in their own directory.
  • Public assets (CSS, images) are isolated for easy reference.
  • Views are where EJS templates are stored, allowing us to easily manage the HTML structure of each page.
  • app.js acts as the control center, handling the routes, form submissions, and the logic for rendering content.

Setting Up the Express Server

The core of the application is the Express.js server, which powers the entire backend. In app.js, we initialize Express, set up the middleware, and define the routes. But before we get into the route handling, lets break down the middleware and configuration settings we used.

1. Loading Dependencies

Here are the key dependencies we load at the top of the file:

require('dotenv').config(); // Load environment variables from .env
const express = require('express');
const path = require('path');
const fs = require('fs');
const { marked } = require('marked'); // For parsing Markdown files
const nodemailer = require('nodemailer'); // For sending emails from the contact form
const hljs = require('highlight.js'); // For syntax highlighting in code blocks
const axios = require('axios'); // For making HTTP requests, e.g., reCAPTCHA verification
const { format } = require('date-fns'); // For formatting dates in RSS feeds and sitemaps

const app = express(); // Initialize Express

Heres what each dependency does:

  • dotenv: Loads environment variables from a .env file, which we use to store sensitive information like API keys and email credentials.
  • path and fs: Standard Node.js modules that help us work with file paths and file systems. We use these to read Markdown files and serve static assets.
  • marked: A Markdown parser that converts Markdown syntax into HTML, which allows us to write blog posts using a simple syntax.
  • nodemailer: Handles sending emails when users submit the contact form.
  • highlight.js: Provides syntax highlighting for any code blocks in the blog posts. This is essential for making technical posts more readable.
  • axios: Used for making external HTTP requests (e.g., verifying the Google reCAPTCHA response).
  • date-fns: A utility for formatting dates, which we use to ensure dates are correctly formatted in RSS feeds and sitemaps.

2. Setting Up Middleware and Template Engine

Express makes it easy to set up middleware, which is crucial for handling static assets (like CSS files), parsing form data, and rendering templates using a view engine.

EJS Templating Engine

We use EJS as the templating engine. This allows us to embed JavaScript logic directly within our HTML, making it possible to render dynamic content like blog posts and form submission results.

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

This configuration tells Express to use the views folder for storing the HTML templates and EJS as the engine to render those templates.

Serving Static Files

Static files (like CSS and images) need to be served from the /public directory. This is where we store the CSS styles used to make the blog look visually appealing.

app.use(express.static(path.join(__dirname, 'public')));

Parsing Form Data

When users submit the contact form, the form data is sent as URL-encoded data. To handle this, we use express.urlencoded middleware, which parses the form submissions and makes the data accessible via req.body.

app.use(express.urlencoded({ extended: false }));

Markdown Parsing with Syntax Highlighting

One of the primary features of this blog platform is that it allows you to write blog posts using Markdown. Markdown is a simple markup language that converts easily to HTML and is especially popular among developers because of its lightweight syntax for writing formatted text.

1. Setting Up marked and highlight.js

To convert Markdown content to HTML, I used Marked.js, a fast and lightweight Markdown parser. Additionally, since many blog posts contain code snippets, Highlight.js is used to provide syntax highlighting for those snippets.

marked.setOptions({
    highlight: function (code, language) {
        const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
        return hljs.highlight(validLanguage, code).value;
    }
});
  • highlight: The function accepts the code block and its language (e.g., javascript, python) and passes it to highlight.js for highlighting. If the language is invalid, we default to plain text.

This makes code blocks in blog posts look more readable by colorizing keywords, variables, and other syntax elements, which improves the user experience, especially for technical blogs.

Blog Post Storage and Rendering

1. Storing Blog Posts as Markdown Files

Instead of storing blog posts in a database, this platform uses a simpler approach: each blog post is a .md file stored in the /markdown directory. This approach is not only easier to manage, but it also gives writers the flexibility to create and update posts using any text editor.

Heres what a sample Markdown file (post-1.md) might look like:

  • Lead Section: The comment <-!-- lead --> marks the start of the lead section. This is a short summary or introduction that appears on the homepage or in the RSS feed.

2. Rendering Markdown as HTML

To render the Markdown content as HTML on the frontend, we define a function loadMarkdownWithLead that reads the Markdown file, parses it, and extracts the lead section if available.

function loadMarkdownWithLead(file) {
    const markdownContent = fs.readFileSync(path.join(__dirname, 'markdown', file), 'utf-8');

    let lead = '';
    let contentMarkdown = markdownContent;

    // Extract the lead section marked by `<-!-- lead -->`
    const leadKeyword = '<-!-- lead -->';
    if (contentMarkdown.includes(leadKeyword)) {
        const [beforeLead, afterLead] = contentMarkdown.split(leadKeyword);
        lead = afterLead.split('\n').find(line => line.trim() !== '').trim();
        contentMarkdown = beforeLead + afterLead.replace(lead, '').

trim();
    }

    const contentHtml = marked.parse(contentMarkdown);
    return { contentHtml, lead };
}
  • Markdown Parsing: This function reads the Markdown file using fs.readFileSync, extracts the lead if present, and passes the remaining content to marked for parsing into HTML.
  • Lead Section: We use a special comment tag <-!-- lead --> to mark the start of the lead section. The loadMarkdownWithLead function extracts this section and removes it from the rest of the content, allowing us to display it separately on the homepage or in feeds.

3. Dynamically Rendering Blog Posts

For each blog post, we generate a URL based on its title (converted to a slug format). The user can access a blog post by navigating to /blog/{slug}, where {slug} is the URL-friendly version of the title.

Blog Post Route:

app.get('/blog/:slug', (req, res) => {
    const slug = req.params.slug;
    const markdownFile = fs.readdirSync(path.join(__dirname, 'markdown'))
        .find(file => titleToSlug(file.replace('.md', '')) === slug);

    if (markdownFile) {
        const originalTitle = markdownFile.replace('.md', ''); // Retain original title case
        const { contentHtml, lead } = loadMarkdownWithLead(markdownFile);

        res.render('blog-post', {
            title: originalTitle,
            content: contentHtml,
            lead: lead
        });
    } else {
        res.redirect('/'); // If the post doesn't exist, redirect to the homepage
    }
});

This route handles the logic for displaying a single blog post:

  • We take the slug from the URL, convert it into a filename, and attempt to find the corresponding Markdown file.
  • If the file exists, we render it using the blog-post.ejs template, passing the title, lead, and content as variables.

4. Slug Generation for Blog Posts

The title of each post is converted into a URL-friendly slug, which is used in the post's URL. Here's the utility function for converting a title into a slug:

function titleToSlug(title) {
    return title.toLowerCase()
        .replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric characters
        .replace(/\s+/g, '-');        // Replace spaces with dashes
}

This function ensures that each blog post URL is clean and readable, with no special characters or extra whitespace.

Pagination: Listing Blog Posts with Page Navigation

To improve the user experience, we add pagination to the homepage. Rather than displaying all blog posts at once, we only show a limited number of posts per page (e.g., 5 posts per page). Users can navigate between pages using pagination controls.

1. Paginating Blog Posts

To implement pagination, we first define a function getAllBlogPosts that reads all the Markdown files, sorts them by date, and slices the list based on the current page:

function getAllBlogPosts(page = 1, postsPerPage = 5) {
    const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));

    const totalPosts = blogFiles.length;
    const totalPages = Math.ceil(totalPosts / postsPerPage);

    const start = (page - 1) * postsPerPage;
    const end = start + postsPerPage;
    const paginatedFiles = blogFiles.slice(start, end);

    const blogPosts = paginatedFiles.map(file => {
        const title = file.replace('.md', '').replace(/-/g, ' '); // Keep original casing for title
        const slug = titleToSlug(title);

        const stats = fs.statSync(path.join(__dirname, 'markdown', file));
        const dateCreated = new Date(stats.birthtime); // Get file creation date
        const formattedDate = dateCreated.toLocaleDateString('en-US', {
            year: 'numeric',
            month: 'long',
            day: 'numeric'
        });

        return {
            title,
            slug,
            date: formattedDate
        };
    });

    return { blogPosts, totalPages };
}

Heres whats happening:

  • We load all Markdown files from the /markdown directory.
  • Pagination logic: We calculate how many posts to display per page. For example, if there are 10 posts and the user is on page 2, we slice the array of blog posts to show posts 6 through 10.
  • Each blog posts title, slug, and date are extracted and returned as part of the blogPosts array, which is then rendered on the homepage.

2. Rendering the Homepage with Pagination

The homepage route loads the blog posts for the current page and renders them in the index.ejs template. Pagination controls are also added to navigate between pages.

app.get('/', (req, res) => {
    const page = parseInt(req.query.page) || 1;

    if (page < 1) {
        return res.redirect(req.hostname);
    }

    const postsPerPage = 5; // Set how many posts to display per page
    const { blogPosts, totalPages } = getAllBlogPosts(page, postsPerPage);

    res.render('index', {
        title: 'Raven Scott Blog',
        blogPosts,
        currentPage: page,
        totalPages
    });
});
  • Current Page Handling: We extract the current page number from the query parameters (req.query.page). If no page is specified, we default to page 1.
  • Redirection: If the user tries to navigate to an invalid page (e.g., page 0 or below), theyre redirected to the homepage.

3. Pagination Controls in EJS

Heres how we render the pagination controls in the index.ejs template:

<nav aria-label="Page navigation">
    <ul class="pagination justify-content-center mt-4">
        <% if (currentPage > 1) { %>
            <li class="page-item">
                <a class="page-link" href="?page=<%= currentPage - 1 %>">Previous</a>
            </li>
        <% } %>

        <% for (let i = 1; i <= totalPages; i++) { %>
            <li class="page-item <%= currentPage === i ? 'active' : '' %>">
                <a class="page-link" href="?page=<%= i %>"><%= i %></a>
            </li>
        <% } %>

        <% if (currentPage < totalPages) { %>
            <li class="page-item">
                <a class="page-link" href="?page=<%= currentPage + 1 %>">Next</a>
            </li>
        <% } %>
    </ul>
</nav>

This generates pagination links dynamically based on the current page and the total number of pages. The current page is highlighted, and users can navigate to the previous or next page easily.

Contact Form with Google reCAPTCHA

One of the essential features of this platform is the contact form, where users can submit messages that get sent to the admin via email. To prevent spam and bot submissions, we integrate Google reCAPTCHA for human verification.

1. Creating the Contact Form

The contact form is rendered in the contact.ejs template. It includes fields for the users name, email, subject, and message, as well as a Google reCAPTCHA widget.

<form action="/contact" method="POST" class="mt-4">
    <div class="mb-3">
        <label for="name" class="form-label">Your Name<span class="text-danger">*</span></label>
        <input type="text" class="form-control" id="name" name="name" required>
    </div>
    <div class="mb-3">
        <label for="email" class="form-label">Email Address<span class="text-danger">*</span></label>
        <input type="email" class="form-control" id="email" name="email" required>
    </div>
    <div class="mb-3">
        <label for="subject" class="form-label">Subject<span class="text-danger">*</span></label>
        <input type="text" class="form-control" id="subject" name="subject" required>
    </div>
    <div class="mb-3">
        <label for="message" class="form-label">Your Message<span class="text-danger">*</span></label>
        <textarea class="form-control" id="message" name="message" rows="6" required></textarea>
    </div>
    <div class="g-recaptcha" data-sitekey="<%= process.env.CAPTCHA_SITE_KEY %>"></div>
    <button type="submit" class="btn btn-primary">Send Message</button>
</form>

The reCAPTCHA widget is included as a <div> element with the data-sitekey attribute. This ensures that the user needs to verify they are human before submitting the form.

2. Form Submission Handling and reCAPTCHA Validation

When the form is submitted, the data (along with the reCAPTCHA response token) is sent to the server, where its validated and processed.

app.post('/contact', async (req, res) => {
    const { name, email, subject, message, 'g-recaptcha-response': captchaToken

 } = req.body;

    if (!name || !email || !subject || !message) {
        return res.render('contact', { msg: 'All fields are required.' });
    }

    // Verify the reCAPTCHA token with Google
    const captchaSecret = process.env.CAPTCHA_SECRET_KEY;
    const captchaVerifyUrl = `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captchaToken}`;

    try {
        const captchaResponse = await axios.post(captchaVerifyUrl);
        if (!captchaResponse.data.success) {
            return res.render('contact', { msg: 'Captcha verification failed.' });
        }

        // If CAPTCHA is verified, send email
        const output = `
            <p>You have a new contact request from <strong>${name}</strong>.</p>
            <h3>Contact Details</h3>
            <ul>
                <li>Name: ${name}</li>
                <li>Email: ${email}</li>
                <li>Subject: ${subject}</li>
            </ul>
            <h3>Message</h3>
            <p>${message}</p>
        `;

        // Send email using nodemailer
        const transporter = nodemailer.createTransport({
            host: process.env.SMTP_HOST,
            port: process.env.SMTP_PORT,
            auth: {
                user: process.env.EMAIL_USER,
                pass: process.env.EMAIL_PASS
            }
        });

        const mailOptions = {
            from: email,
            to: process.env.RECEIVER_EMAIL,
            subject,
            html: output
        };

        transporter.sendMail(mailOptions, (error, info) => {
            if (error) {
                console.error(error);
                return res.render('contact', { msg: 'An error occurred. Please try again.' });
            } else {
                console.log('Message sent: %s', info.messageId);
                return res.render('contact', { msg: 'Your message has been sent successfully!' });
            }
        });
    } catch (error) {
        console.error('Error verifying reCAPTCHA:', error);
        return res.render('contact', { msg: 'Captcha verification failed. Please try again.' });
    }
});
  • Basic Validation: We check that all required fields (name, email, subject, message) are filled in before proceeding.
  • reCAPTCHA Verification: The reCAPTCHA token is sent to Googles API for verification. If the response is valid, the form submission proceeds. Otherwise, an error message is displayed to the user.
  • Sending Email: Once reCAPTCHA is verified, the form data is formatted into an HTML message and sent to the site administrator via email using Nodemailer.

Generating an RSS Feed

For users who want to subscribe to the blog, I added an RSS feed feature. RSS feeds allow users to get updates from the blog using feed readers. Each time a new post is published, it automatically appears in the feed.

RSS Feed Route

The route /rss generates the RSS feed in XML format, listing all the recent blog posts. The feed includes the posts title, description (lead), link, and publication date.

app.get('/rss', (req, res) => {
    const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown'))
        .filter(file => file.endsWith('.md'))
        .sort((a, b) => {
            const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
            const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
            return statB - statA;
        });

    let rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>\n<rss version="2.0">\n<channel>\n`;
    rssFeed += `<title>Raven Scott Blog</title>\n`;
    rssFeed += `<link>https://${req.hostname}</link>\n`;
    rssFeed += `<description>This is the RSS feed for Raven Scott's blog.</description>\n`;

    blogFiles.forEach(file => {
        const { lead } = loadMarkdownWithLead(file);
        const title = file.replace('.md', '');
        const slug = titleToSlug(title);
        const stats = fs.statSync(path.join(__dirname, 'markdown', file));
        const lastModifiedDate = new Date(stats.birthtime).toUTCString();

        rssFeed += `<item>\n`;
        rssFeed += `<title>${title}</title>\n`;
        rssFeed += `<link>https://${req.hostname}/blog/${slug}</link>\n`;
        rssFeed += `<description>${lead || 'Read the full post on the blog.'}</description>\n`;
        rssFeed += `<pubDate>${lastModifiedDate}</pubDate>\n`;
        rssFeed += `<guid>https://${req.hostname}/blog/${slug}</guid>\n`;
        rssFeed += `</item>\n`;
    });

    rssFeed += `</channel>\n</rss>`;

    res.header('Content-Type', 'application/rss+xml');
    res.send(rssFeed);
});

Generating a Sitemap for SEO

To help search engines like Google crawl and index the blog, I implemented a sitemap. The sitemap lists all the URLs on the site and includes metadata about each page (e.g., when it was last modified, how frequently it changes).

Sitemap Route

The /sitemap.xml route generates an XML sitemap listing all the static and dynamic pages (like blog posts):

app.get('/sitemap.xml', (req, res) => {
    const hostname = req.headers.host || 'http://localhost';
    const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));

    const staticUrls = [
        { url: '/', changefreq: 'weekly', priority: 1.0 },
        { url: '/about', changefreq: 'monthly', priority: 0.8 },
        { url: '/contact', changefreq: 'monthly', priority: 0.8 }
    ];

    const blogUrls = blogFiles.map(file => {
        const title = file.replace('.md', '');
        const slug = titleToSlug(title);
        const stats = fs.statSync(path.join(__dirname, 'markdown', file));
        const lastModifiedDate = format(new Date(stats.birthtime), 'yyyy-MM-dd');

        return {
            url: `/blog/${slug}`,
            lastmod: lastModifiedDate,
            changefreq: 'monthly',
            priority: 0.9
        };
    });

    const urls = [...staticUrls, ...blogUrls];
    let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;

    urls.forEach(({ url, lastmod, changefreq, priority }) => {
        sitemap += `  <url>\n`;
        sitemap += `    <loc>https://${hostname}${url}</loc>\n`;
        if (lastmod) {
            sitemap += `    <lastmod>${lastmod}</lastmod>\n`;
        }
        sitemap += `    <changefreq>${changefreq}</changefreq>\n`;
        sitemap += `    <priority>${priority}</priority>\n`;
        sitemap += `  </url>\n`;
    });

    sitemap += `</urlset>`;

    res.header('Content-Type', 'application/xml');
    res.send(sitemap);
});

My thoughts

This blog platform demonstrates how you can combine several modern technologies to build a feature-rich website. The use of Node.js and Express offers a lightweight, fast, and scalable solution for serving content. By storing blog posts as Markdown files, we avoid the complexity of using a database while still providing dynamic functionality.