diff --git a/js/blog.js b/js/blog.js
index 930ae75..1f8a4be 100644
--- a/js/blog.js
+++ b/js/blog.js
@@ -1,218 +1,206 @@
+// blog.js – Loads and renders blog posts with automatic collapse for long entries
+
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) {
+// crude HTML parser for directory listings (when manifest is missing)
+function parseIndexHtmlList(html) {
const hrefs = [];
const re = /href="([^"]+)"/g;
let m;
- while ((m = re.exec(text)) !== null) {
+ while ((m = re.exec(html)) !== null) {
const name = m[1];
- if (TXT_EXT.test(name)) hrefs.push(name);
+ // accept typical text file extensions (and no extension)
+ if (name.match(/\.(txt|md|text)$/i) || !name.includes('.')) {
+ // avoid directory links (ending with /)
+ if (!name.endsWith('/')) hrefs.push(name);
+ }
}
return hrefs;
}
-async function loadBlogFiles() {
+// fetch list of blog post filenames (manifest first, fallback to directory listing)
+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 (_) { /* ignore */ }
try {
- const response = await fetch(BLOG_DIR, { cache: 'no-cache' });
- if (response.ok) {
- const text = await response.text();
- return parseIndexHtmlList(text);
+ const r = await fetch(BLOG_DIR, { cache: 'no-cache' });
+ if (r.ok) {
+ const html = await r.text();
+ return parseIndexHtmlList(html);
}
} 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;
+// fetch a single post file
+async function fetchPost(filename) {
+ const res = await fetch(BLOG_DIR + filename, { cache: 'no-cache' });
+ if (!res.ok) throw new Error(`Failed to fetch ${filename}`);
+ return await res.text();
}
-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);
-}
+// generate preview: up to 3 sentences, but not exceeding ~300 chars
+function getPreview(text, maxLen = 300) {
+ if (text.length <= maxLen) return text; // not needed, but safe
-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);
-}
-
-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 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';
-
- const body = document.createElement('div');
- body.className = 'blog-body';
- const paragraphs = createParagraphs(entry.body);
- if (!paragraphs.length) {
- const empty = document.createElement('p');
- empty.textContent = 'No content yet.';
- body.appendChild(empty);
+ // try to split on sentence boundaries (. ! ?) followed by space or newline
+ const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
+ let preview = '';
+ let count = 0;
+ for (const s of sentences) {
+ if (preview.length + s.length <= maxLen) {
+ preview += s;
+ count++;
+ if (count >= 3) break;
} else {
- paragraphs.forEach((p) => body.appendChild(p));
+ // if we haven't reached 3 sentences, break and use what we have
+ break;
}
+ }
- 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);
- }
+ // If we have at least one sentence, use that; otherwise fallback to word‑break
+ if (preview.trim().length > 0) {
+ return preview.trim();
+ }
- article.appendChild(title);
- article.appendChild(meta);
- article.appendChild(body);
- container.appendChild(article);
- });
+ // fallback: take first maxLen chars and cut at last space
+ let truncated = text.slice(0, maxLen);
+ const lastSpace = truncated.lastIndexOf(' ');
+ if (lastSpace > 0) truncated = truncated.slice(0, lastSpace);
+ return truncated + '…';
}
-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);
- });
+// ---------- rendering ----------
+
+function renderPost(filename, content) {
+ const lines = content.split('\n');
+ if (lines.length < 3) {
+ // malformed: skip or treat as title‑only
+ return null;
+ }
+ const dateTime = lines[0].trim();
+ const title = lines[1].trim();
+ const bodyLines = lines.slice(2);
+ const body = bodyLines.join('\n').trim();
+
+ const postDiv = document.createElement('div');
+ postDiv.className = 'blog-post';
+
+ // title
+ const titleEl = document.createElement('h3');
+ titleEl.className = 'blog-title';
+ titleEl.textContent = title;
+ postDiv.appendChild(titleEl);
+
+ // meta
+ const metaEl = document.createElement('p');
+ metaEl.className = 'blog-meta';
+ metaEl.textContent = dateTime;
+ postDiv.appendChild(metaEl);
+
+ const isLong = body.length > 300;
+
+ if (!isLong) {
+ // short post – display full body directly
+ const bodyDiv = document.createElement('div');
+ bodyDiv.className = 'blog-body';
+ bodyDiv.style.whiteSpace = 'pre-line';
+ bodyDiv.textContent = body;
+ postDiv.appendChild(bodyDiv);
+ } else {
+ // long post – use with preview
+ const details = document.createElement('details');
+ details.className = 'blog-collapse';
+
+ const summary = document.createElement('summary');
+ // preview container
+ const previewDiv = document.createElement('div');
+ previewDiv.className = 'blog-preview';
+ previewDiv.style.whiteSpace = 'pre-line';
+ const previewText = getPreview(body);
+ previewDiv.textContent = previewText + (previewText.endsWith('…') ? '' : '…'); // add ellipsis if not already
+
+ const readMoreSpan = document.createElement('span');
+ readMoreSpan.className = 'read-more-text';
+ readMoreSpan.textContent = 'Read more';
+
+ summary.appendChild(previewDiv);
+ summary.appendChild(readMoreSpan);
+ details.appendChild(summary);
+
+ // full body (hidden until opened)
+ const fullDiv = document.createElement('div');
+ fullDiv.className = 'blog-body';
+ fullDiv.style.whiteSpace = 'pre-line';
+ fullDiv.textContent = body;
+ details.appendChild(fullDiv);
+
+ // toggle text when details opens/closes
+ details.addEventListener('toggle', function() {
+ const span = this.querySelector('.read-more-text');
+ span.textContent = this.open ? 'Show less' : 'Read more';
+ });
+
+ postDiv.appendChild(details);
+ }
+
+ return postDiv;
}
+// ---------- main ----------
+
async function initBlog() {
- const fileNames = await loadBlogFiles();
- if (!fileNames.length) {
- renderBlogEntries([]);
- return;
- }
+ const feed = document.querySelector('.blog-feed');
+ if (!feed) return;
- const promises = fileNames
- .filter((name) => TXT_EXT.test(name))
- .map(async (name) => {
+ feed.textContent = 'Loading blog posts…';
+
+ try {
+ const files = await loadPostList();
+ if (!files.length) {
+ feed.textContent = 'No blog posts found.';
+ return;
+ }
+
+ // sort files by name (oldest first? we'll sort by date if embedded, but we'll just alphabetical)
+ // Typically filenames like 2025-01-01.txt, so alphabetical = chronological
+ files.sort();
+
+ // clear feed
+ feed.innerHTML = '';
+
+ for (const file of files) {
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 content = await fetchPost(file);
+ const el = renderPost(file, content);
+ if (el) feed.appendChild(el);
+ } catch (err) {
+ console.warn(`Failed to load ${file}:`, err);
+ // optionally show error placeholder
+ const errDiv = document.createElement('div');
+ errDiv.className = 'blog-post';
+ errDiv.style.color = '#b91c1c';
+ errDiv.textContent = `⚠️ Could not load post: ${file}`;
+ feed.appendChild(errDiv);
}
- });
+ }
- const entries = (await Promise.all(promises)).filter(Boolean);
- renderBlogEntries(sortEntries(entries));
+ // if nothing was added, show message
+ if (feed.children.length === 0) {
+ feed.textContent = 'No valid blog posts found.';
+ }
+ } catch (err) {
+ console.error('Blog init error:', err);
+ feed.textContent = 'Error loading blog posts.';
+ }
}
-document.addEventListener('DOMContentLoaded', initBlog);
+document.addEventListener('DOMContentLoaded', initBlog);
\ No newline at end of file