206 lines
6.1 KiB
JavaScript
206 lines
6.1 KiB
JavaScript
// blog.js – Loads and renders blog posts with automatic collapse for long entries
|
||
|
||
const BLOG_DIR = 'blog/';
|
||
const MANIFEST = BLOG_DIR + 'manifest.json';
|
||
|
||
// ---------- helpers ----------
|
||
|
||
// 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(html)) !== null) {
|
||
const name = m[1];
|
||
// 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;
|
||
}
|
||
|
||
// fetch list of blog post filenames (manifest first, fallback to directory listing)
|
||
async function loadPostList() {
|
||
try {
|
||
const r = await fetch(MANIFEST, { cache: 'no-cache' });
|
||
if (r.ok) return await r.json();
|
||
} catch (_) { /* ignore */ }
|
||
|
||
try {
|
||
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 [];
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// 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
|
||
|
||
// 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 {
|
||
// if we haven't reached 3 sentences, break and use what we have
|
||
break;
|
||
}
|
||
}
|
||
|
||
// If we have at least one sentence, use that; otherwise fallback to word‑break
|
||
if (preview.trim().length > 0) {
|
||
return preview.trim();
|
||
}
|
||
|
||
// 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 + '…';
|
||
}
|
||
|
||
// ---------- 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 <details> 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 feed = document.querySelector('.blog-feed');
|
||
if (!feed) return;
|
||
|
||
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 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);
|
||
}
|
||
}
|
||
|
||
// 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); |