diff --git a/js/blog.js b/js/blog.js index fed1b6d..5347489 100644 --- a/js/blog.js +++ b/js/blog.js @@ -1,312 +1,193 @@ +// blog.js – with collapsible posts 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, '''); -} +// ---------- helpers ---------- 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); + if (name.match(/\.(txt)$/i)) hrefs.push(name); } return hrefs; } -async function loadBlogFiles() { +// fetch a list of post filenames +async function loadPostList() { try { - const response = await fetch(MANIFEST, { cache: 'no-cache' }); - if (response.ok) { - return await response.json(); - } - } catch (e) { - // manifest missing or unreadable - } + const r = await fetch(MANIFEST, { cache: 'no-cache' }); + if (r.ok) return await r.json(); + } catch (_) { /* fall through */ } try { - const response = await fetch(BLOG_DIR, { cache: 'no-cache' }); - if (response.ok) { - const text = await response.text(); + 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 []; } -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; +// 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 }; } -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); -} +// ---------- rendering with collapse ---------- +function createPostElement(post) { + const article = document.createElement('article'); + article.className = 'blog-post'; -function parseBlogText(text, filename) { - const lines = text.replace(/\r/g, '').split('\n'); - let index = 0; - while (index < lines.length && !lines[index].trim()) index += 1; + // 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); - const rawDate = index < lines.length ? lines[index].trim() : ''; - if (rawDate.toLowerCase().startsWith('date:')) { - index += 1; - } else { - index += 1; - } + // content container + const contentDiv = document.createElement('div'); + contentDiv.className = 'post-content'; - while (index < lines.length && !lines[index].trim()) index += 1; - const title = index < lines.length ? lines[index].trim() : 'Untitled'; - index += 1; + // full content (preserve line breaks) + const fullContent = document.createElement('div'); + fullContent.className = 'post-full'; + fullContent.style.whiteSpace = 'pre-wrap'; + fullContent.textContent = post.content; - while (index < lines.length && !lines[index].trim()) index += 1; - const body = lines.slice(index).join('\n').trim(); + // preview content (only if needed) + const previewContent = document.createElement('div'); + previewContent.className = 'post-preview'; + previewContent.style.whiteSpace = 'pre-wrap'; - return { - filename, - title: title || 'Untitled', - date: parseDateLine(rawDate) || null, - dateLabel: rawDate || '', - body: body || '', - attachments: extractAttachments(body), - }; -} + const fullText = post.content; + const needsCollapse = fullText.length > 300; -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 }; + 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; + } } - } - - // 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 }; + // 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; + } } - } - - // 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); + previewContent.textContent = previewLines.join('\n'); + // if preview is empty, fallback to first 300 chars + if (!previewContent.textContent) { + previewContent.textContent = fullText.slice(0, 300); } - // 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 button + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'read-more-btn'; + toggleBtn.textContent = 'Read more'; + toggleBtn.setAttribute('aria-expanded', 'false'); - // 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); - } + // Initially, full content is hidden, preview is visible + fullContent.style.display = 'none'; + previewContent.style.display = 'block'; - 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; + 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'); } }); - const entries = (await Promise.all(promises)).filter(Boolean); - renderBlogEntries(sortEntries(entries)); + 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); \ No newline at end of file