add article

This commit is contained in:
Raven Scott 2024-09-20 01:56:49 -04:00
parent 64b493bb31
commit b962825ba3

View File

@ -0,0 +1,547 @@
<!-- lead -->
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:
```javascript
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.
```javascript
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.
```javascript
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`.
```javascript
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.
```javascript
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.
```js
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:
```javascript
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:
```javascript
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:
```javascript
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.
```javascript
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:
```html
<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.
```html
<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.
```javascript
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.
```javascript
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):
```javascript
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.