// 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(/]*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 ${ANSI.reset} Load a page in the panel`); this.writeln(`${ANSI.dim}web ${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 `); 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 ).${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 `); 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 `); 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); });