diff --git a/js/blog.js b/js/blog.js index 5347489..9d15360 100644 --- a/js/blog.js +++ b/js/blog.js @@ -1,31 +1,33 @@ -// blog.js – with collapsible posts +// blog.js 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() { +// ------------------------------------------------------------ +// 1. Fetch the list of blog post files +// ------------------------------------------------------------ +async function getPostFiles() { + // Try manifest first try { const r = await fetch(MANIFEST, { cache: 'no-cache' }); - if (r.ok) return await r.json(); - } catch (_) { /* fall through */ } + if (r.ok) { + const data = await r.json(); + return data.filter(f => f.endsWith('.txt')); + } + } catch (_) { /* ignore */ } + // Fallback: parse directory listing (requires autoindex) try { const r = await fetch(BLOG_DIR, { cache: 'no-cache' }); if (r.ok) { const text = await r.text(); - return parseIndexHtmlList(text); + const hrefs = []; + const re = /href="([^"]+)"/g; + let m; + while ((m = re.exec(text)) !== null) { + const name = m[1]; + if (name.endsWith('.txt')) hrefs.push(name); + } + return hrefs; } } catch (e) { console.warn('Could not read blog directory listing', e); @@ -33,161 +35,174 @@ async function loadPostList() { return []; } -// parse a single post file: first line = date/time, second = title, rest = content +// ------------------------------------------------------------ +// 2. Parse a single blog post file +// ------------------------------------------------------------ async function fetchPost(filename) { - const resp = await fetch(BLOG_DIR + filename); - if (!resp.ok) throw new Error('Failed to fetch ' + filename); + const url = BLOG_DIR + filename; + const resp = await fetch(url); + 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 lines = text.split('\n'); + // First line: date/time, second: title, rest: content + const dateTime = lines[0]?.trim() || 'Unknown date'; const title = lines[1]?.trim() || 'Untitled'; const content = lines.slice(2).join('\n').trim(); - return { dateTime, title, content, filename }; + return { filename, dateTime, title, content }; } -// ---------- rendering with collapse ---------- -function createPostElement(post) { +// ------------------------------------------------------------ +// 3. Generate a preview for long posts +// ------------------------------------------------------------ +function createPreview(content, maxLen = 300) { + // Return { preview: string, truncated: boolean } + if (content.length <= maxLen) { + return { preview: content, truncated: false }; + } + + // Try to get at least 3 sentences + const sentences = content.match(/[^.!?]*[.!?](\s+|$)/g) || []; + if (sentences.length >= 3) { + let preview = sentences.slice(0, 3).join(''); + // If preview is still > maxLen, trim to nearest space + if (preview.length > maxLen) { + const trimmed = preview.slice(0, maxLen); + const lastSpace = trimmed.lastIndexOf(' '); + if (lastSpace > 0) preview = trimmed.slice(0, lastSpace) + '…'; + else preview = trimmed + '…'; + } else { + preview = preview.trim() + '…'; + } + return { preview, truncated: true }; + } + + // Fallback: at least 3 lines (by newline) + const lines = content.split('\n').filter(l => l.trim() !== ''); + if (lines.length >= 3) { + let preview = lines.slice(0, 3).join('\n'); + if (preview.length > maxLen) { + const trimmed = preview.slice(0, maxLen); + const lastSpace = trimmed.lastIndexOf(' '); + if (lastSpace > 0) preview = trimmed.slice(0, lastSpace) + '…'; + else preview = trimmed + '…'; + } else { + preview = preview.trim() + '…'; + } + return { preview, truncated: true }; + } + + // Last resort: take first maxLen chars, break at word boundary + if (content.length > maxLen) { + const trimmed = content.slice(0, maxLen); + const lastSpace = trimmed.lastIndexOf(' '); + const preview = (lastSpace > 0 ? trimmed.slice(0, lastSpace) : trimmed) + '…'; + return { preview, truncated: true }; + } + + return { preview: content, truncated: false }; +} + +// ------------------------------------------------------------ +// 4. Render a single blog post +// ------------------------------------------------------------ +function renderPost(post) { + const { dateTime, title, content } = 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); + // Title + const h3 = document.createElement('h3'); + h3.className = 'blog-title'; + h3.textContent = title; + article.appendChild(h3); - // content container - const contentDiv = document.createElement('div'); - contentDiv.className = 'post-content'; + // Meta (date) + const meta = document.createElement('div'); + meta.className = 'blog-meta'; + meta.textContent = dateTime; + article.appendChild(meta); - // full content (preserve line breaks) - const fullContent = document.createElement('div'); - fullContent.className = 'post-full'; - fullContent.style.whiteSpace = 'pre-wrap'; - fullContent.textContent = post.content; + // Body content: split paragraphs by double newline, wrap in

+ const bodyDiv = document.createElement('div'); + bodyDiv.className = 'blog-body'; + const paragraphs = content.split(/\n\s*\n/); + paragraphs.forEach(para => { + const p = document.createElement('p'); + p.textContent = para.trim(); + bodyDiv.appendChild(p); + }); - // preview content (only if needed) - const previewContent = document.createElement('div'); - previewContent.className = 'post-preview'; - previewContent.style.whiteSpace = 'pre-wrap'; + // Decide if we need collapse + const { preview, truncated } = createPreview(content); - 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); + if (!truncated) { + // Short post – show full content directly + article.appendChild(bodyDiv); } else { - // short post – show full content only - fullContent.style.display = 'block'; - contentDiv.appendChild(fullContent); + // Long post – wrap in

+ const details = document.createElement('details'); + details.className = 'blog-collapse'; + + const summary = document.createElement('summary'); + // Preview text + const previewDiv = document.createElement('div'); + previewDiv.className = 'blog-preview'; + // Split preview by newline and create lines, but we can just set innerHTML with
+ previewDiv.innerHTML = preview.replace(/\n/g, '
'); + summary.appendChild(previewDiv); + + // "Read more" text + const moreSpan = document.createElement('span'); + moreSpan.className = 'read-more-text'; + moreSpan.textContent = 'Read more'; + summary.appendChild(moreSpan); + + details.appendChild(summary); + // Full content inside the details + details.appendChild(bodyDiv); + article.appendChild(details); } - article.appendChild(contentDiv); return article; } -// ---------- main init ---------- +// ------------------------------------------------------------ +// 5. Main: load posts and render +// ------------------------------------------------------------ async function initBlog() { - const list = await loadPostList(); const feed = document.querySelector('.blog-feed'); - feed.innerHTML = ''; + if (!feed) return; - if (!list.length) { - feed.textContent = 'No blog posts found. Create blog/manifest.json or enable directory listing.'; - return; + feed.innerHTML = 'Loading blog posts…'; + + try { + const files = await getPostFiles(); + if (!files.length) { + feed.innerHTML = 'No blog posts found.'; + return; + } + + // Fetch and parse all posts + const posts = await Promise.all(files.map(f => fetchPost(f))); + + // Sort by date descending (assuming ISO-like date in first line) + posts.sort((a, b) => { + const dA = new Date(a.dateTime); + const dB = new Date(b.dateTime); + return dB - dA; + }); + + // Render each post + feed.innerHTML = ''; + posts.forEach(post => { + const el = renderPost(post); + feed.appendChild(el); + }); + } catch (err) { + console.error(err); + feed.innerHTML = 'Error loading blog posts.'; } - - // 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); \ No newline at end of file