authoring server 3
All checks were successful
Build Org Website / build (push) Successful in 1m1s

This commit is contained in:
2026-05-07 13:46:25 +01:00
parent d3a668f353
commit d33e1771af
11 changed files with 295 additions and 37 deletions

View File

@@ -4,12 +4,14 @@
from __future__ import annotations
import json
import mimetypes
import os
import re
import subprocess
import sys
import threading
import time
import cgi
from dataclasses import dataclass, field
from datetime import datetime
from email.utils import formatdate
@@ -23,6 +25,8 @@ from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parent
BLOGS_DIR = ROOT / "blogs"
POSTS_DIR = ROOT / "posts"
LIMA_DIR = ROOT / "lima"
HZONE_ASSETS_DIR = ROOT / "assets" / "images" / "hzone"
GENERATED_ORG_NAMES = {
"blogs-list.org",
"posts-list.org",
@@ -31,6 +35,18 @@ GENERATED_ORG_NAMES = {
"recently-updated.org",
"wip.org",
}
GENERATED_CONTENT_NAMES = GENERATED_ORG_NAMES | {"lima-list.org"}
ALLOWED_UPLOAD_EXTENSIONS = {
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".mp4",
".webm",
".mov",
}
MONTH_NAMES = [
"january",
"february",
@@ -112,6 +128,21 @@ class OrgPage:
wip: str | None = None
@dataclass
class ContentPage:
path: str
page_type: str
title: str
slug: str
tags: list[str]
content: str
date: str
comments: bool
options: str
format: str
wip: str | None = None
@dataclass
class BuildJob:
id: int
@@ -216,25 +247,55 @@ def safe_relative_path(path: str) -> Path:
full = (ROOT / rel).resolve()
if not full.is_relative_to(ROOT):
raise ValueError("Path must stay inside this repository.")
if full.suffix != ".org":
raise ValueError("Only .org files can be edited.")
if not (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
raise ValueError("Only blogs/ and posts/ files can be edited here.")
if full.suffix == ".org" and (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
return full
if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
return full
raise ValueError("Only .org files under blogs/posts and .md files under lima can be edited here.")
return full
def safe_target_path(path: str, slug: str) -> Path:
def safe_target_path(path: str, slug: str, page_type: str) -> Path:
candidate = path.strip()
if not candidate:
raise ValueError("Path is required.")
default_ext = ".md" if page_type == "lima" else ".org"
if candidate.endswith("/"):
candidate = f"{candidate}{slug}.org"
candidate = f"{candidate}{slug}{default_ext}"
elif not Path(candidate).suffix:
candidate = f"{candidate}.org"
candidate = f"{candidate}{default_ext}"
return safe_relative_path(candidate)
def read_page(path: Path) -> OrgPage:
def markdown_title(content: str, fallback: str) -> str:
for line in content.splitlines():
match = re.match(r"^#\s+(.+?)\s*$", line)
if match:
return match.group(1).strip()
return fallback.replace("-", " ").replace("_", " ").title()
def read_markdown_page(path: Path) -> ContentPage:
content = path.read_text(encoding="utf-8")
rel = path.relative_to(ROOT).as_posix()
title = markdown_title(content, path.stem)
return ContentPage(
path=rel,
page_type="lima",
title=title,
slug=path.stem,
tags=[],
content=content,
date="",
comments=True,
options="",
format="markdown",
)
def read_page(path: Path) -> ContentPage:
if path.suffix == ".md" and path.is_relative_to(LIMA_DIR):
return read_markdown_page(path)
text = path.read_text(encoding="utf-8")
meta: dict[str, str] = {}
body_lines: list[str] = []
@@ -250,7 +311,7 @@ def read_page(path: Path) -> OrgPage:
page_type = "blog" if path.is_relative_to(BLOGS_DIR) else "post"
slug = meta.get("SLUG") or path.stem
tags = normalise_tags(meta.get("FILETAGS", ""))
return OrgPage(
return ContentPage(
path=rel,
page_type=page_type,
title=meta.get("TITLE", path.stem),
@@ -260,11 +321,12 @@ def read_page(path: Path) -> OrgPage:
date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))),
comments=meta.get("COMMENTS", "t").lower() == "t",
options=meta.get("OPTIONS", "num:nil"),
format="org",
wip=meta.get("WIP"),
)
def page_to_dict(page: OrgPage) -> dict[str, Any]:
def page_to_dict(page: ContentPage) -> dict[str, Any]:
return {
"path": page.path,
"pageType": page.page_type,
@@ -275,17 +337,22 @@ def page_to_dict(page: OrgPage) -> dict[str, Any]:
"date": page.date,
"comments": page.comments,
"options": page.options,
"format": page.format,
"wip": page.wip or "",
}
def list_pages() -> list[dict[str, Any]]:
pages = []
for base, page_type in ((BLOGS_DIR, "blog"), (POSTS_DIR, "post")):
for base, page_type, extension in (
(BLOGS_DIR, "blog", "*.org"),
(POSTS_DIR, "post", "*.org"),
(LIMA_DIR, "lima", "*.md"),
):
if not base.exists():
continue
for path in sorted(base.rglob("*.org")):
if path.name in GENERATED_ORG_NAMES or "sync-conflict" in path.name:
for path in sorted(base.rglob(extension)):
if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
continue
try:
page = read_page(path)
@@ -300,6 +367,7 @@ def list_pages() -> list[dict[str, Any]]:
"slug": page.slug,
"tags": page.tags,
"date": page.date,
"format": page.format,
"timestamp": parsed.timestamp() if parsed else path.stat().st_mtime,
}
)
@@ -311,10 +379,10 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
return safe_relative_path(existing_path)
title = str(data.get("title") or "").strip()
slug = slugify(str(data.get("slug") or title))
page_type = str(data.get("pageType") or "blog")
explicit_path = str(data.get("targetPath") or "").strip()
if explicit_path:
return safe_target_path(explicit_path, slug)
page_type = str(data.get("pageType") or "blog")
return safe_target_path(explicit_path, slug, page_type)
if page_type == "blog":
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
folder = BLOGS_DIR / str(dt.year) / f"{dt.month:02d}-{MONTH_NAMES[dt.month - 1]}"
@@ -324,10 +392,22 @@ def target_path(data: dict[str, Any], existing_path: str | None) -> Path:
section = slugify(raw_section) if raw_section else ""
folder = POSTS_DIR / section if section else POSTS_DIR
return folder / f"{slug}.org"
raise ValueError("pageType must be blog or post.")
if page_type == "lima":
return LIMA_DIR / f"{slug}.md"
raise ValueError("pageType must be blog, post, or lima.")
def render_org(data: dict[str, Any], previous: OrgPage | None) -> str:
def render_markdown(data: dict[str, Any]) -> str:
content = str(data.get("content") or "").replace("\r\n", "\n").strip()
title = str(data.get("title") or "").strip()
if not title:
raise ValueError("Title is required.")
if content:
return content + "\n"
return f"# {title}\n"
def render_org(data: dict[str, Any], previous: ContentPage | None) -> str:
title = str(data.get("title") or "").strip()
if not title:
raise ValueError("Title is required.")
@@ -368,10 +448,39 @@ def save_page(data: dict[str, Any]) -> dict[str, Any]:
target.parent.mkdir(parents=True)
if target.exists() and not existing_path:
raise ValueError(f"{target.relative_to(ROOT)} already exists.")
target.write_text(render_org(data, previous), encoding="utf-8")
if target.suffix == ".md":
target.write_text(render_markdown(data), encoding="utf-8")
else:
target.write_text(render_org(data, previous), encoding="utf-8")
return page_to_dict(read_page(target))
def save_upload(filename: str, payload: bytes) -> dict[str, str]:
original = Path(filename or "attachment").name
ext = Path(original).suffix.lower()
if ext not in ALLOWED_UPLOAD_EXTENSIONS:
raise ValueError("Only common image and video files can be uploaded.")
now = datetime.now()
target_dir = HZONE_ASSETS_DIR / f"{now.year}" / f"{now.month:02d}"
target_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(Path(original).stem)
target = target_dir / f"{now.strftime('%Y%m%d-%H%M%S')}-{stem}{ext}"
counter = 2
while target.exists():
target = target_dir / f"{now.strftime('%Y%m%d-%H%M%S')}-{stem}-{counter}{ext}"
counter += 1
target.write_bytes(payload)
rel = target.relative_to(ROOT).as_posix()
url = f"/{rel}"
mime = mimetypes.guess_type(target.name)[0] or ""
alt = Path(original).stem.replace("-", " ").replace("_", " ").strip() or "attachment"
if mime.startswith("video/") or ext in {".mp4", ".webm", ".mov"}:
markdown = f'<video controls src="{url}"></video>'
else:
markdown = f"![{alt}]({url})"
return {"url": url, "path": rel, "markdown": markdown, "filename": target.name}
def run_build_commands() -> tuple[bool, str, str]:
venv_python = ROOT / ".venv" / "bin" / "python"
venv_pip = ROOT / ".venv" / "bin" / "pip"
@@ -421,6 +530,7 @@ APP_HTML = r"""<!doctype html>
button, input, textarea, select { font: inherit; }
button { border: 1px solid var(--line); background: #fff; color: var(--ink); min-height: 38px; padding: 0 12px; border-radius: 6px; cursor: pointer; }
button.primary { background: var(--accent); color: white; border-color: var(--accent); }
button.icon { min-width: 38px; padding: 0 10px; font-weight: 700; }
.app { min-height: 100vh; display: grid; grid-template-columns: minmax(260px, 360px) 1fr; }
aside { border-right: 1px solid var(--line); background: #eee8dc; padding: 18px; overflow: auto; max-height: 100vh; }
main { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; max-height: 100vh; }
@@ -439,6 +549,8 @@ APP_HTML = r"""<!doctype html>
.page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
form { display: grid; gap: 14px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 7px; }
.toolbar input[type="file"] { display: none; }
label { display: grid; gap: 5px; font-size: 13px; font-weight: 700; color: var(--muted); }
input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); padding: 9px 10px; }
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
@@ -470,6 +582,7 @@ APP_HTML = r"""<!doctype html>
<option value="">Blogs and posts</option>
<option value="blog">Blogs</option>
<option value="post">Posts</option>
<option value="lima">Lima</option>
</select>
</div>
<div id="pages" class="page-list"></div>
@@ -485,6 +598,7 @@ APP_HTML = r"""<!doctype html>
<select name="pageType">
<option value="blog">Blog</option>
<option value="post">Post</option>
<option value="lima">Lima</option>
</select>
</label>
<label>Post section
@@ -506,6 +620,20 @@ APP_HTML = r"""<!doctype html>
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
</label>
</div>
<div id="mdToolbar" class="toolbar" hidden>
<button class="icon" type="button" data-md="bold" title="Bold">B</button>
<button class="icon" type="button" data-md="italic" title="Italic"><i>I</i></button>
<button class="icon" type="button" data-md="underline" title="Underline"><u>U</u></button>
<button type="button" data-md="h1">H1</button>
<button type="button" data-md="h2">H2</button>
<button type="button" data-md="h3">H3</button>
<button type="button" data-md="bullet">List</button>
<button type="button" data-md="numbered">1. List</button>
<button type="button" data-md="quote">Quote</button>
<button type="button" data-md="link">Link</button>
<button type="button" id="attachBtn">Insert attachment</button>
<input id="attachInput" type="file" accept="image/*,video/*" />
</div>
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
@@ -526,6 +654,8 @@ APP_HTML = r"""<!doctype html>
const state = { pages: [], currentPath: "", build: null, pathManual: false };
const $ = (selector) => document.querySelector(selector);
const editor = $("#editor");
const mdToolbar = $("#mdToolbar");
const attachInput = $("#attachInput");
const statusBox = $("#status");
const pagesBox = $("#pages");
const buildLog = $("#buildLog");
@@ -616,6 +746,7 @@ APP_HTML = r"""<!doctype html>
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
$("#pathLabel").textContent = state.currentPath;
editor.targetPath.disabled = Boolean(state.currentPath);
updateEditorMode();
updateSuggestedPath();
saveMessage.textContent = "";
}
@@ -643,10 +774,22 @@ APP_HTML = r"""<!doctype html>
}
});
editor.slug.addEventListener("input", updateSuggestedPath);
editor.pageType.addEventListener("change", updateSuggestedPath);
editor.pageType.addEventListener("change", () => {
if (!state.currentPath) {
state.pathManual = false;
editor.targetPath.value = "";
}
updateEditorMode();
updateSuggestedPath();
});
editor.section.addEventListener("input", updateSuggestedPath);
editor.date.addEventListener("input", updateSuggestedPath);
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
button.addEventListener("click", () => applyMarkdown(button.dataset.md));
});
$("#attachBtn").addEventListener("click", () => attachInput.click());
attachInput.addEventListener("change", uploadAttachment);
$("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
@@ -670,6 +813,10 @@ APP_HTML = r"""<!doctype html>
function updateSuggestedPath() {
if (state.currentPath || state.pathManual) return;
const slug = slugify(editor.slug.value || editor.title.value);
if (editor.pageType.value === "lima") {
editor.targetPath.value = `lima/${slug}.md`;
return;
}
if (editor.pageType.value === "post") {
const section = slugify(editor.section.value || "");
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
@@ -682,6 +829,83 @@ APP_HTML = r"""<!doctype html>
editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
}
function updateEditorMode() {
const isLima = editor.pageType.value === "lima";
mdToolbar.hidden = !isLima;
editor.tags.closest("label").hidden = isLima;
editor.date.closest("label").hidden = isLima;
editor.comments.closest("label").hidden = isLima;
editor.section.closest("label").hidden = editor.pageType.value !== "post";
const pathLabel = editor.targetPath.closest("label").firstChild;
if (pathLabel) pathLabel.textContent = isLima ? "Markdown file path" : "Org file path";
editor.targetPath.placeholder = isLima ? "lima/family-update.md" : "blogs/2026/05-may/my-page.org";
}
function selectedText() {
const area = editor.content;
return {
start: area.selectionStart,
end: area.selectionEnd,
text: area.value.slice(area.selectionStart, area.selectionEnd),
};
}
function replaceSelection(value, selectStart = null, selectEnd = null) {
const area = editor.content;
const { start, end } = selectedText();
area.setRangeText(value, start, end, "end");
area.focus();
if (selectStart !== null && selectEnd !== null) {
area.setSelectionRange(start + selectStart, start + selectEnd);
}
}
function linePrefix(prefix, fallback) {
const area = editor.content;
const { start, end, text } = selectedText();
const value = text || fallback;
const replacement = value.split("\n").map((line) => `${prefix}${line || fallback}`).join("\n");
area.setRangeText(replacement, start, end, "end");
area.focus();
}
function applyMarkdown(action) {
const { text } = selectedText();
const sample = text || "text";
if (action === "bold") replaceSelection(`**${sample}**`, 2, 2 + sample.length);
if (action === "italic") replaceSelection(`*${sample}*`, 1, 1 + sample.length);
if (action === "underline") replaceSelection(`<u>${sample}</u>`, 3, 3 + sample.length);
if (action === "h1") linePrefix("# ", "Heading");
if (action === "h2") linePrefix("## ", "Heading");
if (action === "h3") linePrefix("### ", "Heading");
if (action === "bullet") linePrefix("- ", "List item");
if (action === "numbered") linePrefix("1. ", "List item");
if (action === "quote") linePrefix("> ", "Quote");
if (action === "link") {
const label = sample === "text" ? "link text" : sample;
replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
}
}
async function uploadAttachment() {
const file = attachInput.files[0];
if (!file) return;
saveMessage.textContent = "Uploading attachment.";
const body = new FormData();
body.append("attachment", file);
try {
const res = await fetch("/api/upload", { method: "POST", body });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Upload failed");
replaceSelection(`\n${data.markdown}\n`);
saveMessage.textContent = "Attachment inserted.";
} catch (err) {
saveMessage.textContent = err.message;
} finally {
attachInput.value = "";
}
}
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
refreshPages();
refreshBuild();
@@ -734,6 +958,26 @@ class Handler(BaseHTTPRequestHandler):
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if self.path == "/api/upload":
try:
form = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": self.headers.get("Content-Type", ""),
},
)
field = form["attachment"] if "attachment" in form else None
if field is None or not getattr(field, "filename", ""):
raise ValueError("No attachment was uploaded.")
payload = field.file.read()
if not payload:
raise ValueError("Attachment is empty.")
self.send_json(save_upload(field.filename, payload))
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if self.path != "/api/page":
self.send_error(HTTPStatus.NOT_FOUND)
return