Files
psopeeps_site/site/guide.js
2026-06-14 03:26:18 -04:00

493 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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();
});
})();