From dfa7ef98ef130152c3fedec22d54cb5052e7113a Mon Sep 17 00:00:00 2001 From: organic-CPU Date: Thu, 9 Jul 2026 10:17:57 +0200 Subject: [PATCH] sync --- README.md | 23 +++ blog.html | 36 +++++ blog/2026-06-06.text | 4 + blog/2026-07-09-welcome.txt | 4 + blog/2026-07-10-attachment-test.txt | 6 + blog/files/sample-attachment.txt | 1 + blog/manifest.json | 5 + index.html | 2 +- js/blog.js | 218 ++++++++++++++++++++++++++++ nginx/marcus_allison.conf | 27 ---- scripts/generate_blog_manifest.py | 23 +++ scripts/sync_site.service | 13 ++ scripts/sync_site.sh | 49 +++++++ scripts/sync_site.timer | 9 ++ styles.css | 27 +++- 15 files changed, 411 insertions(+), 36 deletions(-) create mode 100644 blog.html create mode 100644 blog/2026-06-06.text create mode 100644 blog/2026-07-09-welcome.txt create mode 100644 blog/2026-07-10-attachment-test.txt create mode 100644 blog/files/sample-attachment.txt create mode 100644 blog/manifest.json create mode 100644 js/blog.js delete mode 100644 nginx/marcus_allison.conf create mode 100644 scripts/generate_blog_manifest.py create mode 100644 scripts/sync_site.service create mode 100644 scripts/sync_site.sh create mode 100644 scripts/sync_site.timer diff --git a/README.md b/README.md index 94f6df5..d607f8d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,29 @@ location /images/ { } ``` +Blog support: +- Add plain text files to the `blog/` directory. Supported extensions are `.txt` and `.text`. Each file should start with a date/time line, then a title line, then the body. +- To attach a file, place it in `blog/files/` and reference it in the post with `[[file: filename.ext]]` or `[[attachment: filename.ext]]`. +- Run `python3 scripts/generate_blog_manifest.py` from the project root to refresh `blog/manifest.json` after adding or removing posts. + +Git sync: +- Use `./scripts/sync_site.sh` from the website root to pull changes from Git via HTTP. +- The default remote is `https://git.organic-server.org/organic-CPU/website-but-better.git` and the branch is `main`. +- By default it deploys to `/var/www/html`, which is the default nginx document root on most Linux distributions. +- Override with `GIT_REPO_URL`, `GIT_BRANCH`, or `DEPLOY_DIR` if needed. + +Automatic sync every 12 hours: +- Copy `scripts/sync_site.service` and `scripts/sync_site.timer` to `/etc/systemd/system/`. +- Update `sync_site.service` to point at the actual repository path on your server. +- Then enable and start the timer: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now sync_site.timer +``` + +This makes the website sync twice per day and deploy the latest files to nginx root. + Editing the Git link: - Update the header Git link in `index.html` at the element with id `git-link` to your repository URL. diff --git a/blog.html b/blog.html new file mode 100644 index 0000000..4651bfb --- /dev/null +++ b/blog.html @@ -0,0 +1,36 @@ + + + + + + Blog — Marcus Allison + + + + + +
+
+

Latest Posts

+

Each entry is a plain text file in the blog/ directory. The first line is the date/time, the second line is the title, and the rest is the post content.

+

To attach a file, put it in blog/files/ and reference it using [[file: filename.ext]] or [[attachment: filename.ext]] in the body.

