From baefca4e199d9f1e97c51f692ffbbabe1e102f66 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 12 Aug 2026 02:56:15 +0000 Subject: [PATCH] news: /news feed reader and /rss full-screen reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two commands, both wired into the CLI, the pit, help and completion: /news headlines across every subscribed feed /news search the news /news read one feed without subscribing /news add … subscribe to a feed, an OPML list, or a named bundle /rss the same headlines as a full-screen reader Subscriptions live in ~/.moshcode/news.opml. OPML rather than a news.json of our own invention: it is the format every reader already speaks, so a list can be exported from an existing reader, dropped in, and taken back out with `/news export`. The defaults and the search are the sources brisk.news and advis0r.com already use in production — Google News top stories and category feeds (brisk's fetch-feed.ts), the PR Newswire and GlobeNewswire wires plus the Google/Bing search pairing (advis0r's providers/news/rss.ts), and the four public OPML lists brisk seeds its publisher table from, offered by name. Querying both search engines is advis0r's reasoning carried over: Google has the coverage, Bing's links carry the publisher URL that unwrapRedirect can decode into something a reader can actually open. All 13 default feeds were fetched and parsed before being committed as defaults. Feed parsing is dependency-free, like the rest of moshcode: one path for RSS 2.0, Atom and RDF, DOCTYPE stripped and entities decoded from a fixed table so no feed can expand one into a file read, and http(s)-only links so none can talk the reader into opening file: or data:. The reader borrows herd-ui's terminal discipline — hand-written alternate screen and SGR mouse reporting, one restore path — and renders from a pure function so a frame can be asserted without a tty. 71 new tests. Four bugs they caught, all fixed here: escaped HTML in a description survived because entities were decoded after tags were stripped; a greedy attribute group swallowed the `/` of a self-closing OPML outline, so the category stack grew without bound; a mouse release decoded to no event and fell through to the key decoder, typing the mouse position into the search box; and an over-long word was never split, so a link wrapped the terminal and tore the frame. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + bin/moshcode.mjs | 16 + src/cli-schema.mjs | 74 +++ src/news-sources.mjs | 154 ++++++ src/news.mjs | 1071 ++++++++++++++++++++++++++++++++++++++++++ src/rss-ui.mjs | 517 ++++++++++++++++++++ src/tui.mjs | 20 + test/news.test.mjs | 646 +++++++++++++++++++++++++ test/rss-ui.test.mjs | 172 +++++++ 9 files changed, 2672 insertions(+) create mode 100644 src/news-sources.mjs create mode 100644 src/news.mjs create mode 100644 src/rss-ui.mjs create mode 100644 test/news.test.mjs create mode 100644 test/rss-ui.test.mjs diff --git a/README.md b/README.md index 8ceea6f..e81b0ce 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ or miss one that does. A test fails the build when it drifts. | `moshcode trade` | tools | look up markets and trade through Alpaca | | `moshcode stocks`
`advisor` | tools | equity research from advis0r.com | | `moshcode crypto`
`coins` | tools | crypto market data from advis0r.com | +| `moshcode news` | tools | headlines from your feeds, or a search | +| `moshcode rss` | tools | read the same headlines in a full-screen reader | | `moshcode plugin`
`plugins` | extend | install moshcode's slash commands into Claude Code | | `moshcode commands` | script | list built-in moshscript commands | | `moshcode completion` | extend | print a shell completion script | diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 456721b..48c0019 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -394,6 +394,22 @@ async function main() { if (code) process.exitCode = code; return; } + if (cmd === "news") { + const { newsCommand } = await import("../src/news.mjs"); + const code = await newsCommand(rest, { + openUrl: (url) => canOpenBrowser() && openBrowser(url), + }); + if (code) process.exitCode = code; + return; + } + if (cmd === "rss") { + const { rssUi } = await import("../src/rss-ui.mjs"); + const code = await rssUi(rest, { + openUrl: (url) => canOpenBrowser() && openBrowser(url), + }); + if (code) process.exitCode = code; + return; + } if (cmd === "plugin" || cmd === "plugins") { const code = await pluginCommand(rest); if (code) process.exitCode = code; diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 7359a58..2fa2208 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -515,6 +515,50 @@ export const CORE_CLI_COMMANDS = [ note: "research aid, not advice — prices are Alpaca's US crypto venue alone and can differ materially from other exchanges. Crypto trades 24/7 with no circuit breakers. Set MOSHCODE_ADVISOR_URL to point at another instance.", }, { name: "coins", aliasOf: "crypto", description: "alias for crypto" }, + { + name: "news", + group: "tools", + description: "headlines from your feeds, or a search", + synopsis: [ + ["moshcode news", "latest headlines across every subscribed feed"], + ["moshcode news ", "search the news"], + ["moshcode news [args…]", ""], + ], + verbs: "NEWS_VERBS", + flags: [ + ["--json", "structured output instead of headlines", ""], + ["--limit ", "how many headlines to show", "20"], + ["--feed ", "only this feed", "all of them"], + ["--timeout ", "per-feed fetch timeout", "10"], + ], + examples: [ + ["moshcode news", "everything you subscribe to, newest first"], + ["moshcode news openai earnings", "search Google News and Bing News"], + ["moshcode news https://example.com/feed.xml", "read one feed without subscribing"], + ["moshcode news add journalists", "pull in a public OPML bundle"], + ["moshcode news export > feeds.opml", "take the list to another reader"], + ], + seeAlso: ["rss", "stocks", "crypto"], + note: "subscriptions live in ~/.moshcode/news.opml (override with MOSHCODE_NEWS_OPML). " + + "With none, a default set is read instead — `moshcode news sources` lists it. " + + "The defaults and the search are the same sources brisk.news and advis0r.com use.", + }, + { + name: "rss", + group: "tools", + description: "read the same headlines in a full-screen reader", + synopsis: [ + ["moshcode rss", "open the reader on your feeds"], + ["moshcode rss ", "open it on a search"], + ], + examples: [ + ["moshcode rss", "feeds on the left, headlines in the middle"], + ["moshcode rss tariffs", "open straight into a search"], + ], + seeAlso: ["news"], + note: "needs an interactive terminal. ↑↓/jk move · ⏎ read · o open in a browser · " + + "tab the feed list · / search · r refresh · q quit.", + }, { name: "plugin", group: "extend", @@ -740,6 +784,31 @@ export const STOCKS_VERBS = [ { name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode stocks open ", ""]] }, ]; +/** + * `news`'s verbs. + * + * `latest` earns its place the way crypto's `report` does: a bare argument is + * the shortcut — `moshcode news tariffs` searches — so the plain listing needs + * a spelling that a keyword cannot be mistaken for. src/news.mjs owns the + * parser and test/news.test.mjs fails when the two lists disagree. + */ +export const NEWS_VERBS = [ + { name: "latest", description: "headlines across every subscribed feed", synopsis: [["moshcode news latest", "same as `moshcode news`"]] }, + { + name: "search", description: "search the news for a word or phrase", + synopsis: [["moshcode news search ", "same as `moshcode news `"]], + }, + { name: "list", description: "the feeds you are subscribed to", synopsis: [["moshcode news list [--json]", ""]] }, + { + name: "add", description: "subscribe to a feed, an OPML list, or a bundle", + synopsis: [["moshcode news add ", "an RSS/Atom link, an OPML list, or journalists|web3|blockchain"]], + }, + { name: "rm", description: "unsubscribe", synopsis: [["moshcode news rm ", ""]] }, + { name: "open", description: "open a headline from the last listing", synopsis: [["moshcode news open ", ""]] }, + { name: "sources", description: "the default feeds and the bundles on offer", synopsis: [["moshcode news sources", ""]] }, + { name: "export", description: "print the subscription list as OPML", synopsis: [["moshcode news export > feeds.opml", ""]] }, +]; + /** * `crypto`'s verbs. * @@ -888,6 +957,7 @@ export const VERB_TABLES = { TRADE_VERBS, STOCKS_VERBS, CRYPTO_VERBS, + NEWS_VERBS, PLUGIN_VERBS, }; @@ -931,6 +1001,10 @@ export const PIT_COMMANDS = [ description: "equity research from advis0r.com" }, { name: "crypto", aliases: ["coins"], args: " [args…]", cli: "crypto", description: "crypto market data from advis0r.com" }, + { name: "news", args: "[keyword…|verb] [args…]", cli: "news", + description: "headlines from your feeds, or a search" }, + { name: "rss", aliases: ["reader"], cli: "rss", + description: "read the same headlines in a full-screen reader" }, { name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin", description: "install moshcode's slash commands into Claude Code" }, { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games", diff --git a/src/news-sources.mjs b/src/news-sources.mjs new file mode 100644 index 0000000..54b65b9 --- /dev/null +++ b/src/news-sources.mjs @@ -0,0 +1,154 @@ +// Where `/news` gets its headlines when nobody has subscribed to anything yet. +// +// These are not invented: they are the sources the two profullstack properties +// that already do this in production use, lifted so the pit answers the same +// way they do. +// +// brisk.news — apps/web/src/lib/news/fetch-feed.ts builds Google News feeds, +// top stories for the front page and one per category, and +// scripts/import-opml-feeds.ts seeds its publisher table from four public +// OPML lists. Both are represented here: the Google feeds as the defaults, +// the OPML lists as named bundles `/news add` accepts by name. +// +// advis0r.com — src/providers/news/rss.ts pairs a Google News query feed with +// a Bing one (Google's links are interstitials, Bing's carry the publisher +// URL in a `url=` parameter), and reads two newswires directly. The search +// pairing is why `/news ` queries both, and the wires are here +// under `markets`. +// +// Deliberately small. A default list is a claim that every entry works, so it +// holds feeds with stable well-known URLs and defers everything else to the +// OPML bundles, where the list is somebody else's to maintain. + +/** Google News locale. One place, because every builder below needs it. */ +const GOOGLE_LOCALE = "hl=en-US&gl=US&ceid=US:en"; + +/** + * Google News category feeds, keyed the way brisk.news keys them. + * + * `general` is null there and means "top stories", which is a different URL + * rather than a search for the word "general" — the same distinction is kept. + */ +export const GOOGLE_NEWS_CATEGORIES = { + general: null, + science: "science", + sports: "sports", + business: "business", + health: "health", + entertainment: "entertainment", + tech: "technology", + politics: "politics", + food: "food", + travel: "travel", +}; + +/** Google News top stories, or one category from the map above. */ +export function googleNewsFeed(category = null) { + const mapped = category ? GOOGLE_NEWS_CATEGORIES[category] ?? null : null; + if (!mapped) return `https://news.google.com/rss?${GOOGLE_LOCALE}`; + return `https://news.google.com/rss/search?q=${encodeURIComponent(mapped)}&${GOOGLE_LOCALE}`; +} + +/** + * Google News query feed. `when:7d` style windows keep results recent — + * advis0r's googleNewsFeed() does the same, for the same reason. + */ +export function googleNewsSearch(query, { window = "7d" } = {}) { + const q = window ? `${query} when:${window}` : String(query); + return `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&${GOOGLE_LOCALE}`; +} + +/** Bing News query feed — the half of a search whose links are real articles. */ +export function bingNewsSearch(query) { + return `https://www.bing.com/news/search?q=${encodeURIComponent(query)}&format=RSS`; +} + +/** + * Resolve a redirect wrapper to the publisher URL it carries. + * + * Lifted from advis0r's unwrapRedirect for the same reason it exists there: + * aggregator feeds link to themselves, and a reader that opens + * `news.google.com/rss/articles/CBM…` shows an interstitial instead of the + * story. Only decodes what is already in the link — it never follows a + * redirect, so it costs no request and cannot be led somewhere unexpected. + */ +export function unwrapRedirect(url) { + try { + const parsed = new URL(url); + const inner = parsed.searchParams.get("url") ?? parsed.searchParams.get("u"); + if (!inner) return url; + const decoded = decodeURIComponent(inner); + return /^https?:\/\//i.test(decoded) ? decoded : url; + } catch { + return url; + } +} + +/** + * The feeds a fresh install reads. + * + * Google News carries the general desks because that is exactly what brisk.news + * serves its front page from, and it means one URL shape covers ten sections + * without ten publisher relationships to keep working. The named publishers are + * tier-1 hosts from advis0r's own tiering table that publish a stable feed, and + * they earn their place by being readable end to end — a Google News item is a + * headline behind an interstitial, theirs is the article. + */ +export const DEFAULT_FEEDS = [ + { name: "top-stories", title: "Google News — Top Stories", url: googleNewsFeed(), site: "https://news.google.com", category: "" }, + { name: "world", title: "Google News — World", url: googleNewsSearch("world news", { window: "2d" }), site: "https://news.google.com", category: "" }, + + { name: "tech", title: "Google News — Technology", url: googleNewsFeed("tech"), site: "https://news.google.com", category: "tech" }, + { name: "ars-technica", title: "Ars Technica", url: "https://feeds.arstechnica.com/arstechnica/index", site: "https://arstechnica.com", category: "tech" }, + { name: "techcrunch", title: "TechCrunch", url: "https://techcrunch.com/feed/", site: "https://techcrunch.com", category: "tech" }, + { name: "the-register", title: "The Register", url: "https://www.theregister.com/headlines.atom", site: "https://www.theregister.com", category: "tech" }, + { name: "hacker-news", title: "Hacker News — Front Page", url: "https://hnrss.org/frontpage", site: "https://news.ycombinator.com", category: "tech" }, + + { name: "business", title: "Google News — Business", url: googleNewsFeed("business"), site: "https://news.google.com", category: "markets" }, + { name: "marketwatch", title: "MarketWatch — Top Stories", url: "https://feeds.content.dowjones.io/public/rss/mw_topstories", site: "https://www.marketwatch.com", category: "markets" }, + // The two newswires advis0r reads directly. Tier 0 there: issuers speaking + // for themselves rather than a publication speaking about them. + { name: "pr-newswire", title: "PR Newswire — Financial Services", url: "https://www.prnewswire.com/rss/financial-services-latest-news/financial-services-latest-news-list.rss", site: "https://www.prnewswire.com", category: "markets" }, + { name: "globenewswire", title: "GlobeNewswire — Public Companies", url: "https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies", site: "https://www.globenewswire.com", category: "markets" }, + + { name: "science", title: "Google News — Science", url: googleNewsFeed("science"), site: "https://news.google.com", category: "science" }, + { name: "politics", title: "Google News — Politics", url: googleNewsFeed("politics"), site: "https://news.google.com", category: "politics" }, +]; + +/** + * The public OPML lists brisk.news imports its publisher table from. + * + * Offered by name (`/news add journalists`) rather than only by URL because + * these are long, exact raw.githubusercontent paths that nobody is going to + * retype, and importing one is the fastest way from an empty reader to a real + * one. They are somebody else's lists, and that is the point: the feeds inside + * them stay current without moshcode shipping a release. + */ +export const OPML_BUNDLES = [ + { + name: "journalists", + description: "Dave Winer's feedsForJournalists — mainstream desks", + url: "https://raw.githubusercontent.com/scripting/feedsForJournalists/master/list.opml", + }, + { + name: "web3", + description: "ChainFeeds RSSAggregatorforWeb3 — crypto and web3", + url: "https://raw.githubusercontent.com/chainfeeds/RSSAggregatorforWeb3/main/RAW.opml", + }, + { + name: "blockchain", + description: "CoinFabrik decentralized-and-blockchain-feeds", + url: "https://raw.githubusercontent.com/CoinFabrik/resources/master/decentralized-and-blockchain-feeds.opml", + }, +]; + +/** Resolve a bundle name to its OPML URL, or null. */ +export function resolveBundle(name) { + const wanted = String(name ?? "").trim().toLowerCase(); + return OPML_BUNDLES.find((b) => b.name === wanted) ?? null; +} + +/** A fresh copy of the defaults — callers mutate feed lists. */ +export function defaultFeeds() { + return DEFAULT_FEEDS.map((feed) => ({ ...feed })); +} diff --git a/src/news.mjs b/src/news.mjs new file mode 100644 index 0000000..de4f45d --- /dev/null +++ b/src/news.mjs @@ -0,0 +1,1071 @@ +// `moshcode news` — the headlines, in the pit. +// +// The same split as src/crypto.mjs, for the same reasons: argument translation +// is pure and testable, the network call is injectable, and rendering is a +// function of the parsed feed. What differs is where the data comes from — +// there is no advis0r API here, only whatever feeds the operator subscribed to. +// +// Subscriptions live in an OPML file (~/.moshcode/news.opml) rather than in a +// news.json of our own invention. OPML is the interchange format every reader +// already speaks, so the subscription list can be exported from an existing +// reader, dropped in, and taken back out again — which is the whole reason to +// have a file instead of a flag. `/news add` accepts either an OPML document or +// a single RSS/Atom link and works out which it was given, because "a feed" and +// "a list of feeds" are the two shapes a URL handed to a news reader can have. +// +// The XML is read with targeted regexes rather than a parser. That is a real +// constraint and it is deliberate: moshcode ships with no runtime dependencies, +// and feeds are a small, well-trodden subset of XML. It also removes a class of +// risk outright — DOCTYPE is stripped and entities are decoded from a fixed +// table, so a hostile feed cannot expand an entity into a file read. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { acid, ash, amber, bone, danger } from "./ui.mjs"; +import { + bingNewsSearch, + defaultFeeds, + googleNewsSearch, + OPML_BUNDLES, + resolveBundle, + unwrapRedirect, +} from "./news-sources.mjs"; + +const USAGE = `usage: moshcode news [verb|keyword…] [args…] + + (no verb) latest headlines across every subscribed feed + latest the same thing, said out loud + search the news for a word or phrase + read one feed without subscribing to it + list the feeds you are subscribed to + add subscribe — an RSS/Atom link, an OPML list, or + a bundle: ${OPML_BUNDLES.map((b) => b.name).join(", ")} + rm unsubscribe + open open headline from the last listing + sources the default feeds and the bundles on offer + export print the subscription list as OPML + + --json print structured data instead of headlines + --limit how many headlines to show (default 20) + --feed only this subscribed feed + --timeout per-feed fetch timeout (default 10) + +\`moshcode rss\` opens the same headlines as a full-screen reader. + +Feeds live in ~/.moshcode/news.opml — export it to any reader, or point +MOSHCODE_NEWS_OPML at a list you already keep somewhere else. With no +subscriptions the defaults are read instead, so \`/news\` works on a fresh +install; \`/news add\` anything and the defaults step aside.`; + +export function newsUsage() { + return USAGE; +} + +/** Verb names, in help order. cli-schema's NEWS_VERBS must match (drift test). */ +export const NEWS_VERB_NAMES = ["latest", "search", "list", "add", "rm", "open", "sources", "export"]; + +// The same reasoning as crypto's alias table: the obvious synonym should not be +// an error. `import` is the word an OPML file invites, and it is the same verb +// as `add` here precisely because `add` already takes an OPML document. +const VERB_ALIASES = { + new: "latest", recent: "latest", top: "latest", headlines: "latest", + find: "search", q: "search", query: "search", grep: "search", + feeds: "list", ls: "list", subscriptions: "list", + sub: "add", subscribe: "add", import: "add", follow: "add", + remove: "rm", unsub: "rm", unsubscribe: "rm", del: "rm", delete: "rm", + read: "open", browse: "open", www: "open", + bundles: "sources", defaults: "sources", + opml: "export", dump: "export", +}; + +/** Resolve a first argument to a canonical verb, or null when it is not one. */ +export function resolveVerb(word) { + const key = String(word ?? "").toLowerCase(); + if (NEWS_VERB_NAMES.includes(key)) return key; + return VERB_ALIASES[key] ?? null; +} + +/** Owner-only, like aliases.json: a subscription list is a reading history. */ +const FILE_MODE = 0o600; + +/** Enough for a large feed, small enough that one bad URL cannot eat the pit. */ +const MAX_BYTES = 8 * 1024 * 1024; + +/** How many feeds are in flight at once. Politeness, not throughput. */ +const CONCURRENCY = 6; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 200; +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Where the subscription list lives. Derived per call so tests can move $HOME, + * and overridable so an operator can point at a list they already maintain. + */ +export function opmlFile(env = process.env) { + const override = String(env.MOSHCODE_NEWS_OPML || "").trim(); + if (override) return path.resolve(override); + return path.join(os.homedir(), ".moshcode", "news.opml"); +} + +/** Where the last rendered listing is remembered, so `/news open 3` knows what 3 was. */ +export function cacheFile(env = process.env) { + return path.join(path.dirname(opmlFile(env)), "news-last.json"); +} + +// --------------------------------------------------------------------------- +// XML, the small subset feeds actually use +// --------------------------------------------------------------------------- + +const ENTITIES = { + amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", + ldquo: "“", rdquo: "”", lsquo: "‘", rsquo: "’", + mdash: "—", ndash: "–", hellip: "…", eacute: "é", +}; + +/** + * Decode the entities a feed actually carries. A fixed table plus numeric + * escapes — never the document's own DOCTYPE entities, which is what keeps a + * feed from declaring one that expands to the contents of /etc/passwd. + */ +export function decodeEntities(text) { + return String(text ?? "").replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => { + if (body[0] === "#") { + const code = body[1] === "x" || body[1] === "X" + ? Number.parseInt(body.slice(2), 16) + : Number.parseInt(body.slice(1), 10); + // Surrogates and out-of-range code points would throw; leave them as text. + if (!Number.isFinite(code) || code < 1 || code > 0x10ffff) return match; + if (code >= 0xd800 && code <= 0xdfff) return match; + return String.fromCodePoint(code); + } + const named = ENTITIES[body.toLowerCase()]; + return named === undefined ? match : named; + }); +} + +/** Strip the parts of a document that are never content: BOM, comments, DOCTYPE. */ +function scrub(xml) { + return String(xml ?? "") + .replace(/^/, "") + .replace(//g, "") + .replace(/[]*(\[[\s\S]*?\])?[^>]*>/gi, ""); +} + +/** + * CDATA out, entities decoded, tags out, whitespace collapsed. + * + * Decoding before stripping, not after, and the order is load-bearing: a + * `` arrives as markup two different ways. CDATA carries real + * tags, but plenty of publishers — Google News among them — escape the same + * HTML into `<a href=…>` instead. Strip first and the escaped form sails + * through untouched, then decoding turns it back into the markup that was + * supposed to have been removed, and the headline reads as an anchor tag. + */ +function text(raw) { + let value = String(raw ?? "").replace(//g, "$1"); + // Twice, because aggregators escape HTML that was already escaped: a Google + // News description arrives as `<a…>` for the tags and `&nbsp;` for + // the spaces between them, so one round leaves a literal ` ` on screen. + // Bounded at two — that is every doubling seen in the wild, and looping until + // a document stops changing is a decompression bomb waiting to happen. + for (let round = 0; round < 2; round++) { + value = decodeEntities(value).replace(/<[^>]*>/g, " "); + } + return value.replace(/\s+/g, " ").trim(); +} + +/** + * The text of the first `` in a block, namespace prefix optional. + * + * Namespace-agnostic because feeds are inconsistent about it in exactly the + * places that matter: the same publisher's `` and `<dc:title>` mean the + * same thing. Callers that need one specific namespace pass the prefix in. + */ +function pick(block, tag) { + const name = tag.includes(":") ? tag.replace(":", "\\:") : `(?:[a-z0-9]+\\:)?${tag}`; + const match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "i").exec(block); + return match ? text(match[1]) : ""; +} + +/** The value of an attribute on a tag, unescaped. Single or double quoted. */ +function attr(tag, name) { + const match = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i").exec(tag); + return match ? decodeEntities(match[2] ?? match[3] ?? "") : ""; +} + +/** Every `<tag …>` opening in a document, in order, as raw strings. */ +function tagsNamed(xml, name) { + return xml.match(new RegExp(`<${name}(?:\\s[^>]*)?/?>`, "gi")) || []; +} + +/** Only http(s) survives. A feed must not talk us into opening file: or data:. */ +export function safeUrl(raw, base = null) { + const value = String(raw ?? "").trim(); + if (!value) return null; + let url; + try { url = base ? new URL(value, base) : new URL(value); } + catch { return null; } + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null; +} + +// --------------------------------------------------------------------------- +// OPML — the subscription list +// --------------------------------------------------------------------------- + +/** + * Every feed in an OPML document, as { name, url, site, category }. + * + * Outlines nest: readers use a bare `<outline text="Tech">` as a folder around + * the feeds inside it. The stack below tracks that, so an imported list keeps + * the grouping its owner gave it instead of flattening to one pile. Only + * outlines carrying an `xmlUrl` are feeds; the rest are folders. + */ +export function parseOpml(xml) { + const doc = scrub(xml); + const body = /<body(?:\s[^>]*)?>([\s\S]*)<\/body>/i.exec(doc); + const source = body ? body[1] : doc; + const feeds = []; + const seen = new Set(); + const stack = []; + + // One pass over every outline tag and every </outline>, in document order, so + // the folder stack stays in step with the nesting. + const token = /<outline(?:\s[^>]*)?>|<\/outline\s*>/gi; + let match; + while ((match = token.exec(source)) !== null) { + if (match[0][1] === "/") { stack.pop(); continue; } + const tag = match[0]; + // Read the slash off the raw tag rather than capturing it: an attribute + // group greedy enough to hold `text="Tech" xmlUrl="…"` also swallows the + // trailing `/`, so every self-closing outline reads as a folder that never + // closes and the category stack grows without bound. + const selfClosing = /\/\s*>$/.test(tag); + const xmlUrl = safeUrl(attr(tag, "xmlUrl")); + const label = attr(tag, "title") || attr(tag, "text") || ""; + + if (!xmlUrl) { + // A folder. Self-closing folders enclose nothing, so they never nest. + if (!selfClosing) stack.push(label); + continue; + } + if (!seen.has(xmlUrl)) { + seen.add(xmlUrl); + feeds.push({ + name: slugify(label) || hostSlug(xmlUrl), + title: label || hostOf(xmlUrl), + url: xmlUrl, + site: safeUrl(attr(tag, "htmlUrl")) || "", + category: stack.filter(Boolean).join("/"), + }); + } + // A feed outline can still have children in the wild; keep the stack honest. + if (!selfClosing) stack.push(label); + } + return feeds; +} + +/** Escape a string for an XML attribute. */ +function xmlAttr(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +/** Render a subscription list back to OPML, grouped by category. */ +export function buildOpml(feeds, { title = "moshcode news" } = {}) { + const groups = new Map(); + for (const feed of feeds) { + const key = feed.category || ""; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(feed); + } + const outline = (feed, indent) => + `${indent}<outline type="rss" text="${xmlAttr(feed.title || feed.name)}" ` + + `title="${xmlAttr(feed.title || feed.name)}" xmlUrl="${xmlAttr(feed.url)}"` + + `${feed.site ? ` htmlUrl="${xmlAttr(feed.site)}"` : ""}/>`; + + const lines = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<opml version="2.0">', + " <head>", + ` <title>${xmlAttr(title)}`, + " ", + " ", + ]; + // Ungrouped feeds first, then folders — the order a reader displays them in. + for (const [category, rows] of [...groups].sort((a, b) => a[0].localeCompare(b[0]))) { + if (!category) { for (const feed of rows) lines.push(outline(feed, " ")); continue; } + lines.push(` `); + for (const feed of rows) lines.push(outline(feed, " ")); + lines.push(" "); + } + lines.push(" ", "", ""); + return lines.join("\n"); +} + +/** A stable, typeable short name for a feed. */ +export function slugify(label) { + return String(label ?? "") + .toLowerCase() + .replace(/['’]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); +} + +function hostOf(url) { + try { return new URL(url).hostname.replace(/^www\./, ""); } + catch { return String(url); } +} + +function hostSlug(url) { + return slugify(hostOf(url).replace(/\.[a-z]{2,}$/i, "")) || "feed"; +} + +/** Make `name` unique against `taken` by suffixing -2, -3, … */ +function uniqueName(name, taken) { + if (!taken.has(name)) return name; + for (let i = 2; i < 1000; i++) { + const candidate = `${name}-${i}`; + if (!taken.has(candidate)) return candidate; + } + return `${name}-${taken.size}`; +} + +// --------------------------------------------------------------------------- +// The subscription store +// --------------------------------------------------------------------------- + +/** + * The subscribed feeds. + * + * A missing or unreadable file reads as "no subscriptions" rather than + * throwing, the way loadAliases() does: this runs on the way into a command + * that should still be able to tell you how to fix it. + */ +export function loadFeeds(env = process.env) { + let raw; + try { raw = fs.readFileSync(opmlFile(env), "utf8"); } + catch { return []; } + try { return parseOpml(raw); } + catch { return []; } +} + +/** + * The feeds to read, and whether they are the operator's own. + * + * An empty reader is a useless one — `/news` on a fresh install should print + * the news, not instructions for how to earn the news. So with no + * subscriptions the defaults stand in, and the flag comes back with them so + * the UI can say which it is showing rather than quietly implying the operator + * subscribed to thirteen feeds they have never seen. + */ +export function readingList(env = process.env) { + const subscribed = loadFeeds(env); + if (subscribed.length) return { feeds: subscribed, usingDefaults: false }; + return { feeds: defaultFeeds(), usingDefaults: true }; +} + +/** Write the list back, creating ~/.moshcode if this is the first feed. */ +export function saveFeeds(feeds, env = process.env) { + const file = opmlFile(env); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, buildOpml(feeds), { mode: FILE_MODE }); + // `mode` only applies at creation, so tighten every write — aliases.mjs and + // the history file do the same for the same reason. + try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ } +} + +/** Add one feed to a list, naming it uniquely. Returns { feeds, added, existed }. */ +export function withFeed(feeds, candidate) { + const existing = feeds.find((f) => f.url === candidate.url); + if (existing) return { feeds, added: existing, existed: true }; + const taken = new Set(feeds.map((f) => f.name)); + const added = { ...candidate, name: uniqueName(candidate.name || hostSlug(candidate.url), taken) }; + return { feeds: [...feeds, added], added, existed: false }; +} + +/** Find a feed by name, url, or title. */ +export function findFeed(feeds, needle) { + const wanted = String(needle ?? "").trim().toLowerCase(); + if (!wanted) return null; + return feeds.find((f) => f.name.toLowerCase() === wanted) + ?? feeds.find((f) => f.url.toLowerCase() === wanted) + ?? feeds.find((f) => (f.title || "").toLowerCase() === wanted) + ?? null; +} + +// --------------------------------------------------------------------------- +// Feeds — RSS 2.0, Atom, and RSS 1.0/RDF +// --------------------------------------------------------------------------- + +/** Is this document a subscription list rather than a feed? */ +export function looksLikeOpml(xml) { + return /]/i.test(scrub(xml)); +} + +/** + * The link for an entry. + * + * Atom puts it in an attribute and may carry several: `rel="alternate"` (or no + * rel at all, which means alternate) is the human-readable page, while + * `rel="self"`, `"replies"` and `"enclosure"` are not what a reader should + * open. RSS puts it in element text, and some publishers only fill a permalink + * `` — hence the third fallback. + */ +function linkOf(block, base) { + const links = tagsNamed(block, "(?:[a-z0-9]+\\:)?link"); + const alternate = links.find((tag) => { + const rel = attr(tag, "rel").toLowerCase(); + return (!rel || rel === "alternate") && attr(tag, "href"); + }); + if (alternate) return safeUrl(attr(alternate, "href"), base); + + const inline = pick(block, "link"); + if (inline) return safeUrl(inline, base); + + const guid = /<(?:[a-z0-9]+:)?guid(\s[^>]*)?>([\s\S]*?)<\/(?:[a-z0-9]+:)?guid>/i.exec(block); + if (guid && !/isPermaLink\s*=\s*["']false["']/i.test(guid[1] || "")) { + return safeUrl(text(guid[2]), base); + } + return null; +} + +/** The publication time of an entry as epoch ms, or null when it has none. */ +function dateOf(block) { + for (const tag of ["pubDate", "published", "updated", "dc:date", "date", "created"]) { + const raw = pick(block, tag); + if (!raw) continue; + const ms = Date.parse(raw); + if (Number.isFinite(ms)) return ms; + } + return null; +} + +/** + * Parse an RSS/Atom/RDF document into { title, site, items }. + * + * One code path for all three because, at the level a headline list needs, they + * genuinely are the same document: a channel with a title and a list of dated, + * linked entries. Where they disagree — the entry element name, where the link + * lives, which tag holds the date — the difference is handled at that field + * rather than by forking the whole parser. + */ +export function parseFeed(xml, { url = "" } = {}) { + const doc = scrub(xml); + const blocks = doc.match(/<(?:[a-z0-9]+:)?(?:item|entry)(?:\s[^>]*)?>[\s\S]*?<\/(?:[a-z0-9]+:)?(?:item|entry)>/gi) || []; + + // The channel header is whatever precedes the first entry — taking the title + // from the whole document would pick up an entry's title on a feed whose + // channel has none. + const head = blocks.length ? doc.slice(0, doc.indexOf(blocks[0])) : doc; + const feedTitle = pick(head, "title") || hostOf(url); + const site = linkOf(head, url) || ""; + + const items = []; + for (const block of blocks) { + const title = pick(block, "title"); + const link = linkOf(block, url); + if (!title && !link) continue; // nothing to show and nothing to open + items.push({ + title: title || link, + link, + date: dateOf(block), + author: pick(block, "creator") || pick(block, "author") || "", + summary: clip(pick(block, "description") || pick(block, "summary") || "", 400), + }); + } + return { title: feedTitle, site, url, items }; +} + +function clip(value, max) { + const s = String(value ?? ""); + return s.length > max ? `${s.slice(0, max - 1)}…` : s; +} + +// --------------------------------------------------------------------------- +// Fetching +// --------------------------------------------------------------------------- + +/** + * Fetch one document. Returns { ok, body, error }. + * + * Size-capped while streaming rather than after: a feed that turns out to be a + * disk image should cost a few megabytes of transfer, not all of it. + */ +export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const impl = fetchImpl || globalThis.fetch; + if (typeof impl !== "function") return { ok: false, error: "no fetch available in this runtime" }; + const safe = safeUrl(url); + if (!safe) return { ok: false, error: `not an http(s) URL: ${url}` }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await impl(safe, { + signal: controller.signal, + redirect: "follow", + headers: { + accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.8", + "user-agent": "moshcode/news (+https://moshcode.sh)", + }, + }); + if (!res.ok) return { ok: false, status: res.status, error: `${res.status} ${res.statusText || ""}`.trim() }; + + // Prefer the stream so the cap can stop a huge body early; fall back to + // text() for any fetch implementation (tests included) that has no body. + if (!res.body || typeof res.body.getReader !== "function") { + const body = await res.text(); + if (body.length > MAX_BYTES) return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` }; + return { ok: true, body }; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let body = ""; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_BYTES) { + try { await reader.cancel(); } catch { /* already gone */ } + return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` }; + } + body += decoder.decode(value, { stream: true }); + } + body += decoder.decode(); + return { ok: true, body }; + } catch (e) { + const aborted = e?.name === "AbortError"; + return { ok: false, error: aborted ? `timed out after ${Math.round(timeoutMs / 1000)}s` : String(e?.message || e) }; + } finally { + clearTimeout(timer); + } +} + +/** Read a document from a local path, or over the network when it is a URL. */ +export async function readSource(source, opts = {}) { + const asUrl = safeUrl(source); + if (asUrl) return fetchDocument(asUrl, opts); + try { return { ok: true, body: fs.readFileSync(path.resolve(source), "utf8"), local: true }; } + catch (e) { return { ok: false, error: `can't read ${source}: ${e.message}` }; } +} + +/** Run `worker` over `items` with a bounded number in flight. Order is preserved. */ +async function mapLimit(items, limit, worker) { + const out = new Array(items.length); + let cursor = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const index = cursor++; + if (index >= items.length) return; + out[index] = await worker(items[index], index); + } + }); + await Promise.all(runners); + return out; +} + +/** + * Fetch every feed and merge them into one dated list. + * + * A feed that fails is reported, not fatal: a reader whose whole listing + * disappears because one publisher is having an outage is not a reader. The + * failures come back alongside the items so the caller can say which. + */ +export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const results = await mapLimit(feeds, CONCURRENCY, async (feed) => { + const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs }); + if (!res.ok) return { feed, error: res.error }; + let parsed; + try { parsed = parseFeed(res.body, { url: feed.url }); } + catch (e) { return { feed, error: `unreadable feed (${e.message})` }; } + return { feed, parsed }; + }); + + const items = []; + const failures = []; + const seen = new Set(); + for (const result of results) { + if (result.error) { failures.push({ name: result.feed.name, url: result.feed.url, error: result.error }); continue; } + for (const item of result.parsed.items) { + // Aggregator feeds wrap the publisher's URL in one of their own. Unwrap + // before deduping, so the same story arriving via Google News and via the + // publisher's own feed is recognised as one story rather than two. + const link = item.link ? unwrapRedirect(item.link) : null; + const key = link || `${result.feed.name}:${item.title}`; + if (seen.has(key)) continue; + seen.add(key); + items.push({ ...item, link, feed: result.feed.name, feedTitle: result.parsed.title || result.feed.title }); + } + } + // Newest first, and undated entries last rather than pretending they are old: + // plenty of feeds omit dates entirely, and sorting them to the bottom keeps + // them reachable without letting them claim the top of the list. + items.sort((a, b) => (b.date ?? -Infinity) - (a.date ?? -Infinity)); + return { items, failures }; +} + +/** + * The feeds a keyword search reads. + * + * Two engines rather than one, which is advis0r's reasoning carried over + * verbatim: Google has the better index, but its RSS links are interstitials + * that a reader cannot open into an article, while Bing wraps the real + * publisher URL in a `url=` parameter that unwrapRedirect decodes. Querying + * both and deduping on the unwrapped link gets Google's coverage with Bing's + * openable links wherever the two overlap. + */ +export function searchFeeds(query) { + return [ + { name: "google", title: `Google News — ${query}`, url: googleNewsSearch(query), site: "", category: "" }, + { name: "bing", title: `Bing News — ${query}`, url: bingNewsSearch(query), site: "", category: "" }, + ]; +} + +// --------------------------------------------------------------------------- +// Arguments +// --------------------------------------------------------------------------- + +function takeFlag(args, name, { boolean = false } = {}) { + const out = { value: null, rest: [], missing: false, present: false }; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === name) { + out.present = true; + if (boolean) continue; + const next = args[i + 1]; + if (next == null || String(next).startsWith("-")) out.missing = true; + else { out.value = String(next); i++; } + continue; + } + if (!boolean && arg.startsWith(`${name}=`)) { + out.present = true; + const value = arg.slice(name.length + 1); + if (value === "") out.missing = true; else out.value = value; + continue; + } + out.rest.push(arg); + } + return out; +} + +/** + * Translate argv into a request. Pure — no network, no filesystem. + * + * Returns { verb, target, limit, feed, json, timeoutMs } or { error } / { usage }. + */ +export function newsArgs(argv = []) { + const args = (Array.isArray(argv) ? argv : []).map(String); + if (args.includes("--help") || args.includes("-h") || args.includes("help")) return { usage: true }; + + const json = takeFlag(args, "--json", { boolean: true }); + const limitFlag = takeFlag(json.rest, "--limit"); + if (limitFlag.missing) return { error: "--limit needs a number" }; + const feedFlag = takeFlag(limitFlag.rest, "--feed"); + if (feedFlag.missing) return { error: "--feed needs a feed name" }; + const timeoutFlag = takeFlag(feedFlag.rest, "--timeout"); + if (timeoutFlag.missing) return { error: "--timeout needs a number of seconds" }; + + let limit = DEFAULT_LIMIT; + if (limitFlag.value != null) { + const n = Number(limitFlag.value); + if (!Number.isInteger(n) || n < 1) return { error: `--limit takes a whole number of headlines, got ${JSON.stringify(limitFlag.value)}` }; + limit = Math.min(n, MAX_LIMIT); + } + + let timeoutMs = DEFAULT_TIMEOUT_MS; + if (timeoutFlag.value != null) { + const secs = Number(timeoutFlag.value); + if (!Number.isFinite(secs) || secs <= 0 || secs > 120) return { error: `--timeout takes 1-120 seconds, got ${JSON.stringify(timeoutFlag.value)}` }; + timeoutMs = Math.round(secs * 1000); + } + + const rest = timeoutFlag.rest.filter((a) => a !== ""); + const unknown = rest.find((a) => a.startsWith("--")); + if (unknown) return { error: `unknown flag ${unknown}` }; + + const base = { limit, feed: feedFlag.value, json: json.present, timeoutMs }; + const [first, ...tail] = rest; + + // No argument at all: the headlines. This is the common case and it is why + // `/news` is worth having as one word. + if (!first) return { ...base, verb: "headlines", target: null }; + + const verb = resolveVerb(first); + if (!verb) { + // Not a verb. Two things it can still be, and the URL check decides which: + // a feed to read directly — the "or rss link" half of the feature — or a + // keyword to search for. Everything that is not a URL is a keyword, so + // `/news tariffs` and `/news openai earnings` both work and neither needs + // a verb in front of it. That does mean a mistyped verb searches for the + // typo instead of erroring, which is the right trade: `/news lst` finding + // nothing is recoverable, and refusing every unrecognised word would make + // the headline search unreachable without ceremony. + const url = safeUrl(first); + if (url) { + if (tail.length) return { error: "reading one feed takes a single URL" }; + return { ...base, verb: "headlines", target: url, oneOff: true }; + } + return { ...base, verb: "search", query: [first, ...tail].join(" ") }; + } + + if (verb === "latest") { + if (tail.length) return { error: "latest takes no arguments — /news to search" }; + return { ...base, verb: "headlines", target: null }; + } + if (verb === "search") { + const query = tail.join(" ").trim(); + if (!query) return { error: "usage: moshcode news search " }; + return { ...base, verb, query }; + } + if (verb === "add") { + if (!tail.length) return { error: "usage: moshcode news add " }; + if (tail.length > 1) return { error: "add takes one URL or file at a time" }; + return { ...base, verb, target: tail[0] }; + } + if (verb === "rm") { + if (!tail.length) return { error: "usage: moshcode news rm " }; + return { ...base, verb, target: tail.join(" ") }; + } + if (verb === "open") { + if (tail.length !== 1) return { error: "usage: moshcode news open " }; + const n = Number(tail[0]); + if (!Number.isInteger(n) || n < 1) return { error: `open takes a headline number, got ${JSON.stringify(tail[0])}` }; + return { ...base, verb, index: n }; + } + if (tail.length) return { error: `${verb} takes no arguments` }; + return { ...base, verb, target: null }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/** "3h ago" — how long before `now` something was published. */ +export function ago(ms, now = Date.now()) { + if (!Number.isFinite(ms)) return ""; + const secs = Math.round((now - ms) / 1000); + if (secs < 0) return "just now"; + if (secs < 90) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 90) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 36) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 14) return `${days}d ago`; + const weeks = Math.round(days / 7); + if (weeks < 9) return `${weeks}w ago`; + return `${Math.round(days / 30)}mo ago`; +} + +/** The headline list. */ +export function renderHeadlines(items, { failures = [], columns, limit = DEFAULT_LIMIT, now = Date.now(), source = "" } = {}) { + const width = Math.max(48, Math.min(Number(columns) || 88, 100)); + const shown = items.slice(0, limit); + if (!shown.length) { + const lines = ["", ` ${ash("nothing came back")}`]; + if (failures.length) lines.push("", ...failureLines(failures)); + else lines.push("", ` ${ash("subscribe with")} ${bone("/news add ")}`); + return lines.join("\n"); + } + + // The widest index, so "9." and "10." line their titles up. + const gutter = String(shown.length).length + 1; + const tails = shown.map((item) => { + const when = ago(item.date, now); + return `${item.feed || ""}${when ? ` · ${when}` : ""}`.trim(); + }); + // One column width for every tail, not one per row: the feed name and the age + // are what make a headline placeable, so they keep their room and the title + // is what gives — and they line up, which is the whole point of a column. + const tailWidth = Math.max(0, ...tails.map((t) => t.length)); + const room = Math.max(24, width - gutter - tailWidth - 4); + + const lines = ["", ` ${ash(source || `${items.length} headline${items.length === 1 ? "" : "s"}`)}`, ""]; + for (const [i, item] of shown.entries()) { + const n = `${i + 1}.`.padStart(gutter); + lines.push(` ${acid(n)} ${bone(clip(item.title, room).padEnd(room))} ${ash(tails[i].padStart(tailWidth))}`); + } + lines.push("", ` ${ash("open one with")} ${bone("/news open ")}`); + if (failures.length) lines.push("", ...failureLines(failures)); + return lines.join("\n"); +} + +function failureLines(failures) { + return [ + ` ${amber(`${failures.length} feed${failures.length === 1 ? "" : "s"} didn't answer`)}`, + ...failures.map((f) => ` ${ash(`${f.name} — ${f.error}`)}`), + ]; +} + +/** The subscription list. */ +export function renderFeeds(feeds, { file = "", usingDefaults = false } = {}) { + if (!feeds.length) { + return ["", ` ${ash("no feeds yet")}`, "", + ` ${ash("add one:")} ${bone("/news add https://example.com/feed.xml")}`, + ` ${ash("or a list:")} ${bone("/news add ~/subscriptions.opml")}`, + ` ${ash("or a bundle:")}${bone(" /news add journalists")}`].join("\n"); + } + const width = Math.max(...feeds.map((f) => f.name.length)); + const header = usingDefaults + ? `${feeds.length} default feeds · nothing subscribed yet` + : `${feeds.length} feed${feeds.length === 1 ? "" : "s"}${file ? ` · ${file}` : ""}`; + const lines = ["", ` ${ash(header)}`, ""]; + let category = null; + for (const feed of feeds) { + if ((feed.category || "") !== category) { + category = feed.category || ""; + if (category) lines.push(` ${ash(category)}`); + } + lines.push(` ${acid(feed.name.padEnd(width))} ${bone(clip(feed.title || "", 34).padEnd(36))}${ash(feed.url)}`); + } + lines.push("", usingDefaults + ? ` ${ash("subscribe to your own with")} ${bone("/news add ")} ${ash("· see them with")} ${bone("/news sources")}` + : ` ${ash("read them with")} ${bone("/news")} ${ash("· one of them with")} ${bone("/news --feed ")}`); + return lines.join("\n"); +} + +/** What a fresh install reads, and the lists it can pull in by name. */ +export function renderSources() { + const defaults = defaultFeeds(); + const width = Math.max(...defaults.map((f) => f.name.length)); + const lines = ["", ` ${bone("defaults")} ${ash(`— read when nothing is subscribed (${defaults.length})`)}`, ""]; + let category = null; + for (const feed of defaults) { + if ((feed.category || "") !== category) { + category = feed.category || ""; + if (category) lines.push(` ${ash(category)}`); + } + lines.push(` ${acid(feed.name.padEnd(width))} ${ash(clip(feed.title || "", 44))}`); + } + lines.push("", ` ${bone("bundles")} ${ash("— public OPML lists, pull one in by name")}`, ""); + const bw = Math.max(...OPML_BUNDLES.map((b) => b.name.length)); + for (const bundle of OPML_BUNDLES) { + lines.push(` ${acid(bundle.name.padEnd(bw))} ${ash(bundle.description)}`); + } + lines.push("", ` ${ash("pull one in with")} ${bone(`/news add ${OPML_BUNDLES[0].name}`)}`); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// The command +// --------------------------------------------------------------------------- + +/** Remember what the numbers in the last listing pointed at. Best effort. */ +function rememberListing(items, env) { + try { + const file = cacheFile(env); + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const rows = items.map(({ title, link, feed, date }) => ({ title, link, feed, date })); + fs.writeFileSync(file, `${JSON.stringify({ at: Date.now(), items: rows }, null, 2)}\n`, { mode: FILE_MODE }); + try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ } + } catch { /* a cache that cannot be written must not fail the listing */ } +} + +function readListing(env) { + try { + const parsed = JSON.parse(fs.readFileSync(cacheFile(env), "utf8")); + return Array.isArray(parsed?.items) ? parsed.items : []; + } catch { return []; } +} + +/** + * Run a `news` invocation end to end. Returns a process exit code. + * + * `deps` exists so tests drive the whole command — parse, fetch, render — with + * no network and no stdout, the way cryptoCommand's does. + */ +export async function newsCommand(argv = [], deps = {}) { + const { + out = (s) => console.log(s), + fail = (s) => console.error(s), + fetchImpl, + openUrl, + columns = process.stdout.columns, + env = process.env, + now = Date.now(), + } = deps; + + const request = newsArgs(argv); + if (request.usage) { out(newsUsage()); return 0; } + if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; } + + if (request.verb === "list") { + const { feeds, usingDefaults } = readingList(env); + if (request.json) { out(JSON.stringify({ file: opmlFile(env), usingDefaults, feeds }, null, 2)); return 0; } + out(renderFeeds(feeds, { file: opmlFile(env), usingDefaults })); + return 0; + } + + if (request.verb === "sources") { + if (request.json) { out(JSON.stringify({ defaults: defaultFeeds(), bundles: OPML_BUNDLES }, null, 2)); return 0; } + out(renderSources()); + return 0; + } + + if (request.verb === "export") { + // The defaults deliberately: exporting an empty file to hand to a reader is + // not what anyone means by "export my feeds" on a fresh install. + out(buildOpml(readingList(env).feeds).trimEnd()); + return 0; + } + + if (request.verb === "add") return addCommand(request, { out, fail, fetchImpl, env }); + if (request.verb === "rm") return removeCommand(request, { out, fail, env }); + + if (request.verb === "open") { + const items = readListing(env); + if (!items.length) { fail(danger("✗ nothing to open — run `/news` first")); return 1; } + const item = items[request.index - 1]; + if (!item) { fail(danger(`✗ there is no headline ${request.index} — the last listing had ${items.length}`)); return 1; } + if (!item.link) { fail(danger(`✗ "${clip(item.title, 60)}" has no link`)); return 1; } + if (request.json) { out(JSON.stringify(item, null, 2)); return 0; } + const opened = openUrl ? openUrl(item.link) : false; + out(opened + ? `${acid("✓ ")}opened ${bone(clip(item.title, 60))}` + : `${ash("· ")}open this in a browser:\n ${acid(item.link)}`); + return 0; + } + + // Headlines — a keyword search, one URL passed straight in, or the reading list. + let feeds; + let source; + if (request.verb === "search") { + feeds = searchFeeds(request.query); + source = `“${request.query}”`; + } else if (request.oneOff) { + feeds = [{ name: hostSlug(request.target), title: hostOf(request.target), url: request.target, site: "", category: "" }]; + source = hostOf(request.target); + } else { + const list = readingList(env); + feeds = list.feeds; + if (list.usingDefaults) source = "default feeds"; + if (request.feed) { + const one = findFeed(feeds, request.feed); + if (!one) { + fail(danger(`✗ no feed named "${request.feed}"`)); + if (feeds.length) fail(` ${ash("try:")} ${bone(feeds.map((f) => f.name).slice(0, 8).join(", "))}`); + return 1; + } + feeds = [one]; + source = one.title || one.name; + } + } + + const { items, failures } = await collectNews(feeds, { fetchImpl, timeoutMs: request.timeoutMs }); + const shown = items.slice(0, request.limit); + + if (request.json) { + out(JSON.stringify({ items: shown, failures, feeds: feeds.length }, null, 2)); + } else { + out(renderHeadlines(items, { + failures, + columns, + limit: request.limit, + now, + source: source ? `${source} · ${items.length} headline${items.length === 1 ? "" : "s"}` : "", + })); + } + // Cached even for --json: the numbers a script just read are the numbers + // `/news open ` should resolve. + if (shown.length) rememberListing(shown, env); + // Every feed failing is a failed command, not an empty one — a script that + // branches on the exit status should not read a total outage as "no news". + return items.length === 0 && failures.length === feeds.length ? 1 : 0; +} + +/** `news add ` — one feed, or every feed in an OPML list. */ +async function addCommand(request, { out, fail, fetchImpl, env }) { + // A bundle name resolves to somebody else's OPML list. Checked before the URL + // and the path so `journalists` is a name rather than a missing file. + const bundle = resolveBundle(request.target); + const target = bundle ? bundle.url : request.target; + if (bundle) out(`${ash("· ")}fetching ${bone(bundle.name)} ${ash(`— ${bundle.description}`)}`); + + const res = await readSource(target, { fetchImpl, timeoutMs: request.timeoutMs }); + if (!res.ok) { fail(danger(`✗ ${res.error}`)); return 1; } + + // Subscribing for the first time replaces the defaults rather than merging + // with them: the defaults are a stand-in, and silently welding thirteen feeds + // onto the first one somebody chooses is not what `add` means. + const existing = loadFeeds(env); + const asUrl = safeUrl(target); + + if (looksLikeOpml(res.body)) { + const incoming = parseOpml(res.body); + if (!incoming.length) { fail(danger("✗ that OPML file lists no feeds")); return 1; } + let feeds = existing; + const added = []; + let skipped = 0; + for (const feed of incoming) { + const result = withFeed(feeds, feed); + feeds = result.feeds; + if (result.existed) skipped++; else added.push(result.added); + } + if (!added.length) { + out(`${ash("· ")}already subscribed to all ${incoming.length} feed${incoming.length === 1 ? "" : "s"} in that list`); + return 0; + } + try { saveFeeds(feeds, env); } + catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; } + if (request.json) { out(JSON.stringify({ added, skipped }, null, 2)); return 0; } + out(`${acid("✓ ")}subscribed to ${bone(String(added.length))} feed${added.length === 1 ? "" : "s"}${skipped ? ash(` (${skipped} already there)`) : ""}`); + for (const feed of added.slice(0, 10)) out(` ${acid(feed.name.padEnd(18))}${ash(clip(feed.title || feed.url, 56))}`); + if (added.length > 10) out(` ${ash(`…and ${added.length - 10} more — /news list`)}`); + return 0; + } + + // A single feed. It has to be a URL: parsing a local file would subscribe to + // a path that only exists on this machine and would never refresh. + if (!asUrl) { + fail(danger(`✗ ${target} is a feed, not an OPML list — subscribe to it by URL so it can refresh`)); + return 1; + } + let parsed; + try { parsed = parseFeed(res.body, { url: asUrl }); } + catch (e) { fail(danger(`✗ can't read that feed (${e.message})`)); return 1; } + if (!parsed.items.length && !parsed.title) { + fail(danger(`✗ ${asUrl} doesn't look like an RSS, Atom, or OPML document`)); + return 1; + } + + const candidate = { + name: slugify(parsed.title) || hostSlug(asUrl), + title: parsed.title || hostOf(asUrl), + url: asUrl, + site: parsed.site || "", + category: "", + }; + const { feeds, added, existed } = withFeed(existing, candidate); + if (existed) { + out(`${ash("· ")}already subscribed to ${bone(added.name)} ${ash(added.url)}`); + return 0; + } + try { saveFeeds(feeds, env); } + catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; } + if (request.json) { out(JSON.stringify({ added, skipped: 0 }, null, 2)); return 0; } + out(`${acid("✓ ")}subscribed to ${bone(added.name)} ${ash(`— ${added.title}`)}`); + out(` ${ash(`${parsed.items.length} item${parsed.items.length === 1 ? "" : "s"} right now · read them with`)} ${bone(`/news --feed ${added.name}`)}`); + return 0; +} + +/** `news rm ` — unsubscribe. */ +function removeCommand(request, { out, fail, env }) { + const feeds = loadFeeds(env); + const feed = findFeed(feeds, request.target); + if (!feed) { + fail(danger(`✗ no feed named "${request.target}"`)); + if (feeds.length) fail(` ${ash("try:")} ${bone(feeds.map((f) => f.name).slice(0, 8).join(", "))}`); + return 1; + } + try { saveFeeds(feeds.filter((f) => f !== feed), env); } + catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; } + if (request.json) { out(JSON.stringify({ removed: feed }, null, 2)); return 0; } + out(`${acid("✓ ")}unsubscribed from ${bone(feed.name)} ${ash(feed.url)}`); + return 0; +} diff --git a/src/rss-ui.mjs b/src/rss-ui.mjs new file mode 100644 index 0000000..abe6c56 --- /dev/null +++ b/src/rss-ui.mjs @@ -0,0 +1,517 @@ +// `moshcode rss` — the headlines as a place you can sit in. +// +// `/news` prints a list and gives the terminal back, which is the right shape +// for "what happened" and the wrong one for reading. This is the same data as +// somewhere you point at: feeds down the left, headlines in the middle, the +// story itself in place of the list when you pick one. +// +// The same terminal discipline as src/herd-ui.mjs, and for the same reasons: +// no dependencies, alternate screen and SGR mouse reporting written by hand, +// and every escape sequence undone in a single restore path so a crash cannot +// leave a terminal with no cursor and the mouse still captured. +// +// Rendering is a pure function of state (`renderReader`), so a frame can be +// asserted in a test without a tty, a fetch, or a keystroke. +import { parseMouse } from "./herd-ui.mjs"; +import { acid, amber, ash, bone, danger, dim } from "./ui.mjs"; +import { + ago, + collectNews, + findFeed, + readingList, + searchFeeds, +} from "./news.mjs"; + +const ESC = { + altOn: "\x1b[?1049h", altOff: "\x1b[?1049l", + hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h", + mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l", + clear: "\x1b[2J\x1b[H", +}; + +/** Columns given to the feed sidebar, and the fixed chrome around the list. */ +const SIDEBAR = 20; +const HEADER_LINES = 2; // title + rule +const FOOTER_LINES = 2; // rule + keys + +/** Printable width, ignoring the SGR sequences ui.mjs wraps text in. */ +export function visibleWidth(text) { + return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, "").length; +} + +/** Pad to `width` printable columns, colour codes not counted. */ +function pad(text, width) { + const short = width - visibleWidth(text); + return short > 0 ? text + " ".repeat(short) : text; +} + +/** Truncate to `width` printable columns. Only ever called on uncoloured text. */ +function clip(text, width) { + const s = String(text ?? ""); + return s.length > width ? `${s.slice(0, Math.max(0, width - 1))}…` : s; +} + +/** + * Break `text` into lines of at most `width`, on word boundaries where it can. + * + * A word longer than the pane — which in practice means a URL — is split across + * lines rather than truncated. It has to be split somehow: left whole it wraps + * the terminal itself and every line below it lands one row low, which is the + * one failure that tears the whole frame. Splitting rather than cutting because + * the over-long word is usually the link, and half a link is not a link. + */ +export function wrap(text, width) { + const columns = Math.max(1, Math.floor(width)); + const words = String(text ?? "").split(/\s+/).filter(Boolean); + const lines = []; + let line = ""; + const flush = () => { if (line) { lines.push(line); line = ""; } }; + + for (const word of words) { + if (word.length > columns) { + flush(); + for (let i = 0; i < word.length; i += columns) lines.push(word.slice(i, i + columns)); + continue; + } + if (!line) { line = word; continue; } + if (line.length + 1 + word.length <= columns) { line += ` ${word}`; continue; } + flush(); + line = word; + } + flush(); + return lines; +} + +/** + * Decode a chunk of raw-mode input. + * + * herd-ui's parseInput is deliberately narrow — it answers a nine-key screen — + * so this reads the keys a reader needs instead of widening that one and + * changing what the herd list responds to. Mouse reports are shared, because + * SGR decoding has exactly one correct answer. + */ +export function decodeKeys(buffer) { + const events = []; + // Mouse reports are removed from the text, not merely read out of it. A + // release (`…m`) decodes to no event at all, so leaving the sequence behind + // would hand `[ < 0 ; 1 0 ; 5 m` to the key decoder below — which, with the + // search box open, types the mouse position into the query. + const text = String(buffer).replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, (sequence) => { + const parsed = parseMouse(sequence); + if (parsed) events.push(parsed); + return ""; + }); + if (!text) return events; + + // Escape sequences first, longest first, so ESC [ A is an arrow rather than + // an escape followed by two letters. + const SEQUENCES = [ + ["\x1b[A", "up"], ["\x1b[B", "down"], ["\x1b[C", "right"], ["\x1b[D", "left"], + ["\x1b[5~", "pageup"], ["\x1b[6~", "pagedown"], + ["\x1b[H", "home"], ["\x1b[F", "end"], + ]; + let i = 0; + while (i < text.length) { + const seq = SEQUENCES.find(([code]) => text.startsWith(code, i)); + if (seq) { events.push({ kind: "key", name: seq[1] }); i += seq[0].length; continue; } + const ch = text[i]; + if (ch === "\x1b") { events.push({ kind: "key", name: "escape" }); i += 1; continue; } + if (ch === "\r" || ch === "\n") { events.push({ kind: "key", name: "enter" }); i += 1; continue; } + if (ch === "\x7f" || ch === "\b") { events.push({ kind: "key", name: "backspace" }); i += 1; continue; } + if (ch === "\x03") { events.push({ kind: "key", name: "ctrl-c" }); i += 1; continue; } + events.push({ kind: "key", name: ch, char: ch }); + i += 1; + } + return events; +} + +/** Feeds down the left, with a count each and an "all" row on top. */ +export function sidebarRows(state) { + const counts = new Map(); + for (const item of state.items) counts.set(item.feed, (counts.get(item.feed) || 0) + 1); + const rows = [{ key: null, label: "all", count: state.items.length }]; + for (const feed of state.feeds) { + rows.push({ key: feed.name, label: feed.name, count: counts.get(feed.name) || 0 }); + } + return rows; +} + +/** The headlines currently on show — everything, or one feed's. */ +export function visibleItems(state) { + return state.filter ? state.items.filter((i) => i.feed === state.filter) : state.items; +} + +/** + * One frame, as an array of exactly `rows` lines. + * + * Exactly, not at most: the screen is repainted by clearing and writing, so a + * short frame leaves the tail of the previous one on screen and a long one + * scrolls the terminal and tears the whole layout. + */ +export function renderReader(state, { rows = 24, cols = 80 } = {}) { + const width = Math.max(60, cols); + const bodyHeight = Math.max(3, rows - HEADER_LINES - FOOTER_LINES); + const listWidth = width - SIDEBAR - 3; + const out = []; + + // Header ------------------------------------------------------------------ + const items = visibleItems(state); + const where = state.query ? `“${state.query}”` + : state.filter ? state.filter + : state.usingDefaults ? "default feeds" : "all feeds"; + const title = ` ${bone("moshcode rss")}${ash(` ${items.length} headline${items.length === 1 ? "" : "s"} · ${where}`)}`; + const status = state.loading ? acid("loading…") + : state.failures.length ? amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} down`) + : ""; + out.push(pad(title, width - visibleWidth(status) - 2) + status + " "); + out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`); + + // Body -------------------------------------------------------------------- + const side = sidebarRows(state); + const bodyLines = state.pane === "article" + ? articleLines(state, { width: width - 4, height: bodyHeight }) + : listLines(state, items, { width: listWidth, height: bodyHeight }); + + for (let row = 0; row < bodyHeight; row++) { + if (state.pane === "article") { out.push(bodyLines[row] ?? ""); continue; } + const feedRow = side[row + state.sideOffset]; + let left = ""; + if (feedRow) { + const selected = state.pane === "feeds" && row + state.sideOffset === state.sideSelected; + const active = (feedRow.key ?? null) === (state.filter ?? null); + const cursor = selected ? acid("▸") : " "; + // 2 indent + cursor + space + label + space + 3 count = SIDEBAR exactly. + // Building it one wider than the column it is padded into is how every + // body row ends up a column past the edge of the terminal. + const label = clip(feedRow.label, SIDEBAR - 8); + const paint = active ? bone : ash; + left = ` ${cursor} ${paint(pad(label, SIDEBAR - 8))} ${dim(String(feedRow.count).padStart(3))}`; + } + out.push(`${pad(left, SIDEBAR)} ${dim("│")} ${bodyLines[row] ?? ""}`); + } + + // Footer ------------------------------------------------------------------ + out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`); + out.push(` ${keyHint(state, width - 4)}`); + return out.slice(0, rows); +} + +/** + * The footer, trimmed to fit. + * + * Hints are dropped from the right until the line fits the terminal, rather + * than being allowed to run past it — a footer one column too wide wraps onto a + * line the frame did not budget for, which scrolls the screen and puts every + * row of the next frame one off. They are ordered least-guessable first, so + * what survives on a narrow terminal is what someone could not have guessed. + */ +function keyHint(state, width) { + if (state.mode === "search") { + const prompt = `${ash("search:")} ${bone(state.input)}${acid("▏")}`; + const help = dim(" ⏎ run · esc cancel"); + return visibleWidth(prompt + help) <= width ? prompt + help : prompt; + } + const keys = state.pane === "article" + ? [["⏎/esc", "back"], ["o", "open"], ["j/k", "next/prev"], ["q", "quit"]] + : [["↑↓", "move"], ["⏎", "read"], ["o", "open"], ["/", "search"], ["tab", "feeds"], ["r", "refresh"], ["q", "quit"]]; + + const sep = dim(" · "); + let line = ""; + for (const [key, what] of keys) { + const next = (line ? line + sep : "") + `${acid(key)} ${ash(what)}`; + if (visibleWidth(next) > width) break; + line = next; + } + return line; +} + +/** The headline list, as `height` lines. */ +function listLines(state, items, { width, height }) { + if (state.loading && !items.length) return [ash("fetching feeds…")]; + if (!items.length) { + const lines = [ash("nothing here")]; + if (state.failures.length) { + lines.push("", amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} didn't answer:`)); + for (const f of state.failures.slice(0, height - 3)) lines.push(` ${dim(`${f.name} — ${f.error}`)}`); + } + return lines; + } + const window = items.slice(state.offset, state.offset + height); + // One tail column for the whole viewport, so the feed names line up instead + // of ending wherever each headline happens to stop. Capped at half the pane + // so a feed with a long name cannot squeeze the headline out of its own list, + // and the title takes whatever is left — floored at nothing, because a floor + // above the available width is how a line ends up wider than the terminal. + const tails = window.map((item) => { + const when = ago(item.date, state.now); + return `${item.feed}${when ? ` · ${when}` : ""}`.trim(); + }); + const tailWidth = Math.min(Math.max(0, ...tails.map((t) => t.length)), Math.floor(width / 2)); + const room = Math.max(1, width - tailWidth - 4); + + return window.map((item, i) => { + const selected = state.offset + i === state.selected; + const cursor = selected ? acid("▸") : " "; + const title = pad(clip(item.title, room), room); + const tail = clip(tails[i], tailWidth).padStart(tailWidth); + return `${cursor} ${selected ? bone(title) : ash(title)} ${dim(tail)}`; + }); +} + +/** The selected story, as `height` lines. */ +function articleLines(state, { width, height }) { + const items = visibleItems(state); + const item = items[state.selected]; + if (!item) return [ash("nothing selected")]; + const lines = [""]; + for (const line of wrap(item.title, width - 4)) lines.push(` ${bone(line)}`); + lines.push(""); + const when = item.date ? `${new Date(item.date).toLocaleString()} · ${ago(item.date, state.now)}` : "no date"; + lines.push(` ${ash(`${item.feedTitle || item.feed} · ${when}`)}`); + if (item.author) lines.push(` ${ash(`by ${item.author}`)}`); + lines.push(""); + if (item.summary) { + for (const line of wrap(item.summary, width - 4)) lines.push(` ${ash(line)}`); + lines.push(""); + } + // Wrapped, not clipped: this is the line someone copies out of the reader. + if (item.link) for (const line of wrap(item.link, width - 4)) lines.push(` ${acid(line)}`); + else lines.push(` ${dim("this item has no link")}`); + return lines.slice(0, height); +} + +/** Keep the selection inside the list, and the viewport around the selection. */ +function clampView(state, height) { + const items = visibleItems(state); + state.selected = Math.max(0, Math.min(state.selected, Math.max(0, items.length - 1))); + if (state.selected < state.offset) state.offset = state.selected; + if (state.selected >= state.offset + height) state.offset = state.selected - height + 1; + state.offset = Math.max(0, Math.min(state.offset, Math.max(0, items.length - height))); +} + +/** + * Run the reader. Returns a process exit code. + * + * `deps` mirrors herdUi's: injectable stdin/stdout and an injectable fetch, so + * the loop can be driven in a test with no terminal and no network. + */ +export async function rssUi(argv = [], deps = {}) { + const { + stdin = process.stdin, + stdout = process.stdout, + fetchImpl, + openUrl, + env = process.env, + write = (s) => process.stdout.write(`${s}\n`), + } = deps; + + if (!stdin.isTTY || !stdout.isTTY) { + write("moshcode rss needs an interactive terminal — try `moshcode news`"); + return 1; + } + + // A query on the command line (`moshcode rss tariffs`) opens straight into + // the search, which is the same shape `/news ` has. + const query = argv.filter((a) => !String(a).startsWith("-")).join(" ").trim(); + const list = readingList(env); + + const state = { + feeds: query ? searchFeeds(query) : list.feeds, + usingDefaults: query ? false : list.usingDefaults, + query: query || null, + items: [], + failures: [], + selected: 0, + offset: 0, + sideSelected: 0, + sideOffset: 0, + filter: null, + pane: "list", + mode: "browse", + input: "", + loading: true, + now: Date.now(), + }; + + let done = false; + let restored = false; + const wasRaw = Boolean(stdin.isRaw); + const restore = () => { + if (restored) return; + restored = true; + stdout.write(ESC.mouseOff + ESC.showCursor + ESC.altOff); + try { stdin.setRawMode?.(wasRaw); } catch { /* already gone */ } + stdin.pause(); + }; + const enter = () => { + restored = false; + stdout.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn); + try { stdin.setRawMode?.(true); } catch { /* not a tty */ } + stdin.resume(); + }; + const onSignal = () => { restore(); process.exit(130); }; + process.on("exit", restore); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + const height = () => Math.max(3, (stdout.rows || 24) - HEADER_LINES - FOOTER_LINES); + const draw = () => { + if (done) return; + clampView(state, height()); + stdout.write(ESC.clear + renderReader(state, { rows: stdout.rows || 24, cols: stdout.columns || 80 }).join("\r\n")); + }; + + const load = async () => { + state.loading = true; + draw(); + const { items, failures } = await collectNews(state.feeds, { fetchImpl }); + state.items = items; + state.failures = failures; + state.now = Date.now(); + state.loading = false; + state.selected = 0; + state.offset = 0; + draw(); + }; + + enter(); + draw(); + const onResize = () => draw(); + stdout.on("resize", onResize); + await load(); + + await new Promise((resolve) => { + const onData = async (buf) => { + for (const event of decodeKeys(buf)) { + if (done) return; + + // The search prompt owns every key while it is up, or typing "q" into + // it would quit instead of searching for the letter q. + if (state.mode === "search") { + if (event.kind !== "key") continue; + if (event.name === "escape") { state.mode = "browse"; state.input = ""; draw(); continue; } + if (event.name === "backspace") { state.input = state.input.slice(0, -1); draw(); continue; } + if (event.name === "enter") { + const q = state.input.trim(); + state.mode = "browse"; + state.input = ""; + if (!q) { draw(); continue; } + state.query = q; + state.feeds = searchFeeds(q); + state.usingDefaults = false; + state.filter = null; + state.pane = "list"; + await load(); + continue; + } + if (event.char && event.char >= " ") { state.input += event.char; draw(); } + continue; + } + + if (event.kind === "wheel") { + state.selected += event.direction; + draw(); + continue; + } + if (event.kind === "click") { + // Row 1-2 are the header, so the first list row is line 3. + const index = state.offset + (event.row - HEADER_LINES - 1); + if (event.col <= SIDEBAR) { + const side = sidebarRows(state)[state.sideOffset + (event.row - HEADER_LINES - 1)]; + if (side) { state.filter = side.key; state.selected = 0; state.offset = 0; state.pane = "list"; draw(); } + continue; + } + const items = visibleItems(state); + if (index >= 0 && index < items.length) { + // A single click selects; a second on the same row reads it — the + // rule herd-ui settled on, so one stray click is never a trip. + const opening = index === state.selected && state.pane === "list"; + state.selected = index; + state.pane = opening ? "article" : "list"; + draw(); + } + continue; + } + if (event.kind !== "key") continue; + + const name = event.name; + if (name === "q" || name === "ctrl-c") { done = true; resolve(); return; } + + if (state.pane === "article") { + if (name === "enter" || name === "escape" || name === "left" || name === "h") { state.pane = "list"; draw(); continue; } + if (name === "j" || name === "down") { state.selected += 1; draw(); continue; } + if (name === "k" || name === "up") { state.selected -= 1; draw(); continue; } + } + + if (name === "/") { state.mode = "search"; state.input = ""; draw(); continue; } + if (name === "r") { await load(); continue; } + if (name === "tab" || name === "\t") { + state.pane = state.pane === "feeds" ? "list" : "feeds"; + draw(); + continue; + } + + // The sidebar has its own selection, so it has to claim the movement + // keys before the headline list does — otherwise tab would highlight a + // feed and j/k would scroll the headlines beside it. + if (state.pane === "feeds") { + const side = sidebarRows(state); + const move = (delta) => { + state.sideSelected = Math.max(0, Math.min(state.sideSelected + delta, side.length - 1)); + const rows = height(); + if (state.sideSelected < state.sideOffset) state.sideOffset = state.sideSelected; + if (state.sideSelected >= state.sideOffset + rows) state.sideOffset = state.sideSelected - rows + 1; + draw(); + }; + if (name === "j" || name === "down") { move(1); continue; } + if (name === "k" || name === "up") { move(-1); continue; } + if (name === "g" || name === "home") { state.sideSelected = 0; state.sideOffset = 0; draw(); continue; } + if (name === "G" || name === "end") { move(side.length); continue; } + if (name === "enter" || name === "right" || name === "l") { + const row = side[state.sideSelected]; + state.filter = row ? row.key : null; + state.selected = 0; + state.offset = 0; + state.pane = "list"; + draw(); + continue; + } + if (name === "escape") { state.pane = "list"; draw(); continue; } + continue; + } + + if (name === "j" || name === "down") { state.selected += 1; draw(); continue; } + if (name === "k" || name === "up") { state.selected -= 1; draw(); continue; } + if (name === "pagedown" || name === " ") { state.selected += height(); draw(); continue; } + if (name === "pageup") { state.selected -= height(); draw(); continue; } + if (name === "g" || name === "home") { state.selected = 0; draw(); continue; } + if (name === "G" || name === "end") { state.selected = visibleItems(state).length - 1; draw(); continue; } + if (name === "enter") { state.pane = "article"; draw(); continue; } + if (name === "o") { + const item = visibleItems(state)[state.selected]; + if (!item?.link) continue; + // The browser gets the terminal only for as long as the opener runs; + // a headless box just falls through with nothing opened. + const opened = openUrl ? openUrl(item.link) : false; + if (!opened) { + restore(); + write(`open this in a browser:\n ${item.link}`); + enter(); + } + draw(); + continue; + } + if (name === "a") { state.filter = null; state.selected = 0; draw(); continue; } + } + }; + stdin.on("data", onData); + }); + + stdout.off("resize", onResize); + restore(); + process.off("exit", restore); + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + stdout.write("\n"); + return 0; +} diff --git a/src/tui.mjs b/src/tui.mjs index 87a69ff..238e774 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -879,6 +879,26 @@ export async function tui() { await cryptoCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); continue; } + // `/news` renders in the pit rather than handing the terminal over: it is a + // list and a prompt to come back to, the same as `/stocks`. + if (cmd === "news") { + const { newsCommand } = await import("./news.mjs"); + await newsCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); + continue; + } + // `/rss` is the exception `/attach` is: it takes the whole terminal, so + // readline has to let go of stdin first or the two fight over every key. + if (cmd === "rss" || cmd === "reader") { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.log(err("/rss needs an interactive terminal — try /news")); + continue; + } + const { rssUi } = await import("./rss-ui.mjs"); + rl.close(); + await rssUi(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); + rl = mkrl(); + continue; + } if (cmd === "plugin" || cmd === "plugins") { await pluginCommand(rest); continue; diff --git a/test/news.test.mjs b/test/news.test.mjs new file mode 100644 index 0000000..c305169 --- /dev/null +++ b/test/news.test.mjs @@ -0,0 +1,646 @@ +// `moshcode news` — argument translation, the three feed dialects, and the +// things a reader must never get wrong: opening a link a feed talked it into, +// letting one dead publisher take the whole listing down, and rendering an +// escaped anchor tag as if it were the headline. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + NEWS_VERB_NAMES, ago, buildOpml, collectNews, decodeEntities, findFeed, + loadFeeds, looksLikeOpml, newsArgs, newsCommand, newsUsage, opmlFile, + parseFeed, parseOpml, readingList, renderFeeds, renderHeadlines, resolveVerb, + safeUrl, saveFeeds, searchFeeds, slugify, withFeed, +} from "../src/news.mjs"; +import { DEFAULT_FEEDS, resolveBundle, unwrapRedirect } from "../src/news-sources.mjs"; +import { NEWS_VERBS } from "../src/cli-schema.mjs"; + +// --- fixtures ---------------------------------------------------------------- + +const RSS = ` + + Example Wire + https://example.com + + First story + https://example.com/1 + Tue, 11 Aug 2026 12:00:00 GMT + A summary & more

]]>
+
+ + Second story + https://example.com/2 + Mon, 10 Aug 2026 12:00:00 GMT + +
`; + +const ATOM = ` + + Atom Daily + + + + Atom story + + + 2026-08-11T09:00:00Z + An atom summary + +`; + +const RDF = ` + + RDF Weekly + + RDF story + https://rdf.example/1 + 2026-08-09T00:00:00Z + +`; + +const OPML = ` + + subs + + + + + + + + +`; + +/** A fetch stand-in that answers from a url → body map. */ +function fakeFetch(routes) { + return async (url) => { + const body = routes[url]; + if (body === undefined) return { ok: false, status: 404, statusText: "Not Found", text: async () => "" }; + if (body instanceof Error) throw body; + return { ok: true, status: 200, text: async () => body }; + }; +} + +/** A throwaway $HOME so the real ~/.moshcode is never touched. */ +function sandbox() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-news-")); + return { env: { MOSHCODE_NEWS_OPML: path.join(dir, "news.opml") }, dir }; +} + +/** Collect a command's output the way cryptoCommand's tests do. */ +function sink() { + const lines = []; + const errors = []; + return { + lines, errors, + out: (s) => lines.push(String(s)), + fail: (s) => errors.push(String(s)), + text: () => lines.join("\n"), + errorText: () => errors.join("\n"), + }; +} + +// --- verbs and arguments ----------------------------------------------------- + +test("every verb the schema documents is one the parser resolves", () => { + // The schema drives help and completion; the parser drives behaviour. A verb + // in one and not the other is a command that completes and then fails. + assert.deepEqual(NEWS_VERBS.map(({ name }) => name).sort(), [...NEWS_VERB_NAMES].sort()); + for (const { name } of NEWS_VERBS) assert.equal(resolveVerb(name), name, `${name} does not resolve`); +}); + +test("no arguments is the headline list, not usage", () => { + // Unlike crypto, a bare `/news` has an obvious answer, and printing usage + // instead would make the common case the one that needs a manual. + const request = newsArgs([]); + assert.equal(request.verb, "headlines"); + assert.equal(request.target, null); + assert.match(newsUsage(), /usage: moshcode news/); +}); + +test("`latest` says the default out loud and takes nothing", () => { + assert.equal(newsArgs(["latest"]).verb, "headlines"); + assert.equal(newsArgs(["headlines"]).verb, "headlines"); + assert.match(newsArgs(["latest", "tariffs"]).error, /takes no arguments/); +}); + +test("a bare word is a search, a bare URL is a feed to read", () => { + assert.deepEqual( + { verb: newsArgs(["tariffs"]).verb, query: newsArgs(["tariffs"]).query }, + { verb: "search", query: "tariffs" }, + ); + // Several words are one phrase, not a verb plus arguments. + assert.equal(newsArgs(["openai", "earnings"]).query, "openai earnings"); + const url = newsArgs(["https://example.com/feed.xml"]); + assert.equal(url.verb, "headlines"); + assert.equal(url.oneOff, true); + assert.equal(url.target, "https://example.com/feed.xml"); +}); + +test("search flags are flags, not part of the phrase", () => { + const request = newsArgs(["fed", "rates", "--limit", "5", "--json"]); + assert.equal(request.query, "fed rates"); + assert.equal(request.limit, 5); + assert.equal(request.json, true); +}); + +test("flags that need a value say so instead of silently defaulting", () => { + assert.match(newsArgs(["--limit"]).error, /--limit needs a number/); + assert.match(newsArgs(["--limit", "zero"]).error, /whole number/); + assert.match(newsArgs(["--limit", "0"]).error, /whole number/); + assert.match(newsArgs(["--feed"]).error, /--feed needs a feed name/); + assert.match(newsArgs(["--timeout", "999"]).error, /1-120 seconds/); + assert.match(newsArgs(["--nope"]).error, /unknown flag --nope/); +}); + +test("--limit is capped rather than trusted", () => { + assert.equal(newsArgs(["--limit", "100000"]).limit, 200); +}); + +test("open takes a headline number and nothing else", () => { + assert.equal(newsArgs(["open", "3"]).index, 3); + assert.match(newsArgs(["open"]).error, /usage: moshcode news open/); + assert.match(newsArgs(["open", "x"]).error, /headline number/); + assert.match(newsArgs(["open", "0"]).error, /headline number/); +}); + +test("add takes exactly one target", () => { + assert.equal(newsArgs(["add", "journalists"]).target, "journalists"); + assert.match(newsArgs(["add"]).error, /usage: moshcode news add/); + assert.match(newsArgs(["add", "a", "b"]).error, /one URL or file at a time/); +}); + +// --- URLs -------------------------------------------------------------------- + +test("only http(s) survives — a feed cannot talk the reader into file: or data:", () => { + assert.equal(safeUrl("https://example.com/x"), "https://example.com/x"); + assert.equal(safeUrl("http://example.com/x"), "http://example.com/x"); + assert.equal(safeUrl("file:///etc/passwd"), null); + assert.equal(safeUrl("javascript:alert(1)"), null); + assert.equal(safeUrl("data:text/html,