feat: enhance blog functionality with markdown rendering and bundling

- Updated build process to bundle main and blog scripts separately using esbuild.
- Modified HTML files to reference new bundled JavaScript files for improved performance.
- Implemented a new blogPage.js script to handle markdown rendering and copy functionality.
- Replaced inline markdown rendering with JSON payloads for better separation of content and presentation.
- Added Prism.js for syntax highlighting in blog posts.
- Improved CSS styles for better layout and scrolling behavior on blog pages.
This commit is contained in:
2026-05-10 19:53:19 +10:00
parent 35b049b833
commit a16445dd78
9 changed files with 192 additions and 309 deletions

View File

@@ -98,7 +98,7 @@ class StaticBlogGenerator {
? `<div class="blog-post-tags">${tags.map((tag) => `<span class="blog-tag">${this.escapeHtml(tag)}</span>`).join('')}</div>`
: '';
const contentHtml = StaticMarkdownRenderer.render(post.content || '');
const markdownJson = JSON.stringify(post.content || '').replace(/<\//g, '<\\/');
const content = [
'<main class="blog-shell">',
@@ -109,7 +109,8 @@ class StaticBlogGenerator {
metaBits.length > 0 ? ` <p class="blog-post-meta">${metaBits.join('<span aria-hidden="true">-</span>')}</p>` : '',
tagsHtml,
' </header>',
` <section class="blog-post-content">${contentHtml}</section>`,
' <section class="blog-post-content" data-blog-post-content></section>',
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
'</article>',
'</main>'
].join('\n');
@@ -137,11 +138,21 @@ class StaticBlogGenerator {
` <link rel="canonical" href="https://lit.ruv.wtf${canonicalPath}">`,
' <link rel="icon" type="image/png" sizes="32x32" href="/logos/32px.png">',
' <link rel="icon" type="image/png" sizes="64x64" href="/logos/64px.png">',
' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">',
' <link rel="stylesheet" href="/styles/main.css">',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>',
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>',
'</head>',
'<body class="blog-page">',
this.renderHeaderLinks(),
bodyHtml,
' <script type="module" src="/scripts/blogPage.js"></script>',
'</body>',
'</html>'
].join('\n');
@@ -204,178 +215,6 @@ class StaticBlogGenerator {
}
}
/**
* 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 `<pre class="md-pre"><code class="${languageClass}">${this.escapeHtml(item.code)}</code></pre>`;
}
if (/^[-*_]{3,}$/.test(trimmed)) {
return '<hr class="md-hr" />';
}
const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (heading && trimmed.split('\n').length === 1) {
const level = heading[1].length;
return `<h${level} class="md-h${level}">${this.renderInline(heading[2])}</h${level}>`;
}
if (/^>\s?/.test(trimmed)) {
const quoteText = trimmed.split('\n').map((line) => line.replace(/^>\s?/, '')).join('\n');
return `<blockquote class="md-blockquote">${this.renderInline(quoteText).replace(/\n/g, '<br />')}</blockquote>`;
}
if (/^[-*+]\s+/.test(trimmed)) {
const items = trimmed
.split('\n')
.filter((line) => /^\s*[-*+]\s+/.test(line))
.map((line) => line.replace(/^\s*[-*+]\s+/, ''));
return `<ul class="md-ul">${items.map((item) => `<li class="md-li">${this.renderInline(item)}</li>`).join('')}</ul>`;
}
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 `<ol class="md-ol">${items.map((item) => `<li class="md-li">${this.renderInline(item)}</li>`).join('')}</ol>`;
}
const imageOnly = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/);
if (imageOnly) {
const imageMeta = this.parseImageMeta(imageOnly[1], imageOnly[2], imageOnly[3] || '');
const captionHtml = imageMeta.caption ? `<figcaption class="md-figcaption">${this.escapeHtml(imageMeta.caption)}</figcaption>` : '';
return `<figure class="md-figure"><img class="md-img" src="${imageMeta.src}" alt="${this.escapeHtml(imageMeta.alt)}"${imageMeta.attributes} />${captionHtml}</figure>`;
}
const lines = trimmed.split('\n').map((line) => this.renderInline(line));
return `<p class="md-p">${lines.join('<br />')}</p>`;
});
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+&quot;([^&]*)&quot;)?\)/g, (_, alt, url, title) => {
const imageMeta = this.parseImageMeta(alt, url, title || '');
return `<img class="md-img md-img--inline" src="${imageMeta.src}" alt="${this.escapeHtml(imageMeta.alt)}"${imageMeta.attributes} />`;
});
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/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 `<a class="md-link" href="${href}"${titleAttr}${rel}>${label}</a>`;
});
html = html.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/__INLINE_CODE_(\d+)__/g, (_, idx) => {
const code = codeSpans[Number(idx)] || '';
return `<code class="md-code">${this.escapeHtml(code)}</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
module.exports = {
StaticBlogGenerator
};