// blog.js – Loads and renders blog posts with automatic collapse for long entries const BLOG_DIR = 'blog/'; const MANIFEST = BLOG_DIR + 'manifest.json'; // ---------- helpers ---------- // crude HTML parser for directory listings (when manifest is missing) function parseIndexHtmlList(html) { const hrefs = []; const re = /href="([^"]+)"/g; let m; while ((m = re.exec(html)) !== null) { const name = m[1]; // accept typical text file extensions (and no extension) if (name.match(/\.(txt|md|text)$/i) || !name.includes('.')) { // avoid directory links (ending with /) if (!name.endsWith('/')) hrefs.push(name); } } return hrefs; } // fetch list of blog post filenames (manifest first, fallback to directory listing) async function loadPostList() { try { const r = await fetch(MANIFEST, { cache: 'no-cache' }); if (r.ok) return await r.json(); } catch (_) { /* ignore */ } try { const r = await fetch(BLOG_DIR, { cache: 'no-cache' }); if (r.ok) { const html = await r.text(); return parseIndexHtmlList(html); } } catch (e) { console.warn('Could not read blog directory listing', e); } return []; } // fetch a single post file async function fetchPost(filename) { const res = await fetch(BLOG_DIR + filename, { cache: 'no-cache' }); if (!res.ok) throw new Error(`Failed to fetch ${filename}`); return await res.text(); } // generate preview: up to 3 sentences, but not exceeding ~300 chars function getPreview(text, maxLen = 300) { if (text.length <= maxLen) return text; // try to split on sentence boundaries (. ! ?) followed by space or newline const sentences = text.match(/[^.!?]+[.!?]+/g) || []; let preview = ''; let count = 0; for (const s of sentences) { if (preview.length + s.length <= maxLen) { preview += s; count++; if (count >= 3) break; } else { // if we haven't reached 3 sentences, break and use what we have break; } } // If we have at least one sentence, use that; otherwise fallback to word‑break if (preview.trim().length > 0) { return preview.trim(); } // fallback: take first maxLen chars and cut at last space let truncated = text.slice(0, maxLen); const lastSpace = truncated.lastIndexOf(' '); if (lastSpace > 0) truncated = truncated.slice(0, lastSpace); return truncated + '…'; } // ---------- rendering ---------- function renderPost(filename, content) { const lines = content.split('\n'); if (lines.length < 3) { // malformed: skip or treat as title‑only return null; } const dateTime = lines[0].trim(); const title = lines[1].trim(); const bodyLines = lines.slice(2); const body = bodyLines.join('\n').trim(); const postDiv = document.createElement('div'); postDiv.className = 'blog-post'; // title const titleEl = document.createElement('h3'); titleEl.className = 'blog-title'; titleEl.textContent = title; postDiv.appendChild(titleEl); // meta const metaEl = document.createElement('p'); metaEl.className = 'blog-meta'; metaEl.textContent = dateTime; postDiv.appendChild(metaEl); const isLong = body.length > 300; if (!isLong) { // short post – display full body directly const bodyDiv = document.createElement('div'); bodyDiv.className = 'blog-body'; bodyDiv.style.whiteSpace = 'pre-line'; bodyDiv.textContent = body; postDiv.appendChild(bodyDiv); } else { // long post – show preview with toggle const container = document.createElement('div'); container.className = 'blog-collapsible'; // preview const previewDiv = document.createElement('div'); previewDiv.className = 'blog-preview'; previewDiv.style.whiteSpace = 'pre-line'; let previewText = getPreview(body); // ensure ellipsis if preview is truncated if (previewText.length < body.length && !previewText.endsWith('…')) { previewText += '…'; } previewDiv.textContent = previewText; container.appendChild(previewDiv); // Read more button const readMoreBtn = document.createElement('button'); readMoreBtn.className = 'blog-toggle read-more'; readMoreBtn.textContent = 'Read more'; container.appendChild(readMoreBtn); // Full content (hidden initially) const fullDiv = document.createElement('div'); fullDiv.className = 'blog-full'; fullDiv.style.whiteSpace = 'pre-line'; fullDiv.textContent = body; fullDiv.style.display = 'none'; container.appendChild(fullDiv); // Show less button (hidden initially) – appears at the bottom const showLessBtn = document.createElement('button'); showLessBtn.className = 'blog-toggle show-less'; showLessBtn.textContent = '✕ Show less'; showLessBtn.style.display = 'none'; container.appendChild(showLessBtn); // Toggle logic function expand() { fullDiv.style.display = 'block'; readMoreBtn.style.display = 'none'; showLessBtn.style.display = 'inline-block'; } function collapse() { fullDiv.style.display = 'none'; readMoreBtn.style.display = 'inline-block'; showLessBtn.style.display = 'none'; } readMoreBtn.addEventListener('click', expand); showLessBtn.addEventListener('click', collapse); postDiv.appendChild(container); } return postDiv; } // ---------- main ---------- async function initBlog() { const feed = document.querySelector('.blog-feed'); if (!feed) return; feed.textContent = 'Loading blog posts…'; try { const files = await loadPostList(); if (!files.length) { feed.textContent = 'No blog posts found.'; return; } // sort files by name (oldest first? we'll sort by date if embedded, but we'll just alphabetical) // Typically filenames like 2025-01-01.txt, so alphabetical = chronological files.sort(); // clear feed feed.innerHTML = ''; for (const file of files) { try { const content = await fetchPost(file); const el = renderPost(file, content); if (el) feed.appendChild(el); } catch (err) { console.warn(`Failed to load ${file}:`, err); // optionally show error placeholder const errDiv = document.createElement('div'); errDiv.className = 'blog-post'; errDiv.style.color = '#b91c1c'; errDiv.textContent = `⚠️ Could not load post: ${file}`; feed.appendChild(errDiv); } } // if nothing was added, show message if (feed.children.length === 0) { feed.textContent = 'No valid blog posts found.'; } } catch (err) { console.error('Blog init error:', err); feed.textContent = 'Error loading blog posts.'; } } document.addEventListener('DOMContentLoaded', initBlog);