// blog.js – with collapsible posts const BLOG_DIR = 'blog/'; const MANIFEST = BLOG_DIR + 'manifest.json'; // ---------- helpers ---------- function parseIndexHtmlList(text) { const hrefs = []; const re = /href="([^"]+)"/g; let m; while ((m = re.exec(text)) !== null) { const name = m[1]; if (name.match(/\.(txt)$/i)) hrefs.push(name); } return hrefs; } // fetch a list of post filenames async function loadPostList() { try { const r = await fetch(MANIFEST, { cache: 'no-cache' }); if (r.ok) return await r.json(); } catch (_) { /* fall through */ } try { const r = await fetch(BLOG_DIR, { cache: 'no-cache' }); if (r.ok) { const text = await r.text(); return parseIndexHtmlList(text); } } catch (e) { console.warn('Could not read blog directory listing', e); } return []; } // parse a single post file: first line = date/time, second = title, rest = content async function fetchPost(filename) { const resp = await fetch(BLOG_DIR + filename); if (!resp.ok) throw new Error('Failed to fetch ' + filename); const text = await resp.text(); const lines = text.split(/\r?\n/); const dateTime = lines[0]?.trim() || ''; const title = lines[1]?.trim() || 'Untitled'; const content = lines.slice(2).join('\n').trim(); return { dateTime, title, content, filename }; } // ---------- rendering with collapse ---------- function createPostElement(post) { const article = document.createElement('article'); article.className = 'blog-post'; // header: date & title const header = document.createElement('header'); const dateP = document.createElement('p'); dateP.className = 'post-date'; dateP.textContent = post.dateTime || 'No date'; const titleH = document.createElement('h3'); titleH.textContent = post.title; header.appendChild(dateP); header.appendChild(titleH); article.appendChild(header); // content container const contentDiv = document.createElement('div'); contentDiv.className = 'post-content'; // full content (preserve line breaks) const fullContent = document.createElement('div'); fullContent.className = 'post-full'; fullContent.style.whiteSpace = 'pre-wrap'; fullContent.textContent = post.content; // preview content (only if needed) const previewContent = document.createElement('div'); previewContent.className = 'post-preview'; previewContent.style.whiteSpace = 'pre-wrap'; const fullText = post.content; const needsCollapse = fullText.length > 300; if (needsCollapse) { // Build preview: take up to 3 lines, but keep total length < 300 const lines = fullText.split(/\r?\n/); let previewLines = []; let previewLength = 0; // take at least 3 lines if available, but stop if adding next line exceeds 300 for (let i = 0; i < Math.min(lines.length, 3); i++) { const line = lines[i]; if (previewLength + line.length + (i > 0 ? 1 : 0) < 300) { previewLines.push(line); previewLength += line.length + (i > 0 ? 1 : 0); } else { // if we haven't taken any line yet, take part of this line if (i === 0) { previewLines.push(line.slice(0, 300)); previewLength = 300; } break; } } // if we still have less than 3 lines but we have more lines, force include them up to 300 chars while (previewLines.length < 3 && previewLines.length < lines.length) { const nextLine = lines[previewLines.length]; if (previewLength + nextLine.length + (previewLines.length > 0 ? 1 : 0) < 300) { previewLines.push(nextLine); previewLength += nextLine.length + (previewLines.length > 0 ? 1 : 0); } else { // truncate the next line to fit within 300 const remaining = 300 - previewLength - (previewLines.length > 0 ? 1 : 0); if (remaining > 0) { previewLines.push(nextLine.slice(0, remaining)); } break; } } previewContent.textContent = previewLines.join('\n'); // if preview is empty, fallback to first 300 chars if (!previewContent.textContent) { previewContent.textContent = fullText.slice(0, 300); } // toggle button const toggleBtn = document.createElement('button'); toggleBtn.className = 'read-more-btn'; toggleBtn.textContent = 'Read more'; toggleBtn.setAttribute('aria-expanded', 'false'); // Initially, full content is hidden, preview is visible fullContent.style.display = 'none'; previewContent.style.display = 'block'; toggleBtn.addEventListener('click', () => { const expanded = toggleBtn.getAttribute('aria-expanded') === 'true'; if (expanded) { fullContent.style.display = 'none'; previewContent.style.display = 'block'; toggleBtn.textContent = 'Read more'; toggleBtn.setAttribute('aria-expanded', 'false'); } else { fullContent.style.display = 'block'; previewContent.style.display = 'none'; toggleBtn.textContent = 'Show less'; toggleBtn.setAttribute('aria-expanded', 'true'); } }); contentDiv.appendChild(previewContent); contentDiv.appendChild(fullContent); contentDiv.appendChild(toggleBtn); } else { // short post – show full content only fullContent.style.display = 'block'; contentDiv.appendChild(fullContent); } article.appendChild(contentDiv); return article; } // ---------- main init ---------- async function initBlog() { const list = await loadPostList(); const feed = document.querySelector('.blog-feed'); feed.innerHTML = ''; if (!list.length) { feed.textContent = 'No blog posts found. Create blog/manifest.json or enable directory listing.'; return; } // fetch and sort posts (newest first based on filename or date? we'll sort by filename descending) const posts = await Promise.all(list.map(f => fetchPost(f).catch(err => { console.warn('Skipping', f, err); return null; }))); const validPosts = posts.filter(p => p !== null); // sort by dateTime (if possible) or filename – we'll sort by dateTime descending validPosts.sort((a, b) => { // try to parse as ISO date, else compare as strings const dA = new Date(a.dateTime); const dB = new Date(b.dateTime); if (!isNaN(dA) && !isNaN(dB)) return dB - dA; return b.dateTime.localeCompare(a.dateTime); }); validPosts.forEach(post => { const el = createPostElement(post); feed.appendChild(el); }); } document.addEventListener('DOMContentLoaded', initBlog);