(() => { "use strict"; const SERVER_INFO = { us: "108.175.11.140", eu: "65.21.79.231", }; const GUIDE_TREE = { connection: { label: "Connection Guide", children: { overview: { label: "Overview", doc: "docs/guide/connection/overview.md", }, dreamcast: { label: "Dreamcast", children: { hardware: { label: "Hardware", doc: "docs/guide/connection/dreamcast/hardware.md", }, flycastDialup: { label: "Flycast (Dialup)", doc: "docs/guide/connection/dreamcast/flycast-dialup.md", }, flycastBba: { label: "Flycast (BBA)", doc: "docs/guide/connection/dreamcast/flycast-bba.md", }, }, }, pc: { label: "PC", children: { pc: { label: "PC", children: { windows: { label: "Windows", doc: "docs/guide/connection/pc/pc-windows.md", }, linux: { label: "Linux", doc: "docs/guide/connection/pc/pc-linux.md", }, }, }, blueBurst: { label: "Blue Burst", children: { windows: { label: "Windows", doc: "docs/guide/connection/pc/blue-burst-windows.md", }, linux: { label: "Linux", doc: "docs/guide/connection/pc/blue-burst-linux.md", }, }, }, }, }, gamecube: { label: "GameCube", children: { hardwareBba: { label: "Hardware (BBA)", doc: "docs/guide/connection/gamecube/hardware-bba.md", }, dolphin: { label: "Dolphin", doc: "docs/guide/connection/gamecube/dolphin.md", }, nintendont: { label: "Nintendont", doc: "docs/guide/connection/gamecube/nintendont.md", }, }, }, xbox: { label: "Xbox", doc: "docs/guide/connection/xbox.md", }, psp: { label: "Phantasy Star Portable", doc: "docs/guide/connection/phantasy-star-portable.md", }, serverSideSaves: { label: "Server-side saves", doc: "docs/guide/connection/server-side-saves.md", }, commonProblems: { label: "Common problems", doc: "docs/guide/connection/common-problems.md", }, quickReference: { label: "Quick reference", doc: "docs/guide/connection/quick-reference.md", }, }, }, peeps: { label: "Peeps Guide", children: { gettingStarted: { label: "Getting Started", doc: "docs/guide/peeps/getting-started.md", }, crossplayRooms: { label: "Crossplay Rooms", doc: "docs/guide/peeps/crossplay-rooms.md", }, xpPeriods: { label: "XP Periods", doc: "docs/guide/peeps/xp-periods.md", }, brutalPeeps: { label: "Brutal Peeps", doc: "docs/guide/peeps/brutal-peeps.md", }, }, }, hardcore: { label: "Hardcore Peeps Guide", children: { gettingStarted: { label: "Getting Started", doc: "docs/guide/hardcore/getting-started.md", }, progression: { label: "Progression", doc: "docs/guide/hardcore/progression.md", }, mesetaAndBankLimits: { label: "Meseta and Bank Limits", doc: "docs/guide/hardcore/meseta-and-bank-limits.md", }, pointsSystem: { label: "Points System", doc: "docs/guide/hardcore/points-system.md", }, brutalPeeps: { label: "Brutal Peeps", doc: "docs/guide/hardcore/brutal-peeps.md", }, }, }, }; const qs = (sel) => document.querySelector(sel); const selects = [ qs("#guide-level-0"), qs("#guide-level-1"), qs("#guide-level-2"), qs("#guide-level-3"), ]; const wrappers = [ null, qs("#guide-level-1-wrap"), qs("#guide-level-2-wrap"), qs("#guide-level-3-wrap"), ]; const labels = [ null, qs('label[for="guide-level-1"]'), qs('label[for="guide-level-2"]'), qs('label[for="guide-level-3"]'), ]; const box = qs("#guide-content"); let pendingPath = null; let loadingPath = ""; const mdCache = new Map(); function esc(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function nodeChildren(node) { return node && node.children ? Object.entries(node.children) : []; } function fillSelect(select, children, preferredValue) { if (!select) return ""; const entries = Object.entries(children || {}); const previous = preferredValue || select.value; select.innerHTML = ""; for (const [key, child] of entries) { const opt = document.createElement("option"); opt.value = key; opt.textContent = child.label; select.appendChild(opt); } if (entries.some(([key]) => key === previous)) { select.value = previous; } else if (entries.length) { select.value = entries[0][0]; } return select.value; } function setStatus(message, kind = "") { if (!box) return; box.innerHTML = `
${esc(message)}
`; } function pathLabels(path) { let node = { children: GUIDE_TREE }; const out = []; for (const key of path) { const next = node.children?.[key]; if (!next) break; out.push(next.label); node = next; } return out; } function safeHref(raw) { const href = String(raw || "").trim(); if (/^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i.test(href)) return href; if (!/^[a-z][a-z0-9+.-]*:/i.test(href)) return href; return "#"; } function inlineMarkdown(raw) { const codeSpans = []; let text = String(raw ?? "").replace(/`([^`]+)`/g, (_, code) => { const token = `@@CODE${codeSpans.length}@@`; codeSpans.push(`${esc(code)}`); return token; }); let html = esc(text); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => { return `${label}`; }); html = html .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1"); codeSpans.forEach((span, index) => { html = html.replaceAll(`@@CODE${index}@@`, span); }); return html; } function renderMarkdown(markdown) { const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n"); const out = []; let paragraph = []; let list = []; let orderedList = []; let code = []; let inFence = false; const fence = String.fromCharCode(96, 96, 96); function flushParagraph() { if (!paragraph.length) return; out.push(`

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

`); paragraph = []; } function flushList() { if (!list.length) return; out.push(``); list = []; } function flushOrderedList() { if (!orderedList.length) return; out.push(`
    ${orderedList.map((item) => `
  1. ${inlineMarkdown(item)}
  2. `).join("")}
`); orderedList = []; } function flushCode() { if (!code.length) return; out.push(`
${esc(code.join("\n"))}
`); code = []; } function flushTextBlocks() { flushParagraph(); flushList(); flushOrderedList(); } for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith(fence)) { flushTextBlocks(); if (inFence) { flushCode(); inFence = false; } else { inFence = true; code = []; } continue; } if (inFence) { code.push(line); continue; } if (/^ {4,}/.test(line)) { flushTextBlocks(); code.push(line.replace(/^ {4}/, "")); continue; } if (code.length && trimmed === "") { flushCode(); continue; } if (code.length) { flushCode(); } if (!trimmed) { flushTextBlocks(); continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushTextBlocks(); out.push("
"); continue; } const heading = trimmed.match(/^(#{1,5})\s+(.+)$/); if (heading) { flushTextBlocks(); const level = Math.min(heading[1].length + 1, 6); out.push(`${inlineMarkdown(heading[2])}`); continue; } const bullet = line.match(/^\s*[-*]\s+(.+)$/); if (bullet) { flushParagraph(); flushOrderedList(); list.push(bullet[1]); continue; } const ordered = line.match(/^\s*\d+\.\s+(.+)$/); if (ordered) { flushParagraph(); flushList(); orderedList.push(ordered[1]); continue; } paragraph.push(trimmed); } flushTextBlocks(); flushCode(); return `
${out.join("\n")}
`; } async function loadDoc(node, path) { if (!node.doc) { setStatus("Choose a guide page from the menus above."); return; } const pathKey = path.join("/"); loadingPath = pathKey; setStatus(`Loading ${pathLabels(path).join(" › ")}...`); try { let markdown = mdCache.get(node.doc); if (!markdown) { const response = await fetch(node.doc, { cache: "no-cache" }); if (!response.ok) { throw new Error(`Unable to load ${node.doc} (${response.status})`); } markdown = await response.text(); mdCache.set(node.doc, markdown); } if (loadingPath !== pathKey) return; const breadcrumb = pathLabels(path).join(" › "); box.innerHTML = `
${esc(breadcrumb)}
${renderMarkdown(markdown)} `; } catch (err) { if (loadingPath !== pathKey) return; setStatus(err?.message || "Unable to load guide page.", "error"); } } function updateHash(path) { const next = `#${path.join("/")}`; if (window.location.hash !== next) { history.replaceState(null, "", next); } } function readHashPath() { const raw = window.location.hash.replace(/^#/, "").trim(); if (!raw) return null; return raw.split("/").filter(Boolean); } function syncGuide(changedLevel = -1) { if (!box || selects.some((select) => !select)) return; if (changedLevel >= 0) { for (let i = changedLevel + 1; i < selects.length; i += 1) { selects[i].value = ""; } } let node = { children: GUIDE_TREE }; const path = []; for (let level = 0; level < selects.length; level += 1) { const select = selects[level]; if (!node.children) { for (let hideLevel = level; hideLevel < wrappers.length; hideLevel += 1) { if (wrappers[hideLevel]) wrappers[hideLevel].hidden = true; } break; } const preferred = pendingPath?.[level] || select.value; const selected = fillSelect(select, node.children, preferred); if (wrappers[level]) wrappers[level].hidden = false; const current = node.children[selected]; path.push(selected); node = current; if (labels[level]) { labels[level].textContent = level === 1 && path[0] === "connection" ? "Platform" : level === 1 ? "Topic" : level === 2 && path[0] === "connection" && path[1] === "pc" ? "Client" : level === 2 && path[0] === "connection" ? "Setup" : level === 3 ? "System" : "Option"; } } pendingPath = null; for (let level = 1; level < wrappers.length; level += 1) { const parent = path.slice(0, level).reduce((cur, key) => cur?.children?.[key], { children: GUIDE_TREE }); if (!parent?.children && wrappers[level]) wrappers[level].hidden = true; } updateHash(path); loadDoc(node, path); } document.addEventListener("DOMContentLoaded", () => { pendingPath = readHashPath(); selects.forEach((select, index) => { select?.addEventListener("change", () => syncGuide(index)); }); syncGuide(); }); })();