back again
This commit is contained in:
+192
-182
@@ -1,208 +1,218 @@
|
||||
// blog.js
|
||||
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;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1. Fetch the list of blog post files
|
||||
// ------------------------------------------------------------
|
||||
async function getPostFiles() {
|
||||
// Try manifest first
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.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 r = await fetch(MANIFEST, { cache: 'no-cache' });
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
return data.filter(f => f.endsWith('.txt'));
|
||||
const response = await fetch(MANIFEST, { cache: 'no-cache' });
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
} catch (e) {
|
||||
// manifest missing or unreadable
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 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 };
|
||||
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;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 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);
|
||||
function extractAttachments(body) {
|
||||
const attachments = new Set();
|
||||
body.replace(ATTACHMENT_RE, (_, filename) => {
|
||||
const safeName = filename.trim();
|
||||
if (safeName) attachments.add(safeName);
|
||||
return '';
|
||||
});
|
||||
|
||||
// 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;
|
||||
return Array.from(attachments);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 5. Main: load posts and render
|
||||
// ------------------------------------------------------------
|
||||
async function initBlog() {
|
||||
const feed = document.querySelector('.blog-feed');
|
||||
if (!feed) return;
|
||||
function parseBlogText(text, filename) {
|
||||
const lines = text.replace(/\r/g, '').split('\n');
|
||||
let index = 0;
|
||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
||||
|
||||
feed.innerHTML = 'Loading blog posts…';
|
||||
const rawDate = index < lines.length ? lines[index].trim() : '';
|
||||
if (rawDate.toLowerCase().startsWith('date:')) {
|
||||
index += 1;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await getPostFiles();
|
||||
if (!files.length) {
|
||||
feed.innerHTML = 'No blog posts found.';
|
||||
return;
|
||||
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);
|
||||
}
|
||||
|
||||
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 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';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'blog-body';
|
||||
const paragraphs = createParagraphs(entry.body);
|
||||
if (!paragraphs.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.textContent = 'No content yet.';
|
||||
body.appendChild(empty);
|
||||
} else {
|
||||
paragraphs.forEach((p) => body.appendChild(p));
|
||||
}
|
||||
|
||||
// Fetch and parse all posts
|
||||
const posts = await Promise.all(files.map(f => fetchPost(f)));
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.';
|
||||
}
|
||||
article.appendChild(title);
|
||||
article.appendChild(meta);
|
||||
article.appendChild(body);
|
||||
container.appendChild(article);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initBlog);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user