This commit is contained in:
@@ -15,4 +15,6 @@ The editor writes normal `.org` pages with the metadata used by the publishing p
|
|||||||
- new pages can use an explicit path under `blogs/` or `posts/`
|
- 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
|
- saving a page starts `emacs -Q --script build-site.el` and then regenerates the search index
|
||||||
|
|
||||||
|
Lima pages are Markdown files under `lima/`. Selecting the Lima page type shows a Markdown toolbar for common formatting and an attachment button. Uploaded images and videos are stored under `assets/images/hzone/YYYY/MM/` and inserted into the Markdown page.
|
||||||
|
|
||||||
Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results.
|
Saves enqueue builds. The editor remains usable while publishing runs, and the queue section shows the current build, pending builds, and recent build results.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -4,12 +4,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import cgi
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
@@ -23,6 +25,8 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
BLOGS_DIR = ROOT / "blogs"
|
BLOGS_DIR = ROOT / "blogs"
|
||||||
POSTS_DIR = ROOT / "posts"
|
POSTS_DIR = ROOT / "posts"
|
||||||
|
LIMA_DIR = ROOT / "lima"
|
||||||
|
HZONE_ASSETS_DIR = ROOT / "assets" / "images" / "hzone"
|
||||||
GENERATED_ORG_NAMES = {
|
GENERATED_ORG_NAMES = {
|
||||||
"blogs-list.org",
|
"blogs-list.org",
|
||||||
"posts-list.org",
|
"posts-list.org",
|
||||||
@@ -31,6 +35,18 @@ GENERATED_ORG_NAMES = {
|
|||||||
"recently-updated.org",
|
"recently-updated.org",
|
||||||
"wip.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 = [
|
MONTH_NAMES = [
|
||||||
"january",
|
"january",
|
||||||
"february",
|
"february",
|
||||||
@@ -112,6 +128,21 @@ class OrgPage:
|
|||||||
wip: str | None = None
|
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
|
@dataclass
|
||||||
class BuildJob:
|
class BuildJob:
|
||||||
id: int
|
id: int
|
||||||
@@ -216,25 +247,55 @@ def safe_relative_path(path: str) -> Path:
|
|||||||
full = (ROOT / rel).resolve()
|
full = (ROOT / rel).resolve()
|
||||||
if not full.is_relative_to(ROOT):
|
if not full.is_relative_to(ROOT):
|
||||||
raise ValueError("Path must stay inside this repository.")
|
raise ValueError("Path must stay inside this repository.")
|
||||||
if full.suffix != ".org":
|
if full.suffix == ".org" and (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
|
||||||
raise ValueError("Only .org files can be edited.")
|
return full
|
||||||
if not (full.is_relative_to(BLOGS_DIR) or full.is_relative_to(POSTS_DIR)):
|
if full.suffix == ".md" and full.is_relative_to(LIMA_DIR):
|
||||||
raise ValueError("Only blogs/ and posts/ files can be edited here.")
|
return full
|
||||||
|
raise ValueError("Only .org files under blogs/posts and .md files under lima can be edited here.")
|
||||||
return full
|
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()
|
candidate = path.strip()
|
||||||
if not candidate:
|
if not candidate:
|
||||||
raise ValueError("Path is required.")
|
raise ValueError("Path is required.")
|
||||||
|
default_ext = ".md" if page_type == "lima" else ".org"
|
||||||
if candidate.endswith("/"):
|
if candidate.endswith("/"):
|
||||||
candidate = f"{candidate}{slug}.org"
|
candidate = f"{candidate}{slug}{default_ext}"
|
||||||
elif not Path(candidate).suffix:
|
elif not Path(candidate).suffix:
|
||||||
candidate = f"{candidate}.org"
|
candidate = f"{candidate}{default_ext}"
|
||||||
return safe_relative_path(candidate)
|
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")
|
text = path.read_text(encoding="utf-8")
|
||||||
meta: dict[str, str] = {}
|
meta: dict[str, str] = {}
|
||||||
body_lines: list[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"
|
page_type = "blog" if path.is_relative_to(BLOGS_DIR) else "post"
|
||||||
slug = meta.get("SLUG") or path.stem
|
slug = meta.get("SLUG") or path.stem
|
||||||
tags = normalise_tags(meta.get("FILETAGS", ""))
|
tags = normalise_tags(meta.get("FILETAGS", ""))
|
||||||
return OrgPage(
|
return ContentPage(
|
||||||
path=rel,
|
path=rel,
|
||||||
page_type=page_type,
|
page_type=page_type,
|
||||||
title=meta.get("TITLE", path.stem),
|
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))),
|
date=meta.get("DATE", org_date(datetime.fromtimestamp(path.stat().st_mtime))),
|
||||||
comments=meta.get("COMMENTS", "t").lower() == "t",
|
comments=meta.get("COMMENTS", "t").lower() == "t",
|
||||||
options=meta.get("OPTIONS", "num:nil"),
|
options=meta.get("OPTIONS", "num:nil"),
|
||||||
|
format="org",
|
||||||
wip=meta.get("WIP"),
|
wip=meta.get("WIP"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def page_to_dict(page: OrgPage) -> dict[str, Any]:
|
def page_to_dict(page: ContentPage) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"path": page.path,
|
"path": page.path,
|
||||||
"pageType": page.page_type,
|
"pageType": page.page_type,
|
||||||
@@ -275,17 +337,22 @@ def page_to_dict(page: OrgPage) -> dict[str, Any]:
|
|||||||
"date": page.date,
|
"date": page.date,
|
||||||
"comments": page.comments,
|
"comments": page.comments,
|
||||||
"options": page.options,
|
"options": page.options,
|
||||||
|
"format": page.format,
|
||||||
"wip": page.wip or "",
|
"wip": page.wip or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_pages() -> list[dict[str, Any]]:
|
def list_pages() -> list[dict[str, Any]]:
|
||||||
pages = []
|
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():
|
if not base.exists():
|
||||||
continue
|
continue
|
||||||
for path in sorted(base.rglob("*.org")):
|
for path in sorted(base.rglob(extension)):
|
||||||
if path.name in GENERATED_ORG_NAMES or "sync-conflict" in path.name:
|
if path.name in GENERATED_CONTENT_NAMES or "sync-conflict" in path.name:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
page = read_page(path)
|
page = read_page(path)
|
||||||
@@ -300,6 +367,7 @@ def list_pages() -> list[dict[str, Any]]:
|
|||||||
"slug": page.slug,
|
"slug": page.slug,
|
||||||
"tags": page.tags,
|
"tags": page.tags,
|
||||||
"date": page.date,
|
"date": page.date,
|
||||||
|
"format": page.format,
|
||||||
"timestamp": parsed.timestamp() if parsed else path.stat().st_mtime,
|
"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)
|
return safe_relative_path(existing_path)
|
||||||
title = str(data.get("title") or "").strip()
|
title = str(data.get("title") or "").strip()
|
||||||
slug = slugify(str(data.get("slug") or title))
|
slug = slugify(str(data.get("slug") or title))
|
||||||
|
page_type = str(data.get("pageType") or "blog")
|
||||||
explicit_path = str(data.get("targetPath") or "").strip()
|
explicit_path = str(data.get("targetPath") or "").strip()
|
||||||
if explicit_path:
|
if explicit_path:
|
||||||
return safe_target_path(explicit_path, slug)
|
return safe_target_path(explicit_path, slug, page_type)
|
||||||
page_type = str(data.get("pageType") or "blog")
|
|
||||||
if page_type == "blog":
|
if page_type == "blog":
|
||||||
dt = parse_org_datetime(str(data.get("date") or "")) or datetime.now()
|
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]}"
|
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 ""
|
section = slugify(raw_section) if raw_section else ""
|
||||||
folder = POSTS_DIR / section if section else POSTS_DIR
|
folder = POSTS_DIR / section if section else POSTS_DIR
|
||||||
return folder / f"{slug}.org"
|
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()
|
title = str(data.get("title") or "").strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise ValueError("Title is required.")
|
raise ValueError("Title is required.")
|
||||||
@@ -368,10 +448,39 @@ def save_page(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
target.parent.mkdir(parents=True)
|
target.parent.mkdir(parents=True)
|
||||||
if target.exists() and not existing_path:
|
if target.exists() and not existing_path:
|
||||||
raise ValueError(f"{target.relative_to(ROOT)} already exists.")
|
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))
|
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""
|
||||||
|
return {"url": url, "path": rel, "markdown": markdown, "filename": target.name}
|
||||||
|
|
||||||
|
|
||||||
def run_build_commands() -> tuple[bool, str, str]:
|
def run_build_commands() -> tuple[bool, str, str]:
|
||||||
venv_python = ROOT / ".venv" / "bin" / "python"
|
venv_python = ROOT / ".venv" / "bin" / "python"
|
||||||
venv_pip = ROOT / ".venv" / "bin" / "pip"
|
venv_pip = ROOT / ".venv" / "bin" / "pip"
|
||||||
@@ -421,6 +530,7 @@ APP_HTML = r"""<!doctype html>
|
|||||||
button, input, textarea, select { font: inherit; }
|
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 { 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.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; }
|
.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; }
|
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; }
|
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; }
|
.page-item span { display: block; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||||
form { display: grid; gap: 14px; }
|
form { display: grid; gap: 14px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
.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); }
|
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; }
|
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; }
|
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="">Blogs and posts</option>
|
||||||
<option value="blog">Blogs</option>
|
<option value="blog">Blogs</option>
|
||||||
<option value="post">Posts</option>
|
<option value="post">Posts</option>
|
||||||
|
<option value="lima">Lima</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="pages" class="page-list"></div>
|
<div id="pages" class="page-list"></div>
|
||||||
@@ -485,6 +598,7 @@ APP_HTML = r"""<!doctype html>
|
|||||||
<select name="pageType">
|
<select name="pageType">
|
||||||
<option value="blog">Blog</option>
|
<option value="blog">Blog</option>
|
||||||
<option value="post">Post</option>
|
<option value="post">Post</option>
|
||||||
|
<option value="lima">Lima</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Post section
|
<label>Post section
|
||||||
@@ -506,6 +620,20 @@ APP_HTML = r"""<!doctype html>
|
|||||||
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
|
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<label>Content
|
||||||
<textarea name="content" spellcheck="true"></textarea>
|
<textarea name="content" spellcheck="true"></textarea>
|
||||||
</label>
|
</label>
|
||||||
@@ -526,6 +654,8 @@ APP_HTML = r"""<!doctype html>
|
|||||||
const state = { pages: [], currentPath: "", build: null, pathManual: false };
|
const state = { pages: [], currentPath: "", build: null, pathManual: false };
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
const editor = $("#editor");
|
const editor = $("#editor");
|
||||||
|
const mdToolbar = $("#mdToolbar");
|
||||||
|
const attachInput = $("#attachInput");
|
||||||
const statusBox = $("#status");
|
const statusBox = $("#status");
|
||||||
const pagesBox = $("#pages");
|
const pagesBox = $("#pages");
|
||||||
const buildLog = $("#buildLog");
|
const buildLog = $("#buildLog");
|
||||||
@@ -616,6 +746,7 @@ APP_HTML = r"""<!doctype html>
|
|||||||
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
$("#formTitle").textContent = state.currentPath ? "Edit page" : "New page";
|
||||||
$("#pathLabel").textContent = state.currentPath;
|
$("#pathLabel").textContent = state.currentPath;
|
||||||
editor.targetPath.disabled = Boolean(state.currentPath);
|
editor.targetPath.disabled = Boolean(state.currentPath);
|
||||||
|
updateEditorMode();
|
||||||
updateSuggestedPath();
|
updateSuggestedPath();
|
||||||
saveMessage.textContent = "";
|
saveMessage.textContent = "";
|
||||||
}
|
}
|
||||||
@@ -643,10 +774,22 @@ APP_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
editor.slug.addEventListener("input", updateSuggestedPath);
|
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.section.addEventListener("input", updateSuggestedPath);
|
||||||
editor.date.addEventListener("input", updateSuggestedPath);
|
editor.date.addEventListener("input", updateSuggestedPath);
|
||||||
editor.targetPath.addEventListener("input", () => { state.pathManual = true; });
|
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);
|
$("#search").addEventListener("input", renderPages);
|
||||||
$("#typeFilter").addEventListener("change", renderPages);
|
$("#typeFilter").addEventListener("change", renderPages);
|
||||||
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
|
||||||
@@ -670,6 +813,10 @@ APP_HTML = r"""<!doctype html>
|
|||||||
function updateSuggestedPath() {
|
function updateSuggestedPath() {
|
||||||
if (state.currentPath || state.pathManual) return;
|
if (state.currentPath || state.pathManual) return;
|
||||||
const slug = slugify(editor.slug.value || editor.title.value);
|
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") {
|
if (editor.pageType.value === "post") {
|
||||||
const section = slugify(editor.section.value || "");
|
const section = slugify(editor.section.value || "");
|
||||||
editor.targetPath.value = section === "untitled" ? `posts/${slug}.org` : `posts/${section}/${slug}.org`;
|
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`;
|
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 });
|
setForm({ pageType: "blog", date: currentOrgDate(), comments: true });
|
||||||
refreshPages();
|
refreshPages();
|
||||||
refreshBuild();
|
refreshBuild();
|
||||||
@@ -734,6 +958,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
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":
|
if self.path != "/api/page":
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,6 +7,4 @@
|
|||||||
|
|
||||||
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 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]]
|
[[../../../assets/images/reviews/timesheets/2026-05-03-timesheet.png]]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#+TITLE: Soaking in the sun
|
#+TITLE: Soaking in the sun
|
||||||
#+OPTIONS: num:nil
|
#+OPTIONS: num:nil
|
||||||
#+DATE: <2026-05-01 Fri 12:27>
|
#+DATE: <2026-05-01 Fri 12:27>
|
||||||
#+filetags: :life:
|
#+filetags: :life:
|
||||||
#+COMMENTS: t
|
#+COMMENTS: t
|
||||||
#+SLUG: sun-soak-01-05
|
#+SLUG: sun-soak-01-05
|
||||||
|
|
||||||
I really underestimated the benefit of just sitting in the sun for a few minutes. Just did it a few minutes ago, and I feel so much better.
|
I really underestimated the benefit of just sitting in the sun for a few minutes. Just did it a few minutes ago, and I feel so much better.
|
||||||
|
|
||||||
[[../../../assets/images/blogs/2026-05-01-sun.jpg]]
|
[[../../../assets/images/blogs/2026-05-01-sun.jpg]]
|
||||||
|
|||||||
@@ -414,6 +414,15 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
|||||||
(make-directory target t)
|
(make-directory target t)
|
||||||
(copy-directory d target t t t)))))
|
(copy-directory d target t t t)))))
|
||||||
|
|
||||||
|
(defun z/markdown-h1-title (filename fallback)
|
||||||
|
"Return the first Markdown H1 title in FILENAME, or FALLBACK."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents filename nil 0 4096)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(if (re-search-forward "^# +\\(.+?\\)\\s-*$" nil t)
|
||||||
|
(match-string 1)
|
||||||
|
fallback)))
|
||||||
|
|
||||||
(defun z/publish-lima-file (plist filename pub-dir)
|
(defun z/publish-lima-file (plist filename pub-dir)
|
||||||
"Publish an Org or Markdown file from the lima directory."
|
"Publish an Org or Markdown file from the lima directory."
|
||||||
(let* ((ext (downcase (or (file-name-extension filename) "")))
|
(let* ((ext (downcase (or (file-name-extension filename) "")))
|
||||||
@@ -440,7 +449,7 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
|||||||
(insert-file-contents temp-org)
|
(insert-file-contents temp-org)
|
||||||
(goto-char (point-min))
|
(goto-char (point-min))
|
||||||
(insert (format "#+TITLE: %s\n#+OPTIONS: num:nil\n#+DATE: %s\n#+COMMENTS: t\n#+SLUG: %s\n\n"
|
(insert (format "#+TITLE: %s\n#+OPTIONS: num:nil\n#+DATE: %s\n#+COMMENTS: t\n#+SLUG: %s\n\n"
|
||||||
base
|
(z/markdown-h1-title filename base)
|
||||||
(format-time-string "<%Y-%m-%d %a %H:%M>")
|
(format-time-string "<%Y-%m-%d %a %H:%M>")
|
||||||
base))
|
base))
|
||||||
(write-region (point-min) (point-max) temp-org))
|
(write-region (point-min) (point-max) temp-org))
|
||||||
@@ -491,7 +500,7 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
|||||||
|
|
||||||
("org-assets"
|
("org-assets"
|
||||||
:base-directory ,(site-path "assets/")
|
:base-directory ,(site-path "assets/")
|
||||||
:base-extension "css\\|js\\|png\\|jpg\\|gif\\|svg\\|pdf\\|woff\\|woff2\\|ttf"
|
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|woff\\|woff2\\|ttf"
|
||||||
:publishing-directory ,(site-path "output/assets/")
|
:publishing-directory ,(site-path "output/assets/")
|
||||||
:recursive t
|
:recursive t
|
||||||
:publishing-function org-publish-attachment)
|
:publishing-function org-publish-attachment)
|
||||||
|
|||||||
@@ -9,4 +9,9 @@ I made quite a few changes. It all started when I realised that not everyone kno
|
|||||||
- One other thing is the [server dashboard](https://zone.zainezq.com/), the purpose of this is so that whenever any changes are made here, you can just click the **Update Website** button which rebuilds the website (cool isn’t it. Had to meddle with makefiles and threads).
|
- One other thing is the [server dashboard](https://zone.zainezq.com/), the purpose of this is so that whenever any changes are made here, you can just click the **Update Website** button which rebuilds the website (cool isn’t it. Had to meddle with makefiles and threads).
|
||||||
- I probably didn’t explain it as well, but you can create folders and files under the `lima-website` directory. The files must have a `.md` extension at the end (markdown).
|
- I probably didn’t explain it as well, but you can create folders and files under the `lima-website` directory. The files must have a `.md` extension at the end (markdown).
|
||||||
|
|
||||||
- One other thing, a more personal one: what do you think of all this? am i doing too much? am i doing too little? am i overengineering things? am i forcing you to do something you dont wanna do? these little trinkets work for me, but i’m not sure if it would work for someone else, so at any point if you have reservations, let me know okay?
|
- One other thing, a more personal one: what do you think of all this? am i doing too much? am i doing too little? am i overengineering things? am i forcing you to do something you dont wanna do? these little trinkets work for me, but i’m not sure if it would work for someone else, so at any point if you have reservations, let me know okay?
|
||||||
|
|
||||||
|
# **This is a test**
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||||
|
|
||||||
* Posts:
|
* Posts:
|
||||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 12:30</span>@@
|
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">07-05-2026 13:46</span>@@
|
||||||
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:<span class="post-date">14-04-2026 16:36</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/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>@@
|
- [[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>@@
|
||||||
|
|||||||
@@ -59,10 +59,10 @@
|
|||||||
- [[file:tags/notes.org][Tag: notes]]
|
- [[file:tags/notes.org][Tag: notes]]
|
||||||
- [[file:tags/review.org][Tag: review]]
|
- [[file:tags/review.org][Tag: review]]
|
||||||
- [[file:tags/website.org][Tag: website]]
|
- [[file:tags/website.org][Tag: website]]
|
||||||
- [[file:tags/life.org][Tag: life]]
|
|
||||||
- [[file:tags/update.org][Tag: update]]
|
- [[file:tags/update.org][Tag: update]]
|
||||||
|
- [[file:tags/life.org][Tag: life]]
|
||||||
- [[file:tags/insights.org][Tag: insights]]
|
- [[file:tags/insights.org][Tag: insights]]
|
||||||
|
- [[file:tags/emacs.org][Tag: emacs]]
|
||||||
- [[file:tags/education.org][Tag: education]]
|
- [[file:tags/education.org][Tag: education]]
|
||||||
- [[file:tags/reading.org][Tag: reading]]
|
- [[file:tags/reading.org][Tag: reading]]
|
||||||
- [[file:tags/emacs.org][Tag: emacs]]
|
|
||||||
- [[file:tags/maths.org][Tag: maths]]
|
- [[file:tags/maths.org][Tag: maths]]
|
||||||
Reference in New Issue
Block a user