authoring service changes
All checks were successful
Build Org Website / build (push) Successful in 46s
All checks were successful
Build Org Website / build (push) Successful in 46s
This commit is contained in:
@@ -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"" if is_markdown else f"[[{relative_url}]]"
|
||||
insert_text = f"" 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
||||
|
||||
Reference in New Issue
Block a user