+
+ +
+
Loading blog posts…
+
+
+ + + + + + diff --git a/blog/2026-06-06.text b/blog/2026-06-06.text new file mode 100644 index 0000000..cb86898 --- /dev/null +++ b/blog/2026-06-06.text @@ -0,0 +1,4 @@ +2026-07-09 18:30 +Welco + +This blog is a simple read-only collection of notes from my home lab and self-hosting experiments. Add a new text file to the `blog/` folder with a date/time on the first line, a title on the second line, and the content after a blank line. diff --git a/blog/2026-07-09-welcome.txt b/blog/2026-07-09-welcome.txt new file mode 100644 index 0000000..c24382c --- /dev/null +++ b/blog/2026-07-09-welcome.txt @@ -0,0 +1,4 @@ +2026-07-09 18:30 +Welcome to the blog + +This blog is a simple read-only collection of notes from my home lab and self-hosting experiments. Add a new text file to the `blog/` folder with a date/time on the first line, a title on the second line, and the content after a blank line. diff --git a/blog/2026-07-10-attachment-test.txt b/blog/2026-07-10-attachment-test.txt new file mode 100644 index 0000000..f3986bf --- /dev/null +++ b/blog/2026-07-10-attachment-test.txt @@ -0,0 +1,6 @@ +2026-07-10 12:45 +Attachment test + +This post includes a referenced attachment file. + +You can download it here: [[file: sample-attachment.txt]] diff --git a/blog/files/sample-attachment.txt b/blog/files/sample-attachment.txt new file mode 100644 index 0000000..cdf7456 --- /dev/null +++ b/blog/files/sample-attachment.txt @@ -0,0 +1 @@ +This is a sample attachment file used to test blog file linking. diff --git a/blog/manifest.json b/blog/manifest.json new file mode 100644 index 0000000..d6fc449 --- /dev/null +++ b/blog/manifest.json @@ -0,0 +1,5 @@ +[ + "2026-06-06.text", + "2026-07-09-welcome.txt", + "2026-07-10-attachment-test.txt" +] \ No newline at end of file diff --git a/index.html b/index.html index f845b86..67c8c6c 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@

Marcus Allison

Systems Administrator · Self-Hosting · Network Security

