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

View File

@@ -6,12 +6,13 @@ The site is written using Emacs and Org-mode, and exported using the `org-publis
## Local authoring UI
Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser.
Run `make author` and open `http://127.0.0.1:8765` to create or edit blog and post Org files from a browser. When deployed behind the reverse proxy, use `https://author.zainezq.com`.
The editor writes normal `.org` pages with the metadata used by the publishing pipeline:
- blogs are created under `blogs/YYYY/MM-month/slug.org`
- posts are created under `posts/slug.org`, or `posts/section/slug.org` when a section is provided
- blogs default to `blogs/YYYY/MM-month/slug.org`
- posts default to `posts/slug.org`, or `posts/section/slug.org` when a section is provided
- new pages can use an explicit path under `blogs/` or `posts/`
- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index
While that build is running, the UI disables editing and the API rejects further saves. The status panel unlocks the editor when publishing finishes.
Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results.

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:
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 = ""
@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 = ""
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"running": self.running,
@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,
"log": self.log[-12000:],
}
if include_log:
data["log"] = self.log[-12000:]
return data
class BuildQueue:
def __init__(self) -> None:
self._lock = threading.Lock()
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": 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:
def _run_worker(self) -> None:
while True:
with self._lock:
self.running = False
self.finished_at = time.time()
self.ok = ok
self.message = message
self.log = log
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)

View File

@@ -7,4 +7,6 @@
This week was spent finalising the removals of the deployment flags, as well as starting the ESS work. I picked up a task to create a generic import module script.
This is a test
[[../../../assets/images/reviews/timesheets/2026-05-03-timesheet.png]]

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:18</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:30</span>@@
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</span>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@