diff --git a/js/blog.js b/js/blog.js
index 5817b6a..cdd50a5 100644
--- a/js/blog.js
+++ b/js/blog.js
@@ -1,3 +1,129 @@
+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;
+const BLOG_COLLAPSE_THRESHOLD = 300; // characters before collapsing
+const BLOG_PREVIEW_LINES = 3; // number of lines shown in preview
+
+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 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);
+}
+
+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 = '';
diff --git a/styles.css b/styles.css
index 2e897f7..4535cf0 100644
--- a/styles.css
+++ b/styles.css
@@ -56,3 +56,9 @@ a:hover { color:#991b1b; text-decoration:underline; }
.modal-prev { left:20px; top:50%; transform:translateY(-50%); }
.modal-next { right:20px; top:50%; transform:translateY(-50%); }
+.blog-collapse summary { cursor: pointer; list-style: none; }
+.blog-collapse summary::-webkit-details-marker { display: none; }
+.blog-preview { white-space: pre-line; margin-bottom: 0.5rem;}
+.read-more-text {display: inline-block; font-weight: bold; margin-top: 0.5rem; }
+.read-more-text:hover { text-decoration: underline; }
+.blog-collapse[open] .read-more-text { margin-bottom: 1rem; }
\ No newline at end of file