diff --git a/js/blog.js b/js/blog.js index 9d15360..930ae75 100644 --- a/js/blog.js +++ b/js/blog.js @@ -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, '''); +} + +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
- 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
- previewDiv.innerHTML = preview.replace(/\n/g, '
');
- 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 `${escapeHtml(safeName)}`;
+ });
+ const lines = text.split('\n');
+ p.innerHTML = lines.join('
');
+ 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);
\ No newline at end of file
+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);