@@ -734,6 +757,12 @@ APP_HTML = r"""
const editor = $("#editor");
const mdToolbar = $("#mdToolbar");
const attachInput = $("#attachInput");
+ const attachBtn = $("#attachBtn");
+ const pageLinkPicker = $("#pageLinkPicker");
+ const pageLinkSelect = $("#pageLinkSelect");
+ const contentLayout = $("#contentLayout");
+ const previewWrap = $("#previewWrap");
+ const markdownPreview = $("#markdownPreview");
const statusBox = $("#status");
const pagesBox = $("#pages");
const buildLog = $("#buildLog");
@@ -809,6 +838,79 @@ APP_HTML = r"""
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
}
+ function renderInlineMarkdown(value) {
+ let out = html(value);
+ out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '

');
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1');
+ out = out.replace(/\*\*([^*]+)\*\*/g, '
$1');
+ out = out.replace(/\*([^*]+)\*/g, '
$1');
+ out = out.replace(/<u>(.+?)<\/u>/g, '
$1');
+ return out;
+ }
+
+ function markdownToHtml(markdown) {
+ const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
+ const blocks = [];
+ let paragraph = [];
+ let list = null;
+
+ function flushParagraph() {
+ if (!paragraph.length) return;
+ blocks.push(`
${renderInlineMarkdown(paragraph.join(" "))}
`);
+ paragraph = [];
+ }
+
+ function closeList() {
+ if (!list) return;
+ blocks.push(`<${list.type}>${list.items.map((item) => `
${renderInlineMarkdown(item)}`).join("")}${list.type}>`);
+ list = null;
+ }
+
+ lines.forEach((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ flushParagraph();
+ closeList();
+ return;
+ }
+ const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
+ if (heading) {
+ flushParagraph();
+ closeList();
+ blocks.push(`
${renderInlineMarkdown(heading[2])}`);
+ return;
+ }
+ const bullet = trimmed.match(/^[-*]\s+(.+)$/);
+ const numbered = trimmed.match(/^\d+\.\s+(.+)$/);
+ if (bullet || numbered) {
+ flushParagraph();
+ const type = bullet ? "ul" : "ol";
+ if (!list || list.type !== type) {
+ closeList();
+ list = { type, items: [] };
+ }
+ list.items.push((bullet || numbered)[1]);
+ return;
+ }
+ const quote = trimmed.match(/^>\s?(.+)$/);
+ if (quote) {
+ flushParagraph();
+ closeList();
+ blocks.push(`
${renderInlineMarkdown(quote[1])}
`);
+ return;
+ }
+ closeList();
+ paragraph.push(trimmed);
+ });
+ flushParagraph();
+ closeList();
+ return blocks.join("\n");
+ }
+
+ function updateMarkdownPreview() {
+ markdownPreview.innerHTML = markdownToHtml(editor.content.value);
+ }
+
function setForm(page) {
state.currentPath = page.path || "";
state.pathManual = Boolean(page.path);
@@ -826,6 +928,7 @@ APP_HTML = r"""
editor.targetPath.disabled = Boolean(state.currentPath);
updateEditorMode();
updateSuggestedPath();
+ updateMarkdownPreview();
saveMessage.textContent = "";
}
@@ -866,8 +969,12 @@ APP_HTML = r"""
mdToolbar.querySelectorAll("[data-md]").forEach((button) => {
button.addEventListener("click", () => applyMarkdown(button.dataset.md));
});
- $("#attachBtn").addEventListener("click", () => attachInput.click());
+ attachBtn.addEventListener("click", () => attachInput.click());
+ $("#pageLinkBtn").addEventListener("click", showPageLinkPicker);
+ $("#insertPageLinkBtn").addEventListener("click", insertSelectedPageLink);
+ $("#cancelPageLinkBtn").addEventListener("click", () => { pageLinkPicker.hidden = true; });
attachInput.addEventListener("change", uploadAttachment);
+ editor.content.addEventListener("input", updateMarkdownPreview);
$("#search").addEventListener("input", renderPages);
$("#typeFilter").addEventListener("change", renderPages);
$("#newBtn").addEventListener("click", () => setForm({ pageType: "blog", date: currentOrgDate(), comments: true }));
@@ -909,14 +1016,22 @@ APP_HTML = r"""
function updateEditorMode() {
const isLima = editor.pageType.value === "lima";
- mdToolbar.hidden = !isLima;
+ const isOrg = !isLima;
+ mdToolbar.querySelectorAll("[data-md='link']").forEach((button) => {
+ button.textContent = isLima ? "Link" : "Org link";
+ });
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";
+ attachBtn.hidden = isOrg;
+ previewWrap.hidden = !isLima;
+ contentLayout.classList.toggle("previewing", isLima);
+ pageLinkPicker.hidden = true;
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";
+ updateMarkdownPreview();
}
function selectedText() {
@@ -936,35 +1051,142 @@ APP_HTML = r"""
if (selectStart !== null && selectEnd !== null) {
area.setSelectionRange(start + selectStart, start + selectEnd);
}
+ updateMarkdownPreview();
}
- function linePrefix(prefix, fallback) {
+ function toggleLinePrefix(prefix, fallback, ordered = false) {
+ const area = editor.content;
+ let { start, end, text } = selectedText();
+ if (start === end) {
+ start = area.value.lastIndexOf("\n", start - 1) + 1;
+ const lineEnd = area.value.indexOf("\n", end);
+ end = lineEnd === -1 ? area.value.length : lineEnd;
+ text = area.value.slice(start, end);
+ }
+ const value = text || fallback;
+ const lines = value.split("\n");
+ const hasPrefix = lines.every((line) => ordered ? /^\d+\.\s/.test(line) : line.startsWith(prefix));
+ const replacement = lines.map((line, index) => {
+ if (hasPrefix) return ordered ? line.replace(/^\d+\.\s/, "") : line.slice(prefix.length);
+ return ordered ? `${index + 1}. ${line || fallback}` : `${prefix}${line || fallback}`;
+ }).join("\n");
+ area.setRangeText(replacement, start, end, "end");
+ area.setSelectionRange(start, start + replacement.length);
+ area.focus();
+ updateMarkdownPreview();
+ }
+
+ function toggleWrap(prefix, suffix, 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");
+ const before = area.value.slice(start - prefix.length, start);
+ const after = area.value.slice(end, end + suffix.length);
+ if (text && text.startsWith(prefix) && text.endsWith(suffix)) {
+ const inner = text.slice(prefix.length, text.length - suffix.length);
+ area.setRangeText(inner, start, end, "select");
+ area.setSelectionRange(start, start + inner.length);
+ } else if (text && before === prefix && after === suffix) {
+ area.setSelectionRange(start - prefix.length, end + suffix.length);
+ area.setRangeText(text, start - prefix.length, end + suffix.length, "select");
+ area.setSelectionRange(start - prefix.length, start - prefix.length + text.length);
+ } else {
+ const value = text || fallback;
+ area.setRangeText(`${prefix}${value}${suffix}`, start, end, "select");
+ area.setSelectionRange(start + prefix.length, start + prefix.length + value.length);
+ }
area.focus();
+ updateMarkdownPreview();
}
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(`
${sample}`, 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");
+ const isLima = editor.pageType.value === "lima";
+ if (action === "bold") toggleWrap(isLima ? "**" : "*", isLima ? "**" : "*", sample);
+ if (action === "italic") toggleWrap(isLima ? "*" : "/", isLima ? "*" : "/", sample);
+ if (action === "underline") toggleWrap(isLima ? "
" : "_", isLima ? "" : "_", sample);
+ if (action === "h1") toggleLinePrefix(isLima ? "# " : "* ", "Heading");
+ if (action === "h2") toggleLinePrefix(isLima ? "## " : "** ", "Heading");
+ if (action === "h3") toggleLinePrefix(isLima ? "### " : "*** ", "Heading");
+ if (action === "bullet") toggleLinePrefix(isLima ? "- " : "- ", "List item");
+ if (action === "numbered") toggleLinePrefix("", "List item", true);
+ if (action === "quote") {
+ if (isLima) {
+ toggleLinePrefix("> ", "Quote");
+ } else {
+ const body = text || "Quote";
+ if (body.startsWith("#+begin_quote") && body.trimEnd().endsWith("#+end_quote")) {
+ replaceSelection(body.replace(/^#\+begin_quote\s*\n?/i, "").replace(/\n?#\+end_quote\s*$/i, ""));
+ } else {
+ replaceSelection(`#+begin_quote\n${body}\n#+end_quote`, 14, 14 + body.length);
+ }
+ }
+ }
if (action === "link") {
const label = sample === "text" ? "link text" : sample;
- replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
+ if (isLima) {
+ replaceSelection(`[${label}](https://)`, 1, 1 + label.length);
+ } else {
+ replaceSelection(`[[https://][${label}]]`, 11, 11);
+ }
}
}
+ function pageUrl(page) {
+ const path = page.path.replace(/\.(org|md)$/i, ".html");
+ return `https://zainezq.com/${path}`;
+ }
+
+ function currentEditorPath() {
+ return state.currentPath || editor.targetPath.value || "";
+ }
+
+ function dirname(path) {
+ const index = path.lastIndexOf("/");
+ return index === -1 ? "" : path.slice(0, index);
+ }
+
+ function relativePath(fromDir, toPath) {
+ const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : [];
+ const toParts = toPath.split("/").filter(Boolean);
+ while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
+ fromParts.shift();
+ toParts.shift();
+ }
+ return [...fromParts.map(() => ".."), ...toParts].join("/") || toPath;
+ }
+
+ function orgFileLink(page) {
+ const current = currentEditorPath();
+ const target = current ? relativePath(dirname(current), page.path) : page.path;
+ return `[[file:${target}][${page.title}]]`;
+ }
+
+ function markdownPageLink(page, label) {
+ return `[${label || page.title}](${pageUrl(page)})`;
+ }
+
+ function showPageLinkPicker() {
+ pageLinkSelect.innerHTML = "";
+ state.pages.forEach((page) => {
+ const option = document.createElement("option");
+ option.value = page.path;
+ option.textContent = `${page.title} - ${page.path}`;
+ pageLinkSelect.appendChild(option);
+ });
+ pageLinkPicker.hidden = false;
+ pageLinkSelect.focus();
+ }
+
+ function insertSelectedPageLink() {
+ const page = state.pages.find((item) => item.path === pageLinkSelect.value);
+ if (!page) return;
+ const { text } = selectedText();
+ const isLima = editor.pageType.value === "lima";
+ replaceSelection(isLima ? markdownPageLink(page, text || page.title) : orgFileLink(page));
+ pageLinkPicker.hidden = true;
+ }
+
async function uploadAttachment() {
const file = attachInput.files[0];
if (!file) return;
diff --git a/gitea-build-monitor.ps1 b/gitea-build-monitor.ps1
index db16c0b..59f93f0 100755
--- a/gitea-build-monitor.ps1
+++ b/gitea-build-monitor.ps1
@@ -58,6 +58,10 @@ $Config = @{
MaxDiscordEmbeds = 10
RetryCount = 3
RetryDelaySeconds = 2
+ AuthoringTestsEnabled = $true
+ AuthoringTestsPattern = 'test_authoring_server.py'
+ AuthoringTestsDirectory = Join-Path $PSScriptRoot 'tests'
+ AuthoringTestTimeoutSec = 120
# Tune these patterns to your build tooling.
ErrorPatterns = @(
@@ -613,6 +617,148 @@ function Send-DiscordNotification {
}
}
+function Get-PythonCommand {
+ $linuxVenvPython = Join-Path $PSScriptRoot '.venv/bin/python'
+ $windowsVenvPython = Join-Path $PSScriptRoot '.venv/Scripts/python.exe'
+
+ if (Test-Path -Path $linuxVenvPython) {
+ return $linuxVenvPython
+ }
+ if (Test-Path -Path $windowsVenvPython) {
+ return $windowsVenvPython
+ }
+
+ return 'python3'
+}
+
+function Invoke-AuthoringServerTests {
+ if (-not $Config.AuthoringTestsEnabled) {
+ return $null
+ }
+
+ if (-not (Test-Path -Path $Config.AuthoringTestsDirectory)) {
+ throw "Authoring test directory does not exist: $($Config.AuthoringTestsDirectory)"
+ }
+
+ $python = Get-PythonCommand
+ $arguments = @(
+ '-m',
+ 'unittest',
+ 'discover',
+ '-s',
+ $Config.AuthoringTestsDirectory,
+ '-p',
+ $Config.AuthoringTestsPattern,
+ '-v'
+ )
+
+ Write-Log -Message "Running authoring server tests with $python."
+
+ $process = New-Object System.Diagnostics.Process
+ $process.StartInfo.FileName = $python
+ foreach ($argument in $arguments) {
+ $process.StartInfo.ArgumentList.Add([string]$argument)
+ }
+ $process.StartInfo.WorkingDirectory = $PSScriptRoot
+ $process.StartInfo.UseShellExecute = $false
+ $process.StartInfo.RedirectStandardOutput = $true
+ $process.StartInfo.RedirectStandardError = $true
+
+ [void]$process.Start()
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
+ $completed = $process.WaitForExit([int]$Config.AuthoringTestTimeoutSec * 1000)
+ if (-not $completed) {
+ $process.Kill()
+ $process.WaitForExit()
+ }
+ else {
+ $process.WaitForExit()
+ }
+
+ $stdout = $stdoutTask.Result
+ $stderr = $stderrTask.Result
+ $output = (($stdout, $stderr) -join "`n").Trim()
+ $exitCode = if ($completed) { $process.ExitCode } else { 124 }
+ $ran = 0
+ $failures = 0
+ $errors = 0
+ $skipped = 0
+
+ if ($output -match 'Ran\s+(\d+)\s+tests?') {
+ $ran = [int]$Matches[1]
+ }
+ if ($output -match 'failures=(\d+)') {
+ $failures = [int]$Matches[1]
+ }
+ if ($output -match 'errors=(\d+)') {
+ $errors = [int]$Matches[1]
+ }
+ if ($output -match 'skipped=(\d+)') {
+ $skipped = [int]$Matches[1]
+ }
+
+ $status = if ($completed -and $exitCode -eq 0) { 'passed' } elseif (-not $completed) { 'timed out' } else { 'failed' }
+ $tailLines = @($output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 12)
+ $summary = if ($tailLines.Count -gt 0) { $tailLines -join "`n" } else { 'No test output captured.' }
+ if ($summary.Length -gt 950) {
+ $summary = $summary.Substring(0, 947) + '...'
+ }
+
+ $result = [pscustomobject]@{
+ Name = 'authoring_server.py tests'
+ Status = $status
+ Success = ($completed -and $exitCode -eq 0)
+ ExitCode = $exitCode
+ Ran = $ran
+ Failures = $failures
+ Errors = $errors
+ Skipped = $skipped
+ Summary = $summary
+ Completed = $completed
+ Timestamp = (Get-Date).ToUniversalTime().ToString('o')
+ }
+
+ Write-Log -Message ("Authoring server tests {0}: ran={1}, failures={2}, errors={3}, skipped={4}, exit={5}" -f $result.Status, $result.Ran, $result.Failures, $result.Errors, $result.Skipped, $result.ExitCode)
+ return $result
+}
+
+function Send-AuthoringTestNotification {
+ param([Parameter(Mandatory)][object]$Result)
+
+ $emoji = if ($Result.Success) { '✅' } else { '❌' }
+ $color = if ($Result.Success) { 3066993 } else { 15158332 }
+ $payload = @{
+ username = 'Gitea Build Monitor'
+ content = "$emoji Authoring server test result: $($Result.Status)."
+ embeds = @(
+ @{
+ title = "$emoji $($Result.Name)"
+ color = $color
+ description = "Unit test results from gitea-build-monitor.ps1"
+ fields = @(
+ @{ name = 'Status'; value = $Result.Status; inline = $true },
+ @{ name = 'Tests'; value = [string]$Result.Ran; inline = $true },
+ @{ name = 'Exit code'; value = [string]$Result.ExitCode; inline = $true },
+ @{ name = 'Failures'; value = [string]$Result.Failures; inline = $true },
+ @{ name = 'Errors'; value = [string]$Result.Errors; inline = $true },
+ @{ name = 'Skipped'; value = [string]$Result.Skipped; inline = $true },
+ @{ name = 'Summary'; value = $Result.Summary; inline = $false }
+ )
+ timestamp = $Result.Timestamp
+ }
+ )
+ }
+
+ Invoke-DiscordWebhook -Payload $payload
+ if ($DryRun) {
+ Write-Log -Message 'Prepared authoring server test notification in dry-run mode.'
+ }
+ else {
+ Write-Log -Message 'Sent authoring server test notification.'
+ }
+}
+
function Test-IsRecentBuild {
param([Parameter(Mandatory)][object]$Job)
@@ -657,6 +803,11 @@ function Start-BuildMonitor {
return
}
+ $authoringTestResult = Invoke-AuthoringServerTests
+ if ($null -ne $authoringTestResult) {
+ Send-AuthoringTestNotification -Result $authoringTestResult
+ }
+
$cache = if ($DryRun) { @{} } else { Read-ProcessedCache }
$analyses = New-Object System.Collections.Generic.List[object]
@@ -705,6 +856,10 @@ function Start-BuildMonitor {
if (-not $DryRun) {
Save-ProcessedCache -Cache $cache
}
+
+ if ($null -ne $authoringTestResult -and -not $authoringTestResult.Success) {
+ throw 'Authoring server tests failed.'
+ }
}
try {
diff --git a/lima/index.md b/lima/index.md
index 9e9a981..d8e997e 100755
--- a/lima/index.md
+++ b/lima/index.md
@@ -1,4 +1,4 @@
-# Weeee
+# Main Index
I made quite a few changes. It all started when I realised that not everyone knows how to use `.org` files and editing with them (they are quite niche). So I decided to make a very unique solution by having markdown files, which through a process of very complicated transformations result into the very page you’re looking at now :)
- You can edit files using this link in [nextcloud](https://nextcloud.zainezq.com/apps/files/files/18016?dir=/lima-website).
diff --git a/posts/posts-list.org b/posts/posts-list.org
index fdba436..74a1bcb 100755
--- a/posts/posts-list.org
+++ b/posts/posts-list.org
@@ -4,7 +4,7 @@
See the categories: @@html:
Categories@@
* Posts:
-- [[file:career/career-list.org][Career List]] @@html:
07-05-2026 14:34@@
+- [[file:career/career-list.org][Career List]] @@html:
07-05-2026 15:23@@
- [[file:posts-list.sync-conflict-20260417-233432-VT6366A.org][Posts List]] @@html:
14-04-2026 16:36@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:
11-03-2026 17:18@@ @@html:
learning@@ @@html:
notes@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:
11-03-2026 16:52@@ @@html:
learning@@ @@html:
notes@@
diff --git a/sitemap.org b/sitemap.org
index 46aa752..b988940 100755
--- a/sitemap.org
+++ b/sitemap.org
@@ -16,8 +16,8 @@
- [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/insights.org][Tag: insights]]
- - [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
+ - [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]]
- home
- [[file:home/countdown.org][Countdown]]
diff --git a/tests/test_authoring_server.py b/tests/test_authoring_server.py
new file mode 100644
index 0000000..6547f6d
--- /dev/null
+++ b/tests/test_authoring_server.py
@@ -0,0 +1,208 @@
+import tempfile
+import unittest
+from datetime import datetime
+from pathlib import Path
+from unittest import mock
+
+import authoring_server as server
+
+
+class AuthoringServerTestCase(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.root = Path(self.tmp.name)
+ self.blogs = self.root / "blogs"
+ self.posts = self.root / "posts"
+ self.lima = self.root / "lima"
+ self.hzone = self.root / "assets" / "images" / "hzone"
+ self.blogs.mkdir()
+ self.posts.mkdir()
+ self.lima.mkdir()
+
+ patches = {
+ "ROOT": self.root,
+ "BLOGS_DIR": self.blogs,
+ "POSTS_DIR": self.posts,
+ "LIMA_DIR": self.lima,
+ "HZONE_ASSETS_DIR": self.hzone,
+ }
+ self.patchers = [mock.patch.object(server, name, value) for name, value in patches.items()]
+ for patcher in self.patchers:
+ patcher.start()
+
+ def tearDown(self):
+ for patcher in reversed(self.patchers):
+ patcher.stop()
+ self.tmp.cleanup()
+
+
+class UtilityTests(AuthoringServerTestCase):
+ def test_slugify_normalises_text_and_keeps_fallback(self):
+ self.assertEqual(server.slugify("Hello, Org Web!"), "hello-org-web")
+ self.assertEqual(server.slugify(" "), "untitled")
+
+ def test_normalise_tags_accepts_strings_and_deduplicates(self):
+ self.assertEqual(
+ server.normalise_tags("Life, review:Life Emacs"),
+ ["life", "review", "emacs"],
+ )
+
+ def test_parse_org_datetime_handles_date_and_optional_time(self):
+ self.assertEqual(
+ server.parse_org_datetime("<2026-05-07 Thu 14:35>"),
+ datetime(2026, 5, 7, 14, 35),
+ )
+ self.assertEqual(
+ server.parse_org_datetime("<2026-05-07 Thu>"),
+ datetime(2026, 5, 7, 12, 0),
+ )
+ self.assertIsNone(server.parse_org_datetime("2026-05-07"))
+
+ def test_safe_relative_path_allows_expected_content_roots(self):
+ self.assertEqual(
+ server.safe_relative_path("blogs/example.org"),
+ self.blogs / "example.org",
+ )
+ self.assertEqual(
+ server.safe_relative_path("posts/career/example.org"),
+ self.posts / "career" / "example.org",
+ )
+ self.assertEqual(
+ server.safe_relative_path("lima/index.md"),
+ self.lima / "index.md",
+ )
+
+ def test_safe_relative_path_rejects_escapes_and_wrong_locations(self):
+ for path in ("../secret.org", "/tmp/secret.org", "sitemap.org", "lima/index.org"):
+ with self.subTest(path=path):
+ with self.assertRaises(ValueError):
+ server.safe_relative_path(path)
+
+ def test_image_dimensions_detects_png_and_gif(self):
+ png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 + (640).to_bytes(4, "big") + (480).to_bytes(4, "big")
+ gif = b"GIF89a" + (320).to_bytes(2, "little") + (200).to_bytes(2, "little")
+ self.assertEqual(server.image_dimensions(png, ".png"), (640, 480))
+ self.assertEqual(server.image_dimensions(gif, ".gif"), (320, 200))
+ self.assertIsNone(server.image_dimensions(b"not an image", ".png"))
+
+
+class PageRenderingTests(AuthoringServerTestCase):
+ def test_render_org_writes_metadata_and_body(self):
+ rendered = server.render_org(
+ {
+ "title": "A New Note",
+ "slug": "Custom Slug",
+ "tags": ["Life", "life", "Review"],
+ "content": "Body text",
+ "date": "<2026-05-07 Thu 10:30>",
+ "comments": False,
+ "wip": "draft",
+ },
+ previous=None,
+ )
+
+ self.assertIn("#+TITLE: A New Note", rendered)
+ self.assertIn("#+DATE: <2026-05-07 Thu 10:30>", rendered)
+ self.assertIn("#+filetags: :life:review:", rendered)
+ self.assertIn("#+COMMENTS: ", rendered)
+ self.assertIn("#+SLUG: custom-slug", rendered)
+ self.assertIn("#+WIP: draft", rendered)
+ self.assertTrue(rendered.endswith("Body text\n"))
+
+ def test_render_markdown_adds_or_replaces_title_heading(self):
+ self.assertEqual(
+ server.render_markdown({"title": "Family Update", "content": "Body"}),
+ "# Family Update\n\nBody\n",
+ )
+ self.assertEqual(
+ server.render_markdown({"title": "New Title", "content": "## Old\n\nBody"}),
+ "# New Title\n\nBody\n",
+ )
+
+ def test_save_page_creates_blog_and_round_trips_content(self):
+ saved = server.save_page(
+ {
+ "pageType": "blog",
+ "title": "Test Post",
+ "slug": "test-post",
+ "date": "<2026-05-07 Thu 09:00>",
+ "tags": "test, blog",
+ "content": "The body",
+ "comments": True,
+ }
+ )
+
+ self.assertEqual(saved["path"], "blogs/2026/05-may/test-post.org")
+ self.assertEqual(saved["title"], "Test Post")
+ self.assertEqual(saved["tags"], ["test", "blog"])
+ self.assertEqual(saved["content"], "The body")
+
+ def test_save_page_creates_lima_markdown(self):
+ saved = server.save_page(
+ {
+ "pageType": "lima",
+ "title": "Lima Entry",
+ "slug": "lima-entry",
+ "content": "Some markdown",
+ }
+ )
+
+ path = self.lima / "lima-entry.md"
+ self.assertEqual(saved["path"], "lima/lima-entry.md")
+ self.assertEqual(path.read_text(encoding="utf-8"), "# Lima Entry\n\nSome markdown\n")
+
+ def test_list_pages_excludes_generated_and_sync_conflict_files(self):
+ (self.blogs / "keep.org").write_text("#+TITLE: Keep\n#+DATE: <2026-05-07 Thu 12:00>\n", encoding="utf-8")
+ (self.posts / "posts-list.org").write_text("#+TITLE: Generated\n", encoding="utf-8")
+ (self.blogs / "note.sync-conflict-1.org").write_text("#+TITLE: Conflict\n", encoding="utf-8")
+ (self.lima / "index.md").write_text("# Lima Home\n", encoding="utf-8")
+
+ paths = [page["path"] for page in server.list_pages()]
+
+ self.assertEqual(set(paths), {"blogs/keep.org", "lima/index.md"})
+
+ def test_relative_asset_path_is_calculated_from_lima_page_directory(self):
+ self.assertEqual(
+ server.relative_asset_path("lima/family/update.md", "assets/images/hzone/pic.png"),
+ "../../assets/images/hzone/pic.png",
+ )
+ self.assertEqual(
+ server.relative_asset_path("blogs/example.org", "assets/images/hzone/pic.png"),
+ "../assets/images/hzone/pic.png",
+ )
+
+ def test_save_upload_rejects_disallowed_extensions(self):
+ with self.assertRaises(ValueError):
+ server.save_upload("shell.php", b"