mirror of
https://github.com/ThunixdotNet/www.git
synced 2026-01-24 07:10:18 +00:00
546 lines
15 KiB
JavaScript
546 lines
15 KiB
JavaScript
|
|
// thunix terminal UI powered by xterm.js (ESM via jsDelivr).
|
||
|
|
|
||
|
|
import { Terminal } from "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/+esm";
|
||
|
|
import { FitAddon } from "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/+esm";
|
||
|
|
import { WebLinksAddon } from "https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.12.0/+esm";
|
||
|
|
|
||
|
|
const MODULE_BASE = new URL(".", import.meta.url);
|
||
|
|
const urlFromBase = (path) => new URL(path, MODULE_BASE).toString();
|
||
|
|
|
||
|
|
const DEFAULT_WEBMAIL_PATH = "/webmail/";
|
||
|
|
|
||
|
|
const ESC = "\x1b";
|
||
|
|
const CSI = `${ESC}[`;
|
||
|
|
const OSC = `${ESC}]`;
|
||
|
|
|
||
|
|
const ANSI = {
|
||
|
|
reset: `${CSI}0m`,
|
||
|
|
bold: `${CSI}1m`,
|
||
|
|
dim: `${CSI}2m`,
|
||
|
|
underline: `${CSI}4m`,
|
||
|
|
noUnderline: `${CSI}24m`,
|
||
|
|
clrLine: `${CSI}2K`,
|
||
|
|
fgBrightYellow: `${CSI}93m`,
|
||
|
|
fgWhite: `${CSI}37m`,
|
||
|
|
};
|
||
|
|
|
||
|
|
function osc8(url, text) {
|
||
|
|
const BEL = "\x07";
|
||
|
|
return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function absolutizeUrl(href) {
|
||
|
|
if (!href) return href;
|
||
|
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return href;
|
||
|
|
if (href.startsWith("//")) return `${location.protocol}${href}`;
|
||
|
|
if (href.startsWith("/")) return `${location.origin}${href}`;
|
||
|
|
return new URL(href, location.href).toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderInlineLinks(line) {
|
||
|
|
return line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => {
|
||
|
|
const url = absolutizeUrl(String(href).trim());
|
||
|
|
const label = `${ANSI.underline}${ANSI.fgBrightYellow}${text}${ANSI.reset}`;
|
||
|
|
return osc8(url, label);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function stripHtmlToText(html) {
|
||
|
|
let s = String(html || "");
|
||
|
|
|
||
|
|
s = s.replace(/<\s*br\s*\/?\s*>/gi, "\n");
|
||
|
|
s = s.replace(/<\s*\/(p|div|tr|li|table|form|h1|h2|h3|ul|ol)\s*>/gi, "\n");
|
||
|
|
s = s.replace(/<\s*(p|div|tr|li|table|form|h1|h2|h3|ul|ol)(\s[^>]*)?>/gi, "\n");
|
||
|
|
|
||
|
|
s = s.replace(/<a\s+[^>]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/gi, (_m, href, text) => {
|
||
|
|
const cleanText = String(text).replace(/<[^>]+>/g, "").trim() || href;
|
||
|
|
return `[${cleanText}](${href})`;
|
||
|
|
});
|
||
|
|
|
||
|
|
s = s.replace(/<[^>]+>/g, "");
|
||
|
|
|
||
|
|
s = s.replace(/ /g, " ");
|
||
|
|
s = s.replace(/&/g, "&");
|
||
|
|
s = s.replace(/</g, "<");
|
||
|
|
s = s.replace(/>/g, ">");
|
||
|
|
s = s.replace(/"/g, '"');
|
||
|
|
s = s.replace(/'/g, "'");
|
||
|
|
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderMarkdown(md) {
|
||
|
|
const input = stripHtmlToText(md);
|
||
|
|
const out = [];
|
||
|
|
const lines = input.replace(/\r\n/g, "\n").split("\n");
|
||
|
|
|
||
|
|
let inCode = false;
|
||
|
|
|
||
|
|
for (const raw of lines) {
|
||
|
|
let line = raw;
|
||
|
|
|
||
|
|
if (/^\s*```/.test(line)) {
|
||
|
|
inCode = !inCode;
|
||
|
|
out.push(inCode ? `${ANSI.dim}--- code ---${ANSI.reset}` : `${ANSI.dim}--- end ---${ANSI.reset}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (inCode) {
|
||
|
|
out.push(line);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (/^\s*#\s+/.test(line)) {
|
||
|
|
const title = line.replace(/^\s*#\s+/, "").trim();
|
||
|
|
out.push(`${ANSI.bold}${title}${ANSI.reset}`);
|
||
|
|
out.push(`${ANSI.dim}${"=".repeat(Math.min(78, title.length || 1))}${ANSI.reset}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (/^\s*##\s+/.test(line)) {
|
||
|
|
const title = line.replace(/^\s*##\s+/, "").trim();
|
||
|
|
out.push(`${ANSI.bold}${title}${ANSI.reset}`);
|
||
|
|
out.push(`${ANSI.dim}${"-".repeat(Math.min(78, title.length || 1))}${ANSI.reset}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const mBullet = line.match(/^\s*[-*]\s+(.*)$/);
|
||
|
|
if (mBullet) {
|
||
|
|
line = ` - ${mBullet[1]}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
line = line.replace(/\s+$/g, "");
|
||
|
|
line = renderInlineLinks(line);
|
||
|
|
out.push(line);
|
||
|
|
}
|
||
|
|
|
||
|
|
return out.join("\r\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
class ThunixTerminal {
|
||
|
|
constructor(hostEl, frameEl) {
|
||
|
|
this.el = hostEl;
|
||
|
|
this.frame = frameEl;
|
||
|
|
|
||
|
|
this.term = null;
|
||
|
|
this.fit = new FitAddon();
|
||
|
|
|
||
|
|
this.buffer = "";
|
||
|
|
this.history = [];
|
||
|
|
this.historyIdx = -1;
|
||
|
|
|
||
|
|
this.pages = new Map();
|
||
|
|
this.menu = null;
|
||
|
|
|
||
|
|
this.webLinks = new WebLinksAddon((ev, uri) => {
|
||
|
|
try {
|
||
|
|
ev?.preventDefault?.();
|
||
|
|
} catch {
|
||
|
|
}
|
||
|
|
this.handleLinkActivate(uri);
|
||
|
|
});
|
||
|
|
|
||
|
|
this.init();
|
||
|
|
}
|
||
|
|
|
||
|
|
init() {
|
||
|
|
this.term = new Terminal({
|
||
|
|
cursorBlink: true,
|
||
|
|
convertEol: true,
|
||
|
|
scrollback: 4000,
|
||
|
|
fontFamily:
|
||
|
|
'"Departure Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||
|
|
fontSize: 18,
|
||
|
|
theme: {
|
||
|
|
background: "#111111",
|
||
|
|
foreground: "#edb200",
|
||
|
|
cursor: "#ffc828",
|
||
|
|
selection: "rgba(255, 200, 40, 0.25)",
|
||
|
|
black: "#000000",
|
||
|
|
brightYellow: "#ffc828",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
this.term.loadAddon(this.fit);
|
||
|
|
this.term.loadAddon(this.webLinks);
|
||
|
|
|
||
|
|
this.term.open(this.el);
|
||
|
|
this.fit.fit();
|
||
|
|
|
||
|
|
window.addEventListener("resize", () => this.fit.fit());
|
||
|
|
|
||
|
|
this.el.addEventListener("mousedown", () => this.term.focus());
|
||
|
|
this.term.focus();
|
||
|
|
|
||
|
|
this.term.onKey((e) => this.onKey(e));
|
||
|
|
|
||
|
|
this.boot();
|
||
|
|
}
|
||
|
|
|
||
|
|
writeln(s = "") {
|
||
|
|
this.term.writeln(s);
|
||
|
|
}
|
||
|
|
|
||
|
|
write(s = "") {
|
||
|
|
this.term.write(s);
|
||
|
|
}
|
||
|
|
|
||
|
|
prompt() {
|
||
|
|
this.write(`\r\n${ANSI.bold}guest@thunix${ANSI.reset}:${ANSI.fgWhite}~${ANSI.reset}$ `);
|
||
|
|
}
|
||
|
|
|
||
|
|
redrawInput(newValue) {
|
||
|
|
this.write(`\r${ANSI.clrLine}${ANSI.bold}guest@thunix${ANSI.reset}:${ANSI.fgWhite}~${ANSI.reset}$ ${newValue}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async boot() {
|
||
|
|
this.writeln(`${ANSI.bold}thunix terminal${ANSI.reset}`);
|
||
|
|
this.writeln(`${ANSI.dim}Terminal commands + real HTML panel so forms actually work.${ANSI.reset}`);
|
||
|
|
this.writeln("");
|
||
|
|
|
||
|
|
await Promise.allSettled([this.loadPages(), this.loadMenu()]);
|
||
|
|
|
||
|
|
const hash = (location.hash || "").replace(/^#/, "").trim();
|
||
|
|
const initial = hash && this.normalizeSlug(hash);
|
||
|
|
if (initial) {
|
||
|
|
this.openInPanel(initial);
|
||
|
|
} else {
|
||
|
|
this.openInPanel("main");
|
||
|
|
}
|
||
|
|
|
||
|
|
this.writeln(`${ANSI.dim}Type ${ANSI.reset}${ANSI.bold}help${ANSI.reset}${ANSI.dim} for commands.${ANSI.reset}`);
|
||
|
|
this.prompt();
|
||
|
|
}
|
||
|
|
|
||
|
|
async loadPages() {
|
||
|
|
const res = await fetch(urlFromBase("api/pages.php"), { cache: "no-store" });
|
||
|
|
if (!res.ok) return;
|
||
|
|
const data = await res.json();
|
||
|
|
if (!data?.pages) return;
|
||
|
|
for (const p of data.pages) {
|
||
|
|
this.pages.set(p.slug, p.title || p.slug);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async loadMenu() {
|
||
|
|
const res = await fetch(urlFromBase("api/menu.php"), { cache: "no-store" });
|
||
|
|
if (!res.ok) return;
|
||
|
|
this.menu = await res.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
handleLinkActivate(uri) {
|
||
|
|
const u = (() => {
|
||
|
|
try {
|
||
|
|
return new URL(uri, location.origin);
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
|
||
|
|
if (!u) {
|
||
|
|
window.open(uri, "_blank", "noopener");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (u.pathname.endsWith("/terminal/view.php")) {
|
||
|
|
const p = u.searchParams.get("page") || "";
|
||
|
|
if (p) {
|
||
|
|
this.openInPanel(p);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (u.origin === location.origin && u.pathname.startsWith("/")) {
|
||
|
|
const slug = this.normalizeSlug(u.pathname.replace(/^\/+/, ""));
|
||
|
|
if (slug && this.pages.has(slug)) {
|
||
|
|
this.openInPanel(slug);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
window.open(u.toString(), "_blank", "noopener");
|
||
|
|
}
|
||
|
|
|
||
|
|
onKey({ key, domEvent }) {
|
||
|
|
const ev = domEvent;
|
||
|
|
|
||
|
|
if (ev.ctrlKey && ev.key.toLowerCase() === "l") {
|
||
|
|
ev.preventDefault();
|
||
|
|
this.cmdClear();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ev.ctrlKey && ev.key.toLowerCase() === "c") {
|
||
|
|
ev.preventDefault();
|
||
|
|
this.write("^C");
|
||
|
|
this.buffer = "";
|
||
|
|
this.historyIdx = -1;
|
||
|
|
this.prompt();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ev.key === "Enter") {
|
||
|
|
const line = this.buffer.trim();
|
||
|
|
this.buffer = "";
|
||
|
|
this.historyIdx = -1;
|
||
|
|
this.write("\r\n");
|
||
|
|
if (line) this.history.unshift(line);
|
||
|
|
this.run(line);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ev.key === "Backspace") {
|
||
|
|
if (this.buffer.length > 0) {
|
||
|
|
this.buffer = this.buffer.slice(0, -1);
|
||
|
|
this.write("\b \b");
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ev.key === "ArrowUp") {
|
||
|
|
if (this.history.length === 0) return;
|
||
|
|
if (this.historyIdx + 1 < this.history.length) this.historyIdx++;
|
||
|
|
const next = this.history[this.historyIdx] ?? "";
|
||
|
|
this.buffer = next;
|
||
|
|
this.redrawInput(this.buffer);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ev.key === "ArrowDown") {
|
||
|
|
if (this.history.length === 0) return;
|
||
|
|
if (this.historyIdx > 0) this.historyIdx--;
|
||
|
|
else this.historyIdx = -1;
|
||
|
|
const next = this.historyIdx >= 0 ? (this.history[this.historyIdx] ?? "") : "";
|
||
|
|
this.buffer = next;
|
||
|
|
this.redrawInput(this.buffer);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!ev.altKey && !ev.ctrlKey && !ev.metaKey && key && key.length === 1) {
|
||
|
|
this.buffer += key;
|
||
|
|
this.write(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async run(line) {
|
||
|
|
if (!line) {
|
||
|
|
this.prompt();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const [cmdRaw, ...args] = line.split(/\s+/);
|
||
|
|
const cmd = cmdRaw.toLowerCase();
|
||
|
|
|
||
|
|
switch (cmd) {
|
||
|
|
case "help":
|
||
|
|
this.cmdHelp();
|
||
|
|
break;
|
||
|
|
case "clear":
|
||
|
|
this.cmdClear();
|
||
|
|
break;
|
||
|
|
case "menu":
|
||
|
|
await this.cmdMenu();
|
||
|
|
break;
|
||
|
|
case "pages":
|
||
|
|
case "ls":
|
||
|
|
await this.cmdPages();
|
||
|
|
break;
|
||
|
|
case "open":
|
||
|
|
await this.cmdOpen(args.join(" "));
|
||
|
|
break;
|
||
|
|
case "cat":
|
||
|
|
await this.cmdCat(args.join(" "));
|
||
|
|
break;
|
||
|
|
case "web":
|
||
|
|
this.cmdWeb(args.join(" "));
|
||
|
|
break;
|
||
|
|
case "webmail":
|
||
|
|
case "mail":
|
||
|
|
this.cmdWebmail(args.join(" "));
|
||
|
|
break;
|
||
|
|
case "users":
|
||
|
|
await this.cmdOpen("users");
|
||
|
|
break;
|
||
|
|
case "server":
|
||
|
|
await this.cmdOpen("server");
|
||
|
|
break;
|
||
|
|
case "news":
|
||
|
|
await this.cmdOpen("news");
|
||
|
|
break;
|
||
|
|
case "main":
|
||
|
|
case "home":
|
||
|
|
await this.cmdOpen("main");
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
this.writeln(`${ANSI.dim}Unknown command:${ANSI.reset} ${cmdRaw}`);
|
||
|
|
this.writeln(`${ANSI.dim}Try:${ANSI.reset} ${ANSI.bold}help${ANSI.reset}`);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.prompt();
|
||
|
|
}
|
||
|
|
|
||
|
|
cmdHelp() {
|
||
|
|
this.writeln(`${ANSI.bold}Commands${ANSI.reset}`);
|
||
|
|
this.writeln(`${ANSI.dim}help${ANSI.reset} Show this help`);
|
||
|
|
this.writeln(`${ANSI.dim}pages | ls${ANSI.reset} List available content pages`);
|
||
|
|
this.writeln(`${ANSI.dim}open <page>${ANSI.reset} Load a page in the panel`);
|
||
|
|
this.writeln(`${ANSI.dim}web <page>${ANSI.reset} Print web URLs for a page`);
|
||
|
|
this.writeln(`${ANSI.dim}webmail [url]${ANSI.reset} Open webmail in a new tab (alias: mail)`);
|
||
|
|
this.writeln(`${ANSI.dim}clear${ANSI.reset} Clear the terminal (Ctrl+L)`);
|
||
|
|
this.writeln("");
|
||
|
|
this.writeln(`${ANSI.dim}Aliases:${ANSI.reset} home, main, users, server, news, mail`);
|
||
|
|
}
|
||
|
|
|
||
|
|
cmdClear() {
|
||
|
|
this.term.clear();
|
||
|
|
this.term.reset();
|
||
|
|
this.writeln(`${ANSI.bold}thunix terminal${ANSI.reset}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async cmdMenu() {
|
||
|
|
if (!this.menu) await this.loadMenu();
|
||
|
|
const menu = this.menu;
|
||
|
|
|
||
|
|
if (!menu?.sections?.length) {
|
||
|
|
this.writeln(`${ANSI.dim}Menu not available.${ANSI.reset}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const section of menu.sections) {
|
||
|
|
this.writeln("");
|
||
|
|
this.writeln(`${ANSI.bold}${section.title}${ANSI.reset}`);
|
||
|
|
this.writeln(`${ANSI.dim}${"-".repeat(Math.min(78, section.title.length || 1))}${ANSI.reset}`);
|
||
|
|
for (const item of section.items) {
|
||
|
|
const href = item.internal ? `${location.origin}/${item.slug}` : absolutizeUrl(item.href);
|
||
|
|
const label = `${ANSI.underline}${ANSI.fgBrightYellow}${item.text}${ANSI.reset}`;
|
||
|
|
const link = osc8(href, label);
|
||
|
|
const hint = item.internal ? `${ANSI.dim} (open ${item.slug})${ANSI.reset}` : "";
|
||
|
|
this.writeln(` • ${link}${hint}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async cmdPages() {
|
||
|
|
if (this.pages.size === 0) await this.loadPages();
|
||
|
|
if (this.pages.size === 0) {
|
||
|
|
this.writeln(`${ANSI.dim}No pages found.${ANSI.reset}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.writeln(`${ANSI.bold}Pages${ANSI.reset}`);
|
||
|
|
const slugs = [...this.pages.keys()].sort();
|
||
|
|
this.writeln(slugs.map((s) => ` - ${s}`).join("\r\n"));
|
||
|
|
}
|
||
|
|
|
||
|
|
cmdWeb(arg) {
|
||
|
|
const slug = this.normalizeSlug(arg);
|
||
|
|
if (!slug) {
|
||
|
|
this.writeln(`${ANSI.dim}Usage:${ANSI.reset} web <page>`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const classic = `${location.origin}/${slug}`;
|
||
|
|
const panelUrl = new URL("view.php", MODULE_BASE);
|
||
|
|
panelUrl.searchParams.set("page", slug);
|
||
|
|
const panel = panelUrl.toString();
|
||
|
|
this.writeln(`${ANSI.dim}Classic:${ANSI.reset} ${osc8(classic, `${ANSI.underline}${ANSI.fgBrightYellow}${classic}${ANSI.reset}`)}`);
|
||
|
|
this.writeln(`${ANSI.dim}Panel:${ANSI.reset} ${osc8(panel, `${ANSI.underline}${ANSI.fgBrightYellow}${panel}${ANSI.reset}`)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
cmdWebmail(arg) {
|
||
|
|
const raw = String(arg || "").trim();
|
||
|
|
|
||
|
|
const configured = (() => {
|
||
|
|
try {
|
||
|
|
const v = window.THUNIX_WEBMAIL_URL;
|
||
|
|
return typeof v === "string" ? v.trim() : "";
|
||
|
|
} catch {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
|
||
|
|
const target = raw || configured || DEFAULT_WEBMAIL_PATH;
|
||
|
|
const url = absolutizeUrl(target);
|
||
|
|
|
||
|
|
window.open(url, "_blank", "noopener");
|
||
|
|
|
||
|
|
const label = `${ANSI.underline}${ANSI.fgBrightYellow}${url}${ANSI.reset}`;
|
||
|
|
this.writeln(`${ANSI.dim}Opened webmail:${ANSI.reset} ${osc8(url, label)}`);
|
||
|
|
|
||
|
|
if (!raw && !configured && DEFAULT_WEBMAIL_PATH !== "/webmail/") {
|
||
|
|
this.writeln(`${ANSI.dim}Hint:${ANSI.reset} set window.THUNIX_WEBMAIL_URL if your webmail lives elsewhere.`);
|
||
|
|
} else if (!raw && !configured) {
|
||
|
|
this.writeln(
|
||
|
|
`${ANSI.dim}Hint:${ANSI.reset} if your webmail isn't at ${ANSI.bold}${DEFAULT_WEBMAIL_PATH}${ANSI.reset}${ANSI.dim}, set window.THUNIX_WEBMAIL_URL (or run: webmail <url>).${ANSI.reset}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizeSlug(arg) {
|
||
|
|
if (!arg) return "";
|
||
|
|
let s = String(arg).trim();
|
||
|
|
if (s.startsWith("/")) s = s.replace(/^\/+/, "");
|
||
|
|
if (s.includes("?")) s = s.split("?")[0];
|
||
|
|
if (s === "") return "";
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
openInPanel(slug) {
|
||
|
|
if (!this.frame) return;
|
||
|
|
const clean = this.normalizeSlug(slug);
|
||
|
|
this.frame.src = `${urlFromBase("view.php")}?page=${encodeURIComponent(clean)}`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
history.replaceState(null, "", `#${encodeURIComponent(clean)}`);
|
||
|
|
} catch {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async cmdOpen(arg) {
|
||
|
|
const slug = this.normalizeSlug(arg);
|
||
|
|
if (!slug) {
|
||
|
|
this.writeln(`${ANSI.dim}Usage:${ANSI.reset} open <page>`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (/^https?:\/\//i.test(slug) || slug.startsWith("//")) {
|
||
|
|
const url = absolutizeUrl(slug);
|
||
|
|
window.open(url, "_blank", "noopener");
|
||
|
|
this.writeln(`${ANSI.dim}Opened externally:${ANSI.reset} ${osc8(url, `${ANSI.underline}${ANSI.fgBrightYellow}${url}${ANSI.reset}`)}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.pages.size === 0) await this.loadPages();
|
||
|
|
if (this.pages.size && !this.pages.has(slug) && !/^success\d+$/.test(slug)) {
|
||
|
|
this.writeln(`${ANSI.dim}No such page:${ANSI.reset} ${slug}`);
|
||
|
|
this.writeln(`${ANSI.dim}Try:${ANSI.reset} pages`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.openInPanel(slug);
|
||
|
|
this.writeln(`${ANSI.dim}Loaded in panel:${ANSI.reset} ${ANSI.bold}${slug}${ANSI.reset}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async cmdCat(arg) {
|
||
|
|
const slug = this.normalizeSlug(arg);
|
||
|
|
if (!slug) {
|
||
|
|
this.writeln(`${ANSI.dim}Usage:${ANSI.reset} cat <page>`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const res = await fetch(`${urlFromBase("api/page.php")}?p=${encodeURIComponent(slug)}`, { cache: "no-store" });
|
||
|
|
if (!res.ok) {
|
||
|
|
this.writeln(`${ANSI.dim}No such page:${ANSI.reset} ${slug}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const data = await res.json();
|
||
|
|
const md = String(data?.markdown ?? "");
|
||
|
|
this.writeln("");
|
||
|
|
this.writeln(renderMarkdown(md));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.addEventListener("DOMContentLoaded", () => {
|
||
|
|
const host = document.getElementById("terminal");
|
||
|
|
const frame = document.getElementById("contentFrame");
|
||
|
|
if (!host) return;
|
||
|
|
new ThunixTerminal(host, frame);
|
||
|
|
});
|