493 lines
13 KiB
JavaScript
493 lines
13 KiB
JavaScript
(() => {
|
||
"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 = `<div class="guide-status ${kind ? `guide-status--${esc(kind)}` : ""}">${esc(message)}</div>`;
|
||
}
|
||
|
||
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(`<code>${esc(code)}</code>`);
|
||
return token;
|
||
});
|
||
|
||
let html = esc(text);
|
||
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
|
||
return `<a href="${esc(safeHref(href))}">${label}</a>`;
|
||
});
|
||
|
||
html = html
|
||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||
|
||
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(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
|
||
paragraph = [];
|
||
}
|
||
|
||
function flushList() {
|
||
if (!list.length) return;
|
||
out.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join("")}</ul>`);
|
||
list = [];
|
||
}
|
||
|
||
function flushOrderedList() {
|
||
if (!orderedList.length) return;
|
||
out.push(`<ol>${orderedList.map((item) => `<li>${inlineMarkdown(item)}</li>`).join("")}</ol>`);
|
||
orderedList = [];
|
||
}
|
||
|
||
function flushCode() {
|
||
if (!code.length) return;
|
||
out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
|
||
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("<hr>");
|
||
continue;
|
||
}
|
||
|
||
const heading = trimmed.match(/^(#{1,5})\s+(.+)$/);
|
||
if (heading) {
|
||
flushTextBlocks();
|
||
const level = Math.min(heading[1].length + 1, 6);
|
||
out.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||
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 `<div class="guide-doc">${out.join("\n")}</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="guide-breadcrumb">${esc(breadcrumb)}</div>
|
||
${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();
|
||
});
|
||
})();
|