This commit is contained in:
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(/<u>(.+?)<\/u>/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;
|
||||
|
||||
Reference in New Issue
Block a user