hackernews-tui 0.1.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 +72 -0
- package/bin/hackernews-tui +2 -0
- package/package.json +48 -0
- package/readme.png +0 -0
- package/src/app/constants.ts +58 -0
- package/src/hooks/use-hn-browser-state.ts +337 -0
- package/src/index.tsx +502 -0
- package/src/lib/comment-loader.test.ts +34 -0
- package/src/lib/comment-loader.ts +45 -0
- package/src/lib/hn-api.ts +74 -0
- package/src/lib/hn-utils.test.ts +59 -0
- package/src/lib/hn-utils.ts +157 -0
- package/src/lib/open-external-url.ts +17 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
classifyStory,
|
|
4
|
+
cycleValue,
|
|
5
|
+
decodeHtmlEntities,
|
|
6
|
+
formatRelativeTime,
|
|
7
|
+
getErrorMessage,
|
|
8
|
+
htmlToText,
|
|
9
|
+
toSingleLine,
|
|
10
|
+
truncate,
|
|
11
|
+
} from "./hn-utils";
|
|
12
|
+
|
|
13
|
+
describe("hn-utils", () => {
|
|
14
|
+
test("truncate returns original when under limit", () => {
|
|
15
|
+
expect(truncate("hello", 10)).toBe("hello");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("truncate adds ellipsis when over limit", () => {
|
|
19
|
+
expect(truncate("abcdefghij", 7)).toBe("abcd...");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("toSingleLine strips newlines and tabs", () => {
|
|
23
|
+
expect(toSingleLine("foo\nbar\t baz")).toBe("foo bar baz");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("decodeHtmlEntities decodes named and numeric entities", () => {
|
|
27
|
+
expect(decodeHtmlEntities("Tom & Jerry 'ok' <3")).toBe("Tom & Jerry 'ok' <3");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("htmlToText strips tags and keeps structure", () => {
|
|
31
|
+
const input = "<p>Hello & welcome</p><li>one</li><li>two</li><br/>done";
|
|
32
|
+
expect(htmlToText(input)).toBe("Hello & welcome\n- one\n- two\ndone");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("formatRelativeTime uses provided now timestamp", () => {
|
|
36
|
+
const nowMs = 1_700_000_000_000;
|
|
37
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
38
|
+
expect(formatRelativeTime(nowSeconds - 30, nowMs)).toBe("30s ago");
|
|
39
|
+
expect(formatRelativeTime(nowSeconds - 3600, nowMs)).toBe("1h ago");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("classifyStory detects ask/show/job", () => {
|
|
43
|
+
expect(classifyStory({ id: 1, title: "Ask HN: something", type: "story" })).toBe("ask");
|
|
44
|
+
expect(classifyStory({ id: 2, title: "Show HN: app", type: "story" })).toBe("show");
|
|
45
|
+
expect(classifyStory({ id: 3, title: "job", type: "job" })).toBe("job");
|
|
46
|
+
expect(classifyStory({ id: 4, title: "Normal story", type: "story" })).toBe("story");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("cycleValue rotates and wraps", () => {
|
|
50
|
+
const values = ["a", "b", "c"] as const;
|
|
51
|
+
expect(cycleValue(values, "a")).toBe("b");
|
|
52
|
+
expect(cycleValue(values, "c")).toBe("a");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("getErrorMessage handles unknown values", () => {
|
|
56
|
+
expect(getErrorMessage(new Error("boom"))).toBe("boom");
|
|
57
|
+
expect(getErrorMessage("x")).toBe("Unknown error");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export interface HNItem {
|
|
2
|
+
id: number;
|
|
3
|
+
deleted?: boolean;
|
|
4
|
+
type?: "job" | "story" | "comment" | "poll" | "pollopt";
|
|
5
|
+
by?: string;
|
|
6
|
+
time?: number;
|
|
7
|
+
text?: string;
|
|
8
|
+
dead?: boolean;
|
|
9
|
+
parent?: number;
|
|
10
|
+
poll?: number;
|
|
11
|
+
kids?: number[];
|
|
12
|
+
url?: string;
|
|
13
|
+
score?: number;
|
|
14
|
+
title?: string;
|
|
15
|
+
parts?: number[];
|
|
16
|
+
descendants?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type StoryKind = "story" | "ask" | "show" | "job";
|
|
20
|
+
|
|
21
|
+
export function getErrorMessage(error: unknown): string {
|
|
22
|
+
if (error instanceof Error && error.message) {
|
|
23
|
+
return error.message;
|
|
24
|
+
}
|
|
25
|
+
return "Unknown error";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function truncate(value: string, maxLength: number): string {
|
|
29
|
+
if (value.length <= maxLength) {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
if (maxLength <= 3) {
|
|
33
|
+
return value.slice(0, maxLength);
|
|
34
|
+
}
|
|
35
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function toSingleLine(value: string): string {
|
|
39
|
+
return value
|
|
40
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
41
|
+
.replace(/\s{2,}/g, " ")
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function decodeHtmlEntities(value: string): string {
|
|
46
|
+
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (_, rawEntity: string) => {
|
|
47
|
+
if (rawEntity.startsWith("#x") || rawEntity.startsWith("#X")) {
|
|
48
|
+
const codePoint = Number.parseInt(rawEntity.slice(2), 16);
|
|
49
|
+
if (Number.isFinite(codePoint)) {
|
|
50
|
+
return String.fromCodePoint(codePoint);
|
|
51
|
+
}
|
|
52
|
+
return `&${rawEntity};`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (rawEntity.startsWith("#")) {
|
|
56
|
+
const codePoint = Number.parseInt(rawEntity.slice(1), 10);
|
|
57
|
+
if (Number.isFinite(codePoint)) {
|
|
58
|
+
return String.fromCodePoint(codePoint);
|
|
59
|
+
}
|
|
60
|
+
return `&${rawEntity};`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
switch (rawEntity) {
|
|
64
|
+
case "amp":
|
|
65
|
+
return "&";
|
|
66
|
+
case "lt":
|
|
67
|
+
return "<";
|
|
68
|
+
case "gt":
|
|
69
|
+
return ">";
|
|
70
|
+
case "quot":
|
|
71
|
+
return '"';
|
|
72
|
+
case "apos":
|
|
73
|
+
case "rsquo":
|
|
74
|
+
case "lsquo":
|
|
75
|
+
case "#39":
|
|
76
|
+
case "#x27":
|
|
77
|
+
return "'";
|
|
78
|
+
case "nbsp":
|
|
79
|
+
return " ";
|
|
80
|
+
default:
|
|
81
|
+
return `&${rawEntity};`;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function htmlToText(input?: string): string {
|
|
87
|
+
if (!input) {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const withBreaks = input
|
|
92
|
+
.replace(/<p>/gi, "\n\n")
|
|
93
|
+
.replace(/<\/p>/gi, "")
|
|
94
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
95
|
+
.replace(/<li>/gi, "\n- ")
|
|
96
|
+
.replace(/<\/li>/gi, "");
|
|
97
|
+
|
|
98
|
+
const withoutTags = withBreaks.replace(/<[^>]+>/g, "");
|
|
99
|
+
return decodeHtmlEntities(withoutTags)
|
|
100
|
+
.replace(/\u00a0/g, " ")
|
|
101
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
102
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
103
|
+
.trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatRelativeTime(unixSeconds?: number, nowMs: number = Date.now()): string {
|
|
107
|
+
if (!unixSeconds) {
|
|
108
|
+
return "unknown time";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const seconds = Math.max(0, Math.floor(nowMs / 1000) - unixSeconds);
|
|
112
|
+
if (seconds < 60) {
|
|
113
|
+
return `${seconds}s ago`;
|
|
114
|
+
}
|
|
115
|
+
const minutes = Math.floor(seconds / 60);
|
|
116
|
+
if (minutes < 60) {
|
|
117
|
+
return `${minutes}m ago`;
|
|
118
|
+
}
|
|
119
|
+
const hours = Math.floor(minutes / 60);
|
|
120
|
+
if (hours < 24) {
|
|
121
|
+
return `${hours}h ago`;
|
|
122
|
+
}
|
|
123
|
+
const days = Math.floor(hours / 24);
|
|
124
|
+
if (days < 30) {
|
|
125
|
+
return `${days}d ago`;
|
|
126
|
+
}
|
|
127
|
+
const months = Math.floor(days / 30);
|
|
128
|
+
if (months < 12) {
|
|
129
|
+
return `${months}mo ago`;
|
|
130
|
+
}
|
|
131
|
+
const years = Math.floor(months / 12);
|
|
132
|
+
return `${years}y ago`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function classifyStory(item: HNItem): StoryKind {
|
|
136
|
+
if (item.type === "job") {
|
|
137
|
+
return "job";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const title = (item.title ?? "").toLowerCase();
|
|
141
|
+
if (title.startsWith("ask hn")) {
|
|
142
|
+
return "ask";
|
|
143
|
+
}
|
|
144
|
+
if (title.startsWith("show hn")) {
|
|
145
|
+
return "show";
|
|
146
|
+
}
|
|
147
|
+
return "story";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function cycleValue<T>(values: readonly T[], current: T): T {
|
|
151
|
+
const currentIndex = values.indexOf(current);
|
|
152
|
+
if (currentIndex < 0) {
|
|
153
|
+
return values[0] as T;
|
|
154
|
+
}
|
|
155
|
+
const nextIndex = (currentIndex + 1) % values.length;
|
|
156
|
+
return values[nextIndex] as T;
|
|
157
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function openExternalUrl(url: string): void {
|
|
4
|
+
const platform = process.platform;
|
|
5
|
+
if (platform === "darwin") {
|
|
6
|
+
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
7
|
+
child.unref();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (platform === "win32") {
|
|
11
|
+
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
|
|
12
|
+
child.unref();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
16
|
+
child.unref();
|
|
17
|
+
}
|