(() => { const state = { tree: null, currentPath: null, page: null, virtualFolder: null, expanded: new Set([""]), loadingTree: false, loadingPage: false, saving: false, error: null, editMode: false, editorText: "", baseMarkdown: "", commitMessage: "", }; const FRONT_PAGE_PATH = "README"; const app = document.getElementById("app"); let previewTimer = null; const DEBUG = new URLSearchParams(window.location.search).has("debug") || window.localStorage.getItem("SIDOR_DEBUG") === "1"; function debugLog(...args) { if (DEBUG) { console.debug("[sidor-debug]", ...args); } } function encodeWikiPath(path) { return path .split("/") .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join("/"); } function decodeRoutePathname(pathname) { const prefix = "/wiki/"; if (!pathname.startsWith(prefix)) return null; const raw = pathname.slice(prefix.length); if (!raw) return null; return raw .split("/") .map((segment) => decodeURIComponent(segment)) .join("/"); } function escapeHtml(value) { const text = String(value == null ? "" : value); return text .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function sanitizeHref(href) { const trimmed = (href || "").trim(); if (!trimmed) return "#"; if (/^(javascript|vbscript|data):/i.test(trimmed)) return "#"; return escapeHtml(trimmed); } function renderInlineMarkdown(text) { let html = escapeHtml(text); html = html.replace(/`([^`]+)`/g, "$1"); html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); html = html.replace(/\*([^*]+)\*/g, "$1"); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, href) => { return `${label}`; }); return html; } function renderMarkdown(markdown) { const lines = (markdown || "").replace(/\r\n/g, "\n").split("\n"); const out = []; let paragraph = []; let listItems = []; let inCode = false; let codeLines = []; function flushParagraph() { if (!paragraph.length) return; out.push(`

${renderInlineMarkdown(paragraph.join(" "))}

`); paragraph = []; } function flushList() { if (!listItems.length) return; const listHtml = listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join(""); out.push(``); listItems = []; } function flushCode() { if (!inCode) return; out.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); inCode = false; codeLines = []; } for (const line of lines) { if (line.startsWith("```")) { flushParagraph(); flushList(); if (inCode) { flushCode(); } else { inCode = true; codeLines = []; } continue; } if (inCode) { codeLines.push(line); continue; } const heading = line.match(/^(#{1,6})\s+(.*)$/); if (heading) { flushParagraph(); flushList(); const level = heading[1].length; out.push(`${renderInlineMarkdown(heading[2].trim())}`); continue; } const listItem = line.match(/^\s*[-*]\s+(.*)$/); if (listItem) { flushParagraph(); listItems.push(listItem[1].trim()); continue; } if (!line.trim()) { flushParagraph(); flushList(); continue; } paragraph.push(line.trim()); } flushParagraph(); flushList(); flushCode(); return out.join("\n"); } function slugifyHeading(text) { const normalized = (text || "") .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9\s-]/g, "") .trim() .replace(/\s+/g, "-"); return normalized || "section"; } function pageTitleCandidates(page) { const baseName = (page.path || "") .split("/") .filter(Boolean) .slice(-1)[0]; return new Set( [page.title, page.name, baseName] .filter(Boolean) .map((value) => slugifyHeading(String(value).replace(/\.md$/i, ""))) ); } function buildPageViewModel(page) { const parser = new DOMParser(); const doc = parser.parseFromString(page.html || "", "text/html"); const usedIds = new Map(); const toc = []; const titleCandidates = pageTitleCandidates(page); let firstHeading = true; doc.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => { const title = (heading.textContent || "").trim(); if (!title) return; const level = Number(heading.tagName.slice(1)); const baseId = slugifyHeading(title); const seen = usedIds.get(baseId) || 0; usedIds.set(baseId, seen + 1); const id = seen === 0 ? baseId : `${baseId}-${seen + 1}`; heading.id = id; const normalizedTitle = slugifyHeading(title.replace(/\.md$/i, "")); const isDocumentTitle = firstHeading && titleCandidates.has(normalizedTitle); firstHeading = false; if (!isDocumentTitle) { toc.push({ id, level, title }); } }); return { ...page, html: doc.body.innerHTML, toc, }; } async function parseApiResponse(response, requestInfo) { const contentType = response.headers.get("content-type") || ""; const text = await response.text(); debugLog("API response", { request: requestInfo, status: response.status, contentType, bodyPreview: text.slice(0, 220), }); if (!text) return {}; try { return JSON.parse(text); } catch (_error) { const e = new Error( `API response was not JSON (${response.status}). content-type=${contentType || "unknown"} body=${text.slice(0, 120)}` ); e.status = response.status; e.contentType = contentType; e.bodyPreview = text.slice(0, 600); throw e; } } async function apiRequest(method, url, body) { const requestInfo = { method, url, body }; debugLog("API request", requestInfo); const headers = { "content-type": "application/json" }; if (DEBUG) headers["x-sidor-debug"] = "1"; const response = await fetch(url, { method, headers, body: body == null ? undefined : JSON.stringify(body), }); const parsed = await parseApiResponse(response, requestInfo); if (!response.ok) { const message = parsed.error || `Request failed (${response.status})`; const error = new Error(message); error.status = response.status; error.payload = parsed; throw error; } return parsed; } async function apiGet(url) { const requestInfo = { method: "GET", url }; debugLog("API request", requestInfo); const headers = {}; if (DEBUG) headers["x-sidor-debug"] = "1"; const response = await fetch(url, { headers }); const parsed = await parseApiResponse(response, requestInfo); if (!response.ok) { const error = new Error(parsed.error || `Request failed (${response.status})`); error.status = response.status; error.payload = parsed; throw error; } return parsed; } function hasPagePath(node, path) { if (!node) return false; if (node.type === "page") return node.path === path; for (const child of node.children || []) { if (hasPagePath(child, path)) return true; } return false; } function hasFolderPath(node, path) { if (!node) return false; if (node.type === "folder" && node.path === path) return true; for (const child of node.children || []) { if (hasFolderPath(child, path)) return true; } return false; } function pathBaseName(path) { const segments = (path || "").split("/").filter(Boolean); return segments.length ? segments[segments.length - 1] : ""; } function ensureExpandedForPath(path) { if (!path) return; const segments = path.split("/"); let current = ""; state.expanded.add(""); for (let i = 0; i < segments.length - 1; i += 1) { current = current ? `${current}/${segments[i]}` : segments[i]; state.expanded.add(current); } } async function navigate(path, { pushState = true } = {}) { if (!path) return; if (state.editMode && path !== state.currentPath) { const confirmed = window.confirm("Leave edit mode without saving?"); if (!confirmed) return; state.editMode = false; } state.currentPath = path; state.virtualFolder = null; ensureExpandedForPath(path); if (pushState) { history.pushState({ path }, "", `/wiki/${encodeWikiPath(path)}`); } await loadPage(path); render(); } async function loadTree() { state.loadingTree = true; state.error = null; render(); try { const data = await apiGet("/api/tree"); state.tree = data.tree; if (!state.currentPath) { const routePath = decodeRoutePathname(location.pathname); if (routePath) { await navigate(routePath, { pushState: false }); } else if (hasPagePath(state.tree, FRONT_PAGE_PATH)) { await navigate(FRONT_PAGE_PATH, { pushState: false }); } } } catch (error) { console.error("[sidor] loadTree failed", error); debugLog("loadTree failed", error); state.error = error.message; } finally { state.loadingTree = false; render(); } } async function loadPage(path) { state.loadingPage = true; state.error = null; state.virtualFolder = null; render(); try { const data = await apiGet(`/api/page/${encodeWikiPath(path)}`); state.page = buildPageViewModel(data); state.virtualFolder = null; if (!state.editMode) { state.editorText = ""; state.baseMarkdown = ""; state.commitMessage = ""; } } catch (error) { console.error("[sidor] loadPage failed", { path, error }); debugLog("loadPage failed", { path, error }); if (error.status === 404 && hasFolderPath(state.tree, path)) { state.page = null; state.virtualFolder = { path, name: pathBaseName(path) || path }; state.error = null; } else { state.page = null; state.virtualFolder = null; state.error = error.message; } } finally { state.loadingPage = false; render(); } } function toggleFolder(path) { if (state.expanded.has(path)) { state.expanded.delete(path); } else { state.expanded.add(path); } render(); } function renderNode(node) { if (node.type === "page") { const isActive = node.path === state.currentPath; return `
  • ${escapeHtml(node.name)}
  • `; } const isExpanded = state.expanded.has(node.path); const isActive = node.path === state.currentPath; const children = (node.children || []).map(renderNode).join(""); const label = node.name; return `
  • ${escapeHtml(label)}
  • `; } function renderVirtualFolderBody() { if (!state.virtualFolder) return ""; const path = state.virtualFolder.path; return `

    ${escapeHtml(path)}

    No page exists yet for ${escapeHtml(path)}.md.

    `; } async function createCurrentFolderPage() { if (!state.virtualFolder) return; await createPageAtPath(state.virtualFolder.path); } function renderPageToc() { if (!state.page || !Array.isArray(state.page.toc) || state.page.toc.length === 0) return ""; const itemsHtml = state.page.toc .map((item) => { const level = Math.max(1, Math.min(6, Number(item.level) || 1)); return `
  • ${escapeHtml(item.title)}
  • `; }) .join(""); return ` `; } function scrollToHeading(id) { if (!id) return; const target = document.getElementById(id); if (!target) return; target.scrollIntoView({ behavior: "smooth", block: "start" }); history.replaceState(history.state || {}, "", `${location.pathname}${location.search}#${encodeURIComponent(id)}`); } function resolveInternalLink(currentPath, href) { if (!href || href.startsWith("#") || /^https?:\/\//i.test(href)) return null; const normalizedHref = href.replace(/\.md$/i, ""); if (normalizedHref.startsWith("/")) { return normalizedHref.replace(/^\/+/, ""); } const base = currentPath.split("/"); base.pop(); const result = []; for (const segment of [...base, ...normalizedHref.split("/")]) { if (!segment || segment === ".") continue; if (segment === "..") { result.pop(); } else { result.push(segment); } } return result.length ? result.join("/") : null; } function startEdit() { if (!state.page) return; state.editMode = true; state.editorText = state.page.markdown || ""; state.baseMarkdown = state.page.markdown || ""; state.commitMessage = `wiki: update ${state.currentPath}`; render(); } function cancelEdit() { state.editMode = false; state.editorText = ""; state.baseMarkdown = ""; state.commitMessage = ""; render(); } async function saveEdit() { if (!state.currentPath || !state.editMode) return; if (!state.commitMessage.trim()) { state.error = "Commit message krävs"; render(); return; } state.saving = true; state.error = null; render(); try { const updated = await apiRequest("PUT", `/api/page/${encodeWikiPath(state.currentPath)}`, { markdown: state.editorText, expected_markdown: state.baseMarkdown, commit_message: state.commitMessage, }); state.page = updated; state.virtualFolder = null; state.editMode = false; state.editorText = ""; state.baseMarkdown = ""; state.commitMessage = ""; await loadTree(); } catch (error) { debugLog("saveEdit failed", error); state.error = error.message; } finally { state.saving = false; render(); } } async function createPageAtPath(path) { const normalizedPath = (path || "").trim().replace(/^\/+/, "").replace(/\/+$/, ""); if (!normalizedPath) return; const commitMessage = window.prompt("Commit-meddelande", `wiki: create ${normalizedPath}`); if (!commitMessage) return; state.error = null; render(); try { const created = await apiRequest("POST", "/api/page", { path: normalizedPath, markdown: `# ${normalizedPath.split("/").pop()}`, commit_message: commitMessage, }); await loadTree(); await navigate(created.path, { pushState: true }); startEdit(); } catch (error) { debugLog("createPage failed", error); state.error = error.message; render(); } } async function createPage() { const path = window.prompt("Ny sidpath (t.ex. guides/new-page)"); await createPageAtPath(path); } async function createSubpage() { if (!state.currentPath) return; const slug = window.prompt("Undersida (t.ex. setup eller guides/setup)", "new-page"); if (!slug) return; const childPath = slug.replace(/^\/+/, ""); await createPageAtPath(`${state.currentPath}/${childPath}`); } async function movePage() { if (!state.currentPath) return; const to = window.prompt("Flytta sida till", state.currentPath); if (!to || to === state.currentPath) return; const commitMessage = window.prompt("Commit-meddelande", `wiki: move ${state.currentPath} to ${to}`); if (!commitMessage) return; try { const moved = await apiRequest("POST", "/api/page-move", { from: state.currentPath, to, commit_message: commitMessage, }); await loadTree(); await navigate(moved.path, { pushState: true }); } catch (error) { debugLog("movePage failed", error); state.error = error.message; render(); } } async function deletePage() { if (!state.currentPath) return; const confirmed = window.confirm(`Delete ${state.currentPath}?`); if (!confirmed) return; const commitMessage = window.prompt("Commit-meddelande", `wiki: delete ${state.currentPath}`); if (!commitMessage) return; try { await apiRequest("DELETE", `/api/page/${encodeWikiPath(state.currentPath)}`, { commit_message: commitMessage, }); state.page = null; state.virtualFolder = null; state.currentPath = null; history.pushState({}, "", "/wiki"); await loadTree(); } catch (error) { debugLog("deletePage failed", error); state.error = error.message; render(); } } function renderContentBody() { if (state.editMode) { return `

    Markdown

    Preview

    ${renderMarkdown(state.editorText)}
    `; } if (state.loadingPage) { return "

    Loading page...

    "; } if (state.virtualFolder) { return renderVirtualFolderBody(); } if (!state.page) { return "

    add README.md if you want something on the front page of these pages

    "; } return `
    ${state.page.html}
    ${renderPageToc()}
    `; } function render() { const treeHtml = state.tree ? `` : state.loadingTree ? "

    Loading tree...

    " : "

    No pages found.

    "; app.innerHTML = `
    ${!state.editMode && state.page ? ` ` : ""}
    ${state.error ? `

    ${escapeHtml(state.error)}

    ` : ""}
    ${renderContentBody()}
    `; app.querySelectorAll("a[data-path]").forEach((element) => { element.addEventListener("click", async (event) => { event.preventDefault(); await navigate(event.currentTarget.dataset.path, { pushState: true }); }); }); app.querySelectorAll("a[data-folder-path]").forEach((element) => { element.addEventListener("click", async (event) => { event.preventDefault(); await navigate(event.currentTarget.dataset.folderPath, { pushState: true }); }); }); app.querySelectorAll("button[data-folder-toggle-path]").forEach((element) => { element.addEventListener("click", () => toggleFolder(element.dataset.folderTogglePath)); }); app.querySelectorAll("button[data-action]").forEach((element) => { const action = element.dataset.action; element.addEventListener("click", () => { if (action === "edit") startEdit(); if (action === "cancel-edit") cancelEdit(); if (action === "save") saveEdit(); if (action === "create") createPage(); if (action === "create-sub") createSubpage(); if (action === "create-folder-page") createCurrentFolderPage(); if (action === "move") movePage(); if (action === "delete") deletePage(); }); }); app.querySelectorAll("a.toc-link[href^=\"#\"]").forEach((element) => { element.addEventListener("click", (event) => { event.preventDefault(); const href = event.currentTarget.getAttribute("href") || ""; const id = decodeURIComponent(href.slice(1)); scrollToHeading(id); }); }); const commitInput = document.getElementById("commit-message"); if (commitInput) { commitInput.addEventListener("input", (event) => { state.commitMessage = event.target.value; }); } const editorInput = document.getElementById("editor-input"); if (editorInput) { editorInput.addEventListener("input", (event) => { state.editorText = event.target.value; if (previewTimer) window.clearTimeout(previewTimer); previewTimer = window.setTimeout(() => { const preview = document.getElementById("preview-content"); if (preview) { preview.innerHTML = renderMarkdown(state.editorText); attachInternalLinks(preview); } }, 80); }); } const pageContent = document.getElementById("page-content"); if (pageContent) attachInternalLinks(pageContent); const previewContent = document.getElementById("preview-content"); if (previewContent) attachInternalLinks(previewContent); } function attachInternalLinks(container) { container.querySelectorAll("a[href]").forEach((link) => { link.addEventListener("click", async (event) => { const internalPath = resolveInternalLink(state.currentPath || "", event.currentTarget.getAttribute("href")); if (!internalPath) return; event.preventDefault(); await navigate(internalPath, { pushState: true }); }); }); } window.addEventListener("popstate", async () => { const path = decodeRoutePathname(location.pathname); if (path) { await navigate(path, { pushState: false }); } else { state.currentPath = null; state.page = null; state.virtualFolder = null; await loadTree(); } }); render(); loadTree(); })();