const BLOG_DIR = 'blog/'; const ATTACHMENT_DIR = BLOG_DIR + 'files/'; const MANIFEST = BLOG_DIR + 'manifest.json'; const TXT_EXT = /\.(txt|text)$/i; const ATTACHMENT_RE = /\[\[(?:file|attachment):\s*([^\]]+)\]\]/gi; function escapeHtml(value) { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function parseIndexHtmlList(text) { const hrefs = []; const re = /href="([^"]+)"/g; let m; while ((m = re.exec(text)) !== null) { const name = m[1]; if (TXT_EXT.test(name)) hrefs.push(name); } return hrefs; } async function loadBlogFiles() { try { const response = await fetch(MANIFEST, { cache: 'no-cache' }); if (response.ok) { return await response.json(); } } catch (e) { // manifest missing or unreadable } try { const response = await fetch(BLOG_DIR, { cache: 'no-cache' }); if (response.ok) { const text = await response.text(); return parseIndexHtmlList(text); } } catch (e) { console.warn('Could not read blog directory listing', e); } return []; } function parseDateLine(rawDate) { if (!rawDate) return null; let value = rawDate.trim(); if (value.toLowerCase().startsWith('date:')) { value = value.slice(5).trim(); } if (!value) return null; const normalized = value.replace(' ', 'T'); const date = new Date(normalized); return Number.isNaN(date.getTime()) ? null : date; } function extractAttachments(body) { const attachments = new Set(); body.replace(ATTACHMENT_RE, (_, filename) => { const safeName = filename.trim(); if (safeName) attachments.add(safeName); return ''; }); return Array.from(attachments); } function parseBlogText(text, filename) { const lines = text.replace(/\r/g, '').split('\n'); let index = 0; while (index < lines.length && !lines[index].trim()) index += 1; const rawDate = index < lines.length ? lines[index].trim() : ''; if (rawDate.toLowerCase().startsWith('date:')) { index += 1; } else { index += 1; } while (index < lines.length && !lines[index].trim()) index += 1; const title = index < lines.length ? lines[index].trim() : 'Untitled'; index += 1; while (index < lines.length && !lines[index].trim()) index += 1; const body = lines.slice(index).join('\n').trim(); return { filename, title: title || 'Untitled', date: parseDateLine(rawDate) || null, dateLabel: rawDate || '', body: body || '', attachments: extractAttachments(body), }; } function formatDate(date) { if (!date) return 'Unknown date'; return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(date); } // ---------- new preview logic ---------- function getPreviewText(body) { const maxLen = 300; if (body.length <= maxLen) { return { preview: body, isTruncated: false }; } // Try to use first 3 non‑empty lines const lines = body.split('\n').filter(line => line.trim() !== ''); if (lines.length >= 3) { const firstThree = lines.slice(0, 3); const combined = firstThree.join('\n'); if (combined.length < maxLen) { return { preview: combined, isTruncated: true }; } } // Try to use first 3 sentences const sentenceEnd = /[.!?]+\s*/g; const sentences = []; let match; let lastIndex = 0; while ((match = sentenceEnd.exec(body)) !== null && sentences.length < 3) { const end = match.index + match[0].length; const sentence = body.slice(lastIndex, end).trim(); if (sentence) sentences.push(sentence); lastIndex = end; } if (sentences.length >= 3) { const combined = sentences.join(' '); if (combined.length < maxLen) { return { preview: combined, isTruncated: true }; } } // Fallback: take first maxLen chars, cut at last sentence boundary if possible let cut = maxLen; const firstPart = body.slice(0, maxLen); const lastPunct = firstPart.search(/[.!?](?=\s|$)/g); if (lastPunct !== -1 && lastPunct + 1 < maxLen) { cut = lastPunct + 1; } else { // cut at last space const lastSpace = firstPart.lastIndexOf(' '); if (lastSpace > 0) cut = lastSpace; } return { preview: body.slice(0, cut) + '…', isTruncated: true }; } // --------------------------------------- function createParagraphs(body) { return body .split(/\n{2,}/) .filter(Boolean) .map((paragraph) => { const p = document.createElement('p'); const text = escapeHtml(paragraph).replace(ATTACHMENT_RE, (match, filename) => { const safeName = filename.trim(); const url = ATTACHMENT_DIR + encodeURI(safeName); return `${escapeHtml(safeName)}`; }); const lines = text.split('\n'); p.innerHTML = lines.join('
'); return p; }); } function renderPlainText(text) { // Used for preview – shows text with line breaks, no attachment links const escaped = escapeHtml(text); const lines = escaped.split('\n'); return lines.join('
'); } function renderBlogEntries(entries) { const container = document.querySelector('.blog-feed'); container.innerHTML = ''; if (!entries.length) { container.textContent = 'No posts found. Add .txt files to the blog/ folder and enable blog manifest or directory listing.'; return; } entries.forEach((entry) => { const article = document.createElement('article'); article.className = 'blog-post'; const title = document.createElement('h3'); title.className = 'blog-title'; title.textContent = entry.title; const meta = document.createElement('div'); meta.className = 'blog-meta'; meta.textContent = entry.date ? formatDate(entry.date) : entry.dateLabel || 'Date not provided'; // ----- content area with preview & full ----- const contentWrapper = document.createElement('div'); contentWrapper.className = 'blog-content'; const { preview, isTruncated } = getPreviewText(entry.body); // Preview (only shown when truncated) const previewDiv = document.createElement('div'); previewDiv.className = 'blog-preview'; if (isTruncated) { previewDiv.innerHTML = renderPlainText(preview); contentWrapper.appendChild(previewDiv); } // Full body (always created, hidden initially if truncated) const fullDiv = document.createElement('div'); fullDiv.className = 'blog-full'; if (isTruncated) { fullDiv.style.display = 'none'; } const paragraphs = createParagraphs(entry.body); if (!paragraphs.length) { const empty = document.createElement('p'); empty.textContent = 'No content yet.'; fullDiv.appendChild(empty); } else { paragraphs.forEach((p) => fullDiv.appendChild(p)); } contentWrapper.appendChild(fullDiv); // Toggle link (only if truncated) if (isTruncated) { const toggle = document.createElement('a'); toggle.href = '#'; toggle.className = 'blog-toggle'; toggle.textContent = 'Read more'; toggle.addEventListener('click', (e) => { e.preventDefault(); const isExpanded = fullDiv.style.display !== 'none'; fullDiv.style.display = isExpanded ? 'none' : 'block'; previewDiv.style.display = isExpanded ? 'block' : 'none'; toggle.textContent = isExpanded ? 'Read more' : 'Collapse'; }); contentWrapper.appendChild(toggle); } article.appendChild(title); article.appendChild(meta); article.appendChild(contentWrapper); // Attachments (always visible) if (entry.attachments && entry.attachments.length) { const attachTitle = document.createElement('div'); attachTitle.className = 'blog-meta'; attachTitle.textContent = 'Attachments:'; const list = document.createElement('ul'); list.className = 'blog-attachment-list'; entry.attachments.forEach((attachment) => { const item = document.createElement('li'); const link = document.createElement('a'); link.href = ATTACHMENT_DIR + encodeURI(attachment); link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = attachment; item.appendChild(link); list.appendChild(item); }); article.appendChild(attachTitle); article.appendChild(list); } container.appendChild(article); }); } function sortEntries(entries) { return entries.slice().sort((a, b) => { if (a.date && b.date) return b.date - a.date; if (a.date) return -1; if (b.date) return 1; return a.filename.localeCompare(b.filename); }); } async function initBlog() { const fileNames = await loadBlogFiles(); if (!fileNames.length) { renderBlogEntries([]); return; } const promises = fileNames .filter((name) => TXT_EXT.test(name)) .map(async (name) => { try { const response = await fetch(BLOG_DIR + name, { cache: 'no-cache' }); if (!response.ok) throw new Error('Failed to load ' + name); const text = await response.text(); return parseBlogText(text, name); } catch (error) { console.warn(error); return null; } }); const entries = (await Promise.all(promises)).filter(Boolean); renderBlogEntries(sortEntries(entries)); } document.addEventListener('DOMContentLoaded', initBlog);