Add search feature

This commit is contained in:
Raven Scott 2024-09-26 01:27:35 -04:00
parent 5dc2d2e6bc
commit 8e02173583
2 changed files with 51 additions and 90 deletions

124
app.js
View File

@ -13,7 +13,6 @@ const app = express();
// Set options for marked to use highlight.js for syntax highlighting // Set options for marked to use highlight.js for syntax highlighting
marked.setOptions({ marked.setOptions({
highlight: function (code, language) { highlight: function (code, language) {
// Check if the language is valid
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'; const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
return hljs.highlight(validLanguage, code).value; return hljs.highlight(validLanguage, code).value;
} }
@ -36,49 +35,45 @@ function loadMarkdownWithLead(file) {
let lead = ''; let lead = '';
let contentMarkdown = markdownContent; let contentMarkdown = markdownContent;
// Detect and extract the lead section
const leadKeyword = '<!-- lead -->'; const leadKeyword = '<!-- lead -->';
if (contentMarkdown.includes(leadKeyword)) { if (contentMarkdown.includes(leadKeyword)) {
const [beforeLead, afterLead] = contentMarkdown.split(leadKeyword); const [beforeLead, afterLead] = contentMarkdown.split(leadKeyword);
// Extract the first paragraph after the lead keyword
lead = afterLead.split('\n').find(line => line.trim() !== '').trim(); lead = afterLead.split('\n').find(line => line.trim() !== '').trim();
// Remove the lead from the main content
contentMarkdown = beforeLead + afterLead.replace(lead, '').trim(); contentMarkdown = beforeLead + afterLead.replace(lead, '').trim();
} }
// Convert markdown to HTML
const contentHtml = marked.parse(contentMarkdown); const contentHtml = marked.parse(contentMarkdown);
return { contentHtml, lead }; return { contentHtml, lead };
} }
// Function to convert a title (with spaces and special characters) into a URL-friendly slug (with dashes) // Function to convert a title into a URL-friendly slug
function titleToSlug(title) { function titleToSlug(title) {
return title return title
.toLowerCase() // Convert to lowercase .toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove all non-alphanumeric characters except spaces and dashes .replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-'); // Replace spaces with dashes .replace(/\s+/g, '-');
} }
// Function to convert a slug (with dashes) back into a readable title (with spaces) // Function to convert a slug back into a readable title
function slugToTitle(slug) { function slugToTitle(slug) {
return slug.replace(/-/g, ' '); return slug.replace(/-/g, ' ');
} }
// Function to load all blog posts with pagination support // Function to load all blog posts with pagination and search support
function getAllBlogPosts(page = 1, postsPerPage = 5) { function getAllBlogPosts(page = 1, postsPerPage = 5, searchQuery = '') {
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md')); let blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));
if (searchQuery) {
const lowerCaseQuery = searchQuery.toLowerCase();
blogFiles = blogFiles.filter(file => file.toLowerCase().includes(lowerCaseQuery));
}
// Sort by birthtime (latest first) like in your RSS feed
blogFiles.sort((a, b) => { blogFiles.sort((a, b) => {
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime; const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime; const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
return statB - statA; // Descending order, latest first return statB - statA;
}); });
// Paginate the results
const totalPosts = blogFiles.length; const totalPosts = blogFiles.length;
const totalPages = Math.ceil(totalPosts / postsPerPage); const totalPages = Math.ceil(totalPosts / postsPerPage);
const start = (page - 1) * postsPerPage; const start = (page - 1) * postsPerPage;
@ -87,41 +82,35 @@ function getAllBlogPosts(page = 1, postsPerPage = 5) {
const paginatedFiles = blogFiles.slice(start, end); const paginatedFiles = blogFiles.slice(start, end);
const blogPosts = paginatedFiles.map(file => { const blogPosts = paginatedFiles.map(file => {
const title = file.replace('.md', '').replace(/-/g, ' '); // Keep original casing for title const title = file.replace('.md', '').replace(/-/g, ' ');
const slug = titleToSlug(title); // Convert title to slug (lowercase) const slug = titleToSlug(title);
// Get the creation time of the markdown file
const stats = fs.statSync(path.join(__dirname, 'markdown', file)); const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const dateCreated = new Date(stats.birthtime); // Use birthtime for sorting and displaying const dateCreated = new Date(stats.birthtime);
return { return { title, slug, dateCreated };
title,
slug,
dateCreated, // Keep as Date object for sorting
};
}); });
// Return the paginated and sorted blog posts and the total pages
return { blogPosts, totalPages }; return { blogPosts, totalPages };
} }
// Home Route (Blog Home with Pagination) // Home Route (Blog Home with Pagination and Search)
app.get('/', (req, res) => { app.get('/', (req, res) => {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const searchQuery = req.query.search || '';
if (page < 1) { if (page < 1) {
return res.redirect(req.hostname); return res.redirect(req.hostname);
} }
const postsPerPage = 5; // Set how many posts to display per page const postsPerPage = 5;
const { blogPosts, totalPages } = getAllBlogPosts(page, postsPerPage, searchQuery);
const { blogPosts, totalPages } = getAllBlogPosts(page, postsPerPage);
res.render('index', { res.render('index', {
title: 'Raven Scott Blog', title: 'Raven Scott Blog',
blogPosts, blogPosts,
currentPage: page, currentPage: page,
totalPages totalPages,
searchQuery // Pass search query to the view
}); });
}); });
@ -139,23 +128,19 @@ app.get('/contact', (req, res) => {
app.post('/contact', async (req, res) => { app.post('/contact', async (req, res) => {
const { name, email, subject, message, 'g-recaptcha-response': captchaToken } = req.body; const { name, email, subject, message, 'g-recaptcha-response': captchaToken } = req.body;
// Validate form inputs (basic example)
if (!name || !email || !subject || !message) { if (!name || !email || !subject || !message) {
return res.render('contact', { title: 'Contact Raven Scott', msg: 'All fields are required.' }); return res.render('contact', { title: 'Contact Raven Scott', msg: 'All fields are required.' });
} }
// Verify the reCAPTCHA token const captchaSecret = process.env.CAPTCHA_SECRET_KEY;
const captchaSecret = process.env.CAPTCHA_SECRET_KEY; // Your reCAPTCHA secret key
const captchaVerifyUrl = `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captchaToken}`; const captchaVerifyUrl = `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captchaToken}`;
try { try {
const captchaResponse = await axios.post(captchaVerifyUrl); const captchaResponse = await axios.post(captchaVerifyUrl);
if (!captchaResponse.data.success) { if (!captchaResponse.data.success) {
return res.render('contact', { title: 'Contact Raven Scott', msg: 'Captcha verification failed. Please try again.' }); return res.render('contact', { title: 'Contact Raven Scott', msg: 'Captcha verification failed. Please try again.' });
} }
// CAPTCHA passed, proceed with sending email
const output = ` const output = `
<p>You have a new contact request from <strong>${name}</strong>.</p> <p>You have a new contact request from <strong>${name}</strong>.</p>
<h3>Contact Details</h3> <h3>Contact Details</h3>
@ -168,40 +153,31 @@ app.post('/contact', async (req, res) => {
<p>${message}</p> <p>${message}</p>
`; `;
// Set up Nodemailer transporter
let transporter = nodemailer.createTransport({ let transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT, port: process.env.SMTP_PORT,
secure: false, // true for 465, false for other ports secure: false,
auth: { auth: {
user: process.env.EMAIL_USER, // Email user from environment variables user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS, // Email password from environment variables pass: process.env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: false,
}, },
tls: { rejectUnauthorized: false },
}); });
// Set up email options
let mailOptions = { let mailOptions = {
from: `"${name}" <${process.env.RECEIVER_EMAIL}>`, from: `"${name}" <${process.env.RECEIVER_EMAIL}>`,
to: process.env.RECEIVER_EMAIL, // Your email address to receive contact form submissions to: process.env.RECEIVER_EMAIL,
subject: subject, subject: subject,
html: output, html: output,
}; };
// Send email
transporter.sendMail(mailOptions, (error, info) => { transporter.sendMail(mailOptions, (error, info) => {
if (error) { if (error) {
console.error(error);
return res.render('contact', { title: 'Contact Raven Scott', msg: 'An error occurred. Please try again.' }); return res.render('contact', { title: 'Contact Raven Scott', msg: 'An error occurred. Please try again.' });
} else {
console.log('Email sent: ' + info.response);
return res.render('contact', { title: 'Contact Raven Scott', msg: 'Your message has been sent successfully!' });
} }
return res.render('contact', { title: 'Contact Raven Scott', msg: 'Your message has been sent successfully!' });
}); });
} catch (error) { } catch (error) {
console.error('Error verifying CAPTCHA:', error);
return res.render('contact', { title: 'Contact Raven Scott', msg: 'An error occurred while verifying CAPTCHA. Please try again.' }); return res.render('contact', { title: 'Contact Raven Scott', msg: 'An error occurred while verifying CAPTCHA. Please try again.' });
} }
}); });
@ -213,46 +189,41 @@ app.get('/blog/:slug', (req, res) => {
.find(file => titleToSlug(file.replace('.md', '')) === slug); .find(file => titleToSlug(file.replace('.md', '')) === slug);
if (markdownFile) { if (markdownFile) {
const originalTitle = markdownFile.replace('.md', ''); // Original title with casing const originalTitle = markdownFile.replace('.md', '');
const blogPosts = getAllBlogPosts(); const blogPosts = getAllBlogPosts();
const { contentHtml, lead } = loadMarkdownWithLead(markdownFile); const { contentHtml, lead } = loadMarkdownWithLead(markdownFile);
res.render('blog-post', { res.render('blog-post', {
title: originalTitle, // Use the original title with casing title: originalTitle,
content: contentHtml, content: contentHtml,
lead: lead, lead: lead,
blogPosts blogPosts
}); });
} else { } else {
res.redirect('/'); // Redirect to the home page if the blog post is not found res.redirect('/');
} }
}); });
// Sitemap Route
// Sitemap Route // Sitemap Route
app.get('/sitemap.xml', (req, res) => { app.get('/sitemap.xml', (req, res) => {
const hostname = req.headers.host || 'http://localhost'; // Ensure this is your site URL in production const hostname = req.headers.host || 'http://localhost';
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')) const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown'))
.filter(file => file.endsWith('.md')) .filter(file => file.endsWith('.md'))
.sort((a, b) => { .sort((a, b) => {
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime; const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime; const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
return statB - statA; // Sort in descending order (latest first) return statB - statA;
}); });
// Static URLs (e.g., homepage, about, contact)
const staticUrls = [ const staticUrls = [
{ url: `${process.env.HOST_URL}`, changefreq: 'weekly', priority: 1.0 }, { url: `${process.env.HOST_URL}`, changefreq: 'weekly', priority: 1.0 },
{ url: `${process.env.HOST_URL}/about`, changefreq: 'monthly', priority: 0.8 }, { url: `${process.env.HOST_URL}/about`, changefreq: 'monthly', priority: 0.8 },
{ url: `${process.env.HOST_URL}/contact`, changefreq: 'monthly', priority: 0.8 } { url: `${process.env.HOST_URL}/contact`, changefreq: 'monthly', priority: 0.8 }
]; ];
// Dynamic URLs (e.g., blog posts)
const blogUrls = blogFiles.map(file => { const blogUrls = blogFiles.map(file => {
const title = file.replace('.md', ''); const title = file.replace('.md', '');
const slug = titleToSlug(title); const slug = titleToSlug(title);
// Get the last modified date of the markdown file
const stats = fs.statSync(path.join(__dirname, 'markdown', file)); const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const lastModifiedDate = format(new Date(stats.birthtime), 'yyyy-MM-dd'); const lastModifiedDate = format(new Date(stats.birthtime), 'yyyy-MM-dd');
@ -264,10 +235,8 @@ app.get('/sitemap.xml', (req, res) => {
}; };
}); });
// Combine static and dynamic URLs
const urls = [...staticUrls, ...blogUrls]; const urls = [...staticUrls, ...blogUrls];
// Generate the XML for the sitemap
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`; 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 }) => { urls.forEach(({ url, lastmod, changefreq, priority }) => {
sitemap += ` <url>\n`; sitemap += ` <url>\n`;
@ -281,42 +250,33 @@ app.get('/sitemap.xml', (req, res) => {
}); });
sitemap += `</urlset>`; sitemap += `</urlset>`;
// Set the content type to XML and send the response
res.header('Content-Type', 'application/xml'); res.header('Content-Type', 'application/xml');
res.send(sitemap); res.send(sitemap);
}); });
// RSS Feed Route // RSS Feed Route
app.get('/rss', (req, res) => { app.get('/rss', (req, res) => {
const hostname = req.headers.host || 'http://localhost'; // Adjust for production if needed const hostname = req.headers.host || 'http://localhost';
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')) const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown'))
.filter(file => file.endsWith('.md')) .filter(file => file.endsWith('.md'))
.sort((a, b) => { .sort((a, b) => {
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime; const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime; const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
return statB - statA; // Sort in descending order (latest first) return statB - statA;
}); });
// Build RSS feed
let rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>\n<rss version="2.0">\n<channel>\n`; 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 += `<title>Raven Scott Blog</title>\n`;
rssFeed += `<link>https://${hostname}</link>\n`; rssFeed += `<link>https://${hostname}</link>\n`;
rssFeed += `<description>This is the RSS feed for Raven Scott's blog.</description>\n`; rssFeed += `<description>This is the RSS feed for Raven Scott's blog.</description>\n`;
// Generate RSS items for each blog post
blogFiles.forEach(file => { blogFiles.forEach(file => {
const title = file.replace('.md', ''); const title = file.replace('.md', '');
const slug = titleToSlug(title); const slug = titleToSlug(title);
// Get the last modified date of the markdown file
const stats = fs.statSync(path.join(__dirname, 'markdown', file)); const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const lastModifiedDate = new Date(stats.birthtime).toUTCString(); // Use UTC date for RSS const lastModifiedDate = new Date(stats.birthtime).toUTCString();
// Load and parse markdown content to extract a lead or description
const { lead } = loadMarkdownWithLead(file); const { lead } = loadMarkdownWithLead(file);
// RSS item for each post
rssFeed += `<item>\n`; rssFeed += `<item>\n`;
rssFeed += `<title>${title}</title>\n`; rssFeed += `<title>${title}</title>\n`;
rssFeed += `<link>${process.env.BLOG_URL}${slug}</link>\n`; rssFeed += `<link>${process.env.BLOG_URL}${slug}</link>\n`;
@ -327,8 +287,6 @@ app.get('/rss', (req, res) => {
}); });
rssFeed += `</channel>\n</rss>`; rssFeed += `</channel>\n</rss>`;
// Set content type to XML and send the RSS feed
res.header('Content-Type', 'application/rss+xml'); res.header('Content-Type', 'application/rss+xml');
res.send(rssFeed); res.send(rssFeed);
}); });
@ -336,17 +294,13 @@ app.get('/rss', (req, res) => {
// Global 404 handler for unmatched routes // Global 404 handler for unmatched routes
app.use((req, res) => { app.use((req, res) => {
if (req.hostname === 'blog.raven-scott.fyi') { if (req.hostname === 'blog.raven-scott.fyi') {
// Redirect to the main domain
res.redirect(process.env.HOST_URL); res.redirect(process.env.HOST_URL);
} else { } else {
// Redirect to home page of the current domain
res.redirect('/'); res.redirect('/');
} }
}); });
// ================================
// Server Listening // Server Listening
// ================================
const PORT = process.env.PORT || 8899; const PORT = process.env.PORT || 8899;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);

View File

@ -35,12 +35,21 @@
<div class="container text-center"> <div class="container text-center">
<h1>Hello, my name is Raven Scott</h1> <h1>Hello, my name is Raven Scott</h1>
<p class="lead">Where Technology Meets Creativity: Insights from a Linux Enthusiast</p> <p class="lead">Where Technology Meets Creativity: Insights from a Linux Enthusiast</p>
<form action="/" method="get" class="mb-4">
<div class="input-group">
<input type="text" name="search" class="form-control" placeholder="Search blog posts..." value="<%= typeof searchQuery !== 'undefined' ? searchQuery : '' %>">
<button type="submit" class="btn btn-primary">Search</button>
</div>
</form>
</div> </div>
</header> </header>
<!-- Search form -->
<section class="py-5"> <section class="py-5">
<div class="container"> <div class="container">
<h2>Recent Posts</h2>
<!-- Blog post list -->
<h2><%= searchQuery ? `Search results for "${searchQuery}"` : 'Recent Posts' %></h2>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<% blogPosts.forEach(post => { %> <% blogPosts.forEach(post => { %>
<li class="list-group-item d-flex justify-content-between align-items-center py-4"> <li class="list-group-item d-flex justify-content-between align-items-center py-4">
@ -58,8 +67,6 @@
</li> </li>
<% }) %> <% }) %>
</ul> </ul>
</div>
</section>
<!-- Pagination controls --> <!-- Pagination controls -->
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">