224 lines
6.6 KiB
JavaScript
224 lines
6.6 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;
|
||
|
||
// 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 – show preview with toggle
|
||
const container = document.createElement('div');
|
||
container.className = 'blog-collapsible';
|
||
|
||
// preview
|
||
const previewDiv = document.createElement('div');
|
||
previewDiv.className = 'blog-preview';
|
||
previewDiv.style.whiteSpace = 'pre-line';
|
||
let previewText = getPreview(body);
|
||
// ensure ellipsis if preview is truncated
|
||
if (previewText.length < body.length && !previewText.endsWith('…')) {
|
||
previewText += '…';
|
||
}
|
||
previewDiv.textContent = previewText;
|
||
container.appendChild(previewDiv);
|
||
|
||
// Read more button
|
||
const readMoreBtn = document.createElement('button');
|
||
readMoreBtn.className = 'blog-toggle read-more';
|
||
readMoreBtn.textContent = 'Read more';
|
||
container.appendChild(readMoreBtn);
|
||
|
||
// Full content (hidden initially)
|
||
const fullDiv = document.createElement('div');
|
||
fullDiv.className = 'blog-full';
|
||
fullDiv.style.whiteSpace = 'pre-line';
|
||
fullDiv.textContent = body;
|
||
fullDiv.style.display = 'none';
|
||
container.appendChild(fullDiv);
|
||
|
||
// Show less button (hidden initially) – appears at the bottom
|
||
const showLessBtn = document.createElement('button');
|
||
showLessBtn.className = 'blog-toggle show-less';
|
||
showLessBtn.textContent = '✕ Show less';
|
||
showLessBtn.style.display = 'none';
|
||
container.appendChild(showLessBtn);
|
||
|
||
// Toggle logic
|
||
function expand() {
|
||
fullDiv.style.display = 'block';
|
||
readMoreBtn.style.display = 'none';
|
||
showLessBtn.style.display = 'inline-block';
|
||
}
|
||
function collapse() {
|
||
fullDiv.style.display = 'none';
|
||
readMoreBtn.style.display = 'inline-block';
|
||
showLessBtn.style.display = 'none';
|
||
}
|
||
readMoreBtn.addEventListener('click', expand);
|
||
showLessBtn.addEventListener('click', collapse);
|
||
|
||
postDiv.appendChild(container);
|
||
}
|
||
|
||
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); |