208 lines
6.5 KiB
JavaScript
208 lines
6.5 KiB
JavaScript
// blog.js
|
||
const BLOG_DIR = 'blog/';
|
||
const MANIFEST = BLOG_DIR + 'manifest.json';
|
||
|
||
// ------------------------------------------------------------
|
||
// 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) {
|
||
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();
|
||
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);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 2. Parse a single blog post file
|
||
// ------------------------------------------------------------
|
||
async function fetchPost(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('\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 { filename, dateTime, title, content };
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 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';
|
||
|
||
// Title
|
||
const h3 = document.createElement('h3');
|
||
h3.className = 'blog-title';
|
||
h3.textContent = title;
|
||
article.appendChild(h3);
|
||
|
||
// Meta (date)
|
||
const meta = document.createElement('div');
|
||
meta.className = 'blog-meta';
|
||
meta.textContent = dateTime;
|
||
article.appendChild(meta);
|
||
|
||
// Body content: split paragraphs by double newline, wrap in <p>
|
||
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);
|
||
});
|
||
|
||
// Decide if we need collapse
|
||
const { preview, truncated } = createPreview(content);
|
||
|
||
if (!truncated) {
|
||
// Short post – show full content directly
|
||
article.appendChild(bodyDiv);
|
||
} else {
|
||
// Long post – wrap in <details>
|
||
const details = document.createElement('details');
|
||
details.className = 'blog-collapse';
|
||
|
||
const summary = document.createElement('summary');
|
||
// Preview text
|
||
const previewDiv = document.createElement('div');
|
||
previewDiv.className = 'blog-preview';
|
||
// Split preview by newline and create lines, but we can just set innerHTML with <br>
|
||
previewDiv.innerHTML = preview.replace(/\n/g, '<br>');
|
||
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);
|
||
}
|
||
|
||
return article;
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 5. Main: load posts and render
|
||
// ------------------------------------------------------------
|
||
async function initBlog() {
|
||
const feed = document.querySelector('.blog-feed');
|
||
if (!feed) 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.';
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', initBlog); |