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