@ahmd-sh/hntui 0.3.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/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +59 -0
- package/src/App.tsx +629 -0
- package/src/api/hn.test.ts +84 -0
- package/src/api/hn.ts +200 -0
- package/src/api/hnDemo.ts +70 -0
- package/src/api/types.ts +40 -0
- package/src/components/CommentNode.tsx +81 -0
- package/src/components/ContextMenu.tsx +78 -0
- package/src/components/Header.tsx +89 -0
- package/src/components/HelpOverlay.tsx +133 -0
- package/src/components/LinksPopup.tsx +101 -0
- package/src/components/Loader.tsx +30 -0
- package/src/components/StatusBar.tsx +40 -0
- package/src/components/StoryRow.tsx +76 -0
- package/src/hooks/useCommentTree.ts +95 -0
- package/src/hooks/useHistory.ts +40 -0
- package/src/hooks/useItems.ts +34 -0
- package/src/hooks/useSaved.ts +37 -0
- package/src/hooks/useStoryIds.ts +38 -0
- package/src/index.tsx +8 -0
- package/src/runtime.ts +12 -0
- package/src/spinner.ts +243 -0
- package/src/theme.ts +131 -0
- package/src/utils/configDir.ts +22 -0
- package/src/utils/errors.test.ts +21 -0
- package/src/utils/errors.ts +13 -0
- package/src/utils/format.test.ts +56 -0
- package/src/utils/format.ts +97 -0
- package/src/utils/historyStore.ts +38 -0
- package/src/utils/openUrl.ts +10 -0
- package/src/utils/savedStore.ts +34 -0
- package/src/views/MessageView.tsx +34 -0
- package/src/views/StoryDetailView.tsx +116 -0
- package/src/views/StoryListView.tsx +83 -0
package/src/spinner.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Adapted from opentui-spinner (MIT) — examples/knight-rider/utils.ts by Matt Simpson
|
|
2
|
+
import type { ColorInput } from "@opentui/core"
|
|
3
|
+
import { RGBA } from "@opentui/core"
|
|
4
|
+
import type { ColorGenerator } from "opentui-spinner"
|
|
5
|
+
|
|
6
|
+
interface AdvancedGradientOptions {
|
|
7
|
+
colors: ColorInput[]
|
|
8
|
+
trailLength: number
|
|
9
|
+
defaultColor?: ColorInput
|
|
10
|
+
direction?: "forward" | "backward" | "bidirectional"
|
|
11
|
+
holdFrames?: { start?: number; end?: number }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ScannerState {
|
|
15
|
+
activePosition: number
|
|
16
|
+
isHolding: boolean
|
|
17
|
+
holdProgress: number
|
|
18
|
+
holdTotal: number
|
|
19
|
+
movementProgress: number
|
|
20
|
+
movementTotal: number
|
|
21
|
+
isMovingForward: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getScannerState(
|
|
25
|
+
frameIndex: number,
|
|
26
|
+
totalChars: number,
|
|
27
|
+
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames">,
|
|
28
|
+
): ScannerState {
|
|
29
|
+
const { direction = "forward", holdFrames = {} } = options
|
|
30
|
+
|
|
31
|
+
if (direction === "bidirectional") {
|
|
32
|
+
const forwardFrames = totalChars
|
|
33
|
+
const holdEndFrames = holdFrames.end ?? 0
|
|
34
|
+
const backwardFrames = totalChars - 1
|
|
35
|
+
|
|
36
|
+
if (frameIndex < forwardFrames) {
|
|
37
|
+
return {
|
|
38
|
+
activePosition: frameIndex,
|
|
39
|
+
isHolding: false,
|
|
40
|
+
holdProgress: 0,
|
|
41
|
+
holdTotal: 0,
|
|
42
|
+
movementProgress: frameIndex,
|
|
43
|
+
movementTotal: forwardFrames,
|
|
44
|
+
isMovingForward: true,
|
|
45
|
+
}
|
|
46
|
+
} else if (frameIndex < forwardFrames + holdEndFrames) {
|
|
47
|
+
return {
|
|
48
|
+
activePosition: totalChars - 1,
|
|
49
|
+
isHolding: true,
|
|
50
|
+
holdProgress: frameIndex - forwardFrames,
|
|
51
|
+
holdTotal: holdEndFrames,
|
|
52
|
+
movementProgress: 0,
|
|
53
|
+
movementTotal: 0,
|
|
54
|
+
isMovingForward: true,
|
|
55
|
+
}
|
|
56
|
+
} else if (frameIndex < forwardFrames + holdEndFrames + backwardFrames) {
|
|
57
|
+
const backwardIndex = frameIndex - forwardFrames - holdEndFrames
|
|
58
|
+
return {
|
|
59
|
+
activePosition: totalChars - 2 - backwardIndex,
|
|
60
|
+
isHolding: false,
|
|
61
|
+
holdProgress: 0,
|
|
62
|
+
holdTotal: 0,
|
|
63
|
+
movementProgress: backwardIndex,
|
|
64
|
+
movementTotal: backwardFrames,
|
|
65
|
+
isMovingForward: false,
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
return {
|
|
69
|
+
activePosition: 0,
|
|
70
|
+
isHolding: true,
|
|
71
|
+
holdProgress: frameIndex - forwardFrames - holdEndFrames - backwardFrames,
|
|
72
|
+
holdTotal: holdFrames.start ?? 0,
|
|
73
|
+
movementProgress: 0,
|
|
74
|
+
movementTotal: 0,
|
|
75
|
+
isMovingForward: false,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else if (direction === "backward") {
|
|
79
|
+
return {
|
|
80
|
+
activePosition: totalChars - 1 - (frameIndex % totalChars),
|
|
81
|
+
isHolding: false,
|
|
82
|
+
holdProgress: 0,
|
|
83
|
+
holdTotal: 0,
|
|
84
|
+
movementProgress: frameIndex % totalChars,
|
|
85
|
+
movementTotal: totalChars,
|
|
86
|
+
isMovingForward: false,
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
return {
|
|
90
|
+
activePosition: frameIndex % totalChars,
|
|
91
|
+
isHolding: false,
|
|
92
|
+
holdProgress: 0,
|
|
93
|
+
holdTotal: 0,
|
|
94
|
+
movementProgress: frameIndex % totalChars,
|
|
95
|
+
movementTotal: totalChars,
|
|
96
|
+
isMovingForward: true,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function calculateColorIndex(
|
|
102
|
+
frameIndex: number,
|
|
103
|
+
charIndex: number,
|
|
104
|
+
totalChars: number,
|
|
105
|
+
options: Pick<AdvancedGradientOptions, "direction" | "holdFrames" | "trailLength">,
|
|
106
|
+
): number {
|
|
107
|
+
const { trailLength } = options
|
|
108
|
+
const { activePosition, isHolding, holdProgress, isMovingForward } = getScannerState(
|
|
109
|
+
frameIndex,
|
|
110
|
+
totalChars,
|
|
111
|
+
options,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const directionalDistance = isMovingForward ? activePosition - charIndex : charIndex - activePosition
|
|
115
|
+
|
|
116
|
+
if (isHolding) {
|
|
117
|
+
return directionalDistance + holdProgress
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (directionalDistance > 0 && directionalDistance < trailLength) {
|
|
121
|
+
return directionalDistance
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (directionalDistance === 0) {
|
|
125
|
+
return 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return -1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createKnightRiderTrail(options: AdvancedGradientOptions): ColorGenerator {
|
|
132
|
+
const { colors, defaultColor } = options
|
|
133
|
+
|
|
134
|
+
const defaultRgba =
|
|
135
|
+
defaultColor instanceof RGBA ? defaultColor : RGBA.fromHex((defaultColor as string) || "#000000")
|
|
136
|
+
|
|
137
|
+
return (frameIndex: number, charIndex: number, _totalFrames: number, totalChars: number) => {
|
|
138
|
+
const index = calculateColorIndex(frameIndex, charIndex, totalChars, options)
|
|
139
|
+
|
|
140
|
+
const { isHolding, holdProgress, holdTotal, movementProgress, movementTotal } = getScannerState(
|
|
141
|
+
frameIndex,
|
|
142
|
+
totalChars,
|
|
143
|
+
options,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
let alpha = 1.0
|
|
147
|
+
if (isHolding && holdTotal > 0) {
|
|
148
|
+
const progress = Math.min(holdProgress / holdTotal, 1)
|
|
149
|
+
alpha = Math.max(0, 1 - progress)
|
|
150
|
+
} else if (!isHolding && movementTotal > 0) {
|
|
151
|
+
const progress = Math.min(movementProgress / Math.max(1, movementTotal - 1), 1)
|
|
152
|
+
alpha = progress
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
defaultRgba.a = alpha
|
|
156
|
+
|
|
157
|
+
if (index === -1) {
|
|
158
|
+
return defaultRgba
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return colors[index] ?? defaultRgba
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type KnightRiderStyle = "blocks" | "diamonds"
|
|
166
|
+
|
|
167
|
+
export interface KnightRiderOptions {
|
|
168
|
+
width?: number
|
|
169
|
+
style?: KnightRiderStyle
|
|
170
|
+
holdStart?: number
|
|
171
|
+
holdEnd?: number
|
|
172
|
+
colors?: ColorInput[]
|
|
173
|
+
defaultColor?: ColorInput
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createFrames(options: KnightRiderOptions = {}): string[] {
|
|
177
|
+
const width = options.width ?? 8
|
|
178
|
+
const style = options.style ?? "diamonds"
|
|
179
|
+
const holdStart = options.holdStart ?? 30
|
|
180
|
+
const holdEnd = options.holdEnd ?? 9
|
|
181
|
+
|
|
182
|
+
const colors = options.colors ?? [
|
|
183
|
+
RGBA.fromHex("#ff0000"),
|
|
184
|
+
RGBA.fromHex("#ff5555"),
|
|
185
|
+
RGBA.fromHex("#dd0000"),
|
|
186
|
+
RGBA.fromHex("#aa0000"),
|
|
187
|
+
RGBA.fromHex("#770000"),
|
|
188
|
+
RGBA.fromHex("#440000"),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
const trailOptions = {
|
|
192
|
+
colors,
|
|
193
|
+
trailLength: colors.length,
|
|
194
|
+
defaultColor: options.defaultColor ?? RGBA.fromHex("#330000"),
|
|
195
|
+
direction: "bidirectional" as const,
|
|
196
|
+
holdFrames: { start: holdStart, end: holdEnd },
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const totalFrames = width + holdEnd + (width - 1) + holdStart
|
|
200
|
+
|
|
201
|
+
const frames = Array.from({ length: totalFrames }, (_, frameIndex) => {
|
|
202
|
+
return Array.from({ length: width }, (_, charIndex) => {
|
|
203
|
+
const index = calculateColorIndex(frameIndex, charIndex, width, trailOptions)
|
|
204
|
+
|
|
205
|
+
if (style === "diamonds") {
|
|
206
|
+
const shapes = ["⬥", "◆", "⬩", "⬪"]
|
|
207
|
+
if (index >= 0 && index < trailOptions.colors.length) {
|
|
208
|
+
return shapes[Math.min(index, shapes.length - 1)]
|
|
209
|
+
}
|
|
210
|
+
return "·"
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const isActive = index >= 0 && index < trailOptions.colors.length
|
|
214
|
+
return isActive ? "■" : "⬝"
|
|
215
|
+
}).join("")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
return frames
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function createColors(options: KnightRiderOptions = {}): ColorGenerator {
|
|
222
|
+
const holdStart = options.holdStart ?? 30
|
|
223
|
+
const holdEnd = options.holdEnd ?? 9
|
|
224
|
+
|
|
225
|
+
const colors = options.colors ?? [
|
|
226
|
+
RGBA.fromHex("#ff0000"),
|
|
227
|
+
RGBA.fromHex("#ff5555"),
|
|
228
|
+
RGBA.fromHex("#dd0000"),
|
|
229
|
+
RGBA.fromHex("#aa0000"),
|
|
230
|
+
RGBA.fromHex("#770000"),
|
|
231
|
+
RGBA.fromHex("#440000"),
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
const trailOptions = {
|
|
235
|
+
colors,
|
|
236
|
+
trailLength: colors.length,
|
|
237
|
+
defaultColor: options.defaultColor ?? RGBA.fromHex("#330000"),
|
|
238
|
+
direction: "bidirectional" as const,
|
|
239
|
+
holdFrames: { start: holdStart, end: holdEnd },
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return createKnightRiderTrail(trailOptions)
|
|
243
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createContext, useContext } from "react"
|
|
2
|
+
import type { ColorInput } from "@opentui/core"
|
|
3
|
+
|
|
4
|
+
export interface Theme {
|
|
5
|
+
name: "dark" | "light"
|
|
6
|
+
|
|
7
|
+
body: ColorInput
|
|
8
|
+
strip: ColorInput
|
|
9
|
+
border: ColorInput
|
|
10
|
+
|
|
11
|
+
brandTileBg: ColorInput
|
|
12
|
+
brandTileFg: ColorInput
|
|
13
|
+
brandText: ColorInput
|
|
14
|
+
brandSubtle: ColorInput
|
|
15
|
+
|
|
16
|
+
tabActiveBg: ColorInput
|
|
17
|
+
tabActiveFg: ColorInput
|
|
18
|
+
tabInactiveFg: ColorInput
|
|
19
|
+
|
|
20
|
+
statusHint: ColorInput
|
|
21
|
+
|
|
22
|
+
text: ColorInput
|
|
23
|
+
textBody: ColorInput
|
|
24
|
+
textMuted: ColorInput
|
|
25
|
+
textDim: ColorInput
|
|
26
|
+
textVisited: ColorInput
|
|
27
|
+
|
|
28
|
+
accent: ColorInput
|
|
29
|
+
stripAccent: ColorInput
|
|
30
|
+
link: ColorInput
|
|
31
|
+
|
|
32
|
+
rowHighlight: ColorInput
|
|
33
|
+
menuBg: ColorInput
|
|
34
|
+
|
|
35
|
+
selectionBg: ColorInput
|
|
36
|
+
selectionFg: ColorInput
|
|
37
|
+
|
|
38
|
+
scrollTrack: ColorInput
|
|
39
|
+
scrollThumb: ColorInput
|
|
40
|
+
|
|
41
|
+
commentDepth: readonly ColorInput[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const darkTheme: Theme = {
|
|
45
|
+
name: "dark",
|
|
46
|
+
body: "#0a0a0acc",
|
|
47
|
+
strip: "#0e0e0e",
|
|
48
|
+
border: "#2a2a2a",
|
|
49
|
+
|
|
50
|
+
brandTileBg: "#ff6600",
|
|
51
|
+
brandTileFg: "#000000",
|
|
52
|
+
brandText: "#ff6600",
|
|
53
|
+
brandSubtle: "#888888",
|
|
54
|
+
|
|
55
|
+
tabActiveBg: "#ff6600",
|
|
56
|
+
tabActiveFg: "#000000",
|
|
57
|
+
tabInactiveFg: "#cccccc",
|
|
58
|
+
|
|
59
|
+
statusHint: "#888888",
|
|
60
|
+
|
|
61
|
+
text: "#ffffff",
|
|
62
|
+
textBody: "#dddddd",
|
|
63
|
+
textMuted: "#aaaaaa",
|
|
64
|
+
textDim: "#666666",
|
|
65
|
+
textVisited: "#666666",
|
|
66
|
+
|
|
67
|
+
accent: "#ff6600",
|
|
68
|
+
stripAccent: "#ff6600",
|
|
69
|
+
link: "#4488ff",
|
|
70
|
+
|
|
71
|
+
rowHighlight: "#ffffff1a",
|
|
72
|
+
menuBg: "#1a1a1a",
|
|
73
|
+
|
|
74
|
+
selectionBg: "#ffffff",
|
|
75
|
+
selectionFg: "#000000",
|
|
76
|
+
|
|
77
|
+
scrollTrack: "#1a1a1a",
|
|
78
|
+
scrollThumb: "#555555",
|
|
79
|
+
|
|
80
|
+
commentDepth: ["#ff6600", "#ffaa44", "#ffd58a", "#88ccff", "#aa88ff", "#ff88cc"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const lightTheme: Theme = {
|
|
84
|
+
name: "light",
|
|
85
|
+
body: "#f6f6ef",
|
|
86
|
+
strip: "#ff6600",
|
|
87
|
+
border: "#f6f6ef",
|
|
88
|
+
|
|
89
|
+
brandTileBg: "#ffffff",
|
|
90
|
+
brandTileFg: "#000000",
|
|
91
|
+
brandText: "#000000",
|
|
92
|
+
brandSubtle: "#5a2200",
|
|
93
|
+
|
|
94
|
+
tabActiveBg: "#ffffff",
|
|
95
|
+
tabActiveFg: "#000000",
|
|
96
|
+
tabInactiveFg: "#000000",
|
|
97
|
+
|
|
98
|
+
statusHint: "#000000",
|
|
99
|
+
|
|
100
|
+
text: "#000000",
|
|
101
|
+
textBody: "#000000",
|
|
102
|
+
textMuted: "#000000",
|
|
103
|
+
textDim: "#000000",
|
|
104
|
+
textVisited: "#828282",
|
|
105
|
+
|
|
106
|
+
accent: "#ff6600",
|
|
107
|
+
stripAccent: "#000000",
|
|
108
|
+
link: "#4488ff",
|
|
109
|
+
|
|
110
|
+
rowHighlight: "#ffffff",
|
|
111
|
+
menuBg: "#f6f6ef",
|
|
112
|
+
|
|
113
|
+
selectionBg: "#000000",
|
|
114
|
+
selectionFg: "#ffffff",
|
|
115
|
+
|
|
116
|
+
scrollTrack: "#ffffff",
|
|
117
|
+
scrollThumb: "#828282",
|
|
118
|
+
|
|
119
|
+
commentDepth: ["#ff6600", "#cc5200", "#993d00", "#0066cc", "#6633aa", "#aa3366"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const ThemeContext = createContext<Theme>(darkTheme)
|
|
123
|
+
|
|
124
|
+
export function useTheme(): Theme {
|
|
125
|
+
return useContext(ThemeContext)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Spread onto <text> so mouse selection inverts properly in both themes. */
|
|
129
|
+
export function selectionColors(t: Theme): { selectionBg: ColorInput; selectionFg: ColorInput } {
|
|
130
|
+
return { selectionBg: t.selectionBg, selectionFg: t.selectionFg }
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { homedir } from "os"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { existsSync, renameSync } from "fs"
|
|
4
|
+
|
|
5
|
+
const OLD_DIR = join(homedir(), ".config", "hackernuis")
|
|
6
|
+
const NEW_DIR = join(homedir(), ".config", "hntui")
|
|
7
|
+
|
|
8
|
+
let checked = false
|
|
9
|
+
|
|
10
|
+
// The app was renamed from hackernuis to hntui: move existing config
|
|
11
|
+
// (saved posts, view history) to the new location. Once, best-effort.
|
|
12
|
+
export function configDir(): string {
|
|
13
|
+
if (!checked) {
|
|
14
|
+
checked = true
|
|
15
|
+
try {
|
|
16
|
+
if (!existsSync(NEW_DIR) && existsSync(OLD_DIR)) renameSync(OLD_DIR, NEW_DIR)
|
|
17
|
+
} catch {
|
|
18
|
+
// fall through — the stores create the dir on write anyway
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return NEW_DIR
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { HnDecodeError, HnRequestError, HnStatusError, HnTimeoutError } from "../api/hn"
|
|
3
|
+
import { hnErrorMessage } from "./errors"
|
|
4
|
+
|
|
5
|
+
describe("hnErrorMessage", () => {
|
|
6
|
+
test("network failure suggests checking the connection", () => {
|
|
7
|
+
expect(hnErrorMessage(new HnRequestError({ path: "x", cause: 1 }))).toMatch(/connection/)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test("timeout says so", () => {
|
|
11
|
+
expect(hnErrorMessage(new HnTimeoutError({ path: "x" }))).toMatch(/too long/)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("status errors surface the HTTP code", () => {
|
|
15
|
+
expect(hnErrorMessage(new HnStatusError({ path: "x", status: 503 }))).toContain("503")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("decode errors are covered", () => {
|
|
19
|
+
expect(hnErrorMessage(new HnDecodeError({ path: "x", issue: "bad" }))).toMatch(/understand/)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Match } from "effect"
|
|
2
|
+
import type { HnError } from "../api/hn"
|
|
3
|
+
|
|
4
|
+
// The last mile of typed errors: every HnError member must map to copy a
|
|
5
|
+
// human can act on. Match.exhaustive makes this a compile-time guarantee —
|
|
6
|
+
// adding a new error to HnError breaks this build until it gets a message.
|
|
7
|
+
export const hnErrorMessage: (e: HnError) => string = Match.type<HnError>().pipe(
|
|
8
|
+
Match.tag("HnRequestError", () => "Couldn't reach Hacker News — check your connection."),
|
|
9
|
+
Match.tag("HnTimeoutError", () => "Hacker News took too long to respond."),
|
|
10
|
+
Match.tag("HnStatusError", (e) => `Hacker News returned HTTP ${e.status}.`),
|
|
11
|
+
Match.tag("HnDecodeError", () => "Hacker News sent a response the app couldn't understand."),
|
|
12
|
+
Match.exhaustive,
|
|
13
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { parseHnItemLink } from "./format"
|
|
3
|
+
|
|
4
|
+
describe("parseHnItemLink", () => {
|
|
5
|
+
test("story link", () => {
|
|
6
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item?id=8863")).toEqual({
|
|
7
|
+
id: 8863,
|
|
8
|
+
anchorId: undefined,
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("story link with comment anchor", () => {
|
|
13
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item?id=8863#8917")).toEqual({
|
|
14
|
+
id: 8863,
|
|
15
|
+
anchorId: 8917,
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("www and http variants", () => {
|
|
20
|
+
expect(parseHnItemLink("http://www.news.ycombinator.com/item?id=1")).toEqual({
|
|
21
|
+
id: 1,
|
|
22
|
+
anchorId: undefined,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("extra query params are tolerated", () => {
|
|
27
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item?id=42&p=2")?.id).toBe(42)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("non-item HN pages are external", () => {
|
|
31
|
+
expect(parseHnItemLink("https://news.ycombinator.com/user?id=pg")).toBeNull()
|
|
32
|
+
expect(parseHnItemLink("https://news.ycombinator.com/newest")).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("other hosts are external", () => {
|
|
36
|
+
expect(parseHnItemLink("https://example.com/item?id=1")).toBeNull()
|
|
37
|
+
expect(parseHnItemLink("https://hn.algolia.com/item?id=1")).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("missing or malformed ids are external", () => {
|
|
41
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item")).toBeNull()
|
|
42
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item?id=abc")).toBeNull()
|
|
43
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item?id=-5")).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("non-numeric anchor is ignored, link still internal", () => {
|
|
47
|
+
expect(parseHnItemLink("https://news.ycombinator.com/item?id=7#up_8")).toEqual({
|
|
48
|
+
id: 7,
|
|
49
|
+
anchorId: undefined,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("garbage is external", () => {
|
|
54
|
+
expect(parseHnItemLink("not a url")).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export function relativeTime(unixSec?: number): string {
|
|
2
|
+
if (!unixSec) return ""
|
|
3
|
+
const diff = Math.floor(Date.now() / 1000 - unixSec)
|
|
4
|
+
if (diff < 60) return `${diff}s ago`
|
|
5
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
6
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
7
|
+
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
|
|
8
|
+
if (diff < 86400 * 365) return `${Math.floor(diff / 2592000)}mo ago`
|
|
9
|
+
return `${Math.floor(diff / 31536000)}y ago`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function hostname(url?: string): string {
|
|
13
|
+
if (!url) return ""
|
|
14
|
+
try {
|
|
15
|
+
return new URL(url).hostname.replace(/^www\./, "")
|
|
16
|
+
} catch {
|
|
17
|
+
return ""
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ENTITIES: Record<string, string> = {
|
|
22
|
+
"&": "&",
|
|
23
|
+
"<": "<",
|
|
24
|
+
">": ">",
|
|
25
|
+
""": '"',
|
|
26
|
+
"'": "'",
|
|
27
|
+
"'": "'",
|
|
28
|
+
"/": "/",
|
|
29
|
+
"/": "/",
|
|
30
|
+
" ": " ",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function decodeEntities(s: string): string {
|
|
34
|
+
return s
|
|
35
|
+
.replace(/&(amp|lt|gt|quot|nbsp|#x27|#39|#x2F|#47);/g, (m) => ENTITIES[m] ?? m)
|
|
36
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
37
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Link {
|
|
41
|
+
text: string
|
|
42
|
+
url: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function extractLinks(html?: string): Link[] {
|
|
46
|
+
if (!html) return []
|
|
47
|
+
const re = /<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
|
|
48
|
+
const out: Link[] = []
|
|
49
|
+
const seen = new Set<string>()
|
|
50
|
+
let m: RegExpExecArray | null
|
|
51
|
+
while ((m = re.exec(html))) {
|
|
52
|
+
const url = decodeEntities(m[1]!)
|
|
53
|
+
if (seen.has(url)) continue
|
|
54
|
+
seen.add(url)
|
|
55
|
+
const text = decodeEntities(m[2]!.replace(/<[^>]+>/g, "")).trim()
|
|
56
|
+
out.push({ text: text || url, url })
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A link into HN itself: /item?id=POSTID or /item?id=POSTID#COMMENTID.
|
|
62
|
+
// `id` may be a story OR a comment id
|
|
63
|
+
export interface HnItemRef {
|
|
64
|
+
id: number
|
|
65
|
+
anchorId?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseHnItemLink(url: string): HnItemRef | null {
|
|
69
|
+
try {
|
|
70
|
+
const u = new URL(url)
|
|
71
|
+
const host = u.hostname.replace(/^www\./, "")
|
|
72
|
+
if (host !== "news.ycombinator.com" || u.pathname !== "/item") return null
|
|
73
|
+
const id = Number(u.searchParams.get("id"))
|
|
74
|
+
if (!Number.isInteger(id) || id <= 0) return null
|
|
75
|
+
const anchor = Number(u.hash.slice(1))
|
|
76
|
+
const anchorId =
|
|
77
|
+
u.hash.length > 1 && Number.isInteger(anchor) && anchor > 0 ? anchor : undefined
|
|
78
|
+
return { id, anchorId }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function htmlToText(html?: string): string {
|
|
85
|
+
if (!html) return ""
|
|
86
|
+
return decodeEntities(
|
|
87
|
+
html
|
|
88
|
+
.replace(/<p>/gi, "\n\n")
|
|
89
|
+
.replace(/<\/p>/gi, "")
|
|
90
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
91
|
+
.replace(/<i>(.*?)<\/i>/gi, "$1")
|
|
92
|
+
.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, "$2 ($1)")
|
|
93
|
+
.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/gi, "\n$1\n")
|
|
94
|
+
.replace(/<code>(.*?)<\/code>/gi, "`$1`")
|
|
95
|
+
.replace(/<[^>]+>/g, "")
|
|
96
|
+
).trim()
|
|
97
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "path"
|
|
2
|
+
import { mkdirSync } from "fs"
|
|
3
|
+
import { configDir } from "./configDir"
|
|
4
|
+
|
|
5
|
+
export interface HistoryEntry {
|
|
6
|
+
id: number
|
|
7
|
+
viewedAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const HISTORY_CAP = 1000
|
|
11
|
+
|
|
12
|
+
const HISTORY_PATH = join(configDir(), "history.json")
|
|
13
|
+
|
|
14
|
+
export async function loadHistory(): Promise<HistoryEntry[]> {
|
|
15
|
+
try {
|
|
16
|
+
const file = Bun.file(HISTORY_PATH)
|
|
17
|
+
if (!(await file.exists())) return []
|
|
18
|
+
const data = await file.json()
|
|
19
|
+
if (!Array.isArray(data)) return []
|
|
20
|
+
return data
|
|
21
|
+
.filter(
|
|
22
|
+
(e): e is HistoryEntry =>
|
|
23
|
+
e && typeof e.id === "number" && typeof e.viewedAt === "number",
|
|
24
|
+
)
|
|
25
|
+
.slice(0, HISTORY_CAP)
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function persistHistory(entries: HistoryEntry[]): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(configDir(), { recursive: true })
|
|
34
|
+
await Bun.write(HISTORY_PATH, JSON.stringify(entries, null, 2))
|
|
35
|
+
} catch {
|
|
36
|
+
// fail silently — view history is best-effort
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function openUrl(url: string): void {
|
|
2
|
+
if (!url) return
|
|
3
|
+
const cmd =
|
|
4
|
+
process.platform === "darwin"
|
|
5
|
+
? ["open", url]
|
|
6
|
+
: process.platform === "win32"
|
|
7
|
+
? ["cmd", "/c", "start", "", url]
|
|
8
|
+
: ["xdg-open", url]
|
|
9
|
+
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" })
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { join } from "path"
|
|
2
|
+
import { mkdirSync } from "fs"
|
|
3
|
+
import { configDir } from "./configDir"
|
|
4
|
+
|
|
5
|
+
export interface SavedEntry {
|
|
6
|
+
id: number
|
|
7
|
+
savedAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SAVED_PATH = join(configDir(), "saved.json")
|
|
11
|
+
|
|
12
|
+
export async function loadSaved(): Promise<SavedEntry[]> {
|
|
13
|
+
try {
|
|
14
|
+
const file = Bun.file(SAVED_PATH)
|
|
15
|
+
if (!(await file.exists())) return []
|
|
16
|
+
const data = await file.json()
|
|
17
|
+
if (!Array.isArray(data)) return []
|
|
18
|
+
return data.filter(
|
|
19
|
+
(e): e is SavedEntry =>
|
|
20
|
+
e && typeof e.id === "number" && typeof e.savedAt === "number",
|
|
21
|
+
)
|
|
22
|
+
} catch {
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function persistSaved(entries: SavedEntry[]): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(configDir(), { recursive: true })
|
|
30
|
+
await Bun.write(SAVED_PATH, JSON.stringify(entries, null, 2))
|
|
31
|
+
} catch {
|
|
32
|
+
// fail silently — saved state is best-effort
|
|
33
|
+
}
|
|
34
|
+
}
|