- +
diff --git a/js/blog.js b/js/blog.js new file mode 100644 index 0000000..930ae75 --- /dev/null +++ b/js/blog.js @@ -0,0 +1,218 @@ +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; + +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 = ''; + + 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; + } + }); + + const entries = (await Promise.all(promises)).filter(Boolean); + renderBlogEntries(sortEntries(entries)); +} + +document.addEventListener('DOMContentLoaded', initBlog); diff --git a/nginx/marcus_allison.conf b/nginx/marcus_allison.conf deleted file mode 100644 index 1c74e7f..0000000 --- a/nginx/marcus_allison.conf +++ /dev/null @@ -1,27 +0,0 @@ -server { - listen 80; - server_name portfolio.example.com www.portfolio.example.com; - - root /var/www/marcus; - index index.html; - - location / { - try_files $uri $uri/ =404; - } - - # Uncomment to redirect all HTTP to HTTPS if you provision certs separately: - # return 301 https://$host$request_uri; -} - -# Example HTTPS block (use certbot or your CA to populate cert paths): -# server { -# listen 443 ssl http2; -# server_name portfolio.example.com; -# root /var/www/marcus; -# index index.html; -# ssl_certificate /etc/letsencrypt/live/portfolio.example.com/fullchain.pem; -# ssl_certificate_key /etc/letsencrypt/live/portfolio.example.com/privkey.pem; -# include /etc/letsencrypt/options-ssl-nginx.conf; -# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; -# location / { try_files $uri $uri/ =404; } -# } diff --git a/scripts/generate_blog_manifest.py b/scripts/generate_blog_manifest.py new file mode 100644 index 0000000..5e18d34 --- /dev/null +++ b/scripts/generate_blog_manifest.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +""" +Generate `blog/manifest.json` from the text files present in the blog/ folder. +Run this in the project root to refresh the manifest after adding/removing blog entries. +""" +import os +import json + +BASEDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +BLOGDIR = os.path.join(BASEDIR, 'blog') + + +def main(): + exts = ('.txt', '.text') + files = [f for f in sorted(os.listdir(BLOGDIR)) if f.lower().endswith(exts)] + out = os.path.join(BLOGDIR, 'manifest.json') + with open(out, 'w', encoding='utf-8') as fh: + json.dump(files, fh, indent=2) + print(f'Wrote {len(files)} entries to {out}') + + +if __name__ == '__main__': + main() diff --git a/scripts/sync_site.service b/scripts/sync_site.service new file mode 100644 index 0000000..d9f005a --- /dev/null +++ b/scripts/sync_site.service @@ -0,0 +1,13 @@ +[Unit] +Description=Sync website content from Git repository +After=network.target + +[Service] +Type=oneshot +WorkingDirectory=%h/website-but-better +Environment=GIT_REPO_URL=https://git.organic-server.org/organic-CPU/website-but-better.git +Environment=GIT_BRANCH=main +Environment=DEPLOY_DIR=/var/www/html +ExecStart=/bin/bash /home/USERNAME/website-but-better/scripts/sync_site.sh +User=www-data +Group=www-data diff --git a/scripts/sync_site.sh b/scripts/sync_site.sh new file mode 100644 index 0000000..1242a7a --- /dev/null +++ b/scripts/sync_site.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_URL="${GIT_REPO_URL:-https://git.organic-server.org/organic-CPU/website-but-better.git}" +BRANCH="${GIT_BRANCH:-main}" +DEPLOY_DIR="${DEPLOY_DIR:-/var/www/html}" + +cd "$ROOT_DIR" + +if [ ! -d .git ]; then + if [ "$(find . -maxdepth 1 -mindepth 1 | wc -l)" -eq 0 ]; then + echo "Cloning website repository from $REPO_URL into $ROOT_DIR" + git clone "$REPO_URL" . + else + echo "Error: $ROOT_DIR is not a git repository and is not empty." >&2 + echo "Please clone the repository into this directory or initialize git manually." >&2 + exit 1 + fi +fi + +echo "Syncing website from $REPO_URL ($BRANCH)" +git remote set-url origin "$REPO_URL" +git fetch origin --prune + +git checkout "$BRANCH" +git pull --ff-only origin "$BRANCH" + +echo "Regenerating manifests" +if command -v python3 >/dev/null 2>&1; then + python3 scripts/generate_blog_manifest.py + python3 scripts/generate_manifest.py +else + echo "Warning: python3 not found; manifest regeneration skipped." >&2 +fi + +if [ "$DEPLOY_DIR" != "$ROOT_DIR" ]; then + echo "Deploying website files to $DEPLOY_DIR" + mkdir -p "$DEPLOY_DIR" + rsync -a --delete \ + --exclude '.git' \ + --exclude 'scripts/' \ + --exclude 'nginx/' \ + --exclude '.gitignore' \ + --exclude '*.sh' \ + "$ROOT_DIR/" "$DEPLOY_DIR/" +fi + +echo "Website sync complete." diff --git a/scripts/sync_site.timer b/scripts/sync_site.timer new file mode 100644 index 0000000..e139c64 --- /dev/null +++ b/scripts/sync_site.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run website sync every 12 hours + +[Timer] +OnCalendar=*-*-* 00,12:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/styles.css b/styles.css index f474e87..1199a43 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,8 @@ * { box-sizing: border-box; } html { scroll-behavior: smooth; } -body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; color:#16202A; background:#f7fafc; line-height:1.6; } +body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; color:#2b0f0f; background:#fff5f5; line-height:1.6; } .container { max-width:980px; margin:0 auto; padding:24px; } -.site-header { background:linear-gradient(90deg,#0f172a,#0b1320); color:#fff; padding:20px 0; border-bottom:3px solid #0f62fe; } +.site-header { background:linear-gradient(90deg,#7f1d1d,#4b1111); color:#fff; padding:20px 0; border-bottom:3px solid #dc2626; } .site-header .container { padding:20px 24px; } .site-header h1 { margin:0; font-size:2.2rem; font-weight:700; letter-spacing:-0.5px; } .tag { margin-top:6px; opacity:0.9; font-size:0.95rem; } @@ -10,19 +10,30 @@ body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neu .card { background:#fff; padding:20px; border-radius:8px; box-shadow:0 1px 3px rgba(2,6,23,0.06); transition:all 0.3s ease; border:1px solid #e5e7eb; } .card:hover { transform:translateY(-2px); box-shadow:0 8px 16px rgba(2,6,23,0.1); } .site-footer { text-align:center; padding:24px; margin-top:40px; font-size:0.9rem; color:#6b7280; border-top:1px solid #e5e7eb; background:#fafafa; } -h2 { margin:32px 0 16px 0; font-size:1.8rem; font-weight:700; color:#0f172a; padding-bottom:12px; border-bottom:2px solid #f0f0f0; } -h3 { margin:0 0 8px 0; font-size:1.1rem; font-weight:600; color:#0f172a; } +h2 { margin:32px 0 16px 0; font-size:1.8rem; font-weight:700; color:#7f1d1d; padding-bottom:12px; border-bottom:2px solid #fee2e2; } +h3 { margin:0 0 8px 0; font-size:1.1rem; font-weight:600; color:#7f1d1d; } section { margin-bottom:16px; } p { margin:0 0 12px 0; } ol, ul { margin:12px 0; padding-left:24px; } li { margin-bottom:8px; } -a { color:#0f62fe; text-decoration:none; transition:color 0.2s ease; } -a:hover { color:#0b47b8; text-decoration:underline; } +a { color:#dc2626; text-decoration:none; transition:color 0.2s ease; } +a:hover { color:#991b1b; text-decoration:underline; } #links ul { list-style:none; padding:0; display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0; } #links li { margin:0; } #links a { display:block; padding:12px 16px; background:#fff; border:1px solid #e5e7eb; border-radius:6px; transition:all 0.3s ease; } -#links a:hover { border-color:#0f62fe; box-shadow:0 4px 12px rgba(15,98,254,0.15); } +#links a:hover { border-color:#dc2626; box-shadow:0 4px 12px rgba(220,38,38,0.15); } +.blog-feed { display:grid; gap:24px; margin-top:16px; } +.blog-post { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:24px; box-shadow:0 1px 6px rgba(139,18,18,0.08); } +.blog-post + .blog-post { border-top:4px solid #dc2626; margin-top:32px; padding-top:32px; } +.blog-title { margin:0 0 8px 0; font-size:1.5rem; } +.blog-meta { margin:0 0 16px 0; color:#7f1d1d; font-size:0.95rem; } +.blog-body p { margin:0 0 1em 0; } +.blog-body p:last-child { margin-bottom:0; } +.blog-attachment-list { margin:16px 0 0 0; padding:0; list-style:none; } +.blog-attachment-list li { margin:0.5em 0; } +.blog-attachment-list a { color:#dc2626; text-decoration:none; } +.blog-attachment-list a:hover { text-decoration:underline; } @media (max-width:520px){ h2 { font-size:1.4rem; } .site-header h1 { font-size:1.6rem; } .container { padding:16px; } } .header-links { margin-top:12px; } @@ -31,7 +42,7 @@ a:hover { color:#0b47b8; text-decoration:underline; } .gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin-top:16px; } .gallery img { width:100%; height:150px; object-fit:cover; border-radius:8px; cursor:pointer; display:block; transition:all 0.3s ease; border:2px solid transparent; } -.gallery img:hover { transform:scale(1.04); box-shadow:0 4px 12px rgba(2,6,23,0.15); border-color:#0f62fe; } +.gallery img:hover { transform:scale(1.04); box-shadow:0 4px 12px rgba(139,18,18,0.15); border-color:#dc2626; } .modal { position:fixed; inset:0; background:rgba(2,6,23,0.95); display:flex; align-items:center; justify-content:center; z-index:60; backdrop-filter:blur(2px); } .modal.hidden { display:none; }