'use strict'; const fs = require('fs').promises; const path = require('path'); /** * Generates static blog pages and an index page from markdown post data. */ class StaticBlogGenerator { /** * @param {{ siteRootDir: string }} options */ constructor(options) { /** @type {string} */ this.siteRootDir = options.siteRootDir; /** @type {string} */ this.blogRootDir = path.join(this.siteRootDir, 'blog'); /** @type {string} */ this.blogIndexPath = path.join(this.siteRootDir, 'blog.html'); } /** * Generates blog index and post pages. * * @param {Array<{slug: string, title: string, date: string | null, author: string | null, tags?: string[], content: string}>} posts * @returns {Promise} */ async generate(posts) { await fs.rm(this.blogRootDir, { recursive: true, force: true }); await fs.mkdir(this.blogRootDir, { recursive: true }); const indexHtml = this.renderIndexPage(posts); await fs.writeFile(this.blogIndexPath, indexHtml, 'utf-8'); for (const post of posts) { const postDir = path.join(this.blogRootDir, post.slug); await fs.mkdir(postDir, { recursive: true }); const postHtml = this.renderPostPage(post); await fs.writeFile(path.join(postDir, 'index.html'), postHtml, 'utf-8'); } } /** * @param {Array<{slug: string, title: string, date: string | null, author: string | null, tags?: string[]}>} posts * @returns {string} */ renderIndexPage(posts) { const cardsHtml = posts.map((post) => { const dateLabel = post.date ? this.formatDate(post.date) : 'Undated'; const authorLabel = post.author ? this.escapeHtml(post.author) : 'Unknown author'; const safeTitle = this.escapeHtml(post.title || post.slug); const safeSlug = encodeURIComponent(post.slug); const tags = Array.isArray(post.tags) ? post.tags : []; const tagsHtml = tags.length > 0 ? `
${tags.map((tag) => `${this.escapeHtml(tag)}`).join('')}
` : ''; return [ '
', `

${safeTitle}

`, `

${dateLabel} ${authorLabel}

`, tagsHtml, '
' ].join('\n'); }).join('\n'); const content = [ '
', this.renderBrandBar(), '
', '
', '

Blog

', '

Direct links to every post.

', '
', `
${cardsHtml || '

No posts yet.

'}
`, '
', '
' ].join('\n'); return this.renderPageTemplate('Blog', content, '/blog.html'); } /** * @param {{slug: string, title: string, date: string | null, author: string | null, tags?: string[], content: string}} post * @returns {string} */ renderPostPage(post) { const safeTitle = this.escapeHtml(post.title || post.slug); const dateLabel = post.date ? this.formatDate(post.date) : null; const authorLabel = post.author ? this.escapeHtml(post.author) : null; const tags = Array.isArray(post.tags) ? post.tags : []; const metaBits = []; if (dateLabel) metaBits.push(`${dateLabel}`); if (authorLabel) metaBits.push(`${authorLabel}`); const tagsHtml = tags.length > 0 ? `
${tags.map((tag) => `${this.escapeHtml(tag)}`).join('')}
` : ''; const markdownJson = JSON.stringify(post.content || '').replace(/<\//g, '<\\/'); const content = [ '
', this.renderBrandBar(), '
', '
', `

${safeTitle}

`, metaBits.length > 0 ? ` ` : '', tagsHtml, '
', '
', ` `, '
', '
' ].join('\n'); return this.renderPageTemplate(`${safeTitle} - Blog`, content, `/blog/${encodeURIComponent(post.slug)}/`); } /** * @param {string} title * @param {string} bodyHtml * @param {string} canonicalPath * @returns {string} */ renderPageTemplate(title, bodyHtml, canonicalPath) { const escapedTitle = this.escapeHtml(title); return [ '', '', '', ' ', ' ', ` ${escapedTitle}`, ' ', ` `, ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '', '', this.renderHeaderLinks(), bodyHtml, ' ', '', '' ].join('\n'); } /** * @returns {string} */ renderHeaderLinks() { return [ '' ].join('\n'); } /** * @returns {string} */ renderBrandBar() { return [ '', ' ', '' ].join('\n'); } /** * @param {string} dateInput * @returns {string} */ formatDate(dateInput) { const parsed = new Date(dateInput); if (Number.isNaN(parsed.valueOf())) { return this.escapeHtml(dateInput); } return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric' }).format(parsed); } /** * @param {string} value * @returns {string} */ escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } } module.exports = { StaticBlogGenerator };