// ==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 ", "", "The URL is shareable: /search?q="]); else { const result = await this.gateway.search(intent.query, { signal }); if (!isCurrent()) return; this.renderFeed(result, `search: ${intent.query}`); } } else if (intent.type === "categories") { const result = await this.gateway.categories({ signal }); if (!isCurrent()) return; this.renderCategories(result); } else { this.renderDocument("UNSUPPORTED ROUTE", [`path: ${intent.path}`, "", "This route does not have a character renderer yet.", "type 'native' to use the original Linux DO page", "type 'latest' to return to a supported route"]); } if (isCurrent()) this.restoreSnapshot(); } catch (error) { if (!isCurrent() || error?.name === "AbortError") return; this.renderError(error, intent.path); } } restoreSnapshot() { const saved = this.snapshots.get(`${this.window.location.pathname}${this.window.location.search}`); if (!saved) { this.viewport.scrollTop = 0; this.updateLogoCollapse(); return; } this.selectedIndex = Math.min(saved.selectedIndex, Math.max(0, this.items.length - 1)); this.paintSelection(); this.window.requestAnimationFrame(() => { this.viewport.scrollTop = saved.scrollTop; this.updateLogoCollapse(); }); } renderHome(result, state = null) { this.feedState = { path: "/latest", page: 0, label: "latest", home: true, ...state, topics: result.topics }; this.homeResult = result; const fragment = this.document.createDocumentFragment(); fragment.append(node(this.document, "p", "screen-title", "LINUX DO / CHARACTER TERMINAL")); fragment.append(node(this.document, "pre", "home-copy", "A keyboard-first view backed by the real Linux DO routes.\nType ls for every available command. Nothing listed is a mock command.")); this.feedTitle = node(this.document, "p", "screen-title feed-screen-title", `LATEST / ${result.topics.length} TOPICS`); fragment.append(this.feedTitle); fragment.append(this.createFeed(result.topics, result.warning)); this.output.replaceChildren(fragment); this.renderNativeTopicUpdateNotice(); } renderFeed(result, label, state = null) { if (state) this.feedState = { ...state, label, topics: result.topics }; const fragment = this.document.createDocumentFragment(); this.feedTitle = node(this.document, "p", "screen-title feed-screen-title", `${String(label).toUpperCase()} / ${result.topics.length} TOPICS`); fragment.append(this.feedTitle); fragment.append(this.createFeed(result.topics, result.warning)); this.output.replaceChildren(fragment); this.renderNativeTopicUpdateNotice(); } createFeedFrameLine(left, right) { const line = node(this.document, "div", "feed-frame-line"); line.setAttribute("aria-hidden", "true"); line.append( node(this.document, "span", "feed-frame-corner", left), node(this.document, "span", "feed-frame-rule", "─".repeat(Math.max(40, this.columns))), node(this.document, "span", "feed-frame-corner", right) ); return line; } createFeed(topics, warning = "") { this.items = topics; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, topics.length - 1)); const list = node(this.document, "div", "feed-list"); list.append(this.createFeedFrameLine("┌", "┐")); if (!topics.length) { const empty = node(this.document, "div", "feed-empty"); empty.append(node(this.document, "span", "feed-border", "│"), node(this.document, "span", "feed-empty-copy", " no topics found"), node(this.document, "span", "feed-border", "│")); list.append(empty); } topics.forEach((topic, index) => { const item = node(this.document, "div", `feed-item${index === this.selectedIndex ? " is-selected" : ""}`); item.dataset.itemIndex = String(index); const button = node(this.document, "button", "line-button feed-button"); button.type = "button"; button.dataset.openPath = topic.url; if (this.columns >= 82) { const marker = `${topic.pinned ? "*" : " "}${topic.unread ? "+" : " "}`; const row = node(this.document, "span", "feed-row feed-row-wide"); row.append( node(this.document, "span", "feed-border", "│"), node(this.document, "span", "feed-marker", marker), node(this.document, "span", "feed-id", String(topic.id)), node(this.document, "span", "feed-title", topic.title), node(this.document, "span", "feed-count", formatCount(topic.replies)), node(this.document, "span", "feed-count", formatCount(topic.views)), node(this.document, "span", "feed-age", topic.age), node(this.document, "span", "feed-border", "│") ); const meta = [topic.author && `@${topic.author}`, topic.category, ...(topic.tags || []).map((tag) => `#${tag}`)].filter(Boolean).join(" "); const metaRow = node(this.document, "span", "feed-meta"); metaRow.append(node(this.document, "span", "feed-border", "│"), node(this.document, "span", "feed-meta-copy", meta), node(this.document, "span", "feed-border", "│")); button.append(row, metaRow); } else { const row = node(this.document, "span", "feed-row feed-row-mobile"); row.append( node(this.document, "span", "feed-border", "│"), node(this.document, "span", "feed-selection"), node(this.document, "span", "feed-id", String(topic.id)), node(this.document, "span", "feed-title", topic.title), node(this.document, "span", "feed-border", "│") ); const metaRow = node(this.document, "span", "feed-meta"); metaRow.append(node(this.document, "span", "feed-border", "│"), node(this.document, "span", "feed-meta-copy", `@${topic.author} ↳${formatCount(topic.replies)} ◉${formatCount(topic.views)} ${topic.age}`), node(this.document, "span", "feed-border", "│")); button.append(row, metaRow); } item.append(button); list.append(item); }); list.append(this.createFeedFrameLine("└", "┘")); if (warning) list.append(node(this.document, "p", "status-line", `! JSON unavailable; rendered current document data (${warning})`)); return list; } setLogoCollapsed(collapsed) { this.root?.classList.toggle("is-logo-collapsed", Boolean(collapsed)); } updateLogoCollapse() { this.setLogoCollapsed(this.viewport.scrollTop > 0); } startNativeTopicObserver() { if (this.nativeTopicObserver || !this.document.documentElement || !this.window.MutationObserver) return; this.nativeTopicObserver = new this.window.MutationObserver(() => this.syncNativeTopicUpdate()); this.nativeTopicObserver.observe(this.document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["class"] }); this.syncNativeTopicUpdate(); } syncNativeTopicUpdate() { const updates = [...this.document.querySelectorAll(".show-more.has-topics")].map((container) => { const action = container.querySelector("a.alert.alert-info.clickable, a.alert.clickable, a") || container; const label = action.textContent?.replace(/\s+/gu, " ").trim() || ""; const countText = label.match(/[\d,]+/u)?.[0] || ""; return { action, count: Number(countText.replace(/,/gu, "")), label }; }).filter((update) => Number.isFinite(update.count) && update.count > 0 && update.label); this.nativeTopicUpdate = updates.at(-1) || null; this.renderNativeTopicUpdateNotice(); } renderNativeTopicUpdateNotice() { this.feedTitle?.querySelector(".feed-update-inline")?.remove(); const isLatest = this.currentIntent?.type === "home" || this.currentIntent?.type === "feed" && (this.feedState?.path || this.currentIntent.path) === "/latest"; if (!this.nativeTopicUpdate || !isLatest || !this.feedTitle?.isConnected || this.viewStack.length || this.editorActive) return; const notice = node(this.document, "button", "feed-update-inline", ` +${this.nativeTopicUpdate.count} NEW/UPDATED`); notice.type = "button"; notice.dataset.feedRefresh = "true"; notice.title = `${this.nativeTopicUpdate.label};点击或输入 updates 加载`; this.feedTitle.append(notice); } applyNativeTopicUpdate() { const update = this.nativeTopicUpdate; if (!update?.action?.isConnected) return { message: "原页面当前没有新的或更新的话题" }; update.action.dispatchEvent(new this.window.MouseEvent("click", { bubbles: true, cancelable: true })); this.completion.textContent = "$ loading native topic updates ..."; this.window.clearTimeout(this.nativeUpdateReloadTimer); this.nativeUpdateReloadTimer = this.window.setTimeout(() => { void this.renderCurrentRoute(); }, 600); return { message: update.label }; } renderTopic(topic, targetPost) { this.feedState = null; const fragment = this.document.createDocumentFragment(); const head = node(this.document, "header", "topic-head"); head.append(node(this.document, "p", "screen-title", `TOPIC ${topic.id || "?"}`)); head.append(node(this.document, "h2", "topic-title", topic.title)); head.append(node(this.document, "div", "topic-meta", `${formatCount(topic.views)} views ${formatCount(topic.replyCount)} replies${topic.tags.length ? ` ${topic.tags.map((tag) => `#${tag}`).join(" ")}` : ""}`)); fragment.append(head); for (const post of topic.posts) { const article = node(this.document, "article", "topic-post"); article.id = `post-${post.number}`; const rail = node(this.document, "div", "post-rail", "│\n│\n│"); rail.setAttribute("aria-hidden", "true"); const content = node(this.document, "div"); content.append(node(this.document, "header", "post-head", `> #${post.number} @${post.author} ${post.age}${post.replyTo ? ` reply #${post.replyTo}` : ""}`)); const body = node(this.document, "div", "post-body"); const rendered = renderCooked(this.document, post.cooked || post.raw, { baseUrl: this.window.location.origin, linkStart: this.links.length, mediaStart: this.media.length }); body.append(rendered.element); this.links.push(...rendered.links); this.media.push(...rendered.media); content.append(body); article.append(rail, content); fragment.append(article); } if (!topic.posts.length) fragment.append(node(this.document, "p", "status-line", "no visible posts")); this.output.replaceChildren(fragment); if (targetPost) this.window.requestAnimationFrame(() => this.shadow.getElementById(`post-${targetPost}`)?.scrollIntoView()); } renderCategories(categories) { this.feedState = null; const fragment = this.document.createDocumentFragment(); fragment.append(node(this.document, "p", "screen-title", `CATEGORIES / ${categories.length}`)); const list = node(this.document, "div", "categories"); this.items = categories; this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, categories.length - 1)); categories.forEach((category, index) => { const button = node(this.document, "button", `line-button category-item${index === this.selectedIndex ? " is-selected" : ""}`); button.type = "button"; button.dataset.itemIndex = String(index); button.dataset.openPath = category.url; button.append(node(this.document, "span", "category-name", `[${category.name}]`)); button.append(node(this.document, "span", "category-count", `${formatCount(category.topicCount)} topics`)); button.append(node(this.document, "span", "category-description", category.description)); list.append(button); }); fragment.append(list); this.output.replaceChildren(fragment); } pushCurrentView() { this.viewStack.push({ nodes: [...this.output.childNodes], items: this.items, links: this.links, media: this.media, selectedIndex: this.selectedIndex, scrollTop: this.viewport.scrollTop }); } popView() { const previous = this.viewStack.pop(); if (!previous) return false; this.output.replaceChildren(...previous.nodes); this.items = previous.items; this.links = previous.links; this.media = previous.media; this.selectedIndex = previous.selectedIndex; this.paintSelection(); this.window.requestAnimationFrame(() => { this.viewport.scrollTop = previous.scrollTop; }); if (this.reflowPending) this.reflowCurrentView(); this.renderNativeTopicUpdateNotice(); return true; } renderDocument(title, lines, { push = false } = {}) { if (push) this.pushCurrentView(); const fragment = this.document.createDocumentFragment(); fragment.append(node(this.document, "p", "screen-title", title)); fragment.append(node(this.document, "pre", "command-document", lines.join("\n"))); this.output.replaceChildren(fragment); } readDraft(key) { let stored = ""; try { stored = this.draftStorage.getItem(key) || ""; } catch { } let value = { raw: stored }; try { const parsed = JSON.parse(stored); if (parsed && typeof parsed === "object") value = { raw: "", ...parsed }; } catch { } const match = key.match(/^linuxdo-terminal\.draft:(reply|compose):([^:]+):(\d+)$/u); return { ...value, key, mode: match?.[1] || value.mode || "compose", topicId: match?.[2] || value.topicId || "new", targetPost: Number(match?.[3] || value.targetPost || 0) || null }; } localDrafts() { const drafts = []; try { for (const key of this.draftStorage.keys()) drafts.push(this.readDraft(key)); } catch { return []; } return drafts.filter((draft) => draft.raw || draft.title).sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))); } openEditor(mode, targetPost = null, { draftKeyOverride = null, initialRaw = "" } = {}) { if (mode === "reply" && this.currentIntent?.type !== "topic") throw new Error("reply is available inside a topic"); if (!this.editorActive) this.pushCurrentView(); this.editorActive = true; const topicId = this.currentIntent?.topicId || "new"; const draftKey = draftKeyOverride || `linuxdo-terminal.draft:${mode}:${topicId}:${targetPost || 0}`; const draft = this.readDraft(draftKey); if (initialRaw && !draft.raw) draft.raw = initialRaw; this.activeDraftKey = draftKey; const fragment = this.document.createDocumentFragment(); fragment.append(node(this.document, "p", "screen-title", `${mode.toUpperCase()} EDITOR${targetPost ? ` / #${targetPost}` : ""}`)); const metadata = node(this.document, "div", "editor-metadata"); const fields = {}; if (mode === "compose") { for (const [name, label, placeholder] of [["title", "title", "Topic title"], ["category", "category", "category slug or id"], ["tags", "tags", "tag-one, tag-two"]]) { const field = node(this.document, "label", "editor-field"); field.append(node(this.document, "span", "editor-field-label", `${label}>`)); const input = node(this.document, "input", "editor-field-input"); input.type = "text"; input.value = draft[name] || ""; input.placeholder = placeholder; input.setAttribute("aria-label", `compose ${name}`); fields[name] = input; field.append(input); metadata.append(field); } fragment.append(metadata); } fragment.append(node(this.document, "pre", "char-line editor-frame-top", `┌─ source ${"─".repeat(Math.max(6, this.columns - 22))} preview ─┐`)); const grid = node(this.document, "div", "editor-grid"); const sourcePanel = node(this.document, "section", "editor-panel"); sourcePanel.append(node(this.document, "div", "editor-label", "[source / Markdown]")); const textarea = node(this.document, "textarea", "editor-source"); textarea.value = draft.raw || ""; textarea.placeholder = "Write Markdown here. Images: ![alt](https://...) Poll: - [ ] option"; textarea.setAttribute("aria-label", `${mode} markdown source`); const previewPanel = node(this.document, "section", "editor-panel"); previewPanel.append(node(this.document, "div", "editor-label", "[preview / rich content]")); const preview = node(this.document, "div", "post-body editor-preview"); const update = (persist = true) => { const previewFragment = this.document.createDocumentFragment(); if (fields.title?.value) previewFragment.append(node(this.document, "h1", "", fields.title.value)); previewFragment.append(renderMarkdownPreview(this.document, textarea.value, this.window.location.origin)); preview.replaceChildren(previewFragment); if (persist) { const value = { mode, topicId, targetPost, raw: textarea.value, title: fields.title?.value || "", category: fields.category?.value || "", tags: fields.tags?.value || "", updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; try { this.draftStorage.setItem(draftKey, JSON.stringify(value)); } catch { } } }; textarea.addEventListener("input", () => update(true)); for (const input of Object.values(fields)) input.addEventListener("input", () => update(true)); const closeOnEscape = (event) => { if (event.key === "Escape") { event.preventDefault(); void this.closeEditor(); } }; textarea.addEventListener("keydown", closeOnEscape); for (const input of Object.values(fields)) input.addEventListener("keydown", closeOnEscape); sourcePanel.append(textarea); previewPanel.append(preview); grid.append(sourcePanel, previewPanel); fragment.append(grid); fragment.append(node(this.document, "pre", "char-line editor-frame-bottom", `└${"─".repeat(Math.max(8, this.columns - 2))}┘`)); const storageLabel = this.draftStorage.scope === "userscript-private" ? "private userscript draft" : "page-storage fallback draft"; fragment.append(node(this.document, "p", "status-line", `${storageLabel} + safe live preview · Esc or 'cancel' returns · publishing is not enabled in this milestone`)); this.output.replaceChildren(fragment); update(false); this.window.requestAnimationFrame(() => (fields.title || textarea).focus()); } renderDrafts() { const drafts = this.localDrafts(); const lines = drafts.length ? drafts.flatMap((draft, index) => [ `${index + 1}. ${draft.mode}${draft.topicId !== "new" ? ` topic:${draft.topicId}` : ""}${draft.targetPost ? ` #${draft.targetPost}` : ""} ${draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "legacy"}`, ` ${truncateWidth(draft.title || draft.raw.replace(/\s+/gu, " ") || "(empty)", Math.max(20, this.columns - 8))}` ]) : ["no local drafts", "", "type compose or reply inside a topic to create one"]; this.renderDocument("LOCAL DRAFTS", [...lines, "", "draft opens a draft · Esc returns"], { push: true }); } async openDraft(index) { const draft = this.localDrafts()[index - 1]; if (!draft) throw new Error(`no draft ${index}`); if (draft.mode === "reply" && String(this.currentIntent?.topicId) !== String(draft.topicId)) await this.navigate(`/t/topic/${draft.topicId}`); this.openEditor(draft.mode, draft.targetPost, { draftKeyOverride: draft.key }); } deleteDraft(index, confirmed = false) { const draft = this.localDrafts()[index - 1]; if (!draft) throw new Error(`no draft ${index}`); if (!confirmed) { this.renderDocument("CONFIRM DRAFT DELETE", [`draft: ${index} / ${draft.title || truncateWidth(draft.raw, 50)}`, "", `type 'draft-delete ${index} confirm' to delete`, "Esc returns without deleting"], { push: true }); return { message: "confirmation required" }; } this.draftStorage.removeItem(draft.key); return { message: `deleted local draft ${index}` }; } quotePost(postNumber) { const post = this.shadow.getElementById(`post-${postNumber}`); if (!post) throw new Error(`post #${postNumber} is not loaded`); const quoted = post.querySelector(".post-body")?.innerText.split(/\n/u).filter(Boolean).map((line) => `> ${line}`).join("\n") || ""; this.openEditor("reply", postNumber, { initialRaw: `${quoted} ` }); } async closeEditor() { if (!this.editorActive) return { message: "no editor is open" }; this.editorActive = false; if (!this.popView()) await this.renderCurrentRoute(); } renderError(error, path) { const status = error instanceof GatewayError ? error.status : 0; const hint = status === 403 ? "This page needs a signed-in account or additional permission." : status === 404 ? "The route was not found or was removed." : status === 429 ? "Linux DO is rate-limiting requests. Wait, then type refresh." : "Check the connection, type refresh, or type native."; this.renderDocument("LOAD ERROR", [`path: ${path}`, `error: ${error?.message || "unknown error"}`, "", hint]); this.output.querySelector(".command-document").dataset.status = "error"; } async loadMore() { if (!this.feedState || this.feedLoading) return { message: this.feedLoading ? "already loading" : "this view has no next page" }; this.feedLoading = true; const revision = this.routeRevision; const feedState = this.feedState; const signal = this.abortController?.signal; const scrollTop = this.viewport.scrollTop; try { const page = feedState.page + 1; const target = new URL(feedState.path, this.window.location.origin); target.searchParams.set("page", String(page)); const result = await this.gateway.feed(`${target.pathname}${target.search}`, { signal }); if (signal?.aborted || revision !== this.routeRevision || this.feedState !== feedState) return { message: "page request cancelled" }; const merged = new Map(feedState.topics.map((topic) => [topic.id, topic])); for (const topic of result.topics) merged.set(topic.id, topic); const topics = [...merged.values()]; if (topics.length === feedState.topics.length && result.topics.length === 0) return { message: "end of list" }; const state = { ...feedState, page, topics }; if (feedState.home) this.renderHome({ ...this.homeResult, ...result, topics }, state); else this.renderFeed({ ...result, topics }, feedState.label, state); this.window.requestAnimationFrame(() => { this.viewport.scrollTop = scrollTop; }); return { message: `loaded page ${page + 1} · ${topics.length} topics` }; } catch (error) { if (signal?.aborted || error?.name === "AbortError") return { message: "page request cancelled" }; throw error; } finally { if (revision === this.routeRevision) this.feedLoading = false; } } gotoPost(postNumber) { if (this.currentIntent?.type !== "topic") throw new Error("goto is available inside a topic"); const post = this.shadow.getElementById(`post-${postNumber}`); if (!post) throw new Error(`post #${postNumber} is not loaded; type refresh or native`); post.scrollIntoView({ block: "start" }); return { message: `post #${postNumber}` }; } async share(postNumber = null) { const topic = this.window.location.pathname.match(/^\/t\/(?:([^/]+)\/)?(\d+)/u); const topicRoot = topic ? `/t/${topic[1] ? `${topic[1]}/` : ""}${topic[2]}` : null; const path = topicRoot && postNumber ? `${topicRoot}/${postNumber}` : `${this.window.location.pathname}${this.window.location.search}`; const url = new URL(path, this.window.location.origin).href; try { await this.window.navigator.clipboard.writeText(url); return { message: `copied ${url}` }; } catch { return { message: url }; } } openLink(index) { const link = this.links[index - 1]; if (!link) throw new Error(`no link ${index}`); const target = new URL(link.href, this.window.location.origin); if (target.origin === this.window.location.origin) return this.navigate(`${target.pathname}${target.search}${target.hash}`); this.window.open(target.href, "_blank", "noopener,noreferrer"); return { message: `opened ${target.href}` }; } focusMedia(index) { const media = this.media[index - 1]; if (!media) throw new Error(`no media ${index}`); this.pushCurrentView(); const fragment = this.document.createDocumentFragment(); fragment.append(node(this.document, "p", "screen-title", `MEDIA ${index}/${this.media.length} / ${media.type.toUpperCase()}`)); const frame = node(this.document, "div", "media-focus"); let focused; if (media.type === "image") { focused = node(this.document, "img", "media-focus-item"); focused.src = media.src; focused.alt = media.alt || media.element.alt || "focused topic image"; focused.referrerPolicy = "no-referrer"; } else { focused = node(this.document, media.type, "media-focus-item"); focused.controls = true; focused.preload = "none"; if (media.src) focused.src = media.src; for (const source of media.element.querySelectorAll("source")) focused.append(source.cloneNode()); } frame.append(focused); fragment.append(frame); fragment.append(node(this.document, "p", "status-line", "Esc or cancel returns to the post")); this.output.replaceChildren(fragment); this.viewport.scrollTop = 0; } renderProfile(profile) { this.links = []; this.media = []; const fragment = this.document.createDocumentFragment(); fragment.append(node(this.document, "p", "screen-title", `USER / @${profile.username}`)); if (profile.avatarTemplate) { try { const avatarTarget = new URL(profile.avatarTemplate.replace("{size}", "240"), this.window.location.origin); if (!["http:", "https:"].includes(avatarTarget.protocol)) throw new Error("unsafe avatar URL"); const avatarUrl = avatarTarget.href; const avatar = node(this.document, "img", "profile-avatar"); avatar.src = avatarUrl; avatar.alt = `@${profile.username} avatar`; avatar.loading = "lazy"; avatar.referrerPolicy = "no-referrer"; avatar.dataset.mediaIndex = "1"; this.media.push({ type: "image", src: avatarUrl, element: avatar }); fragment.append(avatar); } catch { } } const lines = [ `${profile.name || "(no display name)"}${profile.title ? ` [${profile.title}]` : ""}`, `trust ${profile.trustLevel} joined ${profile.joinedAt ? new Date(profile.joinedAt).toLocaleDateString() : "?"} last seen ${profile.lastSeenAt ? new Date(profile.lastSeenAt).toLocaleDateString() : "?"}`, `topics ${formatCount(profile.stats.topics)} posts ${formatCount(profile.stats.posts)} likes +${formatCount(profile.stats.likesReceived)} / -${formatCount(profile.stats.likesGiven)} days ${formatCount(profile.stats.daysVisited)}`, profile.location && `location ${profile.location}`, profile.website && `website ${profile.website}` ].filter(Boolean); fragment.append(node(this.document, "pre", "command-document", lines.join("\n"))); if (profile.bioCooked) { const body = node(this.document, "div", "post-body profile-bio"); const rendered = renderCooked(this.document, profile.bioCooked, { baseUrl: this.window.location.origin, mediaStart: this.media.length }); body.append(rendered.element); this.links.push(...rendered.links); this.media.push(...rendered.media); fragment.append(body); } this.output.replaceChildren(fragment); } paintSelection() { for (const item of this.output.querySelectorAll("[data-item-index]")) item.classList.toggle("is-selected", Number(item.dataset.itemIndex) === this.selectedIndex); } moveSelection(delta) { if (!this.items.length) return; this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, this.selectedIndex + delta)); this.paintSelection(); const selected = this.output.querySelector(`[data-item-index="${this.selectedIndex}"]`); if (selected) { const viewportRect = this.viewport.getBoundingClientRect(); const selectedRect = selected.getBoundingClientRect(); if (selectedRect.bottom > viewportRect.bottom) this.viewport.scrollTop += selectedRect.bottom - viewportRect.bottom + 4; else if (selectedRect.top < viewportRect.top) this.viewport.scrollTop -= viewportRect.top - selectedRect.top + 4; this.updateLogoCollapse(); } if (this.feedState && this.selectedIndex >= this.items.length - 3) void this.loadMore(); } scrollViewport(action) { const lineHeight = Number.parseFloat(this.window.getComputedStyle(this.root).lineHeight) || 21; const page = Math.max(200, this.viewport.clientHeight * 0.82); const maximum = Math.max(0, this.viewport.scrollHeight - this.viewport.clientHeight); const targets = { lineUp: this.viewport.scrollTop - lineHeight * 3, lineDown: this.viewport.scrollTop + lineHeight * 3, up: this.viewport.scrollTop - page, down: this.viewport.scrollTop + page, top: 0, bottom: maximum }; if (!(action in targets)) throw new Error("unknown scroll action"); this.viewport.scrollTop = Math.max(0, Math.min(maximum, targets[action])); this.updateLogoCollapse(); return { message: `${action} · ${Math.round(this.viewport.scrollTop)}/${Math.round(maximum)}` }; } openSelection(index = this.selectedIndex) { const item = this.items[index]; if (!item?.url) throw new Error(`no item ${index + 1}`); return this.navigate(item.url); } showSuggestions() { const suggestions = this.registry.suggestions(this.input.value); this.completion.textContent = suggestions.map((item) => item.name).join(" "); } async onPromptKeydown(event) { if (event.key === "Enter") { event.preventDefault(); const command = this.input.value.trim(); if (!command) { if (this.items.length && !this.viewStack.length && !this.editorActive) { try { await this.openSelection(); } catch (error) { this.completion.textContent = error?.message || "could not open selection"; } } return; } this.commandHistory.push(command); this.historyIndex = this.commandHistory.length; this.input.value = ""; this.completion.textContent = `$ ${command}`; const result = await this.registry.execute(command); if (result?.message) this.completion.textContent = result.message; this.input.focus(); return; } if (event.key === "Tab") { event.preventDefault(); const suggestion = this.registry.suggestions(this.input.value)[0]; if (suggestion) { this.input.value = suggestion.name; this.showSuggestions(); } return; } if (event.key === "Escape") { this.input.value = ""; this.completion.textContent = ""; if (this.editorActive) void this.closeEditor(); else this.popView(); return; } if (!this.input.value && !this.editorActive) { const paging = { PageUp: "up", PageDown: "down", Home: "top", End: "bottom", " ": event.shiftKey ? "up" : "down" }; if (paging[event.key]) { event.preventDefault(); this.scrollViewport(paging[event.key]); return; } if (event.key === "ArrowDown") { event.preventDefault(); if (this.items.length) this.moveSelection(1); else this.scrollViewport("lineDown"); return; } if (event.key === "ArrowUp") { event.preventDefault(); if (this.items.length) this.moveSelection(-1); else this.scrollViewport("lineUp"); return; } } if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); const movement = event.key === "ArrowUp" ? -1 : 1; this.historyIndex = Math.max(0, Math.min(this.commandHistory.length, this.historyIndex + movement)); this.input.value = this.commandHistory[this.historyIndex] || ""; } } registerCommands() { const add = (definition) => this.registry.register(definition); const go = (name, path, summary) => add({ name, category: "navigation", summary, execute: () => this.navigate(path) }); go("home", "/", "open terminal home"); go("latest", "/latest", "show latest topics"); go("hot", "/hot", "show hot topics"); go("new", "/new", "show new topics"); go("unread", "/unread", "show unread topics"); go("top", "/top", "show top topics"); go("categories", "/categories", "list categories"); add({ name: "ls", usage: "ls [filter]", category: "terminal", summary: "list every implemented command", execute: (_, args) => { this.renderDocument("AVAILABLE COMMANDS", commandListLines(this.registry, args.join(" ")), { push: true }); } }); add({ name: "help", usage: "help [command]", category: "terminal", summary: "show command help", execute: (_, args) => { const command = args[0] && this.registry.commands.get(args[0]); if (!command) this.renderDocument("HELP", ["type ls to list commands", "type help for command usage", "", "Keyboard: ↑/↓ select or scroll · Enter opens · PgUp/PgDn/Space scroll · Home/End jump · Esc returns"], { push: true }); else this.renderDocument(`HELP / ${command.name}`, [`usage: ${command.usage}`, command.summary, command.aliases.length ? `aliases: ${command.aliases.join(", ")}` : ""], { push: true }); } }); add({ name: "pwd", category: "navigation", summary: "print the current route", execute: () => ({ message: `${this.window.location.pathname}${this.window.location.search}` }) }); add({ name: "cd", usage: "cd ", category: "navigation", summary: "navigate to a Linux DO route", execute: (_, args) => { if (!args[0] || args[0] === "~") return this.navigate("/"); const aliases = Object.fromEntries(NAVIGATION); return this.navigate(aliases[args[0]] || args[0]); } }); add({ name: "tag", usage: "tag ", category: "navigation", summary: "show topics with a tag", execute: (_, args) => { if (!args[0]) throw new Error("usage: tag "); return this.navigate(`/tag/${encodeURIComponent(args.join(" "))}`); } }); add({ name: "search", usage: "search ", category: "navigation", summary: "search real Linux DO topics", execute: (_, args) => { if (!args.length) throw new Error("usage: search "); return this.navigate(`/search?q=${encodeURIComponent(args.join(" "))}`); } }); add({ name: "open", usage: "open [n|topic-id|path]", category: "reading", summary: "open a selected topic or route", execute: (_, args) => { if (!args[0]) return this.openSelection(); if (/^\d+$/u.test(args[0])) { const index = Number(args[0]) - 1; if (this.items[index]?.url) return this.openSelection(index); return this.navigate(`/t/topic/${args[0]}`); } return this.navigate(args[0]); } }); add({ name: "back", category: "navigation", summary: "go back in route history", execute: () => this.window.history.back() }); add({ name: "forward", category: "navigation", summary: "go forward in route history", execute: () => this.window.history.forward() }); add({ name: "refresh", category: "navigation", summary: "reload current terminal view", execute: () => this.renderCurrentRoute() }); add({ name: "updates", category: "navigation", summary: "load the original page's new-or-updated topics alert", execute: () => this.applyNativeTopicUpdate() }); add({ name: "more", category: "navigation", summary: "load the next feed page", execute: () => this.loadMore() }); add({ name: "next", category: "reading", summary: "select the next item", execute: () => { this.moveSelection(1); } }); add({ name: "prev", category: "reading", summary: "select the previous item", execute: () => { this.moveSelection(-1); } }); add({ name: "page", usage: "page ", category: "reading", summary: "scroll the terminal viewport", execute: (_, args) => { if (!(/* @__PURE__ */ new Set(["up", "down", "top", "bottom"])).has(args[0])) throw new Error("usage: page "); return this.scrollViewport(args[0]); } }); add({ name: "links", category: "reading", summary: "list links in the current topic", execute: () => this.renderDocument("TOPIC LINKS", this.links.length ? this.links.map((link, index) => `${index + 1}. ${link.text} ${link.href}`) : ["no links in this view"], { push: true }) }); add({ name: "link", usage: "link ", category: "reading", summary: "open an indexed topic link", execute: (_, args) => { const index = Number(args[0]); if (!Number.isInteger(index) || index < 1) throw new Error("usage: link "); return this.openLink(index); } }); add({ name: "goto", usage: "goto ", category: "reading", summary: "jump to a loaded topic post", execute: (_, args) => { const post = Number(String(args[0] || "").replace(/^#/u, "")); if (!Number.isInteger(post) || post < 1) throw new Error("usage: goto "); return this.gotoPost(post); } }); add({ name: "share", usage: "share [post-number]", category: "reading", summary: "copy the canonical route", execute: (_, args) => { const post = args[0] ? Number(args[0].replace(/^#/u, "")) : null; if (args[0] && (!Number.isInteger(post) || post < 1)) throw new Error("usage: share [post-number]"); return this.share(post); } }); add({ name: "media", usage: "media [n]", category: "reading", summary: "focus image, video, or audio", execute: (_, args) => { const index = Number(args[0] || 1); if (!Number.isInteger(index) || index < 1) throw new Error("usage: media [n]"); this.focusMedia(index); } }); add({ name: "reply", usage: "reply [post-number]", category: "writing", summary: "open a local Markdown source/preview editor", execute: (_, args) => { const target = args[0] ? Number(args[0].replace(/^#/u, "")) : null; if (args[0] && !Number.isInteger(target)) throw new Error("usage: reply [post-number]"); this.openEditor("reply", target); } }); add({ name: "compose", category: "writing", summary: "open a local new-topic source/preview editor", execute: () => this.openEditor("compose") }); add({ name: "quote", usage: "quote ", category: "writing", summary: "quote a loaded post into a local reply", execute: (_, args) => { const post = Number(String(args[0] || "").replace(/^#/u, "")); if (!Number.isInteger(post) || post < 1) throw new Error("usage: quote "); this.quotePost(post); } }); add({ name: "drafts", category: "writing", summary: "list locally saved terminal drafts", execute: () => this.renderDrafts() }); add({ name: "draft", usage: "draft ", category: "writing", summary: "open a saved local draft", execute: (_, args) => { const index = Number(args[0]); if (!Number.isInteger(index) || index < 1) throw new Error("usage: draft "); return this.openDraft(index); } }); add({ name: "draft-delete", usage: "draft-delete confirm", category: "writing", summary: "delete a local draft after exact confirmation", execute: (_, args) => { const index = Number(args[0]); if (!Number.isInteger(index) || index < 1) throw new Error("usage: draft-delete confirm"); return this.deleteDraft(index, args[1] === "confirm"); } }); add({ name: "cancel", category: "writing", summary: "close the editor or current document", execute: () => this.editorActive ? this.closeEditor() : { message: this.popView() ? "returned" : "nothing to cancel" } }); add({ name: "whoami", category: "personal", summary: "show current Linux DO identity", execute: () => { const user = this.gateway.currentUser(); return { message: user.guest ? "guest (not signed in)" : `@${user.username}` }; } }); add({ name: "profile", usage: "profile [username]", aliases: ["user"], category: "personal", summary: "show a public Linux DO profile", execute: async (_, args) => { const username = (args[0] || this.gateway.currentUser().username).replace(/^@/u, ""); if (username === "guest") throw new Error("usage: profile "); const revision = this.routeRevision; const signal = this.abortController?.signal; this.pushCurrentView(); this.output.replaceChildren(node(this.document, "p", "status-line", `$ loading @${username} ...`)); try { const profile = await this.gateway.user(username, { signal }); if (revision === this.routeRevision && !signal?.aborted) this.renderProfile(profile); } catch (error) { if (revision !== this.routeRevision || signal?.aborted || error?.name === "AbortError") return; this.popView(); throw error; } } }); add({ name: "clear", category: "terminal", summary: "clear the current output", execute: () => this.output.replaceChildren() }); add({ name: "redraw", category: "terminal", summary: "recalculate and redraw the terminal", execute: () => { this.updateGeometry(); return this.renderCurrentRoute(); } }); add({ name: "theme", usage: "theme ", category: "terminal", summary: "change terminal color theme", execute: (_, args) => { if (!(/* @__PURE__ */ new Set(["dark", "light"])).has(args[0])) throw new Error("usage: theme "); this.preferences.theme = args[0]; this.savePreferences(); } }); add({ name: "density", usage: "density ", category: "terminal", summary: "change vertical density", execute: (_, args) => { if (!(/* @__PURE__ */ new Set(["normal", "compact"])).has(args[0])) throw new Error("usage: density "); this.preferences.density = args[0]; this.savePreferences(); } }); add({ name: "wrap", usage: "wrap ", category: "terminal", summary: "toggle rich-text wrapping", execute: (_, args) => { if (!(/* @__PURE__ */ new Set(["on", "off"])).has(args[0])) throw new Error("usage: wrap "); this.preferences.wrap = args[0] === "on"; this.root.classList.toggle("no-wrap", !this.preferences.wrap); this.savePreferences(); } }); add({ name: "logo", usage: "logo ", category: "terminal", summary: "change character logo size", execute: (_, args) => { if (!(/* @__PURE__ */ new Set(["auto", "large", "medium", "compact", "tiny", "ascii"])).has(args[0])) throw new Error("usage: logo "); this.preferences.logo = args[0]; this.savePreferences(); } }); add({ name: "native", category: "terminal", summary: "show the original Linux DO interface", execute: () => this.enterNative() }); add({ name: "doctor", category: "terminal", summary: "show runtime and route diagnostics", execute: () => this.renderDocument("DOCTOR", [ `route: ${this.window.location.pathname}${this.window.location.search}`, `intent: ${this.currentIntent?.type || "unknown"}`, `viewport: ${this.window.innerWidth}x${this.window.innerHeight} / ${this.columns} cols`, `topics: ${this.items.length} links: ${this.links.length} media: ${this.media.length}`, `user: ${this.gateway.currentUser().username}`, `commands: ${this.registry.definitions().length}` ], { push: true }) }); } }; // src/main.js async function boot() { if (document.querySelector("linuxdo-character-terminal")) return; if (!document.body) await new Promise((resolve) => document.addEventListener("DOMContentLoaded", resolve, { once: true })); const app = new TerminalApp(); try { await app.start(); } catch (error) { app.host?.remove(); app.restoreOriginal(); console.error("[linuxdo-terminal] startup failed", error); } } void boot(); })(); //# sourceURL=linuxdo-terminal.user.js