authoring server 2
All checks were successful
Build Org Website / build (push) Successful in 45s

This commit is contained in:
2026-05-07 12:30:48 +01:00
parent 72cd3e89ad
commit d3a668f353
4 changed files with 191 additions and 75 deletions

233
authoring_server.py Normal file → Executable file
View File

@@ -10,7 +10,7 @@ import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from email.utils import formatdate
from http import HTTPStatus
@@ -112,49 +112,101 @@ class OrgPage:
wip: str | None = None
class BuildState:
@dataclass
class BuildJob:
id: int
path: str
title: str
queued_at: float = field(default_factory=time.time)
started_at: float | None = None
finished_at: float | None = None
ok: bool | None = None
message: str = "Queued"
log: str = ""
@property
def status(self) -> str:
if self.finished_at is not None:
return "done" if self.ok else "failed"
if self.started_at is not None:
return "running"
return "queued"
def to_dict(self, include_log: bool = False) -> dict[str, Any]:
data = {
"id": self.id,
"path": self.path,
"title": self.title,
"queuedAt": self.queued_at,
"startedAt": self.started_at,
"finishedAt": self.finished_at,
"ok": self.ok,
"status": self.status,
"message": self.message,
}
if include_log:
data["log"] = self.log[-12000:]
return data
class BuildQueue:
def __init__(self) -> None:
self._lock = threading.Lock()
self.running = False
self.started_at: float | None = None
self.finished_at: float | None = None
self.ok: bool | None = None
self.message = "No build has run yet."
self.log = ""
self._next_id = 1
self._pending: list[BuildJob] = []
self._current: BuildJob | None = None
self._recent: list[BuildJob] = []
self._worker: threading.Thread | None = None
def snapshot(self) -> dict[str, Any]:
with self._lock:
current = self._current.to_dict(include_log=True) if self._current else None
recent = [job.to_dict(include_log=True) for job in self._recent[-10:]]
pending = [job.to_dict() for job in self._pending]
latest = current or (recent[-1] if recent else None)
message = latest["message"] if latest else "No builds have run yet."
return {
"running": self.running,
"startedAt": self.started_at,
"finishedAt": self.finished_at,
"ok": self.ok,
"message": self.message,
"log": self.log[-12000:],
"running": current is not None,
"queued": len(pending),
"message": message,
"current": current,
"pending": pending,
"recent": recent,
"log": latest.get("log", "") if latest else "",
}
def start(self) -> bool:
def enqueue(self, path: str, title: str) -> BuildJob:
with self._lock:
if self.running:
return False
self.running = True
self.started_at = time.time()
self.finished_at = None
self.ok = None
self.message = "Build started. The editor is locked until publishing finishes."
self.log = ""
return True
job = BuildJob(self._next_id, path, title)
self._next_id += 1
self._pending.append(job)
if self._worker is None or not self._worker.is_alive():
self._worker = threading.Thread(target=self._run_worker, daemon=True)
self._worker.start()
return job
def finish(self, ok: bool, message: str, log: str) -> None:
with self._lock:
self.running = False
self.finished_at = time.time()
self.ok = ok
self.message = message
self.log = log
def _run_worker(self) -> None:
while True:
with self._lock:
if not self._pending:
self._current = None
return
job = self._pending.pop(0)
job.started_at = time.time()
job.message = "Publishing site and search index."
self._current = job
ok, message, log = run_build_commands()
with self._lock:
job.finished_at = time.time()
job.ok = ok
job.message = message
job.log = log
self._recent.append(job)
self._recent = self._recent[-20:]
self._current = None
BUILD_STATE = BuildState()
BUILD_QUEUE = BuildQueue()
def safe_relative_path(path: str) -> Path:
@@ -171,6 +223,17 @@ def safe_relative_path(path: str) -> Path:
return full
def safe_target_path(path: str, slug: str) -> Path:
candidate = path.strip()
if not candidate:
raise ValueError("Path is required.")
if candidate.endswith("/"):
candidate = f"{candidate}{slug}.org"
elif not Path(candidate).suffix:
candidate = f"{candidate}.org"
return safe_relative_path(candidate)
def read_page(path: Path) -> OrgPage:
text = path.read_text(encoding="utf-8")
meta: dict[str, str] = {}
@@ -248,6 +311,9 @@ 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))
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")
if page_type == "blog":
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
@@ -306,7 +372,7 @@ def save_page(data: dict[str, Any]) -> dict[str, Any]:
return page_to_dict(read_page(target))
def run_build() -> None:
def run_build_commands() -> tuple[bool, str, str]:
venv_python = ROOT / ".venv" / "bin" / "python"
venv_pip = ROOT / ".venv" / "bin" / "pip"
commands = [["emacs", "-Q", "--script", "build-site.el"]]
@@ -335,14 +401,11 @@ def run_build() -> None:
combined.append(f"\nCommand exited with {proc.returncode}.\n")
break
message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
BUILD_STATE.finish(ok, message, "".join(combined))
return ok, message, "".join(combined)
def trigger_build() -> None:
if not BUILD_STATE.start():
return
thread = threading.Thread(target=run_build, daemon=True)
thread.start()
def queue_build(page: dict[str, Any]) -> dict[str, Any]:
return BUILD_QUEUE.enqueue(page["path"], page["title"]).to_dict()
APP_HTML = r"""<!doctype html>
@@ -352,13 +415,12 @@ APP_HTML = r"""<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Org Site Authoring</title>
<style>
:root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; --disabled: #ece7dc; }
:root { color-scheme: light; --bg: #f6f3ed; --panel: #fffdf8; --ink: #222; --muted: #64615b; --line: #d8d1c3; --accent: #18615b; --accent-2: #8f3f2b; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--ink); }
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:disabled, input:disabled, textarea:disabled, select:disabled { cursor: not-allowed; background: var(--disabled); color: var(--muted); }
.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; }
@@ -369,6 +431,7 @@ APP_HTML = r"""<!doctype html>
.status.running { border-color: var(--accent-2); }
.status.ok { border-color: var(--accent); }
.status.fail { border-color: #a12a2a; }
.quick-link { display: inline-block; color: var(--accent); font-size: 13px; margin: -6px 0 14px; }
.filters { display: grid; gap: 8px; margin-bottom: 14px; }
.page-list { display: grid; gap: 7px; }
.page-item { width: 100%; text-align: left; min-height: auto; padding: 9px 10px; background: var(--panel); }
@@ -381,8 +444,13 @@ APP_HTML = r"""<!doctype html>
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.hint { color: var(--muted); font-size: 13px; }
.full { grid-column: 1 / -1; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { background: #e5eee9; color: #134d48; padding: 2px 7px; border-radius: 999px; font-size: 12px; }
.queue-list { display: grid; gap: 8px; margin-bottom: 12px; }
.queue-item { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 9px 10px; font-size: 13px; }
.queue-item strong { display: block; overflow-wrap: anywhere; }
.queue-item span { color: var(--muted); overflow-wrap: anywhere; }
pre { white-space: pre-wrap; overflow: auto; max-height: 280px; background: #201f1d; color: #f7f1e4; padding: 12px; border-radius: 6px; font-size: 12px; }
@media (max-width: 860px) { .app { grid-template-columns: 1fr; } aside, main { max-height: none; } aside { border-right: 0; border-bottom: 1px solid var(--line); } .grid { grid-template-columns: 1fr; } }
</style>
@@ -394,6 +462,7 @@ APP_HTML = r"""<!doctype html>
<h1>Authoring</h1>
<button id="newBtn" type="button">New</button>
</div>
<a class="quick-link" href="https://author.zainezq.com">author.zainezq.com</a>
<div id="status" class="status"></div>
<div class="filters">
<input id="search" type="search" placeholder="Filter pages" />
@@ -433,6 +502,9 @@ APP_HTML = r"""<!doctype html>
<label>Date
<input name="date" placeholder="<2026-05-07 Thu 12:00>" />
</label>
<label class="full">Org file path
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
</label>
</div>
<label>Content
<textarea name="content" spellcheck="true"></textarea>
@@ -444,12 +516,14 @@ APP_HTML = r"""<!doctype html>
<span id="saveMessage" class="hint"></span>
</div>
</form>
<h2>Build log</h2>
<h2>Build queue</h2>
<div id="queue" class="queue-list"></div>
<h2>Latest build log</h2>
<pre id="buildLog"></pre>
</main>
</div>
<script>
const state = { pages: [], currentPath: "", build: null };
const state = { pages: [], currentPath: "", build: null, pathManual: false };
const $ = (selector) => document.querySelector(selector);
const editor = $("#editor");
const statusBox = $("#status");
@@ -475,18 +549,33 @@ APP_HTML = r"""<!doctype html>
return data;
}
function setLocked(locked) {
editor.querySelectorAll("input, textarea, select, button").forEach((el) => el.disabled = locked);
$("#newBtn").disabled = locked;
pagesBox.querySelectorAll("button").forEach((el) => el.disabled = locked);
}
function renderStatus() {
const build = state.build || {};
statusBox.className = "status" + (build.running ? " running" : build.ok === true ? " ok" : build.ok === false ? " fail" : "");
const latest = build.current || (build.recent && build.recent[build.recent.length - 1]);
statusBox.className = "status" + (build.running ? " running" : latest && latest.ok === true ? " ok" : latest && latest.ok === false ? " fail" : "");
statusBox.textContent = build.message || "Checking build status.";
buildLog.textContent = build.log || "";
setLocked(Boolean(build.running));
renderQueue();
}
function renderQueue() {
const box = $("#queue");
const build = state.build || {};
const items = [];
if (build.current) items.push(build.current);
(build.pending || []).forEach((job) => items.push(job));
(build.recent || []).slice().reverse().slice(0, 5).forEach((job) => items.push(job));
box.innerHTML = "";
if (!items.length) {
box.innerHTML = `<div class="queue-item"><span>No queued builds.</span></div>`;
return;
}
items.forEach((job) => {
const row = document.createElement("div");
row.className = "queue-item";
row.innerHTML = `<strong>#${job.id} ${html(job.status)} · ${html(job.title)}</strong><span>${html(job.path)} · ${html(job.message)}</span>`;
box.appendChild(row);
});
}
function renderPages() {
@@ -514,8 +603,10 @@ APP_HTML = r"""<!doctype html>
function setForm(page) {
state.currentPath = page.path || "";
state.pathManual = Boolean(page.path);
editor.pageType.value = page.pageType || "blog";
editor.section.value = "";
editor.targetPath.value = page.path || "";
editor.title.value = page.title || "";
editor.slug.value = page.slug || slugify(page.title || "");
editor.tags.value = (page.tags || []).join(", ");
@@ -524,6 +615,8 @@ APP_HTML = r"""<!doctype html>
editor.comments.checked = page.comments !== false;
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
$("#pathLabel").textContent = state.currentPath;
editor.targetPath.disabled = Boolean(state.currentPath);
updateSuggestedPath();
saveMessage.textContent = "";
}
@@ -540,32 +633,55 @@ APP_HTML = r"""<!doctype html>
async function refreshBuild() {
state.build = await api("/api/build");
renderStatus();
if (!state.build.running) await refreshPages();
await refreshPages();
}
editor.title.addEventListener("input", () => {
if (!state.currentPath) editor.slug.value = slugify(editor.title.value);
if (!state.currentPath) {
editor.slug.value = slugify(editor.title.value);
updateSuggestedPath();
}
});
editor.slug.addEventListener("input", updateSuggestedPath);
editor.pageType.addEventListener("change", updateSuggestedPath);
editor.section.addEventListener("input", updateSuggestedPath);
editor.date.addEventListener("input", updateSuggestedPath);
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
$("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
$("#resetBtn").addEventListener("click", () => state.currentPath ? loadPage(state.currentPath) : setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
editor.addEventListener("submit", async (event) => {
event.preventDefault();
saveMessage.textContent = "Saving and starting build.";
saveMessage.textContent = "Saving and queueing build.";
const payload = Object.fromEntries(new FormData(editor).entries());
payload.path = state.currentPath;
payload.comments = editor.comments.checked;
try {
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
setForm(saved);
saveMessage.textContent = "Build started. Editing is locked until it finishes.";
saveMessage.textContent = "Saved. Build queued.";
await refreshBuild();
} catch (err) {
saveMessage.textContent = err.message;
}
});
function updateSuggestedPath() {
if (state.currentPath || state.pathManual) return;
const slug = slugify(editor.slug.value || editor.title.value);
if (editor.pageType.value === "post") {
const section = slugify(editor.section.value || "");
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
return;
}
const match = editor.date.value.match(/<(\d{4})-(\d{2})-/);
const year = match ? match[1] : String(new Date().getFullYear());
const month = match ? Number(match[2]) : new Date().getMonth() + 1;
const months = ["january","february","march","april","may","june","july","august","september","october","november","december"];
editor.targetPath.value = `blogs/${year}/${String(month).padStart(2, "0")}-${months[month - 1]}/${slug}.org`;
}
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
refreshPages();
refreshBuild();
@@ -613,7 +729,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/build":
self.send_json(BUILD_STATE.snapshot())
self.send_json(BUILD_QUEUE.snapshot())
return
self.send_error(HTTPStatus.NOT_FOUND)
@@ -621,14 +737,11 @@ class Handler(BaseHTTPRequestHandler):
if self.path != "/api/page":
self.send_error(HTTPStatus.NOT_FOUND)
return
if BUILD_STATE.snapshot()["running"]:
self.send_json({"error": "A build is already running. Wait for it to finish before editing."}, HTTPStatus.CONFLICT)
return
try:
length = int(self.headers.get("Content-Length", "0"))
data = json.loads(self.rfile.read(length).decode("utf-8"))
saved = save_page(data)
trigger_build()
saved["queuedBuild"] = queue_build(saved)
self.send_json(saved)
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)