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, ''');
}
function parseIndexHtmlList(text) {
const hrefs = [];
const re = /href="([^"]+)"/g;
let m;
while ((m = re.exec(text)) !== null) {
const name = m[1];
if (TXT_EXT.test(name)) hrefs.push(name);
}
return hrefs;
}
async function loadBlogFiles() {
try {
const response = await fetch(MANIFEST, { cache: 'no-cache' });
if (response.ok) {
return await response.json();
}
} catch (e) {
// manifest missing or unreadable
}
try {
const response = await fetch(BLOG_DIR, { cache: 'no-cache' });
if (response.ok) {
const text = await response.text();
return parseIndexHtmlList(text);
}
} 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;
}
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);
}
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);
}
// ---------- new preview logic ----------
function getPreviewText(body) {
const maxLen = 300;
if (body.length <= maxLen) {
return { preview: body, isTruncated: false };
}
// Try to use first 3 non‑empty lines
const lines = body.split('\n').filter(line => line.trim() !== '');
if (lines.length >= 3) {
const firstThree = lines.slice(0, 3);
const combined = firstThree.join('\n');
if (combined.length < maxLen) {
return { preview: combined, isTruncated: true };
}
}
// Try to use first 3 sentences
const sentenceEnd = /[.!?]+\s*/g;
const sentences = [];
let match;
let lastIndex = 0;
while ((match = sentenceEnd.exec(body)) !== null && sentences.length < 3) {
const end = match.index + match[0].length;
const sentence = body.slice(lastIndex, end).trim();
if (sentence) sentences.push(sentence);
lastIndex = end;
}
if (sentences.length >= 3) {
const combined = sentences.join(' ');
if (combined.length < maxLen) {
return { preview: combined, isTruncated: true };
}
}
// Fallback: take first maxLen chars, cut at last sentence boundary if possible
let cut = maxLen;
const firstPart = body.slice(0, maxLen);
const lastPunct = firstPart.search(/[.!?](?=\s|$)/g);
if (lastPunct !== -1 && lastPunct + 1 < maxLen) {
cut = lastPunct + 1;
} else {
// cut at last space
const lastSpace = firstPart.lastIndexOf(' ');
if (lastSpace > 0) cut = lastSpace;
}
return { preview: body.slice(0, cut) + '…', isTruncated: true };
}
// ---------------------------------------
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 renderPlainText(text) {
// Used for preview – shows text with line breaks, no attachment links
const escaped = escapeHtml(text);
const lines = escaped.split('\n');
return lines.join('
');
}
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';
// ----- content area with preview & full -----
const contentWrapper = document.createElement('div');
contentWrapper.className = 'blog-content';
const { preview, isTruncated } = getPreviewText(entry.body);
// Preview (only shown when truncated)
const previewDiv = document.createElement('div');
previewDiv.className = 'blog-preview';
if (isTruncated) {
previewDiv.innerHTML = renderPlainText(preview);
contentWrapper.appendChild(previewDiv);
}
// Full body (always created, hidden initially if truncated)
const fullDiv = document.createElement('div');
fullDiv.className = 'blog-full';
if (isTruncated) {
fullDiv.style.display = 'none';
}
const paragraphs = createParagraphs(entry.body);
if (!paragraphs.length) {
const empty = document.createElement('p');
empty.textContent = 'No content yet.';
fullDiv.appendChild(empty);
} else {
paragraphs.forEach((p) => fullDiv.appendChild(p));
}
contentWrapper.appendChild(fullDiv);
// Toggle link (only if truncated)
if (isTruncated) {
const toggle = document.createElement('a');
toggle.href = '#';
toggle.className = 'blog-toggle';
toggle.textContent = 'Read more';
toggle.addEventListener('click', (e) => {
e.preventDefault();
const isExpanded = fullDiv.style.display !== 'none';
fullDiv.style.display = isExpanded ? 'none' : 'block';
previewDiv.style.display = isExpanded ? 'block' : 'none';
toggle.textContent = isExpanded ? 'Read more' : 'Collapse';
});
contentWrapper.appendChild(toggle);
}
article.appendChild(title);
article.appendChild(meta);
article.appendChild(contentWrapper);
// Attachments (always visible)
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);
}
container.appendChild(article);
});
}
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);
});
}
async function initBlog() {
const fileNames = await loadBlogFiles();
if (!fileNames.length) {
renderBlogEntries([]);
return;
}
const promises = fileNames
.filter((name) => TXT_EXT.test(name))
.map(async (name) => {
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 entries = (await Promise.all(promises)).filter(Boolean);
renderBlogEntries(sortEntries(entries));
}
document.addEventListener('DOMContentLoaded', initBlog);