(() => {
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 `
`;
}
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 `
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.tree.children || []).map(renderNode).join("")}
`
: state.loadingTree
? "Loading tree...
"
: "No pages found.
";
app.innerHTML = `
${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();
})();