hn-bits 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -0
- package/dist/ai/context.js +50 -0
- package/dist/ai/ollama.js +127 -0
- package/dist/ai/summaryPrompts.js +49 -0
- package/dist/api/algolia.js +51 -0
- package/dist/api/firebase.js +35 -0
- package/dist/index.js +93 -0
- package/dist/lib/article.js +71 -0
- package/dist/lib/commentTree.js +60 -0
- package/dist/lib/comments.js +3 -0
- package/dist/lib/config.js +52 -0
- package/dist/lib/configKeys.js +31 -0
- package/dist/lib/configStore.js +64 -0
- package/dist/lib/contactHighlight.js +18 -0
- package/dist/lib/format.js +20 -0
- package/dist/lib/html.js +37 -0
- package/dist/lib/listNavigation.js +18 -0
- package/dist/lib/url.js +10 -0
- package/dist/lib/viewport.js +81 -0
- package/dist/ui/App.js +75 -0
- package/dist/ui/AskAI.js +186 -0
- package/dist/ui/Comments.js +200 -0
- package/dist/ui/HelpOverlay.js +10 -0
- package/dist/ui/Layout.js +21 -0
- package/dist/ui/LoadingIndicator.js +14 -0
- package/dist/ui/SearchInput.js +21 -0
- package/dist/ui/SearchResults.js +128 -0
- package/dist/ui/StoryList.js +134 -0
- package/dist/ui/StoryListView.js +16 -0
- package/dist/ui/StoryRow.js +27 -0
- package/dist/ui/SummaryPanel.js +101 -0
- package/dist/ui/TabBar.js +82 -0
- package/dist/ui/keymap.js +46 -0
- package/dist/ui/theme.js +98 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# hn-bits
|
|
2
|
+
|
|
3
|
+
Terminal-first Hacker News client. Fullscreen TUI built with TypeScript + Ink (React for the terminal), backed by the HN Firebase and Algolia APIs. No database. Optional local-AI features (summaries + Ask AI) read a config file; everything else is fully stateless.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
npm link # exposes the `hn` binary
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
hn # fullscreen shell: top/new/best/ask/show tabs
|
|
17
|
+
hn search <query...> # search results
|
|
18
|
+
hn --theme dracula # themes: hn (default), mocha, dracula, tokyo, nord, gruvbox
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Press `?` from any view for the full keybinding help overlay.
|
|
22
|
+
|
|
23
|
+
### Local AI (summaries + Ask AI)
|
|
24
|
+
|
|
25
|
+
Article/thread summaries (`s`) and interactive Q&A (`a`) run against a local [Ollama](https://ollama.com) instance — no cloud calls, no API keys. Optional: the app works fully without it, `s`/`a` just show a setup hint until configured.
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
**Prerequisites**
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
brew install ollama # or see ollama.com/download
|
|
33
|
+
ollama serve # if not already running as a service
|
|
34
|
+
ollama pull llama3.2 # or any other chat-capable model
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Setup** — one-time:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
hn config set ollama.host http://localhost:11434
|
|
41
|
+
hn config set ollama.model llama3.2
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Missing fields fall back to the defaults above; invalid JSON degrades to "AI disabled" with a warning rather than crashing the reader.
|
|
45
|
+
|
|
46
|
+
### Configuration
|
|
47
|
+
|
|
48
|
+
`hn config` reads/writes `~/.config/hn-bits/config.json` (override the path with `$HN_BITS_CONFIG`) — no need to hand-edit JSON:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
hn config list # every known key + current value
|
|
52
|
+
hn config get ollama.model # single value, raw
|
|
53
|
+
hn config set ollama.model llama3.2 # validates + writes
|
|
54
|
+
hn config unset ollama.model # removes a key
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Sensitive values (e.g. a future Telegram bot token) print masked in `list` but raw from `get`, so scripts can still consume them. File format is plain JSON — hand-editing still works if you prefer it.
|
|
58
|
+
|
|
59
|
+
### Themes
|
|
60
|
+
|
|
61
|
+
`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox` (default: `hn`, HN-orange). Pick with `--theme <name>` or the `HN_THEME` env var — not persisted (no config file yet).
|
|
62
|
+
|
|
63
|
+
### Keybindings
|
|
64
|
+
|
|
65
|
+
**Global**
|
|
66
|
+
|
|
67
|
+
| Key | Action |
|
|
68
|
+
|-----|--------|
|
|
69
|
+
| `q` | quit (suppressed while typing in search) |
|
|
70
|
+
| `?` | help overlay (suppressed while typing in search) |
|
|
71
|
+
|
|
72
|
+
**Story list**
|
|
73
|
+
|
|
74
|
+
| Key | Action |
|
|
75
|
+
|-----|--------|
|
|
76
|
+
| `j`/`k` or ↓/↑ | move selection |
|
|
77
|
+
| `←`/`→` | previous/next feed tab |
|
|
78
|
+
| `t`/`n`/`b` | top / new / best directly |
|
|
79
|
+
| `g g` / `G` | top / bottom |
|
|
80
|
+
| `enter` | open comments |
|
|
81
|
+
| `o` | open story URL in browser |
|
|
82
|
+
| `r` | refresh feed |
|
|
83
|
+
| `/` | search |
|
|
84
|
+
| `s` | AI summary (article, or thread if no article) |
|
|
85
|
+
| `a` | Ask AI (chat grounded in the story) |
|
|
86
|
+
|
|
87
|
+
**Comments**
|
|
88
|
+
|
|
89
|
+
| Key | Action |
|
|
90
|
+
|-----|--------|
|
|
91
|
+
| `j`/`k` or ↓/↑ | move selection |
|
|
92
|
+
| `space`/`enter` | toggle fold on a comment |
|
|
93
|
+
| `C`/`E` | collapse / expand all |
|
|
94
|
+
| `g g` / `G` | top / bottom |
|
|
95
|
+
| `o` | open story URL in browser |
|
|
96
|
+
| `r` | reload |
|
|
97
|
+
| `s` | AI thread summary |
|
|
98
|
+
| `a` | Ask AI (chat grounded in the story) |
|
|
99
|
+
| `esc`/`b` | back |
|
|
100
|
+
|
|
101
|
+
**Search results**
|
|
102
|
+
|
|
103
|
+
Same as story list, minus the tab/feed keys, plus `/` to start a new search. `esc` returns to the list if you got here via `/` in-TUI, or quits if you entered via `hn search`.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npm run dev # run from source (tsx)
|
|
109
|
+
npm test # vitest
|
|
110
|
+
npm run build # tsc
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Tech stack
|
|
114
|
+
|
|
115
|
+
- TypeScript (strict, ESM) + Ink (React for terminals)
|
|
116
|
+
- Commander CLI, native `fetch`
|
|
117
|
+
- HN Firebase + Algolia APIs — no backend, no database
|
|
118
|
+
- Vitest
|
|
119
|
+
|
|
120
|
+
## Specs
|
|
121
|
+
|
|
122
|
+
Implementation specs live in [`specs/`](specs/README.md); phase-by-phase status is tracked in [`PROGRESS.md`](PROGRESS.md). V1, V1.5, V1.6, and V2 (local AI) are feature-complete; V3 (subscriptions/watcher) is spec'd but not started.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { htmlToText } from '../lib/html.js';
|
|
2
|
+
const THREAD_CHAR_BUDGET = 12_000;
|
|
3
|
+
const COMMENT_CHAR_CAP = 1_000;
|
|
4
|
+
const MAX_REPLY_DEPTH = 2;
|
|
5
|
+
function capCommentText(html) {
|
|
6
|
+
const plain = htmlToText(html);
|
|
7
|
+
return plain.length > COMMENT_CHAR_CAP ? `${plain.slice(0, COMMENT_CHAR_CAP)}…` : plain;
|
|
8
|
+
}
|
|
9
|
+
function renderNode(node, depth, lines) {
|
|
10
|
+
lines.push(`${' '.repeat(depth)}${node.author}: ${capCommentText(node.text)}`);
|
|
11
|
+
if (depth < MAX_REPLY_DEPTH) {
|
|
12
|
+
for (const child of node.children)
|
|
13
|
+
renderNode(child, depth + 1, lines);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Walks top-level comments in API order, trimming to a char budget once at least one is included. */
|
|
17
|
+
export function buildThreadContext(comments) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
let includedTopLevel = 0;
|
|
20
|
+
let trimmed = false;
|
|
21
|
+
for (const top of comments) {
|
|
22
|
+
const candidate = [];
|
|
23
|
+
renderNode(top, 0, candidate);
|
|
24
|
+
const projectedLength = lines.join('\n').length + candidate.join('\n').length;
|
|
25
|
+
if (projectedLength > THREAD_CHAR_BUDGET && includedTopLevel > 0) {
|
|
26
|
+
trimmed = true;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
lines.push(...candidate);
|
|
30
|
+
includedTopLevel++;
|
|
31
|
+
}
|
|
32
|
+
return { text: lines.join('\n'), includedTopLevel, trimmed };
|
|
33
|
+
}
|
|
34
|
+
/** Assembles the Ask AI system prompt: story metadata + article text + trimmed discussion. */
|
|
35
|
+
export function buildAskAIContext({ story, article, articleUnavailableReason, comments }) {
|
|
36
|
+
const articleSection = article ? article.text : `unavailable: ${articleUnavailableReason ?? 'no article'}`;
|
|
37
|
+
const discussionSection = comments ? buildThreadContext(comments).text : 'not loaded';
|
|
38
|
+
return [
|
|
39
|
+
'You are answering questions about a Hacker News story and its discussion.',
|
|
40
|
+
"Base answers only on the provided material; say so when it doesn't contain the answer.",
|
|
41
|
+
'',
|
|
42
|
+
`Story: ${story.title} (${story.url ?? 'text post'}) — ${story.score} points, ${story.descendants} comments`,
|
|
43
|
+
'',
|
|
44
|
+
'Article:',
|
|
45
|
+
articleSection,
|
|
46
|
+
'',
|
|
47
|
+
'Discussion:',
|
|
48
|
+
discussionSection,
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export class OllamaDownError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export class ModelMissingError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export class OllamaError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class TimeoutError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** Renders a caught error as a user-facing hint, preferring our typed messages. */
|
|
15
|
+
export function describeError(err) {
|
|
16
|
+
if (err instanceof OllamaDownError || err instanceof ModelMissingError || err instanceof OllamaError || err instanceof TimeoutError) {
|
|
17
|
+
return err.message;
|
|
18
|
+
}
|
|
19
|
+
return err.message ?? 'unknown error';
|
|
20
|
+
}
|
|
21
|
+
const IDLE_TIMEOUT_MS = 60_000;
|
|
22
|
+
function parseLines(buffer) {
|
|
23
|
+
const lines = buffer.split('\n');
|
|
24
|
+
const rest = lines.pop() ?? '';
|
|
25
|
+
const chunks = lines.filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
26
|
+
return { chunks, rest };
|
|
27
|
+
}
|
|
28
|
+
/** Streams content deltas from Ollama's chat endpoint. Yields nothing further and returns on caller-initiated abort. */
|
|
29
|
+
export async function* chatStream(cfg, messages, signal) {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const onAbort = () => controller.abort();
|
|
32
|
+
signal?.addEventListener('abort', onAbort);
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
let idleTimer;
|
|
35
|
+
const resetIdle = () => {
|
|
36
|
+
clearTimeout(idleTimer);
|
|
37
|
+
idleTimer = setTimeout(() => {
|
|
38
|
+
timedOut = true;
|
|
39
|
+
controller.abort();
|
|
40
|
+
}, IDLE_TIMEOUT_MS);
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
resetIdle();
|
|
46
|
+
res = await fetch(`${cfg.host}/api/chat`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ model: cfg.model, messages, stream: true }),
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err.name === 'AbortError') {
|
|
55
|
+
if (timedOut)
|
|
56
|
+
throw new TimeoutError(`Ollama request timed out after ${IDLE_TIMEOUT_MS / 1000}s`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw new OllamaDownError(`Ollama not reachable at ${cfg.host} — is it running?`);
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const bodyText = await res.text().catch(() => '');
|
|
63
|
+
if (res.status === 404 || /model.*not found/i.test(bodyText)) {
|
|
64
|
+
throw new ModelMissingError(`model ${cfg.model} not found — ollama pull ${cfg.model}`);
|
|
65
|
+
}
|
|
66
|
+
throw new OllamaError(res.status, `${res.status} ${bodyText.split('\n')[0]}`);
|
|
67
|
+
}
|
|
68
|
+
if (!res.body)
|
|
69
|
+
throw new OllamaError(res.status, 'empty response body');
|
|
70
|
+
const reader = res.body.getReader();
|
|
71
|
+
const decoder = new TextDecoder();
|
|
72
|
+
let buffer = '';
|
|
73
|
+
try {
|
|
74
|
+
while (true) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done)
|
|
77
|
+
return;
|
|
78
|
+
resetIdle();
|
|
79
|
+
buffer += decoder.decode(value, { stream: true });
|
|
80
|
+
const { chunks, rest } = parseLines(buffer);
|
|
81
|
+
buffer = rest;
|
|
82
|
+
for (const chunk of chunks) {
|
|
83
|
+
if (chunk.message?.content)
|
|
84
|
+
yield chunk.message.content;
|
|
85
|
+
if (chunk.done)
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.name === 'AbortError') {
|
|
92
|
+
if (timedOut)
|
|
93
|
+
throw new TimeoutError(`Ollama request timed out after ${IDLE_TIMEOUT_MS / 1000}s`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
clearTimeout(idleTimer);
|
|
101
|
+
signal?.removeEventListener('abort', onAbort);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Health probe distinguishing "server down" from "model missing" so callers can render tailored hints. */
|
|
105
|
+
export async function checkOllama(cfg) {
|
|
106
|
+
let res;
|
|
107
|
+
try {
|
|
108
|
+
res = await fetch(`${cfg.host}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { ok: false, reason: 'down', detail: `Ollama not reachable at ${cfg.host} — is it running?` };
|
|
112
|
+
}
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
return { ok: false, reason: 'down', detail: `${res.status} ${res.statusText}` };
|
|
115
|
+
}
|
|
116
|
+
const data = (await res.json());
|
|
117
|
+
const models = data.models ?? [];
|
|
118
|
+
const hasModel = models.some((m) => m.name === cfg.model || m.name.startsWith(`${cfg.model}:`));
|
|
119
|
+
if (!hasModel) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: 'model-missing',
|
|
123
|
+
detail: `model ${cfg.model} not found — ollama pull ${cfg.model}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fetchComments } from '../api/algolia.js';
|
|
2
|
+
import { ExtractionError, extractArticle } from '../lib/article.js';
|
|
3
|
+
import { htmlToText } from '../lib/html.js';
|
|
4
|
+
import { buildThreadContext } from './context.js';
|
|
5
|
+
function buildArticlePrompt(title, articleText) {
|
|
6
|
+
return `Summarize this article in 3-5 sentences, then list 2-3 key takeaways as short dashes.\n\nTitle: ${title}\n\n${articleText}`;
|
|
7
|
+
}
|
|
8
|
+
function buildThreadPrompt(title, threadText) {
|
|
9
|
+
return `Summarize this Hacker News discussion: main viewpoints, notable disagreements, and any strong consensus. 4-6 sentences.\n\nStory: ${title}\n\nComments:\n${threadText}`;
|
|
10
|
+
}
|
|
11
|
+
function threadNotice(thread) {
|
|
12
|
+
return thread.trimmed ? `thread trimmed to first ${thread.includedTopLevel} comments` : undefined;
|
|
13
|
+
}
|
|
14
|
+
async function fallbackPrompt(story, extractionNoticePrefix) {
|
|
15
|
+
if (story.text) {
|
|
16
|
+
const notice = extractionNoticePrefix ? `${extractionNoticePrefix} — summarizing post text instead` : undefined;
|
|
17
|
+
return { prompt: buildArticlePrompt(story.title, htmlToText(story.text)), notice };
|
|
18
|
+
}
|
|
19
|
+
const comments = await fetchComments(story.id);
|
|
20
|
+
const thread = buildThreadContext(comments);
|
|
21
|
+
const notice = [
|
|
22
|
+
extractionNoticePrefix ? `${extractionNoticePrefix} — summarizing thread instead` : undefined,
|
|
23
|
+
threadNotice(thread),
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join('; ');
|
|
27
|
+
return { prompt: buildThreadPrompt(story.title, thread.text), notice: notice || undefined };
|
|
28
|
+
}
|
|
29
|
+
/** Story-list `s`: article summary, falling back to post text then thread on extraction failure. */
|
|
30
|
+
export async function buildListSummaryPrompt(story, signal) {
|
|
31
|
+
if (!story.url)
|
|
32
|
+
return fallbackPrompt(story);
|
|
33
|
+
try {
|
|
34
|
+
const article = await extractArticle(story.url, signal);
|
|
35
|
+
return {
|
|
36
|
+
prompt: buildArticlePrompt(story.title, article.text),
|
|
37
|
+
notice: article.truncated ? 'article truncated to 16k chars' : undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const reason = err instanceof ExtractionError ? err.reason : 'unknown';
|
|
42
|
+
return fallbackPrompt(story, `article unavailable (${reason})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Comments view `s`: thread summary from the already-loaded comment tree. */
|
|
46
|
+
export function buildCommentsSummaryPrompt(story, comments) {
|
|
47
|
+
const thread = buildThreadContext(comments);
|
|
48
|
+
return { prompt: buildThreadPrompt(story.title, thread.text), notice: threadNotice(thread) };
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const BASE = 'https://hn.algolia.com/api/v1';
|
|
2
|
+
async function getJson(url) {
|
|
3
|
+
const res = await fetch(url);
|
|
4
|
+
if (!res.ok)
|
|
5
|
+
throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
|
6
|
+
return res.json();
|
|
7
|
+
}
|
|
8
|
+
function toNode(item) {
|
|
9
|
+
// Deleted/dead comments come back with null author or text
|
|
10
|
+
if (item.type !== 'comment' || item.author == null || item.text == null) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
id: item.id,
|
|
15
|
+
author: item.author,
|
|
16
|
+
text: item.text,
|
|
17
|
+
time: item.created_at_i,
|
|
18
|
+
children: item.children
|
|
19
|
+
.map(toNode)
|
|
20
|
+
.filter((c) => c != null),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Fetch the full comment tree of a story in one request. */
|
|
24
|
+
export async function fetchComments(storyId) {
|
|
25
|
+
const item = await getJson(`${BASE}/items/${storyId}`);
|
|
26
|
+
return item.children.map(toNode).filter((c) => c != null);
|
|
27
|
+
}
|
|
28
|
+
export async function searchStories(query, page = 0) {
|
|
29
|
+
const params = new URLSearchParams({
|
|
30
|
+
query,
|
|
31
|
+
tags: 'story',
|
|
32
|
+
page: String(page),
|
|
33
|
+
hitsPerPage: '20',
|
|
34
|
+
});
|
|
35
|
+
const res = await getJson(`${BASE}/search?${params}`);
|
|
36
|
+
return {
|
|
37
|
+
stories: res.hits
|
|
38
|
+
.filter((h) => h.title != null)
|
|
39
|
+
.map((h) => ({
|
|
40
|
+
id: Number(h.objectID),
|
|
41
|
+
title: h.title,
|
|
42
|
+
url: h.url ?? undefined,
|
|
43
|
+
by: h.author ?? '?',
|
|
44
|
+
score: h.points ?? 0,
|
|
45
|
+
descendants: h.num_comments ?? 0,
|
|
46
|
+
time: h.created_at_i,
|
|
47
|
+
})),
|
|
48
|
+
hasMore: page + 1 < res.nbPages,
|
|
49
|
+
totalHits: res.nbHits,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const BASE = 'https://hacker-news.firebaseio.com/v0';
|
|
2
|
+
const FEED_PATHS = {
|
|
3
|
+
top: 'topstories',
|
|
4
|
+
new: 'newstories',
|
|
5
|
+
best: 'beststories',
|
|
6
|
+
ask: 'askstories',
|
|
7
|
+
show: 'showstories',
|
|
8
|
+
};
|
|
9
|
+
async function getJson(url) {
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
|
13
|
+
return res.json();
|
|
14
|
+
}
|
|
15
|
+
export function fetchStoryIds(feed) {
|
|
16
|
+
return getJson(`${BASE}/${FEED_PATHS[feed]}.json`);
|
|
17
|
+
}
|
|
18
|
+
export async function fetchStories(ids) {
|
|
19
|
+
const items = await Promise.all(ids.map((id) => getJson(`${BASE}/item/${id}.json`)));
|
|
20
|
+
return items
|
|
21
|
+
.filter((it) => it != null && !it.deleted && !it.dead && it.title != null)
|
|
22
|
+
.map((it) => ({
|
|
23
|
+
id: it.id,
|
|
24
|
+
title: it.title,
|
|
25
|
+
url: it.url,
|
|
26
|
+
text: it.text,
|
|
27
|
+
by: it.by ?? '?',
|
|
28
|
+
score: it.score ?? 0,
|
|
29
|
+
descendants: it.descendants ?? 0,
|
|
30
|
+
time: it.time ?? 0,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
export function hnItemUrl(id) {
|
|
34
|
+
return `https://news.ycombinator.com/item?id=${id}`;
|
|
35
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
const alternateScreen = process.stdout.isTTY === true;
|
|
6
|
+
function applyTheme(options) {
|
|
7
|
+
if (options.theme)
|
|
8
|
+
process.env['HN_THEME'] = options.theme;
|
|
9
|
+
}
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program
|
|
12
|
+
.name('hn')
|
|
13
|
+
.description('Terminal-first Hacker News client')
|
|
14
|
+
.version('0.1.0')
|
|
15
|
+
.option('-t, --theme <name>', 'color theme (see `hn theme` for the list)');
|
|
16
|
+
program.action(async () => {
|
|
17
|
+
applyTheme(program.opts());
|
|
18
|
+
const { App } = await import('./ui/App.js');
|
|
19
|
+
render(_jsx(App, {}), { alternateScreen });
|
|
20
|
+
});
|
|
21
|
+
program
|
|
22
|
+
.command('search <query...>')
|
|
23
|
+
.description('Search stories by keyword')
|
|
24
|
+
.action(async (queryParts) => {
|
|
25
|
+
applyTheme(program.opts());
|
|
26
|
+
const { App } = await import('./ui/App.js');
|
|
27
|
+
render(_jsx(App, { initialQuery: queryParts.join(' ') }), { alternateScreen });
|
|
28
|
+
});
|
|
29
|
+
program
|
|
30
|
+
.command('theme')
|
|
31
|
+
.description('Show the active color theme and available palettes')
|
|
32
|
+
.action(async () => {
|
|
33
|
+
const { paletteNames, resolvePaletteName } = await import('./ui/theme.js');
|
|
34
|
+
const activeName = resolvePaletteName(program.opts().theme);
|
|
35
|
+
console.log(`Active theme: ${activeName}`);
|
|
36
|
+
console.log(`Available: ${paletteNames().join(', ')}`);
|
|
37
|
+
console.log('Set with `hn --theme <name>` or the HN_THEME environment variable.');
|
|
38
|
+
});
|
|
39
|
+
const config = program.command('config').description('Get or set hn-bits config values');
|
|
40
|
+
config
|
|
41
|
+
.command('list')
|
|
42
|
+
.description('List all known config keys and their current values')
|
|
43
|
+
.action(async () => {
|
|
44
|
+
const { listConfigEntries } = await import('./lib/configStore.js');
|
|
45
|
+
for (const { key, value } of listConfigEntries()) {
|
|
46
|
+
console.log(`${key}=${value ?? '(not set)'}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
config
|
|
50
|
+
.command('get <key>')
|
|
51
|
+
.description('Print a single config value')
|
|
52
|
+
.action(async (key) => {
|
|
53
|
+
const { getConfigValue } = await import('./lib/configStore.js');
|
|
54
|
+
try {
|
|
55
|
+
const value = getConfigValue(key);
|
|
56
|
+
if (value === undefined) {
|
|
57
|
+
console.error(`${key} is not set`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
console.log(value);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error(err.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
config
|
|
68
|
+
.command('set <key> <value>')
|
|
69
|
+
.description('Set a config value')
|
|
70
|
+
.action(async (key, value) => {
|
|
71
|
+
const { setConfigValue } = await import('./lib/configStore.js');
|
|
72
|
+
try {
|
|
73
|
+
setConfigValue(key, value);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error(err.message);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
config
|
|
81
|
+
.command('unset <key>')
|
|
82
|
+
.description('Remove a config value')
|
|
83
|
+
.action(async (key) => {
|
|
84
|
+
const { unsetConfigValue } = await import('./lib/configStore.js');
|
|
85
|
+
try {
|
|
86
|
+
unsetConfigValue(key);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error(err.message);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
program.parse();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
export class ExtractionError extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(reason) {
|
|
5
|
+
super(reason);
|
|
6
|
+
this.reason = reason;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
10
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const TEXT_BUDGET_CHARS = 16_000;
|
|
12
|
+
async function readCappedBody(res) {
|
|
13
|
+
const reader = res.body?.getReader();
|
|
14
|
+
if (!reader)
|
|
15
|
+
return '';
|
|
16
|
+
const decoder = new TextDecoder();
|
|
17
|
+
let bytesRead = 0;
|
|
18
|
+
let text = '';
|
|
19
|
+
while (bytesRead < MAX_BODY_BYTES) {
|
|
20
|
+
const { done, value } = await reader.read();
|
|
21
|
+
if (done)
|
|
22
|
+
break;
|
|
23
|
+
bytesRead += value.byteLength;
|
|
24
|
+
text += decoder.decode(value, { stream: true });
|
|
25
|
+
}
|
|
26
|
+
await reader.cancel().catch(() => { });
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
function normalize(text) {
|
|
30
|
+
return text
|
|
31
|
+
.split(/\n{2,}/)
|
|
32
|
+
.map((paragraph) => paragraph.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join('\n\n');
|
|
35
|
+
}
|
|
36
|
+
function truncate(text, budget) {
|
|
37
|
+
if (text.length <= budget)
|
|
38
|
+
return { text, truncated: false };
|
|
39
|
+
const cut = text.lastIndexOf('\n\n', budget);
|
|
40
|
+
return { text: text.slice(0, cut > 0 ? cut : budget), truncated: true };
|
|
41
|
+
}
|
|
42
|
+
/** Fetches a story URL and extracts readable article text. Throws ExtractionError; callers define fallback. */
|
|
43
|
+
export async function extractArticle(url, signal) {
|
|
44
|
+
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
45
|
+
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(url, {
|
|
49
|
+
headers: { 'User-Agent': `hn-bits/${pkg.version}`, Accept: 'text/html' },
|
|
50
|
+
signal: combinedSignal,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new ExtractionError('fetch-failed');
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
throw new ExtractionError('fetch-failed');
|
|
58
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
59
|
+
if (!contentType.includes('text/html'))
|
|
60
|
+
throw new ExtractionError('not-html');
|
|
61
|
+
const html = await readCappedBody(res);
|
|
62
|
+
const { JSDOM, VirtualConsole } = await import('jsdom');
|
|
63
|
+
const { Readability } = await import('@mozilla/readability');
|
|
64
|
+
const virtualConsole = new VirtualConsole();
|
|
65
|
+
const dom = new JSDOM(html, { url, virtualConsole });
|
|
66
|
+
const parsed = new Readability(dom.window.document).parse();
|
|
67
|
+
if (!parsed?.textContent?.trim())
|
|
68
|
+
throw new ExtractionError('unreadable');
|
|
69
|
+
const { text, truncated } = truncate(normalize(parsed.textContent), TEXT_BUDGET_CHARS);
|
|
70
|
+
return { title: parsed.title ?? '', text, truncated };
|
|
71
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { countDescendants } from './comments.js';
|
|
2
|
+
/** Depth-first flatten; children are skipped when a node is folded or header-only. */
|
|
3
|
+
export function flattenTree(nodes, folded, headerOnly) {
|
|
4
|
+
return nodes.flatMap((node) => flattenNode(node, 0, folded, headerOnly));
|
|
5
|
+
}
|
|
6
|
+
function flattenNode(node, depth, folded, headerOnly) {
|
|
7
|
+
const isHeaderOnly = headerOnly.has(node.id);
|
|
8
|
+
const childrenHidden = isHeaderOnly || folded.has(node.id);
|
|
9
|
+
const entry = {
|
|
10
|
+
node,
|
|
11
|
+
depth,
|
|
12
|
+
descendantCount: countDescendants(node),
|
|
13
|
+
childrenHidden,
|
|
14
|
+
headerOnly: isHeaderOnly,
|
|
15
|
+
};
|
|
16
|
+
if (childrenHidden)
|
|
17
|
+
return [entry];
|
|
18
|
+
return [entry, ...node.children.flatMap((child) => flattenNode(child, depth + 1, folded, headerOnly))];
|
|
19
|
+
}
|
|
20
|
+
export function toggleFold(folded, id) {
|
|
21
|
+
const next = new Set(folded);
|
|
22
|
+
if (next.has(id))
|
|
23
|
+
next.delete(id);
|
|
24
|
+
else
|
|
25
|
+
next.add(id);
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
/** Moves an id from header-only into revealed, so C-originated leaves can toggle back. */
|
|
29
|
+
export function revealHeaderOnly(state, id) {
|
|
30
|
+
const headerOnly = new Set(state.headerOnly);
|
|
31
|
+
headerOnly.delete(id);
|
|
32
|
+
return { headerOnly, revealed: new Set(state.revealed).add(id) };
|
|
33
|
+
}
|
|
34
|
+
/** Moves a revealed id back to header-only — the leaf half of the header ↔ body toggle. */
|
|
35
|
+
export function rehideRevealed(state, id) {
|
|
36
|
+
const revealed = new Set(state.revealed);
|
|
37
|
+
revealed.delete(id);
|
|
38
|
+
return { headerOnly: new Set(state.headerOnly).add(id), revealed };
|
|
39
|
+
}
|
|
40
|
+
/** Every id that has children — the default/collapsed fold set (body shown, children hidden). */
|
|
41
|
+
export function collapseAll(nodes) {
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
const visit = (node) => {
|
|
44
|
+
if (node.children.length > 0)
|
|
45
|
+
ids.add(node.id);
|
|
46
|
+
node.children.forEach(visit);
|
|
47
|
+
};
|
|
48
|
+
nodes.forEach(visit);
|
|
49
|
+
return ids;
|
|
50
|
+
}
|
|
51
|
+
/** Every id, parents and leaves — the header-only set produced by `C` (collapse all). */
|
|
52
|
+
export function headerOnlyAll(nodes) {
|
|
53
|
+
const ids = new Set();
|
|
54
|
+
const visit = (node) => {
|
|
55
|
+
ids.add(node.id);
|
|
56
|
+
node.children.forEach(visit);
|
|
57
|
+
};
|
|
58
|
+
nodes.forEach(visit);
|
|
59
|
+
return ids;
|
|
60
|
+
}
|