'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 contentHtml = StaticMarkdownRenderer.render(post.content || ''); const content = [ '
', this.renderBrandBar(), '
', '
', `

${safeTitle}

`, metaBits.length > 0 ? ` ` : '', tagsHtml, '
', `
${contentHtml}
`, '
', '
' ].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, '''); } } /** * Lightweight markdown renderer for static blog generation. */ class StaticMarkdownRenderer { /** * @param {string} markdown * @returns {string} */ static render(markdown) { const normalized = String(markdown || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); if (!normalized) return ''; const codeBlocks = []; const withCodePlaceholders = normalized.replace(/```([\w-]*)(?:\s*\{[^}]*\})?\s*\n([\s\S]*?)\n?```/g, (_, lang, code) => { const index = codeBlocks.length; codeBlocks.push({ lang: lang || '', code: code || '' }); return `\n__CODEBLOCK_${index}__\n`; }); const blocks = withCodePlaceholders.split(/\n{2,}/); const parts = blocks.map((block) => { const trimmed = block.trim(); if (!trimmed) return ''; const codeMatch = trimmed.match(/^__CODEBLOCK_(\d+)__$/); if (codeMatch) { const item = codeBlocks[Number(codeMatch[1])]; const languageClass = item.lang ? ` language-${this.escapeHtml(item.lang)}` : ''; return `
${this.escapeHtml(item.code)}
`; } if (/^[-*_]{3,}$/.test(trimmed)) { return '
'; } const heading = trimmed.match(/^(#{1,6})\s+(.+)$/); if (heading && trimmed.split('\n').length === 1) { const level = heading[1].length; return `${this.renderInline(heading[2])}`; } if (/^>\s?/.test(trimmed)) { const quoteText = trimmed.split('\n').map((line) => line.replace(/^>\s?/, '')).join('\n'); return `
${this.renderInline(quoteText).replace(/\n/g, '
')}
`; } if (/^[-*+]\s+/.test(trimmed)) { const items = trimmed .split('\n') .filter((line) => /^\s*[-*+]\s+/.test(line)) .map((line) => line.replace(/^\s*[-*+]\s+/, '')); return ``; } if (/^\d+\.\s+/.test(trimmed)) { const items = trimmed .split('\n') .filter((line) => /^\s*\d+\.\s+/.test(line)) .map((line) => line.replace(/^\s*\d+\.\s+/, '')); return `
    ${items.map((item) => `
  1. ${this.renderInline(item)}
  2. `).join('')}
`; } const imageOnly = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/); if (imageOnly) { const imageMeta = this.parseImageMeta(imageOnly[1], imageOnly[2], imageOnly[3] || ''); const captionHtml = imageMeta.caption ? `
${this.escapeHtml(imageMeta.caption)}
` : ''; return `
${this.escapeHtml(imageMeta.alt)}${captionHtml}
`; } const lines = trimmed.split('\n').map((line) => this.renderInline(line)); return `

${lines.join('
')}

`; }); return parts.filter(Boolean).join('\n'); } /** * @param {string} text * @returns {string} */ static renderInline(text) { const codeSpans = []; const withCodePlaceholders = text.replace(/`([^`]+)`/g, (_, code) => { const index = codeSpans.length; codeSpans.push(code); return `__INLINE_CODE_${index}__`; }); let html = this.escapeHtml(withCodePlaceholders); html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (_, alt, url, title) => { const imageMeta = this.parseImageMeta(alt, url, title || ''); return `${this.escapeHtml(imageMeta.alt)}`; }); html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (_, label, url, title) => { const href = this.normalizeUrl(url); const rel = /^https?:\/\//i.test(href) ? ' rel="noopener noreferrer" target="_blank"' : ''; const titleAttr = title ? ` title="${this.escapeHtml(title)}"` : ''; return `${label}`; }); html = html.replace(/\*\*\*([^*]+)\*\*\*/g, '$1'); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); html = html.replace(/\*([^*]+)\*/g, '$1'); html = html.replace(/__INLINE_CODE_(\d+)__/g, (_, idx) => { const code = codeSpans[Number(idx)] || ''; return `${this.escapeHtml(code)}`; }); return html; } /** * @param {string} alt * @param {string} rawUrl * @param {string} title * @returns {{src: string, alt: string, caption: string, attributes: string}} */ static parseImageMeta(alt, rawUrl, title) { const scale = this.parseScale(alt); const src = this.normalizeUrl(rawUrl); const safeAlt = scale !== null ? '' : String(alt || ''); const caption = title ? String(title) : (scale !== null ? '' : String(alt || '')); const widthAttr = scale !== null ? ` style="width:${Math.round(scale * 10000) / 100}%;"` : ''; return { src, alt: safeAlt, caption, attributes: widthAttr }; } /** * @param {string} value * @returns {number | null} */ static parseScale(value) { const normalized = String(value || '').trim(); if (!/^\d*\.?\d+$/.test(normalized)) return null; const parsed = Number(normalized); if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) return null; return parsed; } /** * @param {string} url * @returns {string} */ static normalizeUrl(url) { const value = String(url || '').trim(); if (!value) return '#'; if (/^(https?:|mailto:|tel:|#|\/)/i.test(value)) return value; return `/${value.replace(/^\.\//, '').replace(/\\/g, '/')}`; } /** * @param {string} value * @returns {string} */ static escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } } module.exports = { StaticBlogGenerator };