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
+ 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