adding tests
Some checks failed
Build Org Website / build (push) Failing after 50s

This commit is contained in:
2026-05-07 15:24:00 +01:00
parent 3c1f76e65c
commit 4f0b042f09
7 changed files with 649 additions and 38 deletions

View File

@@ -411,9 +411,9 @@ def render_markdown(data: dict[str, Any]) -> str:
flags=re.IGNORECASE,
)
if content:
if re.search(r"^#{1,6}\s+.+?\s*$", content, flags=re.MULTILINE):
if re.search(r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$", content, flags=re.MULTILINE):
content = re.sub(
r"^#{1,6}\s+.+?\s*$",
r"^#{1,6}[^\S\r\n]+.+?[^\S\r\n]*$",
f"# {title}",
content,
count=1,
@@ -629,9 +629,20 @@ APP_HTML = r"""<!doctype html>
.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; }
.link-picker { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 8px; align-items: center; border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 8px; }
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; }
textarea { min-height: 48vh; resize: vertical; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; line-height: 1.45; font-size: 14px; }
.content-layout { display: grid; gap: 12px; }
.content-layout.previewing { grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); align-items: start; }
.preview-panel { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 12px 14px; min-height: 48vh; overflow: auto; overflow-wrap: anywhere; }
.preview-panel h1, .preview-panel h2, .preview-panel h3 { color: var(--ink); margin: 0.8em 0 0.35em; }
.preview-panel h1 { font-size: 24px; }
.preview-panel h2 { font-size: 20px; }
.preview-panel h3 { font-size: 17px; }
.preview-panel p, .preview-panel ul, .preview-panel ol, .preview-panel blockquote { margin: 0 0 0.8em; }
.preview-panel blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
.preview-panel img, .preview-panel video { max-width: 100%; height: auto; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.hint { color: var(--muted); font-size: 13px; }
.full { grid-column: 1 / -1; }
@@ -642,7 +653,8 @@ APP_HTML = r"""<!doctype html>
.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; } }
@media (max-width: 980px) { .content-layout.previewing { grid-template-columns: 1fr; } }
@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; } .link-picker { grid-template-columns: 1fr; } }
</style>
</head>
<body>
@@ -698,7 +710,7 @@ APP_HTML = r"""<!doctype html>
<input name="targetPath" placeholder="blogs/2026/05-may/my-page.org" />
</label>
</div>
<div id="mdToolbar" class="toolbar" hidden>
<div id="mdToolbar" class="toolbar">
<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>
@@ -709,12 +721,23 @@ APP_HTML = r"""<!doctype html>
<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="pageLinkBtn">Link to page</button>
<button type="button" id="attachBtn">Insert attachment</button>
<input id="attachInput" type="file" accept="image/*,video/*" />
</div>
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
<div id="pageLinkPicker" class="link-picker" hidden>
<select id="pageLinkSelect"></select>
<button type="button" id="insertPageLinkBtn">Insert link</button>
<button type="button" id="cancelPageLinkBtn">Cancel</button>
</div>
<div id="contentLayout" class="content-layout">
<label>Content
<textarea name="content" spellcheck="true"></textarea>
</label>
<label id="previewWrap" hidden>Preview
<div id="markdownPreview" class="preview-panel"></div>
</label>
</div>
<label><span><input name="comments" type="checkbox" checked style="width:auto" /> Comments enabled</span></label>
<div class="actions">
<button class="primary" id="saveBtn" type="submit">Save and build</button>
@@ -734,6 +757,12 @@ APP_HTML = r"""<!doctype html>
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"""<!doctype html>
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
}
function renderInlineMarkdown(value) {
let out = html(value);
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
out = out.replace(/&lt;u&gt;(.+?)&lt;\/u&gt;/g, '<u>$1</u>');
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(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
}
function closeList() {
if (!list) return;
blocks.push(`<${list.type}>${list.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).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(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
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(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
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"""<!doctype html>
editor.targetPath.disabled = Boolean(state.currentPath);
updateEditorMode();
updateSuggestedPath();
updateMarkdownPreview();
saveMessage.textContent = "";
}
@@ -866,8 +969,12 @@ APP_HTML = r"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
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(`<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");
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 ? "<u>" : "_", isLima ? "</u>" : "_", 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;