authoring service changes
All checks were successful
Build Org Website / build (push) Successful in 46s

This commit is contained in:
2026-05-09 16:25:31 +01:00
parent ca82878edf
commit 63f260866e
11 changed files with 160 additions and 196 deletions

View File

@@ -12,14 +12,15 @@ import subprocess
import sys
import threading
import time
import cgi
from dataclasses import dataclass, field
from datetime import datetime
from email.parser import BytesParser
from email.policy import default as email_default_policy
from email.utils import formatdate
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from typing import Any, Callable
from urllib.parse import parse_qs, urlparse
@@ -272,12 +273,16 @@ class BuildQueue:
job.started_at = time.time()
job.message = "Publishing site and search index."
self._current = job
ok, message, log = run_build_commands()
def append_log(chunk: str) -> None:
with self._lock:
job.log = (job.log + chunk)[-200000:]
ok, message, log = run_build_commands(append_log)
with self._lock:
job.finished_at = time.time()
job.ok = ok
job.message = message
job.log = log
job.log = log[-200000:]
self._recent.append(job)
self._recent = self._recent[-20:]
self._current = None
@@ -643,7 +648,7 @@ def save_upload(filename: str, payload: bytes, page_path: str = "") -> dict[str,
absolute_url = f"https://zainezq.com/{rel}"
relative_url = relative_asset_path(page_path, rel)
is_markdown = page_path.endswith(".md")
insert_text = f"![{target.name}]({relative_url})" if is_markdown else f"[[{relative_url}]]"
insert_text = f"![{target.name}]({absolute_url})" if is_markdown else f"[[{relative_url}]]"
return {
"url": absolute_url,
"relativeUrl": relative_url,
@@ -654,7 +659,35 @@ def save_upload(filename: str, payload: bytes, page_path: str = "") -> dict[str,
}
def run_build_commands() -> tuple[bool, str, str]:
def parse_upload_form(content_type: str, body: bytes) -> tuple[str, bytes, str]:
if not content_type.lower().startswith("multipart/form-data"):
raise ValueError("Uploads must use multipart/form-data.")
message = BytesParser(policy=email_default_policy).parsebytes(
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("utf-8") + body
)
if not message.is_multipart():
raise ValueError("Upload form is not multipart.")
filename = ""
payload = b""
page_path = ""
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition")
if name == "attachment":
filename = part.get_filename() or ""
payload = part.get_payload(decode=True) or b""
elif name == "pagePath":
raw_value = part.get_payload(decode=True) or b""
page_path = raw_value.decode(part.get_content_charset() or "utf-8", errors="replace")
if not filename:
raise ValueError("No attachment was uploaded.")
if not payload:
raise ValueError("Attachment is empty.")
return filename, payload, page_path
def run_build_commands(log_callback: Callable[[str], None] | None = None) -> tuple[bool, str, str]:
venv_python = ROOT / ".venv" / "bin" / "python"
venv_pip = ROOT / ".venv" / "bin" / "pip"
commands = [["emacs", "-Q", "--script", "build-site.el"]]
@@ -667,20 +700,33 @@ def run_build_commands() -> tuple[bool, str, str]:
)
commands.append([str(venv_python), "search-index-json.py"])
combined = []
def append_log(text: str) -> None:
combined.append(text)
if log_callback:
log_callback(text)
ok = True
for command in commands:
combined.append(f"$ {' '.join(command)}\n")
proc = subprocess.run(
append_log(f"$ {' '.join(command)}\n")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
proc = subprocess.Popen(
command,
cwd=ROOT,
env=env,
text=True,
stdout=subprocess.PIPE,
bufsize=1,
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
)
combined.append(proc.stdout)
if proc.returncode != 0:
assert proc.stdout is not None
for line in proc.stdout:
append_log(line)
return_code = proc.wait()
if return_code != 0:
ok = False
combined.append(f"\nCommand exited with {proc.returncode}.\n")
append_log(f"\nCommand exited with {return_code}.\n")
break
message = "Build complete. The site output and search index were regenerated." if ok else "Build failed. Check the log below."
return ok, message, "".join(combined)
@@ -1058,9 +1104,21 @@ APP_HTML = r"""<!doctype html>
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function previewImageUrl(src) {
const value = String(src || "");
if (value.startsWith("https://author.zainezq.com/assets/")) {
return value.replace("https://author.zainezq.com/assets/", "https://zainezq.com/assets/");
}
const relativeAsset = value.match(/^(?:\.\.\/)+assets\/(.+)$/);
if (relativeAsset) return `https://zainezq.com/assets/${relativeAsset[1]}`;
if (value.startsWith("/assets/")) return `https://zainezq.com${value}`;
if (value.startsWith("assets/")) return `https://zainezq.com/${value}`;
return value;
}
function renderInlineMarkdown(value) {
let out = html(value);
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => `<img src="${previewImageUrl(src)}" alt="${alt}">`);
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
@@ -1173,14 +1231,15 @@ APP_HTML = r"""<!doctype html>
}
async function refreshBuild() {
const wasRunning = Boolean(state.build && state.build.running);
try {
state.build = await api("/api/build");
renderStatus();
if (wasRunning && !state.build.running) await refreshPages();
} catch (err) {
statusBox.className = "status fail";
statusBox.textContent = `Could not refresh build status: ${err.message}`;
}
await refreshPages();
}
editor.title.addEventListener("input", () => {
@@ -1224,6 +1283,7 @@ APP_HTML = r"""<!doctype html>
const saved = await api("/api/page", { method: "POST", body: JSON.stringify(payload) });
setForm(saved);
saveMessage.textContent = "Saved. Build queued.";
await refreshPages();
await refreshBuild();
} catch (err) {
saveMessage.textContent = err.message;
@@ -1509,24 +1569,12 @@ class Handler(BaseHTTPRequestHandler):
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", ""),
},
length = int(self.headers.get("Content-Length", "0"))
filename, payload, page_path = parse_upload_form(
self.headers.get("Content-Type", ""),
self.rfile.read(length),
)
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.")
page_path = ""
if "pagePath" in form:
page_path = str(form["pagePath"].value or "")
self.send_json(save_upload(field.filename, payload, page_path))
self.send_json(save_upload(filename, payload, page_path))
except Exception as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return