diff --git a/ecosystem.config.js b/ecosystem.config.js index 14630b4..fa4e6cc 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,20 +1,23 @@ module.exports = { - apps: [{ - name: "expressjs-blog", - script: "./src/app.js", - instances: 1, // or "max" for cluster mode - exec_mode: "fork", // or "cluster" - env: { - NODE_ENV: "development" + apps: [ + { + name: "expressjs-blog", + script: "./src/app.js", + instances: 1, // or "max" for cluster mode + exec_mode: "fork", // or "cluster" + env: { + NODE_ENV: "development", + PORT: 3400, + }, + env_production: { + NODE_ENV: "production", + PORT: 3000, + }, + ignore_watch: ["node_modules", "logs"], + log_date_format: "YYYY-MM-DD HH:mm Z", + error_file: "./logs/err.log", + out_file: "./logs/out.log", + pid_file: "./pids/pm2.pid", }, - env_production: { - NODE_ENV: "production", - PORT: 3000 - }, - log_date_format: "YYYY-MM-DD HH:mm Z", - error_file: "./logs/err.log", - out_file: "./logs/out.log", - pid_file: "./pids/pm2.pid" - }] -} - + ], +}; diff --git a/package.json b/package.json index cd30789..c490c7d 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "test": "echo \"Error: no test specified\" && exit 1", "start": "nodemon ./src/app.js --trace-exit", "maildev": "maildev", - "dev": "./node_modules/pm2/bin/pm2 start --env development --watch", - "prod": "./node_modules/pm2/bin/pm2 start --env production", - "stop": "node_modules/pm2/bin/pm2 stop expressjs-blog" + "dev": "./node_modules/pm2/bin/pm2 ecosystem.config.js start --env development --watch", + "prod": "./node_modules/pm2/bin/pm2 ecosystem.config.js start --env production", + "stop": "node_modules/pm2/bin/pm2 delete expressjs-blog" }, "keywords": [], "author": "", diff --git a/public/js/post.js b/public/js/post.js new file mode 100644 index 0000000..3a4bb5e --- /dev/null +++ b/public/js/post.js @@ -0,0 +1,122 @@ +// Syntax Highlighting with Prism.js (include Prism CSS/JS separately) +// Automatically highlight all
blocks
+document.querySelectorAll("pre code").forEach((block) => {
+ Prism.highlightElement(block);
+});
+
+// Copy-to-Clipboard Buttons for Code Blocks
+document.querySelectorAll("pre").forEach((pre) => {
+ const btn = document.createElement("button");
+ btn.textContent = "Copy";
+ btn.type = "button";
+ btn.className = "copy-btn";
+ btn.style.position = "absolute";
+ btn.style.top = "0.5rem";
+ btn.style.right = "0.5rem";
+
+ btn.addEventListener("click", () => {
+ const code = pre.querySelector("code");
+ if (!code) return;
+ navigator.clipboard.writeText(code.innerText).then(() => {
+ btn.textContent = "Copied";
+ setTimeout(() => (btn.textContent = "Copy"), 2000);
+ });
+ });
+
+ pre.style.position = "relative";
+ pre.appendChild(btn);
+});
+
+// Table of Contents Generation from h2, h3 in .post-content
+(function generateTOC() {
+ const content = document.querySelector(".post-content");
+ if (!content) return;
+ const tocContainer = document.createElement("nav");
+ tocContainer.className = "toc";
+ const tocList = document.createElement("ul");
+
+ content.querySelectorAll("h2, h3").forEach((heading) => {
+ if (!heading.id)
+ heading.id = heading.textContent
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-");
+
+ const li = document.createElement("li");
+ li.className = heading.tagName.toLowerCase();
+
+ const a = document.createElement("a");
+ a.href = `#${heading.id}`;
+ a.textContent = heading.textContent;
+
+ li.appendChild(a);
+ tocList.appendChild(li);
+ });
+
+ tocContainer.appendChild(tocList);
+
+ // Insert TOC before post content or in sidebar if exists
+ const sidebar = document.querySelector(".sidebar nav");
+ if (sidebar) sidebar.appendChild(tocContainer);
+ else content.parentNode.insertBefore(tocContainer, content);
+})();
+
+// Smooth Scroll for Anchor Links
+document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
+ anchor.addEventListener("click", (e) => {
+ const target = document.querySelector(anchor.getAttribute("href"));
+ if (!target) return;
+ e.preventDefault();
+ target.scrollIntoView({ behavior: "smooth" });
+ });
+});
+
+// Dark Mode Toggle
+(function darkModeToggle() {
+ const toggle = document.createElement("button");
+ toggle.textContent = "Toggle Dark Mode";
+ toggle.id = "dark-mode-toggle";
+ document.body.prepend(toggle);
+
+ function applyMode(dark) {
+ document.documentElement.setAttribute(
+ "data-theme",
+ dark ? "dark" : "light"
+ );
+ localStorage.setItem("darkMode", dark);
+ }
+
+ toggle.addEventListener("click", () => {
+ const dark = document.documentElement.getAttribute("data-theme") === "dark";
+ applyMode(!dark);
+ });
+
+ // Initialize from saved preference or system
+ const saved = localStorage.getItem("darkMode");
+ if (saved !== null) applyMode(saved === "true");
+ else applyMode(window.matchMedia("(prefers-color-scheme: dark)").matches);
+})();
+
+// External Link Detection and Attributes
+document.querySelectorAll('.post-content a[href^="http"]').forEach((link) => {
+ if (new URL(link.href).origin !== location.origin) {
+ link.setAttribute("target", "_blank");
+ link.setAttribute("rel", "noopener noreferrer");
+ // Optionally append icon for external link:
+ const extIcon = document.createElement("span");
+ extIcon.textContent = " ↗";
+ extIcon.setAttribute("aria-hidden", "true");
+ link.appendChild(extIcon);
+ }
+});
+
+// Accessibility: Focus outlines on keyboard navigation
+(function manageFocusOutline() {
+ function handleFirstTab(e) {
+ if (e.key === "Tab") {
+ document.body.classList.add("user-is-tabbing");
+ window.removeEventListener("keydown", handleFirstTab);
+ }
+ }
+ window.addEventListener("keydown", handleFirstTab);
+})();
diff --git a/src/app.js b/src/app.js
index f757bed..8f25507 100644
--- a/src/app.js
+++ b/src/app.js
@@ -18,6 +18,8 @@
return null;
},
},
+ defaultLayout: "main",
+ extname: ".handlebars",
runtimeOptions: {
allowProtoPropertiesByDefault: true,
allowProtoMethodsByDefault: true,
diff --git a/src/routes/index.js b/src/routes/index.js
index 828573e..38539cd 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -1,60 +1,15 @@
// src/routes/index.js
const express = require("express");
const router = express.Router();
-const { marked } = require("marked");
-const fs = require("fs").promises;
-const path = require("path");
-const matter = require("gray-matter");
const contact = require("./contact");
const about = require("./about");
+const post = require("./post");
const getBaseContext = require("../utils/baseContext");
router.use(contact);
router.use(about);
-router.get("/post/:year/:month/:name", async (req, res, next) => {
- const { year, month, name } = req.params;
-
- // Validate year: 4 digits only
- if (!/^\d{4}$/.test(year)) {
- const error = new Error("Invalid year parameter.");
- error.statusCode = 400;
- return next(error);
- }
-
- // Validate month: 01-12 only
- if (!/^(0[1-9]|1[0-2])$/.test(month)) {
- const error = new Error("Invalid month parameter.");
- error.statusCode = 400;
- return next(error);
- }
-
- // Validate name: allow alphanumeric, dash, underscore only (no dots, no slashes)
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
- const error = new Error("Invalid post name parameter.");
- error.statusCode = 400;
- return next(error);
- }
-
- const mdPath = path.join(__dirname, "../../posts", year, month, `${name}.md`);
-
- try {
- const fileContent = await fs.readFile(mdPath, "utf8");
- const { data: frontmatter, content } = matter(fileContent);
- const htmlContent = marked(content);
- const context = await getBaseContext({
- title: frontmatter.title,
- date: frontmatter.date,
- author: frontmatter.author,
- content: htmlContent,
- });
- res.render("pages/post", context);
- } catch (err) {
- const error = new Error("The requested blog post could not be found.");
- error.statusCode = 404;
- next(error);
- }
-});
+router.get("/post/:year/:month/:name", post);
router.get("/", async (req, res) => {
const context = await getBaseContext({
title: "Blog Home",
diff --git a/src/routes/post.js b/src/routes/post.js
new file mode 100644
index 0000000..86151ac
--- /dev/null
+++ b/src/routes/post.js
@@ -0,0 +1,53 @@
+// src/routes/index.js
+const express = require("express");
+const router = express.Router();
+const { marked } = require("marked");
+const fs = require("fs").promises;
+const path = require("path");
+const matter = require("gray-matter");
+
+const getBaseContext = require("../utils/baseContext");
+
+module.exports = async (req, res, next) => {
+ const { year, month, name } = req.params;
+
+ // Validate year: 4 digits only
+ if (!/^\d{4}$/.test(year)) {
+ const error = new Error("Invalid year parameter.");
+ error.statusCode = 400;
+ return next(error);
+ }
+
+ // Validate month: 01-12 only
+ if (!/^(0[1-9]|1[0-2])$/.test(month)) {
+ const error = new Error("Invalid month parameter.");
+ error.statusCode = 400;
+ return next(error);
+ }
+
+ // Validate name: allow alphanumeric, dash, underscore only (no dots, no slashes)
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
+ const error = new Error("Invalid post name parameter.");
+ error.statusCode = 400;
+ return next(error);
+ }
+
+ const mdPath = path.join(__dirname, "../../posts", year, month, `${name}.md`);
+
+ try {
+ const fileContent = await fs.readFile(mdPath, "utf8");
+ const { data: frontmatter, content } = matter(fileContent);
+ const htmlContent = marked(content);
+ const context = await getBaseContext({
+ title: frontmatter.title,
+ date: frontmatter.date,
+ author: frontmatter.author,
+ content: htmlContent,
+ });
+ res.render("pages/post", context);
+ } catch (err) {
+ const error = new Error("The requested blog post could not be found.");
+ error.statusCode = 404;
+ next(error);
+ }
+};
diff --git a/src/views/layouts/main.handlebars b/src/views/layouts/main.handlebars
index ba372a5..abe308f 100644
--- a/src/views/layouts/main.handlebars
+++ b/src/views/layouts/main.handlebars
@@ -24,6 +24,7 @@
+ {{{_sections.scripts}}}