Files
Website-2026/js/blog.js
T
2026-07-11 23:15:44 +02:00

312 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 nonempty 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 `<a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(safeName)}</a>`;
});
const lines = text.split('\n');
p.innerHTML = lines.join('<br>');
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('<br>');
}
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);