test
This commit is contained in:
+149
-268
@@ -1,312 +1,193 @@
|
|||||||
|
// blog.js – with collapsible posts
|
||||||
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) {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
function parseIndexHtmlList(text) {
|
function parseIndexHtmlList(text) {
|
||||||
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(text)) !== null) {
|
||||||
const name = m[1];
|
const name = m[1];
|
||||||
if (TXT_EXT.test(name)) hrefs.push(name);
|
if (name.match(/\.(txt)$/i)) hrefs.push(name);
|
||||||
}
|
}
|
||||||
return hrefs;
|
return hrefs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBlogFiles() {
|
// fetch a list of post filenames
|
||||||
|
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 (_) { /* fall through */ }
|
||||||
}
|
|
||||||
} 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 text = await r.text();
|
||||||
return parseIndexHtmlList(text);
|
return parseIndexHtmlList(text);
|
||||||
}
|
}
|
||||||
} 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) {
|
// parse a single post file: first line = date/time, second = title, rest = content
|
||||||
if (!rawDate) return null;
|
async function fetchPost(filename) {
|
||||||
let value = rawDate.trim();
|
const resp = await fetch(BLOG_DIR + filename);
|
||||||
if (value.toLowerCase().startsWith('date:')) {
|
if (!resp.ok) throw new Error('Failed to fetch ' + filename);
|
||||||
value = value.slice(5).trim();
|
const text = await resp.text();
|
||||||
}
|
const lines = text.split(/\r?\n/);
|
||||||
if (!value) return null;
|
const dateTime = lines[0]?.trim() || '';
|
||||||
const normalized = value.replace(' ', 'T');
|
const title = lines[1]?.trim() || 'Untitled';
|
||||||
const date = new Date(normalized);
|
const content = lines.slice(2).join('\n').trim();
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
return { dateTime, title, content, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAttachments(body) {
|
// ---------- rendering with collapse ----------
|
||||||
const attachments = new Set();
|
function createPostElement(post) {
|
||||||
body.replace(ATTACHMENT_RE, (_, filename) => {
|
const article = document.createElement('article');
|
||||||
const safeName = filename.trim();
|
article.className = 'blog-post';
|
||||||
if (safeName) attachments.add(safeName);
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
return Array.from(attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBlogText(text, filename) {
|
// header: date & title
|
||||||
const lines = text.replace(/\r/g, '').split('\n');
|
const header = document.createElement('header');
|
||||||
let index = 0;
|
const dateP = document.createElement('p');
|
||||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
dateP.className = 'post-date';
|
||||||
|
dateP.textContent = post.dateTime || 'No date';
|
||||||
|
const titleH = document.createElement('h3');
|
||||||
|
titleH.textContent = post.title;
|
||||||
|
header.appendChild(dateP);
|
||||||
|
header.appendChild(titleH);
|
||||||
|
article.appendChild(header);
|
||||||
|
|
||||||
const rawDate = index < lines.length ? lines[index].trim() : '';
|
// content container
|
||||||
if (rawDate.toLowerCase().startsWith('date:')) {
|
const contentDiv = document.createElement('div');
|
||||||
index += 1;
|
contentDiv.className = 'post-content';
|
||||||
} else {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
// full content (preserve line breaks)
|
||||||
const title = index < lines.length ? lines[index].trim() : 'Untitled';
|
const fullContent = document.createElement('div');
|
||||||
index += 1;
|
fullContent.className = 'post-full';
|
||||||
|
fullContent.style.whiteSpace = 'pre-wrap';
|
||||||
|
fullContent.textContent = post.content;
|
||||||
|
|
||||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
// preview content (only if needed)
|
||||||
const body = lines.slice(index).join('\n').trim();
|
const previewContent = document.createElement('div');
|
||||||
|
previewContent.className = 'post-preview';
|
||||||
|
previewContent.style.whiteSpace = 'pre-wrap';
|
||||||
|
|
||||||
return {
|
const fullText = post.content;
|
||||||
filename,
|
const needsCollapse = fullText.length > 300;
|
||||||
title: title || 'Untitled',
|
|
||||||
date: parseDateLine(rawDate) || null,
|
|
||||||
dateLabel: rawDate || '',
|
|
||||||
body: body || '',
|
|
||||||
attachments: extractAttachments(body),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date) {
|
if (needsCollapse) {
|
||||||
if (!date) return 'Unknown date';
|
// Build preview: take up to 3 lines, but keep total length < 300
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
const lines = fullText.split(/\r?\n/);
|
||||||
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
let previewLines = [];
|
||||||
}).format(date);
|
let previewLength = 0;
|
||||||
}
|
// take at least 3 lines if available, but stop if adding next line exceeds 300
|
||||||
|
for (let i = 0; i < Math.min(lines.length, 3); i++) {
|
||||||
// ---------- new preview logic ----------
|
const line = lines[i];
|
||||||
function getPreviewText(body) {
|
if (previewLength + line.length + (i > 0 ? 1 : 0) < 300) {
|
||||||
const maxLen = 300;
|
previewLines.push(line);
|
||||||
if (body.length <= maxLen) {
|
previewLength += line.length + (i > 0 ? 1 : 0);
|
||||||
return { preview: body, isTruncated: false };
|
} else {
|
||||||
}
|
// if we haven't taken any line yet, take part of this line
|
||||||
|
if (i === 0) {
|
||||||
// Try to use first 3 non‑empty lines
|
previewLines.push(line.slice(0, 300));
|
||||||
const lines = body.split('\n').filter(line => line.trim() !== '');
|
previewLength = 300;
|
||||||
if (lines.length >= 3) {
|
}
|
||||||
const firstThree = lines.slice(0, 3);
|
break;
|
||||||
const combined = firstThree.join('\n');
|
}
|
||||||
if (combined.length < maxLen) {
|
|
||||||
return { preview: combined, isTruncated: true };
|
|
||||||
}
|
}
|
||||||
}
|
// if we still have less than 3 lines but we have more lines, force include them up to 300 chars
|
||||||
|
while (previewLines.length < 3 && previewLines.length < lines.length) {
|
||||||
// Try to use first 3 sentences
|
const nextLine = lines[previewLines.length];
|
||||||
const sentenceEnd = /[.!?]+\s*/g;
|
if (previewLength + nextLine.length + (previewLines.length > 0 ? 1 : 0) < 300) {
|
||||||
const sentences = [];
|
previewLines.push(nextLine);
|
||||||
let match;
|
previewLength += nextLine.length + (previewLines.length > 0 ? 1 : 0);
|
||||||
let lastIndex = 0;
|
} else {
|
||||||
while ((match = sentenceEnd.exec(body)) !== null && sentences.length < 3) {
|
// truncate the next line to fit within 300
|
||||||
const end = match.index + match[0].length;
|
const remaining = 300 - previewLength - (previewLines.length > 0 ? 1 : 0);
|
||||||
const sentence = body.slice(lastIndex, end).trim();
|
if (remaining > 0) {
|
||||||
if (sentence) sentences.push(sentence);
|
previewLines.push(nextLine.slice(0, remaining));
|
||||||
lastIndex = end;
|
}
|
||||||
}
|
break;
|
||||||
if (sentences.length >= 3) {
|
}
|
||||||
const combined = sentences.join(' ');
|
|
||||||
if (combined.length < maxLen) {
|
|
||||||
return { preview: combined, isTruncated: true };
|
|
||||||
}
|
}
|
||||||
}
|
previewContent.textContent = previewLines.join('\n');
|
||||||
|
// if preview is empty, fallback to first 300 chars
|
||||||
// Fallback: take first maxLen chars, cut at last sentence boundary if possible
|
if (!previewContent.textContent) {
|
||||||
let cut = maxLen;
|
previewContent.textContent = fullText.slice(0, 300);
|
||||||
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)
|
// toggle button
|
||||||
const fullDiv = document.createElement('div');
|
const toggleBtn = document.createElement('button');
|
||||||
fullDiv.className = 'blog-full';
|
toggleBtn.className = 'read-more-btn';
|
||||||
if (isTruncated) {
|
toggleBtn.textContent = 'Read more';
|
||||||
fullDiv.style.display = 'none';
|
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
}
|
|
||||||
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)
|
// Initially, full content is hidden, preview is visible
|
||||||
if (isTruncated) {
|
fullContent.style.display = 'none';
|
||||||
const toggle = document.createElement('a');
|
previewContent.style.display = 'block';
|
||||||
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);
|
toggleBtn.addEventListener('click', () => {
|
||||||
article.appendChild(meta);
|
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||||||
article.appendChild(contentWrapper);
|
if (expanded) {
|
||||||
|
fullContent.style.display = 'none';
|
||||||
// Attachments (always visible)
|
previewContent.style.display = 'block';
|
||||||
if (entry.attachments && entry.attachments.length) {
|
toggleBtn.textContent = 'Read more';
|
||||||
const attachTitle = document.createElement('div');
|
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
attachTitle.className = 'blog-meta';
|
} else {
|
||||||
attachTitle.textContent = 'Attachments:';
|
fullContent.style.display = 'block';
|
||||||
const list = document.createElement('ul');
|
previewContent.style.display = 'none';
|
||||||
list.className = 'blog-attachment-list';
|
toggleBtn.textContent = 'Show less';
|
||||||
entry.attachments.forEach((attachment) => {
|
toggleBtn.setAttribute('aria-expanded', 'true');
|
||||||
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);
|
contentDiv.appendChild(previewContent);
|
||||||
renderBlogEntries(sortEntries(entries));
|
contentDiv.appendChild(fullContent);
|
||||||
|
contentDiv.appendChild(toggleBtn);
|
||||||
|
} else {
|
||||||
|
// short post – show full content only
|
||||||
|
fullContent.style.display = 'block';
|
||||||
|
contentDiv.appendChild(fullContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
article.appendChild(contentDiv);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- main init ----------
|
||||||
|
async function initBlog() {
|
||||||
|
const list = await loadPostList();
|
||||||
|
const feed = document.querySelector('.blog-feed');
|
||||||
|
feed.innerHTML = '';
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
feed.textContent = 'No blog posts found. Create blog/manifest.json or enable directory listing.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch and sort posts (newest first based on filename or date? we'll sort by filename descending)
|
||||||
|
const posts = await Promise.all(list.map(f => fetchPost(f).catch(err => {
|
||||||
|
console.warn('Skipping', f, err);
|
||||||
|
return null;
|
||||||
|
})));
|
||||||
|
const validPosts = posts.filter(p => p !== null);
|
||||||
|
// sort by dateTime (if possible) or filename – we'll sort by dateTime descending
|
||||||
|
validPosts.sort((a, b) => {
|
||||||
|
// try to parse as ISO date, else compare as strings
|
||||||
|
const dA = new Date(a.dateTime);
|
||||||
|
const dB = new Date(b.dateTime);
|
||||||
|
if (!isNaN(dA) && !isNaN(dB)) return dB - dA;
|
||||||
|
return b.dateTime.localeCompare(a.dateTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
validPosts.forEach(post => {
|
||||||
|
const el = createPostElement(post);
|
||||||
|
feed.appendChild(el);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initBlog);
|
document.addEventListener('DOMContentLoaded', initBlog);
|
||||||
Reference in New Issue
Block a user