Files
5et-desktop/src/main/wikiServer.ts
T

1649 lines
49 KiB
TypeScript

import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { URL } from "node:url";
import { app } from "electron";
const SHOULD_INJECT_RESKIN = process.env.FIVEETOOLS_DESKTOP_RESKIN !== "0";
const SHOULD_FORCE_NIGHT_THEME = process.env.FIVEETOOLS_DESKTOP_FORCE_NIGHT !== "0";
const SHOULD_USE_EXTERNAL_IMAGE_FALLBACK = process.env.FIVEETOOLS_DESKTOP_EXTERNAL_IMAGES !== "0";
export const DEFAULT_EXTERNAL_IMAGE_ROOT = "https://5e.tools/img/";
const MIME_TYPES: Record<string, string> = {
".avif": "image/avif",
".css": "text/css; charset=utf-8",
".eot": "application/vnd.ms-fontobject",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".otf": "font/otf",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".txt": "text/plain; charset=utf-8",
".wasm": "application/wasm",
".webm": "video/webm",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".webmanifest": "application/manifest+json; charset=utf-8",
".xml": "application/xml; charset=utf-8"
};
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
};
function withCorsHeaders(headers: Record<string, string>): Record<string, string> {
return {
...CORS_HEADERS,
...headers
};
}
function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
return `
(() => {
${externalImageRoot ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(externalImageRoot)};` : ""}
const root = document.documentElement;
root.classList.add("desktop-reskin", "desktop-app-navbar-mode");
${
SHOULD_FORCE_NIGHT_THEME
? `
root.classList.add("ve-night-mode", "ve-night-mode--standard");
try {
window.localStorage.setItem("StyleSwitcher_style", "night");
} catch {}
`
: ""
}
const doToastInfo = (content) => {
try {
if (globalThis.JqueryUtil?.doToast) {
globalThis.JqueryUtil.doToast({ type: "info", content });
return;
}
} catch {}
console.info("[5ET-D]", content);
};
const postToDesktop = (type, payload) => {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type, ...(payload || {}) }, "*");
return true;
}
return false;
};
const requestOnboardingReset = () => {
const message = [
"Reset onboarding and remove local 5etools data?",
"",
"This will clear downloaded source/images and return to first-run setup."
].join("\\n");
if (!window.confirm(message)) {
return;
}
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: "5etd:reset-onboarding" }, "*");
doToastInfo("Resetting onboarding...");
return;
}
doToastInfo("Reset onboarding is unavailable in this context.");
};
const closeDropdown = (li, toggle) => {
if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") {
NavBar._closeDropdownElement(li);
return;
}
li.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
};
const addCharactersMenu = () => {
const navbar = document.getElementById("navbar");
if (!navbar || navbar.querySelector(".desktop-nav-characters")) return;
const li = document.createElement("li");
li.setAttribute("role", "presentation");
li.className = "dropdown dropdown--navbar page__nav-hidden-mobile page__btn-nav-root desktop-nav-characters";
const toggle = document.createElement("a");
toggle.className = "ve-dropdown-toggle";
toggle.href = "#";
toggle.setAttribute("role", "button");
toggle.setAttribute("aria-haspopup", "true");
toggle.setAttribute("aria-expanded", "false");
toggle.innerHTML = 'Characters <span class="caret"></span>';
toggle.onclick = (event) => {
if (typeof NavBar !== "undefined" && typeof NavBar._handleDropdownClick === "function") {
NavBar._handleDropdownClick(toggle, event, false);
return;
}
event.preventDefault();
event.stopPropagation();
const isOpen = li.classList.contains("open");
[...navbar.querySelectorAll(":scope > li.dropdown--navbar.open")]
.forEach((it) => it.classList.remove("open"));
li.classList.toggle("open", !isOpen);
toggle.setAttribute("aria-expanded", String(!isOpen));
};
li.onmouseenter = function () {
if (typeof NavBar !== "undefined" && typeof NavBar._handleItemMouseEnter === "function") {
NavBar._handleItemMouseEnter(this);
}
};
const menu = document.createElement("ul");
menu.setAttribute("role", "menu");
menu.className = "ve-dropdown-menu ve-dropdown-menu--top";
menu.onclick = (event) => event.stopPropagation();
const addMenuItem = (label, onClick) => {
const menuItem = document.createElement("li");
menuItem.setAttribute("role", "presentation");
const link = document.createElement("a");
link.href = "#";
link.textContent = label;
link.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
onClick();
closeDropdown(li, toggle);
};
menuItem.append(link);
menu.append(menuItem);
};
addMenuItem("New", () => {
if (!postToDesktop("5etd:characters:new")) {
doToastInfo("Characters menu is unavailable in this context.");
}
});
addMenuItem("Load from JSON", () => {
if (!postToDesktop("5etd:characters:load-json")) {
doToastInfo("Characters menu is unavailable in this context.");
}
});
addMenuItem("My Characters", () => {
if (!postToDesktop("5etd:characters:list")) {
doToastInfo("Characters menu is unavailable in this context.");
}
});
li.append(toggle, menu);
const playerToggle = [...navbar.querySelectorAll(":scope > li.dropdown--navbar > a.ve-dropdown-toggle")]
.find((it) => it.textContent?.trim().startsWith("Player"));
const playerLi = playerToggle?.parentElement;
const helpLi = navbar.querySelector(':scope > li[data-page^="https://wiki.tercept.net/en/5eTools"]');
if (playerLi?.nextSibling) {
navbar.insertBefore(li, playerLi.nextSibling);
return;
}
if (playerLi) {
navbar.append(li);
return;
}
if (helpLi) {
navbar.insertBefore(li, helpLi);
return;
}
navbar.append(li);
};
const addResetOnboardingMenu = () => {
const navbar = document.getElementById("navbar");
if (!navbar) return;
const settingsToggle = [...navbar.querySelectorAll(":scope > li.dropdown--navbar > a.ve-dropdown-toggle")]
.find((it) => it.textContent?.trim().startsWith("Settings"));
const settingsLi = settingsToggle?.parentElement;
if (!settingsLi) return;
const menu =
settingsLi.querySelector(":scope > ul.ve-dropdown-menu") ||
settingsLi.querySelector(":scope > ul.dropdown-menu");
if (!menu || menu.querySelector(".desktop-nav-reset-onboarding")) return;
const divider = document.createElement("li");
divider.className = "divider desktop-nav-reset-onboarding";
divider.setAttribute("role", "presentation");
const item = document.createElement("li");
item.className = "desktop-nav-reset-onboarding";
item.setAttribute("role", "presentation");
const link = document.createElement("a");
link.href = "#";
link.textContent = "Reset Onboarding";
link.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
requestOnboardingReset();
if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") {
NavBar._closeDropdownElement(settingsLi);
return;
}
settingsLi.classList.remove("open");
settingsToggle?.setAttribute("aria-expanded", "false");
};
item.append(link);
menu.append(divider, item);
};
let isAutoFocusScheduled = false;
let hasAutoFocusedSearch = false;
const tryAutoFocusSearch = () => {
if (hasAutoFocusedSearch) {
return true;
}
const active = document.activeElement;
if (active && active !== document.body && active !== document.documentElement) {
return true;
}
const input = document.querySelector("#navbar .omni__input, .omni__input");
if (!(input instanceof HTMLInputElement)) {
return false;
}
input.focus({ preventScroll: true });
input.select();
if (document.activeElement === input) {
hasAutoFocusedSearch = true;
return true;
}
return false;
};
const scheduleAutoFocusSearch = () => {
if (isAutoFocusScheduled || hasAutoFocusedSearch) {
return;
}
isAutoFocusScheduled = true;
let attempts = 0;
const maxAttempts = 80;
const intervalMs = 75;
const runAttempt = () => {
if (tryAutoFocusSearch()) {
isAutoFocusScheduled = false;
return;
}
attempts += 1;
if (attempts >= maxAttempts) {
isAutoFocusScheduled = false;
}
};
runAttempt();
const interval = window.setInterval(() => {
if (!isAutoFocusScheduled) {
window.clearInterval(interval);
return;
}
runAttempt();
}, intervalMs);
const observer = new MutationObserver(() => {
if (tryAutoFocusSearch()) {
isAutoFocusScheduled = false;
observer.disconnect();
}
});
const obsTarget = document.body || document.documentElement;
if (obsTarget) {
observer.observe(obsTarget, { childList: true, subtree: true });
}
window.setTimeout(() => {
observer.disconnect();
isAutoFocusScheduled = false;
}, maxAttempts * intervalMs + 250);
};
const applyDesktopMenuExtensions = () => {
addCharactersMenu();
addResetOnboardingMenu();
bindNativeNavbarSync();
queueNativeNavbarModelPost({ isForce: true });
};
const NAVBAR_ACTION_ATTR = "data-5etd-nav-action-id";
const cleanNavLabel = (value) => String(value || "").replace(/\\s+/g, " ").replace(/[▾▼►]+/g, "").trim();
const getNavPrimaryText = (ele) => {
for (const node of Array.from(ele.childNodes || [])) {
if (node.nodeType === Node.TEXT_NODE) {
const text = cleanNavLabel(node.textContent || "");
if (text) return text;
}
}
return cleanNavLabel(ele.textContent || "");
};
const getNavEntryMeta = (ele) => {
if (!ele) return {};
const out = {};
const dateEle = ele.querySelector(":scope > .page__nav-date");
if (dateEle) {
const datePrefix = cleanNavLabel(dateEle.textContent || "");
if (datePrefix) out.datePrefix = datePrefix;
}
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
if (sourceEle instanceof HTMLElement) {
const computed = getComputedStyle(sourceEle);
const sourceColor = String(computed.backgroundColor || computed.borderLeftColor || "").trim();
if (
sourceColor &&
sourceColor !== "transparent" &&
!/^rgba?\\(\\s*0\\s*,\\s*0\\s*,\\s*0(?:\\s*,\\s*0)?\\s*\\)$/i.test(sourceColor)
) {
out.sourceColor = sourceColor;
}
}
return out;
};
const isActionLikeHref = (hrefRaw) => {
const href = String(hrefRaw || "").trim().toLowerCase();
return !href || href === "#" || href.startsWith("javascript:");
};
const parseNavbarMenuList = (list, idPrefix) => {
const entries = [];
const liElements = Array.from(list?.children || []).filter((it) => it instanceof HTMLLIElement);
liElements.forEach((li, index) => {
const entryId = \`\${idPrefix}-\${index}\`;
const submenuList = li.querySelector(":scope > ul")
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null);
const submenuChildren = submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : [];
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
entries.push({ id: \`\${entryId}-divider\`, type: "divider" });
return;
}
if (li.classList.contains("nav2-list__label")) {
const label = cleanNavLabel(li.textContent || "");
if (label) entries.push({ id: \`\${entryId}-label\`, type: "label", label });
return;
}
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a");
const label = toggle ? getNavPrimaryText(toggle) : "";
if (!label) return;
entries.push({
id: \`\${entryId}-submenu\`,
type: "submenu",
label,
...getNavEntryMeta(toggle),
children: submenuChildren
});
return;
}
if (li.classList.contains("nav2-accord__wrp")) {
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
const label = headPrimary ? getNavPrimaryText(headPrimary) : "";
if (!label || !submenuChildren.length) return;
entries.push({
id: \`\${entryId}-accordion\`,
type: "submenu",
label,
...getNavEntryMeta(headPrimary),
children: submenuChildren
});
return;
}
const link = li.querySelector(":scope > a");
if (link) {
const label = getNavPrimaryText(link);
const href = String(link.getAttribute("href") || "").trim();
if (submenuChildren.length) {
if (isActionLikeHref(href)) {
const actionId = \`\${entryId}-submenu-action\`;
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({
id: \`\${entryId}-submenu-link\`,
type: "submenu",
label: label || "Section",
...getNavEntryMeta(link),
actionId,
children: submenuChildren
});
return;
}
entries.push({
id: \`\${entryId}-submenu-link\`,
type: "submenu",
label: label || href,
...getNavEntryMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href),
children: submenuChildren
});
return;
}
if (isActionLikeHref(href)) {
const actionId = \`\${entryId}-action\`;
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({ id: \`\${entryId}-action\`, type: "action", label: label || "Action", actionId });
return;
}
entries.push({
id: \`\${entryId}-link\`,
type: "link",
label: label || href,
...getNavEntryMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href)
});
return;
}
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it) => it instanceof HTMLSpanElement);
if (submenuChildren.length && spans.length) {
const first = spans[0];
const label = cleanNavLabel(first.textContent || first.title || "");
const actionId = \`\${entryId}-submenu-span\`;
first.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({
id: \`\${entryId}-submenu-span\`,
type: "submenu",
label: label || "Section",
...getNavEntryMeta(first),
actionId,
children: submenuChildren
});
return;
}
spans.forEach((span, spanIndex) => {
const label = cleanNavLabel(span.textContent || span.title || "");
const actionId = \`\${entryId}-span-\${spanIndex}\`;
span.setAttribute(NAVBAR_ACTION_ATTR, actionId);
entries.push({
id: \`\${entryId}-span-\${spanIndex}\`,
type: "action",
label: label || "Action",
...getNavEntryMeta(span),
actionId
});
});
});
return entries;
};
const buildNativeNavbarGroups = () => {
const navbar = document.getElementById("navbar");
if (!navbar) return [];
const groups = [];
const rootItems = Array.from(navbar.children).filter((it) => it instanceof HTMLLIElement);
rootItems.forEach((li, index) => {
if (
li.classList.contains("omni__wrp-input") ||
li.classList.contains("desktop-nav-characters")
) {
return;
}
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a");
const menuList = li.querySelector(":scope > ul");
const label = toggle ? getNavPrimaryText(toggle) : "";
if (!label) return;
groups.push({
id: \`root-\${index}-dropdown\`,
kind: "dropdown",
label,
items: menuList ? parseNavbarMenuList(menuList, \`root-\${index}-list\`) : []
});
return;
}
const link = li.querySelector(":scope > a");
if (!link) return;
const label = getNavPrimaryText(link);
const href = String(link.getAttribute("href") || "").trim();
if (!label) return;
if (isActionLikeHref(href)) {
const actionId = \`root-\${index}-action\`;
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
groups.push({
id: \`root-\${index}-action\`,
kind: "action",
label,
actionId
});
return;
}
groups.push({
id: \`root-\${index}-link\`,
kind: "link",
label,
href,
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href)
});
});
return groups;
};
let navbarModelLastSignature = "";
let isNavbarModelPostQueued = false;
const postNativeNavbarModel = ({ isForce = false } = {}) => {
if (!window.parent || window.parent === window) return;
const groups = buildNativeNavbarGroups();
let nextSignature = "";
try {
nextSignature = JSON.stringify(groups);
} catch {
nextSignature = \`groups:\${groups.length}:\${Date.now()}\`;
}
if (!isForce && nextSignature === navbarModelLastSignature) {
return;
}
navbarModelLastSignature = nextSignature;
window.parent.postMessage(
{
type: "5etd:native-navbar-data",
groups
},
"*"
);
};
const queueNativeNavbarModelPost = ({ isForce = false } = {}) => {
if (isForce) {
isNavbarModelPostQueued = false;
postNativeNavbarModel({ isForce: true });
return;
}
if (isNavbarModelPostQueued) {
return;
}
isNavbarModelPostQueued = true;
window.setTimeout(() => {
isNavbarModelPostQueued = false;
postNativeNavbarModel();
}, 80);
};
let navbarSyncObserver = null;
const bindNativeNavbarSync = () => {
const navbar = document.getElementById("navbar");
if (!navbar || navbar.dataset.desktopNavSyncBound === "1") {
return;
}
navbar.dataset.desktopNavSyncBound = "1";
navbarSyncObserver?.disconnect?.();
navbarSyncObserver = new MutationObserver(() => {
queueNativeNavbarModelPost();
});
navbarSyncObserver.observe(navbar, { childList: true, subtree: true });
};
const runNativeNavbarAction = (actionId) => {
const id = String(actionId || "").trim();
if (!id) return;
const navbar = document.getElementById("navbar");
if (!navbar) return;
const actionEle = navbar.querySelector(\`[\${NAVBAR_ACTION_ATTR}="\${id}"]\`);
if (!(actionEle instanceof HTMLElement)) {
return;
}
actionEle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
actionEle.click();
};
window.addEventListener("message", (event) => {
const data = event?.data;
if (!data || typeof data !== "object") {
return;
}
if (data.type === "5etd:reset-onboarding-result" && data.ok === false) {
const reason = typeof data.error === "string" && data.error ? data.error : "unknown error";
doToastInfo(\`Reset onboarding failed: \${reason}\`);
return;
}
if (data.type === "5etd:native-navbar-request") {
bindNativeNavbarSync();
queueNativeNavbarModelPost({ isForce: true });
return;
}
if (data.type === "5etd:native-navbar-run-action") {
runNativeNavbarAction(data.actionId);
}
});
window.addEventListener("DOMContentLoaded", applyDesktopMenuExtensions);
window.addEventListener("load", applyDesktopMenuExtensions);
})();
`;
}
const DESKTOP_RESKIN_STYLE = `
html.desktop-reskin {
--desktop-shell-gutter: 14px;
--desktop-topbar-pad-x: 18px;
--desktop-nav-top-offset: 10px;
--desktop-nav-reserved-height: 92px;
}
html.desktop-reskin body {
padding-top: calc(var(--safe-area-inset-top) + var(--desktop-nav-reserved-height)) !important;
background:
radial-gradient(circle at 13% -2%, rgba(77, 140, 222, 0.3), transparent 40%),
radial-gradient(circle at 86% -4%, rgba(241, 176, 92, 0.24), transparent 38%),
linear-gradient(180deg, #0c1422 0%, #0a1019 100%) !important;
color: #dce7f6;
}
html.desktop-reskin .viewport-wrapper {
inset: calc(var(--safe-area-inset-top) + var(--desktop-nav-reserved-height)) 0 0 0 !important;
height: auto !important;
min-height: calc(100vh - (var(--safe-area-inset-top) + var(--desktop-nav-reserved-height)));
}
html.desktop-reskin .page__header {
margin: 8px var(--desktop-shell-gutter) 0 !important;
padding: 0 !important;
border: 1px solid rgba(109, 164, 223, 0.32) !important;
border-radius: 18px;
background: linear-gradient(115deg, rgba(17, 29, 47, 0.98), rgba(24, 38, 58, 0.96) 64%, rgba(87, 58, 32, 0.88));
// backdrop-filter: blur(14px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
}
html.desktop-reskin .page__header .container {
width: 100% !important;
max-width: none !important;
padding: 10px var(--desktop-topbar-pad-x) !important;
}
html.desktop-reskin .page__header .ve-flex-v-baseline {
flex-wrap: wrap;
row-gap: 4px;
}
html.desktop-reskin .page__title {
margin: 0 !important;
color: #ebf4ff !important;
font-size: clamp(2.8rem, 4.6vw, 7rem);
line-height: 1;
}
html.desktop-reskin .page__subtitle {
margin: 0 0 0 14px !important;
color: #bccce0 !important;
font-size: clamp(1.2rem, 1.75vw, 3.1rem);
}
html.desktop-reskin #navigation.page__nav {
margin: 0 !important;
padding: 0 !important;
position: fixed;
top: calc(var(--safe-area-inset-top) + var(--desktop-nav-top-offset));
left: var(--desktop-shell-gutter);
right: var(--desktop-shell-gutter);
width: auto !important;
max-width: none !important;
border: 1px solid rgba(109, 164, 223, 0.32) !important;
border-radius: 18px;
background: linear-gradient(180deg, rgba(12, 21, 35, 0.96), rgba(8, 15, 27, 0.98));
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28);
z-index: 3600 !important;
}
html.desktop-reskin #navigation.page__nav .page__nav-inner {
width: 100% !important;
margin: 0 !important;
padding: 10px var(--desktop-topbar-pad-x) !important;
overflow: visible;
}
html.desktop-reskin #navbar {
background: transparent !important;
border-bottom: 0 !important;
box-shadow: none;
display: flex;
align-items: stretch;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
max-width: 100%;
overflow: visible;
position: relative;
z-index: 3001 !important;
}
html.desktop-reskin #navbar > li {
float: none !important;
flex: 0 0 auto;
min-width: 0;
}
html.desktop-reskin #navbar > li > a {
border: 1px solid rgba(107, 159, 214, 0.28);
background: rgba(19, 31, 49, 0.9);
border-radius: 8px;
margin: 0 !important;
display: flex;
align-items: center;
height: 100%;
transition: background-color 120ms ease, border-color 120ms ease;
}
html.desktop-reskin #navbar > li.page__btn-nav-root > a {
border-color: rgba(116, 174, 233, 0.44) !important;
background: linear-gradient(180deg, rgba(22, 37, 58, 0.94), rgba(14, 24, 39, 0.97)) !important;
box-shadow: inset 0 1px 0 rgba(162, 214, 255, 0.12);
}
html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle {
border-color: rgba(116, 174, 233, 0.44) !important;
background: linear-gradient(180deg, rgba(22, 37, 58, 0.94), rgba(14, 24, 39, 0.97)) !important;
box-shadow: inset 0 1px 0 rgba(162, 214, 255, 0.12);
}
html.desktop-reskin #navbar .navbar-brand,
html.desktop-reskin #navbar > li > a,
html.desktop-reskin #navbar > li > a .caret {
color: #e6f1ff !important;
}
html.desktop-reskin #navbar > li > a:hover {
background: rgba(65, 121, 191, 0.32);
border-color: rgba(118, 184, 247, 0.56);
}
html.desktop-reskin #navbar > li.page__btn-nav-root > a:hover,
html.desktop-reskin #navbar > li.page__btn-nav-root > a:focus {
background: linear-gradient(145deg, rgba(52, 102, 161, 0.78), rgba(31, 86, 142, 0.82)) !important;
border-color: rgba(136, 202, 255, 0.84) !important;
}
html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle:hover,
html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle:focus {
background: linear-gradient(145deg, rgba(52, 102, 161, 0.78), rgba(31, 86, 142, 0.82)) !important;
border-color: rgba(136, 202, 255, 0.84) !important;
}
html.desktop-reskin #navbar > li.active > a,
html.desktop-reskin #navbar > li.open > a {
background: linear-gradient(140deg, rgba(33, 126, 209, 0.9), rgba(25, 95, 167, 0.94));
border-color: rgba(123, 196, 255, 0.86);
color: #f4f9ff !important;
}
html.desktop-reskin #navbar > li.dropdown--navbar.open > a.ve-dropdown-toggle,
html.desktop-reskin #navbar > li.dropdown--navbar.active > a.ve-dropdown-toggle {
background: linear-gradient(140deg, rgba(34, 127, 210, 0.92), rgba(25, 95, 167, 0.96)) !important;
border-color: rgba(139, 207, 255, 0.92) !important;
box-shadow: inset 0 1px 0 rgba(229, 245, 255, 0.22);
}
html.desktop-reskin #navbar > li.page__btn-nav-root.active > a,
html.desktop-reskin #navbar > li.page__btn-nav-root.open > a {
background: linear-gradient(140deg, rgba(34, 127, 210, 0.92), rgba(25, 95, 167, 0.96)) !important;
border-color: rgba(139, 207, 255, 0.92) !important;
box-shadow: inset 0 1px 0 rgba(229, 245, 255, 0.22);
}
html.desktop-reskin #navbar > li.dropdown--navbar > a.ve-dropdown-toggle > .caret {
border-top-color: #c9e6ff !important;
opacity: 0.95;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav {
margin: 0 !important;
width: 48px !important;
min-width: 48px !important;
height: 44px !important;
padding: 0 !important;
border: 1px solid rgba(116, 174, 233, 0.52) !important;
border-radius: 12px !important;
background: linear-gradient(180deg, rgba(22, 36, 56, 0.95), rgba(13, 23, 38, 0.98)) !important;
box-shadow: inset 0 1px 0 rgba(172, 220, 255, 0.16) !important;
position: relative;
overflow: hidden;
text-indent: -9999px;
color: transparent !important;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::before,
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::after {
content: "";
position: absolute;
left: 13px;
right: 13px;
height: 2px;
border-radius: 2px;
background: #ddebff;
transition: transform 150ms ease, top 150ms ease, box-shadow 150ms ease;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::before {
top: 50%;
transform: translateY(-8px);
box-shadow: 0 7px 0 #ddebff;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav::after {
top: 50%;
transform: translateY(8px);
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav:hover,
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav:focus {
border-color: rgba(137, 204, 255, 0.88) !important;
background: linear-gradient(145deg, rgba(51, 101, 160, 0.82), rgba(31, 84, 139, 0.86)) !important;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav.active {
border-color: rgba(139, 207, 255, 0.92) !important;
background: linear-gradient(140deg, rgba(34, 127, 210, 0.92), rgba(25, 95, 167, 0.96)) !important;
box-shadow: inset 0 1px 0 rgba(229, 245, 255, 0.22) !important;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav.active::before {
top: 50%;
transform: translateY(0) rotate(45deg);
box-shadow: none;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav.active::after {
top: 50%;
transform: translateY(0) rotate(-45deg);
}
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) {
margin-left: auto !important;
flex: 1 1 320px;
min-width: 220px;
max-width: 460px;
}
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__input {
min-width: 0 !important;
width: 100%;
border-right: 1px solid rgba(102, 155, 210, 0.42) !important;
border-top-right-radius: 12px !important;
border-bottom-right-radius: 12px !important;
}
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__submit {
display: none !important;
}
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__btn-clear {
right: 12px !important;
}
@media (max-width: 1400px) {
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) {
flex-basis: 260px;
min-width: 190px;
}
}
@media (max-width: 1180px) {
html.desktop-reskin {
--desktop-topbar-pad-x: 12px;
--desktop-nav-reserved-height: 108px;
}
html.desktop-reskin #navbar {
gap: 3px;
}
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) {
margin-left: 0 !important;
flex-basis: 100%;
max-width: 100%;
}
}
@media (max-width: 991px) {
html.desktop-reskin {
--desktop-nav-reserved-height: 84px;
}
html.desktop-reskin #navigation.page__nav {
display: grid !important;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 10px;
row-gap: 8px;
align-items: center;
width: auto !important;
padding: 10px 12px 0 !important;
}
html.desktop-reskin #navigation.page__nav .page__btn-toggle-nav {
grid-column: 1;
grid-row: 1;
align-self: stretch;
}
html.desktop-reskin #navigation.page__nav .page__nav-inner {
grid-column: 2;
grid-row: 1;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
html.desktop-reskin #navbar {
flex-direction: column;
align-items: stretch;
flex-wrap: nowrap;
gap: 8px;
margin: 0 !important;
padding: 0 !important;
}
html.desktop-reskin #navbar > li {
width: 100%;
}
html.desktop-reskin #navbar > li > a {
width: 100%;
min-height: 46px;
padding: 10px 16px !important;
justify-content: flex-start;
}
html.desktop-reskin #navbar > .omni__wrp-input,
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) {
order: -1;
margin: 0 !important;
width: 100%;
max-width: 100%;
flex: 0 0 auto;
}
html.desktop-reskin #navbar > .omni__wrp-input .omni__input,
html.desktop-reskin #navbar > .omni__wrp-input:not(.omni__wrp-input--scrolled) .omni__input {
width: 100%;
min-width: 0 !important;
}
html.desktop-reskin #navbar > li.page__nav-hidden-mobile {
margin: 0 !important;
}
html.desktop-reskin #navbar > li.dropdown--navbar.open > .dropdown-menu,
html.desktop-reskin #navbar > li.dropdown--navbar.open > .ve-dropdown-menu {
position: static !important;
float: none !important;
width: 100% !important;
margin: 6px 0 0 !important;
}
html.desktop-reskin #navbar .ve-dropdown-menu--side {
margin-left: 0 !important;
}
}
html.desktop-reskin #navbar .form-control,
html.desktop-reskin #navbar input[type="search"] {
background: rgba(8, 14, 24, 0.96) !important;
border: 1px solid rgba(102, 155, 210, 0.42) !important;
color: #e4effb !important;
height: 44px !important;
border-radius: 12px !important;
}
html.desktop-reskin #navbar .form-control::placeholder,
html.desktop-reskin #navbar input[type="search"]::placeholder {
color: #96adc8 !important;
}
html.desktop-reskin #navbar .btn {
border-radius: 12px !important;
border-color: rgba(102, 155, 210, 0.42) !important;
background: rgba(14, 24, 40, 0.95) !important;
color: #d9e8fb !important;
}
html.desktop-reskin #navbar .omni__submit,
html.desktop-reskin #navbar .omni__submit.ve-btn {
display: none !important;
}
html.desktop-reskin #navbar .dropdown-menu,
html.desktop-reskin #navbar .ve-dropdown-menu {
border: 1px solid rgba(102, 155, 210, 0.42) !important;
border-radius: 12px !important;
background: linear-gradient(180deg, rgba(13, 22, 37, 0.98), rgba(9, 17, 30, 0.99)) !important;
padding: 4px !important;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.34);
z-index: 3200 !important;
}
html.desktop-reskin #navbar .ve-dropdown-menu--top {
margin-top: 4px !important;
}
html.desktop-reskin #navbar .ve-dropdown-menu--side {
margin-left: 4px !important;
}
html.desktop-reskin #navbar .dropdown-menu > li > a,
html.desktop-reskin #navbar .ve-dropdown-menu > li > a,
html.desktop-reskin #navbar .ve-dropdown-menu > li > span {
color: #dce9fa !important;
border-radius: 8px;
padding: 6px 12px !important;
transition: background-color 100ms ease, color 100ms ease;
}
html.desktop-reskin #navbar .dropdown-menu > li > a:hover,
html.desktop-reskin #navbar .dropdown-menu > li > a:focus,
html.desktop-reskin #navbar .ve-dropdown-menu > li > a:hover,
html.desktop-reskin #navbar .ve-dropdown-menu > li > a:focus,
html.desktop-reskin #navbar .ve-dropdown-menu > li > span:hover,
html.desktop-reskin #navbar .ve-dropdown-menu > li > span:focus {
background: rgba(58, 108, 170, 0.34) !important;
color: #f2f8ff !important;
}
html.desktop-reskin #navbar .dropdown-menu > li.active > a,
html.desktop-reskin #navbar .ve-dropdown-menu > li.active > a,
html.desktop-reskin #navbar .ve-dropdown-menu > li.active > span {
background: linear-gradient(140deg, rgba(36, 124, 202, 0.92), rgba(28, 95, 161, 0.94)) !important;
color: #f5fbff !important;
}
html.desktop-reskin #navbar .dropdown-menu > li.disabled > a,
html.desktop-reskin #navbar .ve-dropdown-menu > li.disabled > a,
html.desktop-reskin #navbar .ve-dropdown-menu > li.disabled > span {
color: #8aa2bd !important;
}
html.desktop-reskin #navbar .ve-dropdown-divider,
html.desktop-reskin #navbar .dropdown-menu .divider {
margin: 6px 4px !important;
height: 1px;
background: rgba(106, 156, 213, 0.42) !important;
}
html.desktop-reskin #navbar .ve-dropdown-ext-link > .glyphicon {
color: #9ec9f3 !important;
}
html.desktop-reskin .input-xs,
html.desktop-reskin .form-control,
html.desktop-reskin select,
html.desktop-reskin input,
html.desktop-reskin textarea {
border-radius: 8px !important;
border-color: rgba(115, 170, 226, 0.45) !important;
}
html.desktop-reskin .btn,
html.desktop-reskin .ve-btn {
border-radius: 9px !important;
}
html.desktop-reskin .omni__wrp-output {
position: relative;
z-index: 3400 !important;
}
html.desktop-reskin .omni__output {
border: 1px solid rgba(114, 171, 232, 0.45) !important;
border-radius: 12px !important;
background: linear-gradient(180deg, rgba(15, 25, 41, 0.98), rgba(9, 17, 29, 0.99)) !important;
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.42);
color: #dce9f8 !important;
}
html.desktop-reskin .omni__output .ve-btn,
html.desktop-reskin .omni__output .btn {
border: 1px solid rgba(112, 167, 225, 0.42) !important;
background: rgba(22, 36, 56, 0.92) !important;
color: #dce9f8 !important;
}
html.desktop-reskin .omni__output .ve-btn.active,
html.desktop-reskin .omni__output .btn.active,
html.desktop-reskin .omni__output .ve-btn:hover,
html.desktop-reskin .omni__output .btn:hover {
background: rgba(53, 95, 145, 0.58) !important;
border-color: rgba(139, 204, 255, 0.72) !important;
}
html.desktop-reskin .omni__row-result {
padding: 3px 6px;
border-radius: 8px;
border-bottom: 1px solid rgba(105, 151, 205, 0.3);
color: #d7e6f7 !important;
}
html.desktop-reskin .omni__row-result:nth-child(odd) {
background: rgba(43, 71, 105, 0.38) !important;
}
html.desktop-reskin .omni__row-result:hover {
background: rgba(63, 103, 151, 0.52) !important;
}
html.desktop-reskin .omni__lnk-name {
color: #8ec9ff !important;
}
html.desktop-reskin .omni__disp-srd,
html.desktop-reskin .omni__disp-source-marker,
html.desktop-reskin .omni__wrp-page {
color: #aecaec !important;
}
html.desktop-reskin .omni__wrp-paginate {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid rgba(107, 156, 211, 0.35);
}
html.desktop-reskin .omni__paginate-ctrl {
color: #c8def8;
}
html.desktop-reskin .omni__paginate-ctrl:hover {
color: #8dc9ff !important;
}
html.desktop-reskin .list,
html.desktop-reskin .list-display-only,
html.desktop-reskin .lst__row,
html.desktop-reskin .ui-list__row,
html.desktop-reskin .list li {
color: #dce8f8;
}
html.desktop-reskin .lst__row,
html.desktop-reskin .ui-list__row,
html.desktop-reskin .list li,
html.desktop-reskin .lst__row-border,
html.desktop-reskin .lst__row-inner,
html.desktop-reskin .veapp__list-row {
border-radius: 10px;
}
html.desktop-reskin .lst__row-border,
html.desktop-reskin .lst__row-inner,
html.desktop-reskin .veapp__list-row,
html.desktop-reskin .lst__wrp-cells {
border-color: rgba(112, 165, 222, 0.42) !important;
color: #dce8f8 !important;
}
html.desktop-reskin .list .lst__row:nth-child(odd) .lst__row-border,
html.desktop-reskin .list .lst__row:nth-child(odd) .lst__row-inner,
html.desktop-reskin .list .lst__row:nth-child(odd).lst__row-border,
html.desktop-reskin .list .lst__row:nth-child(odd).veapp__list-row {
background: rgba(34, 57, 86, 0.36);
}
html.desktop-reskin .list .lst__row:nth-child(even) .lst__row-border,
html.desktop-reskin .list .lst__row:nth-child(even) .lst__row-inner,
html.desktop-reskin .list .lst__row:nth-child(even).lst__row-border,
html.desktop-reskin .list .lst__row:nth-child(even).veapp__list-row {
background: rgba(18, 30, 48, 0.38);
}
html.desktop-reskin .list .lst__row:hover .lst__row-border,
html.desktop-reskin .list .lst__row:hover .lst__row-inner,
html.desktop-reskin .list .lst__row:hover.lst__row-border,
html.desktop-reskin .list .lst__row:hover.veapp__list-row {
background: rgba(64, 105, 151, 0.48) !important;
}
html.desktop-reskin .lst__row a,
html.desktop-reskin .veapp__list-row a,
html.desktop-reskin .lst__wrp-cells a {
color: inherit;
}
html.desktop-reskin .lst__form-top > *,
html.desktop-reskin .lst__form-top .form-control,
html.desktop-reskin .lst__form-top .btn,
html.desktop-reskin .lst__form-top .ve-btn {
border-color: rgba(112, 167, 225, 0.48) !important;
background: rgba(15, 25, 40, 0.94) !important;
color: #dce8f8 !important;
}
html.desktop-reskin .lst__form-top .form-control::placeholder {
color: #99b2cf !important;
}
html.desktop-reskin .fltr__pill,
html.desktop-reskin .fltr__mini-pill,
html.desktop-reskin .fltr__btn_nest {
border-color: rgba(109, 163, 221, 0.45) !important;
background: rgba(20, 32, 50, 0.92) !important;
color: #dce8f8 !important;
}
html.desktop-reskin .fltr__pill[data-state="yes"],
html.desktop-reskin .fltr__mini-pill[data-state="yes"] {
background: linear-gradient(140deg, rgba(38, 120, 196, 0.9), rgba(27, 89, 151, 0.94)) !important;
border-color: rgba(131, 196, 255, 0.84) !important;
}
html.desktop-reskin .fltr__pill[data-state="no"],
html.desktop-reskin .fltr__mini-pill[data-state="no"] {
background: linear-gradient(140deg, rgba(136, 55, 55, 0.92), rgba(109, 44, 44, 0.94)) !important;
border-color: rgba(206, 132, 132, 0.7) !important;
}
html.desktop-reskin .wrp-stats-table {
border-top: 1px solid rgba(114, 171, 232, 0.5) !important;
border-bottom: 1px solid rgba(114, 171, 232, 0.5) !important;
background: rgba(9, 15, 25, 0.5);
}
html.desktop-reskin .table,
html.desktop-reskin table {
color: #dce8f8;
}
html.desktop-reskin .table > thead > tr > th,
html.desktop-reskin .table > tbody > tr > th,
html.desktop-reskin .table > tfoot > tr > th,
html.desktop-reskin .table > thead > tr > td,
html.desktop-reskin .table > tbody > tr > td,
html.desktop-reskin .table > tfoot > tr > td,
html.desktop-reskin table > thead > tr > th,
html.desktop-reskin table > tbody > tr > th,
html.desktop-reskin table > tfoot > tr > th,
html.desktop-reskin table > thead > tr > td,
html.desktop-reskin table > tbody > tr > td,
html.desktop-reskin table > tfoot > tr > td {
border-color: rgba(104, 154, 209, 0.42) !important;
}
html.desktop-reskin .table > thead > tr > th,
html.desktop-reskin table > thead > tr > th {
background: rgba(33, 59, 92, 0.62);
color: #e8f2ff;
}
html.desktop-reskin .table-striped > tbody > tr:nth-of-type(odd),
html.desktop-reskin .stripe-odd-table > tbody > tr:nth-child(odd),
html.desktop-reskin .stripe-even-table > tbody > tr:nth-child(even),
html.desktop-reskin .stripe-odd:nth-child(odd),
html.desktop-reskin .stripe-even:nth-child(even) {
background: rgba(42, 70, 104, 0.38) !important;
}
html.desktop-reskin .hwin,
html.desktop-reskin .ve-window,
html.desktop-reskin .modal__inner {
border: 1px solid rgba(115, 170, 226, 0.35) !important;
border-radius: 12px !important;
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.35);
}
/* App-managed top bar mode: keep 5etools content, hide native header/nav chrome */
html.desktop-reskin.desktop-app-navbar-mode body {
padding-top: 0 !important;
}
html.desktop-reskin.desktop-app-navbar-mode .viewport-wrapper {
inset: 0 !important;
height: auto !important;
min-height: 100vh !important;
}
html.desktop-reskin.desktop-app-navbar-mode header.page__header,
html.desktop-reskin.desktop-app-navbar-mode .page__header,
html.desktop-reskin.desktop-app-navbar-mode #navigation,
html.desktop-reskin.desktop-app-navbar-mode nav.page__nav,
html.desktop-reskin.desktop-app-navbar-mode #navigation.page__nav,
html.desktop-reskin.desktop-app-navbar-mode .page__nav,
html.desktop-reskin.desktop-app-navbar-mode .page__disp-download-progress {
display: none !important;
}
`;
export interface WikiServer {
readonly rootDir: string;
readonly url: string;
close: () => Promise<void>;
}
export interface StartWikiServerOptions {
rootDir?: string;
externalImageRoot?: string | null;
}
function getWikiRootCandidates(explicitRootDir?: string): string[] {
const envDir = process.env.FIVEETOOLS_SRC_DIR;
return [
explicitRootDir,
envDir,
path.resolve(app.getAppPath(), "..", "5etools-src"),
path.resolve(app.getAppPath(), "5etools-src"),
path.resolve(process.cwd(), "..", "5etools-src"),
path.resolve(process.cwd(), "5etools-src"),
path.resolve(process.resourcesPath, "5etools-src")
].filter((it): it is string => Boolean(it));
}
function isWikiRootDir(candidate: string): boolean {
const indexPath = path.join(candidate, "5etools.html");
const dataPath = path.join(candidate, "data");
return fs.existsSync(indexPath) && fs.existsSync(dataPath);
}
function resolveWikiRootDir(explicitRootDir?: string): string {
const candidates = getWikiRootCandidates(explicitRootDir);
for (const candidate of candidates) {
if (isWikiRootDir(candidate)) {
return candidate;
}
}
const formatted = candidates.map((it) => `- ${it}`).join("\n");
throw new Error(`Unable to locate 5etools-src directory. Checked:\n${formatted}`);
}
export function findWikiRootDir(explicitRootDir?: string): string | null {
try {
return resolveWikiRootDir(explicitRootDir);
} catch {
return null;
}
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
}
function normalizeExternalBaseUrl(url: string): string {
const trimmed = url.trim();
return `${trimmed.replace(/\/+$/, "")}/`;
}
function resolveExternalImageRoot(externalImageRoot?: string | null): string | null {
if (externalImageRoot != null) {
const trimmed = externalImageRoot.trim();
return trimmed ? normalizeExternalBaseUrl(trimmed) : null;
}
if (!SHOULD_USE_EXTERNAL_IMAGE_FALLBACK) {
return null;
}
const envValue = process.env.FIVEETOOLS_DESKTOP_IMG_BASE_URL || DEFAULT_EXTERNAL_IMAGE_ROOT;
return normalizeExternalBaseUrl(envValue);
}
function getRequestPath(requestUrl: string): string {
const parsed = new URL(requestUrl, "http://127.0.0.1");
return decodeURIComponent(parsed.pathname);
}
function getExternalImageUrl(requestUrl: string, externalImageRoot: string | null): URL | null {
if (!externalImageRoot) {
return null;
}
let requestPath: string;
try {
requestPath = getRequestPath(requestUrl);
} catch {
return null;
}
if (!requestPath.startsWith("/img/")) {
return null;
}
const relativePath = requestPath.slice("/img/".length);
if (!relativePath) {
return null;
}
return new URL(relativePath, externalImageRoot);
}
async function pTryServeExternalImage(
requestUrl: string,
res: http.ServerResponse,
externalImageRoot: string | null
): Promise<boolean> {
const externalImageUrl = getExternalImageUrl(requestUrl, externalImageRoot);
if (!externalImageUrl) {
return false;
}
try {
const response = await fetch(externalImageUrl.toString());
if (!response.ok) {
return false;
}
const responseBuffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get("content-type") ?? getMimeType(externalImageUrl.pathname);
res.writeHead(200, withCorsHeaders({
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600"
}));
res.end(responseBuffer);
return true;
} catch {
return false;
}
}
function injectDesktopReskin(html: string, bootstrapScript: string): string {
if (html.includes("desktop-reskin-style")) {
return html;
}
const injection = [
`<script id="desktop-reskin-boot">${bootstrapScript}</script>`,
`<style id="desktop-reskin-style">${DESKTOP_RESKIN_STYLE}</style>`
].join("");
if (!/<\/head>/i.test(html)) {
return `${injection}${html}`;
}
return html.replace(/<\/head>/i, `${injection}</head>`);
}
function resolveRequestPath(rootDir: string, requestUrl: string): string {
const reqPath = getRequestPath(requestUrl);
const normalizedReqPath = reqPath === "/" ? "/5etools.html" : reqPath;
const candidate = path.resolve(rootDir, `.${normalizedReqPath}`);
const rootResolved = path.resolve(rootDir);
const relative = path.relative(rootResolved, candidate);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Blocked path traversal attempt");
}
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
const indexPath = path.join(candidate, "index.html");
if (fs.existsSync(indexPath)) {
return indexPath;
}
}
return candidate;
}
export async function startWikiServer(options: StartWikiServerOptions = {}): Promise<WikiServer> {
const rootDir = resolveWikiRootDir(options.rootDir);
const externalImageRoot = resolveExternalImageRoot(options.externalImageRoot);
const reskinBootstrap = getDesktopReskinBootstrap(externalImageRoot);
return await new Promise<WikiServer>((resolve, reject) => {
const server = http.createServer((req, res) => {
if (req.method === "OPTIONS") {
res.writeHead(204, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
res.end();
return;
}
const url = req.url ?? "/";
let filePath: string;
try {
filePath = resolveRequestPath(rootDir, url);
} catch {
res.writeHead(403, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
res.end("Forbidden");
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === "ENOENT") {
void pTryServeExternalImage(url, res, externalImageRoot).then((isServed) => {
if (isServed) {
return;
}
res.writeHead(404, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
res.end("Not Found");
});
return;
}
res.writeHead(500, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
res.end("Internal Server Error");
return;
}
const extension = path.extname(filePath).toLowerCase();
const transformedBody =
extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8"), reskinBootstrap) : data;
res.writeHead(200, withCorsHeaders({
"Content-Type": getMimeType(filePath),
"Cache-Control": "no-store"
}));
if (typeof transformedBody === "string") {
res.end(transformedBody, "utf8");
return;
}
res.end(transformedBody);
});
});
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Failed to resolve local wiki server address"));
return;
}
const url = `http://127.0.0.1:${address.port}`;
resolve({
rootDir,
url,
close: () =>
new Promise<void>((closeResolve, closeReject) => {
server.close((closeErr) => {
if (closeErr) {
closeReject(closeErr);
return;
}
closeResolve();
});
})
});
});
});
}