again test
This commit is contained in:
+166
-178
@@ -1,218 +1,206 @@
|
|||||||
|
// blog.js – Loads and renders blog posts with automatic collapse for long entries
|
||||||
|
|
||||||
const BLOG_DIR = 'blog/';
|
const BLOG_DIR = 'blog/';
|
||||||
const ATTACHMENT_DIR = BLOG_DIR + 'files/';
|
|
||||||
const MANIFEST = BLOG_DIR + 'manifest.json';
|
const MANIFEST = BLOG_DIR + 'manifest.json';
|
||||||
const TXT_EXT = /\.(txt|text)$/i;
|
|
||||||
const ATTACHMENT_RE = /\[\[(?:file|attachment):\s*([^\]]+)\]\]/gi;
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
// ---------- helpers ----------
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIndexHtmlList(text) {
|
// crude HTML parser for directory listings (when manifest is missing)
|
||||||
|
function parseIndexHtmlList(html) {
|
||||||
const hrefs = [];
|
const hrefs = [];
|
||||||
const re = /href="([^"]+)"/g;
|
const re = /href="([^"]+)"/g;
|
||||||
let m;
|
let m;
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(html)) !== null) {
|
||||||
const name = m[1];
|
const name = m[1];
|
||||||
if (TXT_EXT.test(name)) hrefs.push(name);
|
// 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;
|
return hrefs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBlogFiles() {
|
// fetch list of blog post filenames (manifest first, fallback to directory listing)
|
||||||
|
async function loadPostList() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(MANIFEST, { cache: 'no-cache' });
|
const r = await fetch(MANIFEST, { cache: 'no-cache' });
|
||||||
if (response.ok) {
|
if (r.ok) return await r.json();
|
||||||
return await response.json();
|
} catch (_) { /* ignore */ }
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// manifest missing or unreadable
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(BLOG_DIR, { cache: 'no-cache' });
|
const r = await fetch(BLOG_DIR, { cache: 'no-cache' });
|
||||||
if (response.ok) {
|
if (r.ok) {
|
||||||
const text = await response.text();
|
const html = await r.text();
|
||||||
return parseIndexHtmlList(text);
|
return parseIndexHtmlList(html);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not read blog directory listing', e);
|
console.warn('Could not read blog directory listing', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDateLine(rawDate) {
|
// fetch a single post file
|
||||||
if (!rawDate) return null;
|
async function fetchPost(filename) {
|
||||||
let value = rawDate.trim();
|
const res = await fetch(BLOG_DIR + filename, { cache: 'no-cache' });
|
||||||
if (value.toLowerCase().startsWith('date:')) {
|
if (!res.ok) throw new Error(`Failed to fetch ${filename}`);
|
||||||
value = value.slice(5).trim();
|
return await res.text();
|
||||||
}
|
|
||||||
if (!value) return null;
|
|
||||||
const normalized = value.replace(' ', 'T');
|
|
||||||
const date = new Date(normalized);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAttachments(body) {
|
// generate preview: up to 3 sentences, but not exceeding ~300 chars
|
||||||
const attachments = new Set();
|
function getPreview(text, maxLen = 300) {
|
||||||
body.replace(ATTACHMENT_RE, (_, filename) => {
|
if (text.length <= maxLen) return text; // not needed, but safe
|
||||||
const safeName = filename.trim();
|
|
||||||
if (safeName) attachments.add(safeName);
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
return Array.from(attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBlogText(text, filename) {
|
// try to split on sentence boundaries (. ! ?) followed by space or newline
|
||||||
const lines = text.replace(/\r/g, '').split('\n');
|
const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
|
||||||
let index = 0;
|
let preview = '';
|
||||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
let count = 0;
|
||||||
|
for (const s of sentences) {
|
||||||
const rawDate = index < lines.length ? lines[index].trim() : '';
|
if (preview.length + s.length <= maxLen) {
|
||||||
if (rawDate.toLowerCase().startsWith('date:')) {
|
preview += s;
|
||||||
index += 1;
|
count++;
|
||||||
|
if (count >= 3) break;
|
||||||
} else {
|
} else {
|
||||||
index += 1;
|
// if we haven't reached 3 sentences, break and use what we have
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
// If we have at least one sentence, use that; otherwise fallback to word‑break
|
||||||
const title = index < lines.length ? lines[index].trim() : 'Untitled';
|
if (preview.trim().length > 0) {
|
||||||
index += 1;
|
return preview.trim();
|
||||||
|
|
||||||
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) {
|
// fallback: take first maxLen chars and cut at last space
|
||||||
if (!date) return 'Unknown date';
|
let truncated = text.slice(0, maxLen);
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
if (lastSpace > 0) truncated = truncated.slice(0, lastSpace);
|
||||||
}).format(date);
|
return truncated + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
function createParagraphs(body) {
|
// ---------- rendering ----------
|
||||||
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) {
|
function renderPost(filename, content) {
|
||||||
const container = document.querySelector('.blog-feed');
|
const lines = content.split('\n');
|
||||||
container.innerHTML = '';
|
if (lines.length < 3) {
|
||||||
|
// malformed: skip or treat as title‑only
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
article.appendChild(title);
|
|
||||||
article.appendChild(meta);
|
|
||||||
article.appendChild(body);
|
|
||||||
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;
|
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';
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = (await Promise.all(promises)).filter(Boolean);
|
postDiv.appendChild(details);
|
||||||
renderBlogEntries(sortEntries(entries));
|
}
|
||||||
|
|
||||||
|
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);
|
document.addEventListener('DOMContentLoaded', initBlog);
|
||||||
Reference in New Issue
Block a user