// ==UserScript== // @name Linux DO · Terminal UI // @namespace https://linux.do/ // @version 0.2.4 // @description 使用字符终端覆盖层浏览 linux.do,支持真实数据、命令输入和富内容阅读。 // @author Twelveeee // @match https://linux.do/* // @grant GM_getValue // @grant GM_setValue // @grant GM_listValues // @grant GM_deleteValue // @run-at document-start // @sandbox DOM // @noframes // ==/UserScript== (() => { // src/commands/registry.js function tokenize(source) { const tokens = []; let current = ""; let quote = ""; let escaped = false; for (const character of source) { if (escaped) { current += character; escaped = false; continue; } if (character === "\\") { escaped = true; continue; } if (quote) { if (character === quote) quote = ""; else current += character; continue; } if (character === '"' || character === "'") { quote = character; continue; } if (/\s/u.test(character)) { if (current) { tokens.push(current); current = ""; } continue; } current += character; } if (escaped) current += "\\"; if (quote) return { error: "unterminated quote" }; if (current) tokens.push(current); return { tokens }; } var CommandRegistry = class { constructor(contextFactory) { this.contextFactory = contextFactory; this.commands = /* @__PURE__ */ new Map(); } register(definition) { if (!definition?.name || typeof definition.execute !== "function") throw new TypeError("Invalid command"); const value = Object.freeze({ category: "terminal", usage: definition.name, summary: "", aliases: [], ...definition }); this.commands.set(value.name, value); for (const alias of value.aliases) this.commands.set(alias, value); return this; } definitions() { return [...new Map([...this.commands.values()].map((item) => [item.name, item])).values()]; } parse(line) { const source = String(line || "").trim(); if (!source) return { error: "empty command" }; if (source.length > 512) return { error: "command is too long" }; if (/[|><`]/u.test(source)) return { error: "pipes, redirects and command substitution are not supported" }; const result = tokenize(source); if (result.error) return result; const [name = "", ...args] = result.tokens; if (!/^[a-z][a-z0-9-]{0,31}$/iu.test(name)) return { error: `invalid command: ${name}` }; return { name: name.toLowerCase(), args, rawArgs: source.slice(name.length).trim() }; } async execute(line) { const parsed = this.parse(line); if (parsed.error) return { status: "error", message: parsed.error }; const command = this.commands.get(parsed.name); if (!command) return { status: "error", message: `command not found: ${parsed.name} · type 'ls' to list commands` }; try { return await command.execute(this.contextFactory(), parsed.args, parsed.rawArgs) || { status: "success", message: `${command.name}: complete` }; } catch (error) { return { status: "error", message: error?.message || `${command.name}: failed` }; } } suggestions(line) { const prefix = String(line || "").trimStart().split(/\s+/u, 1)[0].toLowerCase(); return this.definitions().filter((item) => !prefix || item.name.startsWith(prefix)).slice(0, 8); } }; function commandListLines(registry, filter = "") { const query = String(filter || "").toLowerCase(); const matches = registry.definitions().filter((item) => !query || item.category.toLowerCase() === query || `${item.name} ${item.usage} ${item.summary}`.toLowerCase().includes(query)); const groups = /* @__PURE__ */ new Map(); for (const item of matches) { const bucket = groups.get(item.category) || []; bucket.push(item); groups.set(item.category, bucket); } const lines = []; for (const [category, items] of groups) { if (lines.length) lines.push(""); lines.push(category.toUpperCase()); const usageWidth = Math.min(34, Math.max(8, ...items.map((item) => item.usage.length))); for (const item of items) lines.push(` ${item.usage.padEnd(usageWidth)} ${item.summary}`); } lines.push("", `${matches.length} commands`); return lines; } // src/content/cooked-renderer.js var BLOCKED_TAGS = /* @__PURE__ */ new Set([ "SCRIPT", "STYLE", "OBJECT", "EMBED", "SVG", "CANVAS", "FORM", "INPUT", "TEXTAREA", "SELECT" ]); var SIMPLE_TAGS = /* @__PURE__ */ new Set([ "P", "DIV", "SPAN", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI", "BLOCKQUOTE", "PRE", "CODE", "STRONG", "B", "EM", "I", "DEL", "S", "BR", "HR", "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD", "DETAILS", "SUMMARY", "FIGURE", "FIGCAPTION", "MARK", "SUP", "SUB", "KBD" ]); function safeUrl(value, baseUrl, { media = false } = {}) { if (!value) return null; try { const url = new URL(value, baseUrl); const protocols = media ? /* @__PURE__ */ new Set(["http:", "https:"]) : /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]); return protocols.has(url.protocol) ? url.href : null; } catch { return null; } } function trustedMediaUrl(value, baseUrl) { const target = new URL(value); const base = new URL(baseUrl); if (["data:", "blob:"].includes(target.protocol) || target.origin === base.origin) return true; return target.hostname === "ldstatic.com" || target.hostname.endsWith(".ldstatic.com") || target.hostname === "linux.do" || target.hostname.endsWith(".linux.do"); } function semanticClass(source) { const names = ["onebox", "poll", "spoiler", "lightbox-wrapper"]; return names.filter((name) => source.classList?.contains(name)).join(" "); } function renderCooked(documentRef, html, options = {}) { const baseUrl = options.baseUrl ?? "https://linux.do"; const linkStart = Number(options.linkStart || 0); const mediaStart = Number(options.mediaStart || 0); const fragment = documentRef.createDocumentFragment(); const links = []; const media = []; const Parser = documentRef.defaultView?.DOMParser ?? globalThis.DOMParser; if (!Parser) { fragment.append(documentRef.createTextNode(String(html ?? ""))); return { element: fragment, links, media }; } const parsed = new Parser().parseFromString(`
${html ?? ""}`, "text/html"); const copyChildren = (source, target) => { for (const child of source.childNodes) { const copied = copyNode(child); if (copied) target.append(copied); } }; const copyMediaSources = (source, target) => { for (const child of source.children) { if (child.tagName !== "SOURCE") continue; const src = safeUrl(child.getAttribute("src"), baseUrl, { media: true }); if (!src) continue; const node2 = documentRef.createElement("source"); node2.src = src; if (child.getAttribute("type")) node2.type = child.getAttribute("type"); target.append(node2); } }; const copyNode = (source) => { if (source.nodeType === 3) return documentRef.createTextNode(source.nodeValue ?? ""); if (source.nodeType !== 1 || BLOCKED_TAGS.has(source.tagName)) return null; if (source.tagName === "A") { const href = safeUrl(source.getAttribute("href"), baseUrl); const anchor = documentRef.createElement(href ? "a" : "span"); copyChildren(source, anchor); if (href) { const isLightbox = source.parentElement?.classList.contains("lightbox-wrapper") && Boolean(source.querySelector("img")); anchor.href = href; anchor.rel = "noopener noreferrer"; if (new URL(href).origin !== new URL(baseUrl).origin) anchor.target = "_blank"; if (isLightbox) { anchor.className = "lightbox-link"; for (const child of [...anchor.children]) { if (!child.matches("img, video, audio, .deferred-media")) child.remove(); } } else anchor.dataset.linkIndex = String(linkStart + links.length + 1); links.push({ href, text: source.textContent?.trim() || href, element: anchor }); } return anchor; } if (source.tagName === "IMG") { const src = safeUrl(source.getAttribute("src"), baseUrl, { media: true }); if (!src) return documentRef.createTextNode(`[image unavailable: ${source.alt || "no alt"}]`); if (!trustedMediaUrl(src, baseUrl)) { const placeholder = documentRef.createElement("button"); placeholder.type = "button"; placeholder.className = "deferred-media"; placeholder.textContent = `[external image: ${new URL(src).hostname} · open media ${mediaStart + media.length + 1}]`; placeholder.dataset.mediaIndex = String(mediaStart + media.length + 1); media.push({ type: "image", src, element: placeholder, deferred: true, alt: source.getAttribute("alt") || "external topic image" }); return placeholder; } const image = documentRef.createElement("img"); image.src = src; image.alt = source.getAttribute("alt") || "topic image"; image.loading = "lazy"; image.decoding = "async"; image.referrerPolicy = "no-referrer"; image.dataset.mediaIndex = String(mediaStart + media.length + 1); media.push({ type: "image", src, element: image }); return image; } if (source.tagName === "VIDEO" || source.tagName === "AUDIO") { const player = documentRef.createElement(source.tagName.toLowerCase()); const src = safeUrl(source.getAttribute("src"), baseUrl, { media: true }); if (src) player.src = src; player.controls = true; player.preload = "none"; copyMediaSources(source, player); player.dataset.mediaIndex = String(mediaStart + media.length + 1); media.push({ type: source.tagName.toLowerCase(), src: src ?? "", element: player }); return player; } if (source.tagName === "IFRAME") { const src = safeUrl(source.getAttribute("src"), baseUrl); if (!src) return documentRef.createTextNode("[embedded content blocked]"); const anchor = documentRef.createElement("a"); anchor.href = src; anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; anchor.textContent = `[embedded content: ${source.getAttribute("title") || new URL(src).hostname}]`; anchor.dataset.linkIndex = String(linkStart + links.length + 1); links.push({ href: src, text: anchor.textContent, element: anchor }); return anchor; } if (source.tagName === "BUTTON") { const button = documentRef.createElement("button"); button.type = "button"; button.disabled = true; button.className = "poll-option"; button.textContent = `[ ] ${source.textContent?.trim() || "poll option"}`; return button; } if (!SIMPLE_TAGS.has(source.tagName)) { const span = documentRef.createElement("span"); copyChildren(source, span); return span; } const node2 = documentRef.createElement(source.tagName.toLowerCase()); const className = semanticClass(source); if (className) node2.className = className; if (source.tagName === "TD" || source.tagName === "TH") { const colspan = Number.parseInt(source.getAttribute("colspan") || "1", 10); const rowspan = Number.parseInt(source.getAttribute("rowspan") || "1", 10); if (colspan > 1 && colspan < 20) node2.colSpan = colspan; if (rowspan > 1 && rowspan < 100) node2.rowSpan = rowspan; } if (source.tagName === "DETAILS" && source.hasAttribute("open")) node2.open = true; copyChildren(source, node2); return node2; }; copyChildren(parsed.body, fragment); return { element: fragment, links, media }; } // src/editor/markdown-preview.js function safeUrl2(value, baseUrl, { media = false } = {}) { try { const url = new URL(value, baseUrl); const protocols = media ? ["http:", "https:", "data:", "blob:"] : ["http:", "https:", "mailto:"]; if (!protocols.includes(url.protocol)) return null; if (url.protocol === "data:" && (!/^data:image\/(?:png|jpeg|gif|webp|svg\+xml)[;,]/iu.test(value) || value.length > 2e6)) return null; return url.href; } catch { return null; } } function appendInline(documentRef, target, source, baseUrl) { const pattern = /(!?\[[^\]]*\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/gu; let cursor = 0; for (const match of source.matchAll(pattern)) { target.append(documentRef.createTextNode(source.slice(cursor, match.index))); const token = match[0]; const link = token.match(/^(!?)\[([^\]]*)\]\(([^)]+)\)$/u); if (link) { const href = safeUrl2(link[3], baseUrl, { media: Boolean(link[1]) }); if (link[1] && href) { const image = documentRef.createElement("img"); image.src = href; image.alt = link[2] || "preview image"; image.loading = "lazy"; image.referrerPolicy = "no-referrer"; target.append(image); } else if (href) { const anchor = documentRef.createElement("a"); anchor.href = href; anchor.textContent = link[2] || href; anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; target.append(anchor); } else target.append(documentRef.createTextNode(link[2])); } else if (token.startsWith("`")) { const code = documentRef.createElement("code"); code.textContent = token.slice(1, -1); target.append(code); } else if (token.startsWith("**")) { const strong = documentRef.createElement("strong"); strong.textContent = token.slice(2, -2); target.append(strong); } else { const emphasis = documentRef.createElement("em"); emphasis.textContent = token.slice(1, -1); target.append(emphasis); } cursor = match.index + token.length; } target.append(documentRef.createTextNode(source.slice(cursor))); } function renderMarkdownPreview(documentRef, markdown, baseUrl = "https://linux.do") { const fragment = documentRef.createDocumentFragment(); const lines = String(markdown || "").split(/\r?\n/u); let code = null; let list = null; for (const line of lines) { if (line.startsWith("```")) { if (code) { fragment.append(code); code = null; } else { code = documentRef.createElement("pre"); } list = null; continue; } if (code) { code.textContent += `${line} `; continue; } const poll = line.match(/^\s*-\s*\[([ xX])\]\s+(.+)$/u); if (poll) { const button = documentRef.createElement("button"); button.type = "button"; button.disabled = true; button.className = "poll-option"; button.textContent = `[${poll[1].trim() ? "x" : " "}] ${poll[2]}`; fragment.append(button); list = null; continue; } const heading = line.match(/^(#{1,6})\s+(.+)$/u); if (heading) { const element2 = documentRef.createElement(`h${heading[1].length}`); appendInline(documentRef, element2, heading[2], baseUrl); fragment.append(element2); list = null; continue; } const item = line.match(/^\s*[-*]\s+(.+)$/u); if (item) { if (!list) { list = documentRef.createElement("ul"); fragment.append(list); } const element2 = documentRef.createElement("li"); appendInline(documentRef, element2, item[1], baseUrl); list.append(element2); continue; } list = null; if (!line.trim()) { fragment.append(documentRef.createElement("br")); continue; } const blockquote = line.match(/^>\s?(.*)$/u); const element = documentRef.createElement(blockquote ? "blockquote" : "p"); appendInline(documentRef, element, blockquote ? blockquote[1] : line, baseUrl); fragment.append(element); } if (code) fragment.append(code); return fragment; } // src/terminal/text-width.js var segmenter = typeof Intl?.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null; function graphemes(value) { const source = String(value ?? ""); return segmenter ? [...segmenter.segment(source)].map((item) => item.segment) : [...source]; } function isWide(codePoint) { return codePoint >= 4352 && (codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 42191 && codePoint !== 12351 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65135 || codePoint >= 65280 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 127744 && codePoint <= 129791 || codePoint >= 131072 && codePoint <= 262141); } function graphemeWidth(value) { const first = value.codePointAt(0); if (first === void 0 || /[\u0300-\u036f\ufe00-\ufe0f\u200d]/u.test(value[0])) return 0; return isWide(first) ? 2 : 1; } function displayWidth(value) { return graphemes(value).reduce((total, item) => total + graphemeWidth(item), 0); } function truncateWidth(value, width, suffix = "...") { const source = String(value ?? ""); if (displayWidth(source) <= width) return source; const suffixWidth = displayWidth(suffix); const limit = Math.max(0, width - suffixWidth); let output = ""; let used = 0; for (const item of graphemes(source)) { const itemWidth = graphemeWidth(item); if (used + itemWidth > limit) break; output += item; used += itemWidth; } return `${output}${suffix}`; } function formatCount(value) { const number = Number(value || 0); if (number >= 1e6) return `${(number / 1e6).toFixed(number >= 1e7 ? 0 : 1)}m`; if (number >= 1e3) return `${(number / 1e3).toFixed(number >= 1e4 ? 0 : 1)}k`; return String(number); } function relativeTime(value, now = Date.now()) { const timestamp = new Date(value).getTime(); if (!Number.isFinite(timestamp)) return "?"; const seconds = Math.max(0, Math.floor((now - timestamp) / 1e3)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d`; return new Date(timestamp).toLocaleDateString(void 0, { month: "short", day: "numeric" }); } // src/linuxdo/gateway.js function text(node2, fallback = "") { return node2?.textContent?.replace(/\s+/gu, " ").trim() || fallback; } function humanCount(value) { const source = String(value || "0").trim().toLowerCase().replace(/,/gu, ""); const number = Number.parseFloat(source) || 0; if (source.endsWith("k")) return Math.round(number * 1e3); if (source.endsWith("m")) return Math.round(number * 1e6); return number; } function safePath(value) { const url = new URL(value, "https://linux.do"); if (url.origin !== "https://linux.do") throw new Error("only linux.do routes are supported"); return `${url.pathname}${url.search}`; } var GatewayError = class extends Error { constructor(message, { status = 0, path = "", cause } = {}) { super(message, { cause }); this.name = "GatewayError"; this.status = status; this.path = path; } }; function normalizeFeed(data) { const users = new Map((data?.users || []).map((user) => [user.id, user])); const categories = new Map((data?.category_list?.categories || data?.categories || []).map((category) => [category.id, category])); const topics = data?.topic_list?.topics || data?.topics || []; return topics.map((topic) => { const poster = topic.posters?.find((item) => item.description?.includes("Original Poster")) || topic.posters?.[0]; const user = users.get(poster?.user_id); const category = categories.get(topic.category_id); return { id: topic.id, title: topic.title || topic.fancy_title || `topic ${topic.id}`, slug: topic.slug || "topic", url: `/t/${topic.slug || "topic"}/${topic.id}`, author: user?.username || topic.last_poster_username || topic.creator?.username || "?", replies: Math.max(0, Number(topic.posts_count || 1) - 1), views: Number(topic.views || 0), age: relativeTime(topic.last_posted_at || topic.bumped_at || topic.created_at), category: category?.name || "", tags: (topic.tags || []).map((tag) => typeof tag === "string" ? tag : tag.name).filter(Boolean), pinned: Boolean(topic.pinned), closed: Boolean(topic.closed), unread: Boolean(topic.unseen || topic.unread > 0) }; }); } function validUsername(value) { const username = String(value || "").replace(/^@/u, "").trim(); if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(username)) return ""; if (/^(avatar|user|current-user|current_user)$/iu.test(username)) return ""; return username; } function currentUserFromDocument(documentRef) { const metadata = [ documentRef.querySelector('meta[name="discourse-username"]')?.content, documentRef.querySelector('meta[name="current-user"]')?.content, documentRef.documentElement?.dataset?.currentUser, documentRef.body?.dataset?.currentUser ]; for (const value of metadata) { const username = validUsername(value); if (username) return { username, guest: false }; } const selectors = [ ".header-dropdown-toggle.current-user [data-user-card]", ".current-user [data-user-card]", "#current-user [data-user-card]", ".header-dropdown-toggle.current-user img.avatar[title]", ".current-user img.avatar[title]", "#current-user img[title]", ".header-dropdown-toggle.current-user img.avatar[alt]", ".current-user img.avatar[alt]", "#current-user img[alt]" ]; for (const selector of selectors) { const current = documentRef.querySelector(selector); const username = validUsername(current?.getAttribute?.("data-user-card") || current?.getAttribute?.("title") || current?.getAttribute?.("alt") || current?.textContent); if (username) return { username, guest: false }; } return { username: "guest", guest: true }; } function normalizeTopic(data) { const posts = data?.post_stream?.posts || []; return { id: data?.id, title: data?.title || data?.fancy_title || "Untitled topic", slug: data?.slug || "topic", categoryId: data?.category_id, tags: (data?.tags || []).map((tag) => typeof tag === "string" ? tag : tag.name).filter(Boolean), views: Number(data?.views || 0), replyCount: Math.max(0, Number(data?.posts_count || posts.length) - 1), posts: posts.map((post) => ({ id: post.id, number: post.post_number, author: post.username || post.name || "anonymous", avatarTemplate: post.avatar_template || "", createdAt: post.created_at, age: relativeTime(post.created_at), replyTo: post.reply_to_post_number || null, cooked: post.cooked || "", raw: post.raw || "", yours: Boolean(post.yours), canEdit: Boolean(post.can_edit), hidden: Boolean(post.hidden) })) }; } function normalizeUser(data) { const user = data?.user || data || {}; const summary = user.user_summary || {}; return { id: user.id, username: user.username || "unknown", name: user.name || "", avatarTemplate: user.avatar_template || "", trustLevel: Number(user.trust_level || 0), title: user.title || "", bioCooked: user.bio_cooked || "", location: user.location || "", website: user.website_name || user.website || "", joinedAt: user.created_at || "", lastSeenAt: user.last_seen_at || "", stats: { topics: Number(summary.topic_count || 0), posts: Number(summary.post_count || 0), likesGiven: Number(summary.likes_given || 0), likesReceived: Number(summary.likes_received || 0), daysVisited: Number(summary.days_visited || 0) } }; } var LinuxDoGateway = class { constructor({ documentRef = document, windowRef = window, fetchRef } = {}) { this.document = documentRef; this.window = windowRef; this.fetch = fetchRef || windowRef.fetch?.bind(windowRef) || fetch; this.sessionUser = null; } currentUser() { return this.sessionUser || currentUserFromDocument(this.document); } async refreshCurrentUser(options = {}) { const fromDocument = currentUserFromDocument(this.document); if (!fromDocument.guest) { this.sessionUser = fromDocument; return fromDocument; } try { const data = await this.fetchJson("/session/current.json", { timeoutMs: 4e3, ...options }); const username = validUsername(data?.current_user?.username || data?.user?.username || data?.username); this.sessionUser = username ? { username, guest: false } : fromDocument; } catch { this.sessionUser = fromDocument; } return this.sessionUser; } resolveRoute(url = this.window.location.href) { const target = new URL(url, this.window.location.origin); const path = target.pathname; const topic = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/u); if (topic) return { type: "topic", path, topicId: Number(topic[1]), postNumber: topic[2] ? Number(topic[2]) : null }; if (path === "/" || path === "/home") return { type: "home", path: "/" }; if (path === "/search") return { type: "search", path, query: target.searchParams.get("q") || "" }; if (path.startsWith("/categories")) return { type: "categories", path }; const tag = path.match(/^\/tag\/([^/]+)/u); if (tag) return { type: "feed", path, label: `tag/${decodeURIComponent(tag[1])}` }; const feed = path.match(/^\/(latest|hot|new|unread|top)(?:\/|$)/u); if (feed) return { type: "feed", path: `/${feed[1]}`, label: feed[1] }; const category = path.match(/^\/c\/([^/]+)(?:\/(\d+))?/u); if (category) return { type: "feed", path, label: `c/${decodeURIComponent(category[1])}` }; return { type: "unsupported", path }; } async fetchJson(path, { signal, timeoutMs = 15e3 } = {}) { const safe = safePath(path); const jsonPath = safe.includes("?") ? safe.replace("?", ".json?") : safe.endsWith(".json") ? safe : `${safe.replace(/\/$/u, "")}.json`; let response; const controller = new AbortController(); let timedOut = false; const abortFromCaller = () => controller.abort(signal?.reason); if (signal?.aborted) abortFromCaller(); else signal?.addEventListener("abort", abortFromCaller, { once: true }); const timeout = this.window.setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); try { response = await this.fetch(jsonPath, { credentials: "same-origin", headers: { Accept: "application/json" }, signal: controller.signal }); } catch (cause) { if (signal?.aborted || cause?.name === "AbortError" && !timedOut) throw cause; if (timedOut) throw new GatewayError(`request timed out while loading ${safe}`, { status: 408, path: safe, cause }); throw new GatewayError(`network error while loading ${safe}`, { path: safe, cause }); } finally { this.window.clearTimeout(timeout); signal?.removeEventListener("abort", abortFromCaller); } if (!response.ok) throw new GatewayError(`${response.status} while loading ${safe}`, { status: response.status, path: safe }); return response.json(); } async feed(path, options = {}) { try { return { topics: normalizeFeed(await this.fetchJson(path, options)), source: "json" }; } catch (error) { if (error?.name === "AbortError") throw error; const topics = this.feedFromDom(); if (topics.length) return { topics, source: "dom-fallback", warning: error.message }; throw error; } } async topic(path, options = {}) { try { return { topic: normalizeTopic(await this.fetchJson(path, options)), source: "json" }; } catch (error) { if (error?.name === "AbortError") throw error; const topic = this.topicFromDom(); if (topic.posts.length) return { topic, source: "dom-fallback", warning: error.message }; throw error; } } async search(query, options = {}) { const data = await this.fetchJson(`/search?q=${encodeURIComponent(query)}`, options); const topicMap = new Map((data.topics || []).map((topic) => [topic.id, topic])); const topics = (data.posts || []).map((post) => topicMap.get(post.topic_id)).filter(Boolean); return { topics: normalizeFeed({ topics: [...new Map(topics.map((topic) => [topic.id, topic])).values()], users: data.users || [] }), source: "json" }; } async categories(options = {}) { const data = await this.fetchJson("/categories", options); return (data.category_list?.categories || []).map((category) => ({ id: category.id, name: category.name, slug: category.slug, description: category.description_text || "", topicCount: Number(category.topic_count || 0), url: `/c/${category.slug}/${category.id}` })); } async user(username, options = {}) { if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(username)) throw new GatewayError("invalid username"); return normalizeUser(await this.fetchJson(`/u/${encodeURIComponent(username)}`, options)); } feedFromDom() { return [...this.document.querySelectorAll(".topic-list-item")].map((row) => { const link = row.querySelector("a.title, a.raw-topic-link"); if (!link) return null; const posters = [...row.querySelectorAll(".posters [data-user-card], .posters img[alt]")]; return { id: Number(row.dataset.topicId || link.href.match(/\/(\d+)(?:\/)?$/u)?.[1] || 0), title: text(link), url: new URL(link.href, this.window.location.origin).pathname, author: posters[0]?.getAttribute("data-user-card") || posters[0]?.getAttribute("alt") || "?", replies: humanCount(text(row.querySelector(".posts .number, .posts"), "0")), views: humanCount(text(row.querySelector(".views .number, .views"), "0")), age: text(row.querySelector(".activity"), "?"), category: text(row.querySelector(".badge-category__name")), tags: [...row.querySelectorAll(".discourse-tag")].map((item) => text(item)).filter(Boolean), pinned: row.classList.contains("pinned"), closed: row.classList.contains("closed"), unread: row.classList.contains("unseen-topic") || row.classList.contains("unread") }; }).filter(Boolean); } topicFromDom() { const title = text(this.document.querySelector("#topic-title h1, h1.fancy-title"), this.document.title); const posts = [...this.document.querySelectorAll(".topic-post")].map((post, index) => ({ id: post.dataset.postId || null, number: Number(post.dataset.postNumber || index + 1), author: text(post.querySelector("[data-user-card]"), "anonymous"), createdAt: post.querySelector("time")?.dateTime || "", age: text(post.querySelector(".relative-date, time")), replyTo: Number(text(post.querySelector(".reply-to-tab")).match(/#(\d+)/u)?.[1] || 0) || null, cooked: post.querySelector(".cooked")?.innerHTML || "", raw: "", yours: false, canEdit: false, hidden: post.classList.contains("hidden") })); return { id: Number(this.window.location.pathname.match(/\/(\d+)(?:\/\d+)?\/?$/u)?.[1] || 0), title, slug: "topic", tags: [], views: 0, replyCount: Math.max(0, posts.length - 1), posts }; } }; // src/storage/draft-storage.js var PREFIX = "linuxdo-terminal.draft:"; function createDraftStorage(windowRef = window) { const hasPrivateStorage = typeof GM_getValue === "function" && typeof GM_setValue === "function" && typeof GM_listValues === "function" && typeof GM_deleteValue === "function"; if (hasPrivateStorage) { return { scope: "userscript-private", getItem: (key) => String(GM_getValue(key, "") || ""), keys: () => GM_listValues().filter((key) => key.startsWith(PREFIX)), removeItem: (key) => GM_deleteValue(key), setItem: (key, value) => GM_setValue(key, String(value)) }; } return { scope: "page-storage-fallback", getItem: (key) => windowRef.localStorage.getItem(key) || "", keys: () => { const keys = []; for (let index = 0; index < windowRef.localStorage.length; index += 1) { const key = windowRef.localStorage.key(index); if (key?.startsWith(PREFIX)) keys.push(key); } return keys; }, removeItem: (key) => windowRef.localStorage.removeItem(key), setItem: (key, value) => windowRef.localStorage.setItem(key, String(value)) }; } // src/terminal/logo-renderer.js var WORDMARK = Object.freeze([ "L III N N U U X X DDDD OOO", "L I NN N U U X X D D O O", "L I N N N U U X D D O O", "L I N NN U U X X D D O O", "LLLLL III N N UUU X X DDDD OOO" ]); var LOGOS = Object.freeze({ large: Object.freeze({ symbol: Object.freeze([ ` ${"█".repeat(8)}`, ` ${"█".repeat(14)}`, ` ${"█".repeat(18)}`, ` ${"█".repeat(22)}`, ` ${"█".repeat(24)}`, ` ${"░".repeat(24)}`, "░░░░░░░░░░░░░░░░░░░░░░░░░░", "░░░░░░░░░░░░░░░░░░░░░░░░░░", "░░░░░░░░░░░░░░░░░░░░░░░░░░", "░░░░░░░░░░░░░░░░░░░░░░░░░░", ` ${"▓".repeat(24)}`, ` ${"▓".repeat(22)}`, ` ${"▓".repeat(18)}`, ` ${"▓".repeat(14)}`, ` ${"▓".repeat(8)}` ]), wordmark: WORDMARK }), medium: Object.freeze({ symbol: Object.freeze([ ` ${"█".repeat(6)}`, ` ${"█".repeat(10)}`, ` ${"█".repeat(14)}`, "░░░░░░░░░░░░░░░░", "░░░░░░░░░░░░░░░░", ` ${"▓".repeat(14)}`, ` ${"▓".repeat(12)}`, ` ${"▓".repeat(8)}`, ` ${"▓".repeat(4)}` ]), wordmark: WORDMARK }), compact: Object.freeze({ symbol: Object.freeze([ ` ${"█".repeat(4)}`, ` ${"█".repeat(8)}`, "░░░░░░░░░░", ` ${"▓".repeat(8)}`, ` ${"▓".repeat(4)}` ]), wordmark: Object.freeze(["LINUX DO"]) }), tiny: Object.freeze({ symbol: Object.freeze(["◉"]), wordmark: Object.freeze(["LINUX DO"]) }), ascii: Object.freeze({ symbol: Object.freeze([ " .----------.", " .'############'.", " /################\\", " |..................|", " |..................|", " |++++++++++++++++++|", " \\++++++++++++++++/", " '.++++++++++++.'", " '----------'" ]), wordmark: Object.freeze(["LINUX DO"]) }) }); function selectLogoMode(columns, height, preference = "auto", blockCharacters = true) { if (!blockCharacters) return "ascii"; if (preference !== "auto" && LOGOS[preference]) { if (preference === "large" && (columns < 110 || height < 680)) return columns >= 88 ? "medium" : "compact"; if (preference === "medium" && columns < 88) return columns >= 40 ? "compact" : "tiny"; return preference; } if (columns >= 110 && height >= 680) return "large"; if (columns >= 88 && height >= 520) return "medium"; if (columns >= 40 && height >= 480) return "compact"; return "tiny"; } function charClass(character, line) { if (character === "░" || character === ".") return "logo-paper"; if (character === "▓" || character === "+" || character === "▀" && line.includes("▓")) return "logo-orange"; if ("█▄#".includes(character)) return "logo-ink"; return "logo-wordmark"; } function createCharacterBlock(documentRef, lines, className, colorize = false) { const block = documentRef.createElement("pre"); block.className = className; for (const line of lines) { const row = documentRef.createElement("span"); row.className = "logo-row"; if (!colorize) row.textContent = line; else { let run = null; let activeClass = ""; for (const character of line) { const classNameForCharacter = charClass(character, line); if (!run || classNameForCharacter !== activeClass) { run = documentRef.createElement("span"); run.className = classNameForCharacter; row.append(run); activeClass = classNameForCharacter; } run.textContent += character; } } block.append(row); } return block; } function createLogo(documentRef, mode) { const definition = LOGOS[mode] || LOGOS.tiny; const wrapper = documentRef.createElement("div"); wrapper.className = "terminal-logo-wrap"; const accessible = documentRef.createElement("h1"); accessible.className = "sr-only"; accessible.textContent = "linux.do terminal"; const logo = documentRef.createElement("div"); logo.className = `terminal-logo terminal-logo-${mode}`; logo.dataset.logoMode = mode; logo.setAttribute("aria-hidden", "true"); logo.append( createCharacterBlock(documentRef, definition.symbol, "terminal-logo-symbol", true), createCharacterBlock(documentRef, definition.wordmark, "terminal-logo-wordmark") ); wrapper.append(accessible, logo); return wrapper; } // src/app/terminal-app.js var NAVIGATION = [ ["home", "/"], ["latest", "/latest"], ["hot", "/hot"], ["new", "/new"], ["unread", "/unread"], ["top", "/top"], ["categories", "/categories"] ]; function node(documentRef, tag, className = "", text2 = "") { const element = documentRef.createElement(tag); if (className) element.className = className; if (text2) element.textContent = text2; return element; } function localPath(value, origin) { const target = new URL(value, origin); if (target.origin !== origin) throw new Error("only linux.do paths can be opened inside the terminal"); return `${target.pathname}${target.search}${target.hash}`; } function readPreferences(windowRef) { try { return { theme: "dark", density: "normal", wrap: true, logo: "auto", ...JSON.parse(windowRef.localStorage.getItem("linuxdo-terminal.preferences") || "{}") }; } catch { return { theme: "dark", density: "normal", wrap: true, logo: "auto" }; } } var TerminalApp = class { constructor({ documentRef = document, windowRef = window, gateway, draftStorage } = {}) { this.document = documentRef; this.window = windowRef; this.gateway = gateway || new LinuxDoGateway({ documentRef, windowRef }); this.draftStorage = draftStorage || createDraftStorage(windowRef); this.preferences = readPreferences(windowRef); this.registry = new CommandRegistry(() => this); this.items = []; this.links = []; this.media = []; this.selectedIndex = 0; this.commandHistory = []; this.historyIndex = 0; this.snapshots = /* @__PURE__ */ new Map(); this.abortController = null; this.columns = 100; this.original = null; this.started = false; this.editorActive = false; this.viewStack = []; this.feedState = null; this.feedLoading = false; this.routeRevision = 0; this.resizeTimer = 0; this.homeResult = null; this.reflowPending = false; this.nativeTopicUpdate = null; this.nativeTopicObserver = null; this.nativeUpdateReloadTimer = 0; } async start() { if (this.started) return; this.started = true; this.buildShell(); this.registerCommands(); this.bindEvents(); this.updateGeometry(); await this.gateway.refreshCurrentUser?.(); this.updateSession(); await this.renderCurrentRoute(); this.activate(); this.input.focus(); } buildShell() { this.host = node(this.document, "linuxdo-character-terminal"); this.shadow = this.host.attachShadow({ mode: "open" }); const style = node(this.document, "style"); style.textContent = ':host {\n position: fixed;\n inset: 0;\n z-index: 2147483647;\n display: block;\n opacity: 0;\n color: #d6d7d2;\n background: transparent;\n font-family: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace;\n font-size: 14px;\n line-height: 1.5;\n font-variant-ligatures: none;\n transition: opacity 80ms linear;\n pointer-events: none;\n}\n\n:host(.is-active) { opacity: 1; pointer-events: auto; }\n:host(.is-native) { opacity: 1; pointer-events: none; }\n* { box-sizing: border-box; }\nbutton, input { font: inherit; }\nbutton { color: inherit; }\n.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n.terminal-root {\n --canvas: #0b0d0e;\n --text: #d6d7d2;\n --dim: #7f898b;\n --accent: #e7b84b;\n --link: #6cb6b2;\n --error: #c96b64;\n display: grid;\n grid-template-rows: auto auto minmax(0, 1fr) auto;\n width: 100%;\n height: 100%;\n overflow: hidden;\n color: var(--text);\n background: var(--canvas);\n}\n\n.terminal-root[data-theme="light"] {\n --canvas: #e8e6df;\n --text: #202325;\n --dim: #62696b;\n --accent: #8c5a00;\n --link: #166c69;\n --error: #922e28;\n}\n\n.terminal-root[data-density="compact"] { line-height: 1.28; }\n.terminal-header { padding: 12px 18px 0; overflow: hidden; }\n.terminal-logo-wrap { overflow: hidden; }\n.terminal-logo { display: grid; grid-template-columns: max-content max-content; align-items: center; gap: 4ch; width: max-content; max-width: 100%; line-height: 1; }\n.terminal-logo-symbol, .terminal-logo-wordmark { margin: 0; overflow: hidden; font: inherit; line-height: 1; white-space: pre; }\n.terminal-logo-wordmark { color: var(--text); }\n.logo-row { display: block; min-height: 1em; }\n.terminal-logo-compact, .terminal-logo-tiny { gap: 2ch; }\n.terminal-root.is-logo-collapsed .logo-slot { display: none; }\n.terminal-root.is-logo-collapsed .session-line { margin-top: 0; }\n.logo-ink { color: #222225; }\n.logo-paper { color: #ecece8; }\n.logo-orange { color: #f3a712; }\n.logo-wordmark { color: var(--text); }\n.session-line, .char-rule { color: var(--dim); white-space: pre; overflow: hidden; }\n.session-line { margin-top: 8px; }\n.char-rule { line-height: 1; }\n\n.terminal-tabs {\n display: flex;\n gap: 1.3ch;\n overflow-x: auto;\n padding: 6px 18px;\n scrollbar-width: none;\n white-space: nowrap;\n}\n\n.terminal-tabs::-webkit-scrollbar { display: none; }\n.terminal-tab, .line-button, .poll-option, .native-return {\n appearance: none;\n padding: 0;\n border: 0;\n border-radius: 0;\n outline: 0;\n color: inherit;\n background: transparent;\n text-align: left;\n cursor: pointer;\n}\n.terminal-tab::before { content: "["; color: var(--dim); }\n.terminal-tab::after { content: "]"; color: var(--dim); }\n.terminal-tab:hover, .terminal-tab:focus-visible, .terminal-tab[aria-current="page"] { color: var(--accent); }\n\n.terminal-viewport { min-height: 0; overflow: auto; padding: 4px 18px 20px; outline: 0; scrollbar-color: var(--dim) transparent; }\n.terminal-output { min-height: 100%; }\n.screen-title { margin: 0 0 .75em; color: var(--accent); font-weight: 600; white-space: pre-wrap; }\n.char-line, .feed-row, .feed-meta, .status-line, .command-document { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }\n.char-line { color: var(--dim); white-space: pre; overflow: hidden; }\n.status-line { padding: .35em 0; color: var(--dim); }\n.status-line[data-status="error"] { color: var(--error); }\n.status-line[data-status="success"] { color: var(--accent); }\n\n.feed-update-inline { appearance: none; margin: 0; padding: 0; border: 0; outline: 0; color: var(--link); background: transparent; font: inherit; font-weight: 700; cursor: pointer; }\n.feed-update-inline:hover, .feed-update-inline:focus-visible { color: var(--text); text-decoration: underline; text-underline-offset: .2em; }\n.feed-list { display: grid; width: 100%; min-width: 0; }\n.feed-frame-line { display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; width: 100%; min-width: 0; overflow: hidden; color: var(--dim); line-height: 1; white-space: nowrap; }\n.feed-frame-rule { min-width: 0; overflow: hidden; }\n.feed-item { min-width: 0; }\n.line-button { display: block; width: 100%; }\n.feed-button { min-width: 0; overflow: hidden; }\n.feed-row { display: grid; grid-template-columns: 1ch 4ch 8ch minmax(0, 1fr) 6ch 7ch 8ch 1ch; align-items: baseline; width: 100%; min-width: 0; }\n.feed-row-mobile { grid-template-columns: 1ch 3ch 8ch minmax(0, 1fr) 1ch; }\n.feed-meta, .feed-empty { display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; width: 100%; min-width: 0; }\n.feed-border { color: var(--dim); text-align: center; }\n.feed-marker, .feed-id, .feed-title, .feed-count, .feed-age, .feed-selection, .feed-meta-copy, .feed-empty-copy { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n.feed-marker { padding-left: 1ch; }\n.feed-id { padding-right: 1ch; }\n.feed-title { padding-right: 1ch; }\n.feed-count, .feed-age { padding-right: 1ch; text-align: right; }\n.feed-selection::before { content: " "; }\n.feed-item.is-selected .feed-selection::before { content: ">"; }\n.feed-row { color: var(--text); }\n.feed-meta { color: var(--dim); }\n.feed-meta-copy { padding-left: 4ch; }\n.feed-item.is-selected .feed-row, .line-button:hover .feed-row, .line-button:focus-visible .feed-row { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }\n\n.home-copy { max-width: 90ch; margin: 0 0 1em; color: var(--dim); white-space: pre-wrap; }\n.topic-head { margin-bottom: 1em; }\n.topic-title { margin: .15em 0; color: var(--text); font-size: 1em; white-space: pre-wrap; }\n.topic-meta { color: var(--dim); }\n.topic-post { display: grid; grid-template-columns: 2ch minmax(0, 1fr); margin: 0 0 1.4em; scroll-margin-top: 1em; }\n.post-rail { color: var(--dim); white-space: pre; }\n.post-head { margin-bottom: .5em; color: var(--accent); }\n.post-body { min-width: 0; overflow-wrap: anywhere; }\n.terminal-root.no-wrap .post-body { overflow-wrap: normal; }\n.terminal-root.no-wrap .post-body p { overflow-x: auto; white-space: pre; }\n.post-body p { margin: 0 0 .8em; }\n.post-body h1, .post-body h2, .post-body h3, .post-body h4, .post-body h5, .post-body h6 { margin: 1.2em 0 .5em; color: var(--accent); font: inherit; font-weight: 700; }\n.post-body h1::before, .post-body h2::before, .post-body h3::before { content: "## "; color: var(--dim); }\n.post-body a { color: var(--link); text-underline-offset: .18em; }\n.post-body a[data-link-index]::after { content: " [" attr(data-link-index) "]"; color: var(--dim); text-decoration: none; }\n.post-body blockquote { margin: .8em 0; padding-left: 2ch; color: var(--dim); }\n.post-body blockquote > :first-child::before { content: "> "; color: var(--accent); }\n.post-body pre { max-width: 100%; overflow: auto; margin: .8em 0; padding: .6em 1ch; color: #d6d7d2; background: #15191b; white-space: pre; }\n.post-body code, .post-body kbd { color: var(--accent); }\n.post-body ul, .post-body ol { padding-left: 3ch; }\n.post-body hr { height: auto; margin: 1em 0; border: 0; color: var(--dim); }\n.post-body hr::after { content: "----------------------------------------"; }\n.post-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }\n.post-body th, .post-body td { padding: .3em 2ch .3em 0; text-align: left; vertical-align: top; }\n.post-body th { color: var(--accent); text-decoration: underline; text-underline-offset: .25em; }\n.post-body img, .post-body video { display: block; max-width: min(100%, 820px); max-height: min(52vh, 520px); margin: .8em 0; object-fit: contain; background: #050606; }\n.lightbox-wrapper { display: block; width: fit-content; max-width: 100%; margin: .8em 0; }\n.lightbox-wrapper .lightbox-link { display: block; width: fit-content; max-width: 100%; overflow: hidden; line-height: 0; }\n.lightbox-wrapper .lightbox-link img { margin: 0; }\n.post-body audio { display: block; width: min(100%, 560px); margin: .8em 0; }\n.post-body details { margin: .8em 0; }\n.post-body summary { color: var(--accent); cursor: pointer; }\n.onebox { margin: .8em 0; color: var(--dim); }\n.onebox::before { content: "[ONEBOX] "; color: var(--accent); }\n.poll { margin: .8em 0; }\n.poll::before { content: "[POLL]"; display: block; color: var(--accent); }\n.poll-option { display: block; margin: .2em 0; color: var(--dim); cursor: not-allowed; }\n.deferred-media { display: block; padding: .4em 0; border: 0; color: var(--link); background: transparent; font: inherit; text-align: left; cursor: pointer; }\n.deferred-media:hover, .deferred-media:focus-visible { color: var(--accent); }\n.media-focus { display: grid; min-height: 58vh; place-items: center; }\n.media-focus-item { display: block; max-width: 100%; max-height: 72vh; object-fit: contain; background: #050606; }\n.media-focus video { width: min(100%, 1100px); }\n.media-focus audio { width: min(100%, 680px); }\n\n.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 3ch; min-height: 46vh; }\n.editor-metadata { display: grid; grid-template-columns: minmax(20ch, 2fr) minmax(14ch, 1fr) minmax(16ch, 1fr); gap: 2ch; margin-bottom: .7em; }\n.editor-field { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1ch; color: var(--accent); }\n.editor-field-input { min-width: 0; padding: 0; border: 0; outline: 0; color: var(--text); background: transparent; }\n.editor-field-input::placeholder { color: var(--dim); }\n.editor-panel { min-width: 0; }\n.editor-label { margin-bottom: .5em; color: var(--accent); }\n.editor-source { display: block; width: 100%; min-height: 42vh; resize: vertical; padding: .7em 1ch; border: 0; border-radius: 0; outline: 0; color: var(--text); background: #111516; font: inherit; line-height: inherit; caret-color: var(--accent); tab-size: 2; }\n.editor-source:focus { background: #151a1c; }\n.editor-preview { min-height: 42vh; }\n\n.categories { display: grid; gap: .5em; }\n.category-item { display: grid; grid-template-columns: minmax(14ch, 24ch) 10ch minmax(0, 1fr); gap: 1ch; }\n.category-item.is-selected, .category-item:hover, .category-item:focus-visible { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }\n.category-name { color: var(--link); }\n.category-count, .category-description { color: var(--dim); }\n.command-document { color: var(--text); }\n.profile-avatar { display: block; width: 12ch; height: 12ch; margin: 0 0 .8em; object-fit: cover; background: #050606; image-rendering: auto; }\n.profile-bio { max-width: 90ch; margin-top: 1em; }\n\n.terminal-footer { padding: 0 18px 10px; background: var(--canvas); }\n.terminal-completion { min-height: 1.5em; color: var(--dim); white-space: pre-wrap; }\n.terminal-prompt { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 1ch; }\n.prompt-label { color: var(--accent); white-space: nowrap; }\n.terminal-input { width: 100%; padding: 0; border: 0; border-radius: 0; outline: 0; color: var(--text); background: transparent; caret-color: var(--accent); }\n.terminal-input::placeholder { color: var(--dim); opacity: .65; }\n.terminal-measure { position: fixed; visibility: hidden; white-space: pre; pointer-events: none; }\n\n.native-return { position: fixed; right: 12px; bottom: 12px; z-index: 2; padding: .3em 1ch; color: #d6d7d2; background: #0b0d0e; pointer-events: auto; }\n.native-return::before { content: "[ "; color: #e7b84b; }\n.native-return::after { content: " ]"; color: #e7b84b; }\n[hidden] { display: none !important; }\n\n@media (max-width: 700px) {\n :host { font-size: 12px; }\n .terminal-header { padding: 8px 10px 0; }\n .terminal-tabs { padding: 5px 10px; gap: 1ch; }\n .terminal-viewport { padding: 3px 10px 16px; }\n .terminal-footer { padding: 0 10px 8px; }\n .category-item { grid-template-columns: minmax(10ch, 20ch) 7ch; }\n .category-description { grid-column: 1 / -1; }\n .editor-grid { grid-template-columns: minmax(0, 1fr); }\n .editor-metadata { grid-template-columns: minmax(0, 1fr); }\n .editor-source { min-height: 34vh; }\n}\n\n@media (prefers-reduced-motion: reduce) { :host { transition: none; } }\n'; this.root = node(this.document, "div", "terminal-root"); this.root.dataset.theme = this.preferences.theme; this.root.dataset.density = this.preferences.density; this.root.classList.toggle("no-wrap", !this.preferences.wrap); const header = node(this.document, "header", "terminal-header"); this.logoSlot = node(this.document, "div", "logo-slot"); this.sessionLine = node(this.document, "div", "session-line"); this.rule = node(this.document, "div", "char-rule"); header.append(this.logoSlot, this.sessionLine, this.rule); this.tabs = node(this.document, "nav", "terminal-tabs"); this.tabs.setAttribute("aria-label", "primary routes"); for (const [label, path] of NAVIGATION) { const button = node(this.document, "button", "terminal-tab", label); button.type = "button"; button.dataset.path = path; this.tabs.append(button); } this.viewport = node(this.document, "main", "terminal-viewport"); this.viewport.tabIndex = -1; this.output = node(this.document, "section", "terminal-output"); this.output.setAttribute("aria-live", "polite"); this.viewport.append(this.output); const footer = node(this.document, "footer", "terminal-footer"); this.completion = node(this.document, "div", "terminal-completion"); const prompt = node(this.document, "label", "terminal-prompt"); this.promptLabel = node(this.document, "span", "prompt-label", "guest@linuxdo:~$"); this.input = node(this.document, "input", "terminal-input"); this.input.type = "text"; this.input.autocomplete = "off"; this.input.spellcheck = false; this.input.placeholder = "type ls to list commands"; this.input.setAttribute("aria-label", "terminal command"); prompt.append(this.promptLabel, this.input); footer.append(this.completion, prompt); this.measure = node(this.document, "span", "terminal-measure", "0000000000"); this.nativeReturn = node(this.document, "button", "native-return", "return to terminal"); this.nativeReturn.type = "button"; this.nativeReturn.hidden = true; this.root.append(header, this.tabs, this.viewport, footer, this.measure); this.shadow.append(style, this.root, this.nativeReturn); this.document.documentElement.append(this.host); } bindEvents() { this.input.addEventListener("keydown", (event) => this.onPromptKeydown(event)); this.input.addEventListener("input", () => this.showSuggestions()); this.tabs.addEventListener("click", (event) => { const button = event.target.closest?.("[data-path]"); if (button) this.navigate(button.dataset.path); }); this.output.addEventListener("click", (event) => { const feedRefresh = event.target.closest?.("[data-feed-refresh]"); if (feedRefresh) { void this.applyNativeTopicUpdate(); return; } const opener = event.target.closest?.("[data-open-path]"); if (opener) { void this.navigate(opener.dataset.openPath); return; } const media = event.target.closest?.("[data-media-index]"); if (media) { event.preventDefault(); this.focusMedia(Number(media.dataset.mediaIndex)); return; } const link = event.target.closest?.("[data-link-index]"); if (link) { event.preventDefault(); void this.openLink(Number(link.dataset.linkIndex)); } }); this.viewport.addEventListener("scroll", () => this.updateLogoCollapse(), { passive: true }); this.viewport.addEventListener("wheel", (event) => { if (event.deltaY > 0) this.setLogoCollapsed(true); else if (event.deltaY < 0 && this.viewport.scrollTop <= 0) this.setLogoCollapsed(false); }, { passive: true }); this.startNativeTopicObserver(); this.nativeReturn.addEventListener("click", () => this.returnToTerminal()); this.window.addEventListener("popstate", () => this.renderCurrentRoute()); this.window.addEventListener("resize", () => { this.window.clearTimeout(this.resizeTimer); this.resizeTimer = this.window.setTimeout(() => { const changed = this.updateGeometry(); if (changed) this.reflowCurrentView(); }, 100); }, { passive: true }); } activate() { if (!this.original) { this.original = { inert: this.document.body.inert, ariaHidden: this.document.body.getAttribute("aria-hidden"), overflow: this.document.documentElement.style.overflow }; } this.document.body.inert = true; this.document.body.setAttribute("aria-hidden", "true"); this.document.documentElement.style.overflow = "hidden"; this.host.classList.remove("is-native"); this.host.classList.add("is-active"); } restoreOriginal() { if (!this.original) return; this.document.body.inert = this.original.inert; if (this.original.ariaHidden === null) this.document.body.removeAttribute("aria-hidden"); else this.document.body.setAttribute("aria-hidden", this.original.ariaHidden); this.document.documentElement.style.overflow = this.original.overflow; } enterNative() { this.restoreOriginal(); this.root.hidden = true; this.nativeReturn.hidden = false; this.host.classList.remove("is-active"); this.host.classList.add("is-native"); this.nativeReturn.focus(); } async returnToTerminal() { this.root.hidden = false; this.nativeReturn.hidden = true; this.host.classList.remove("is-native"); this.activate(); await this.renderCurrentRoute(); this.input.focus(); } updateGeometry() { const previousColumns = this.columns; const cell = Math.max(5, this.measure?.getBoundingClientRect().width / 10 || 8); this.columns = Math.max(20, Math.floor(this.window.innerWidth / cell) - 4); const mode = selectLogoMode(this.columns, this.window.innerHeight, this.preferences.logo, true); this.logoSlot.replaceChildren(createLogo(this.document, mode)); this.rule.textContent = "─".repeat(Math.max(8, this.columns)); this.updateSession(); return previousColumns !== this.columns; } reflowCurrentView() { if (this.editorActive) { const top = this.output.querySelector(".editor-frame-top"); const bottom = this.output.querySelector(".editor-frame-bottom"); if (top) top.textContent = `┌─ source ${"─".repeat(Math.max(6, this.columns - 22))} preview ─┐`; if (bottom) bottom.textContent = `└${"─".repeat(Math.max(8, this.columns - 2))}┘`; this.reflowPending = true; return; } if (this.viewStack.length) { this.reflowPending = true; return; } const scrollTop = this.viewport.scrollTop; if (this.feedState?.home) this.renderHome({ ...this.homeResult, topics: this.feedState.topics }, this.feedState); else if (this.feedState) this.renderFeed({ topics: this.feedState.topics }, this.feedState.label, this.feedState); else if (this.currentIntent?.type === "home" && this.homeResult) this.renderHome(this.homeResult); else { this.reflowPending = false; return; } this.reflowPending = false; this.window.requestAnimationFrame(() => { this.viewport.scrollTop = scrollTop; }); } updateSession() { const user = this.gateway.currentUser(); const path = this.window.location.pathname === "/" ? "~" : `~${this.window.location.pathname}`; const promptPath = this.columns < 62 ? "~" : path; this.promptLabel.textContent = `${user.username}@linuxdo:${promptPath}$`; this.sessionLine.textContent = truncateWidth(`session ${user.guest ? "guest" : `@${user.username}`} path ${path} cols ${this.columns}`, this.columns); for (const button of this.tabs.querySelectorAll("[data-path]")) { const current = button.dataset.path === "/" ? this.window.location.pathname === "/" : this.window.location.pathname.startsWith(button.dataset.path); if (current) button.setAttribute("aria-current", "page"); else button.removeAttribute("aria-current"); } } savePreferences() { try { this.window.localStorage.setItem("linuxdo-terminal.preferences", JSON.stringify(this.preferences)); } catch { } this.root.dataset.theme = this.preferences.theme; this.root.dataset.density = this.preferences.density; this.updateGeometry(); } snapshotCurrent() { this.snapshots.set(`${this.window.location.pathname}${this.window.location.search}`, { selectedIndex: this.selectedIndex, scrollTop: this.viewport.scrollTop }); } async navigate(path, { replace = false } = {}) { this.snapshotCurrent(); const route = localPath(path, this.window.location.origin); this.window.history[replace ? "replaceState" : "pushState"]({ linuxdoTerminal: true }, "", route); await this.renderCurrentRoute(); } async renderCurrentRoute() { this.setLogoCollapsed(false); this.abortController?.abort(); this.abortController = new AbortController(); const revision = ++this.routeRevision; const signal = this.abortController.signal; const isCurrent = () => revision === this.routeRevision && !signal.aborted; const intent = this.gateway.resolveRoute(); this.currentIntent = intent; this.viewStack = []; this.editorActive = false; this.feedState = null; this.feedLoading = false; this.homeResult = null; this.feedTitle = null; this.items = []; this.links = []; this.media = []; this.output.replaceChildren(node(this.document, "p", "status-line", `$ loading ${intent.path || "/"} ...`)); this.updateSession(); try { if (intent.type === "home") { const result = await this.gateway.feed("/latest", { signal }); if (!isCurrent()) return; this.renderHome(result); } else if (intent.type === "feed") { const result = await this.gateway.feed(intent.path, { signal }); if (!isCurrent()) return; this.renderFeed(result, intent.label || "feed", { path: intent.path, page: 0 }); } else if (intent.type === "topic") { const result = await this.gateway.topic(intent.path, { signal }); if (!isCurrent()) return; this.renderTopic(result.topic, intent.postNumber); } else if (intent.type === "search") { if (!intent.query) this.renderDocument("SEARCH", ["usage: search