moshcode 0.41.0 → 0.43.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 +24 -2
- package/bin/moshcode.mjs +16 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +80 -2
- package/src/games-asteroids.mjs +265 -0
- package/src/games-blackjack.mjs +284 -0
- package/src/games-breakout.mjs +186 -0
- package/src/games-centipede.mjs +241 -0
- package/src/games-choplifter.mjs +179 -0
- package/src/games-digdug.mjs +267 -0
- package/src/games-excitebike.mjs +206 -0
- package/src/games-frogger.mjs +194 -0
- package/src/games-hangman.mjs +6 -1
- package/src/games-invaders.mjs +258 -0
- package/src/games-kong.mjs +191 -0
- package/src/games-outrun.mjs +208 -0
- package/src/games-pitfall.mjs +211 -0
- package/src/games-pong.mjs +147 -0
- package/src/games-spyhunter.mjs +230 -0
- package/src/games-stagedive.mjs +206 -0
- package/src/games-tank.mjs +230 -0
- package/src/games.mjs +35 -9
- package/src/news-sources.mjs +154 -0
- package/src/news.mjs +1071 -0
- package/src/rss-ui.mjs +517 -0
- package/src/tui.mjs +20 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Tank. Two of them in a walled yard, one shell each in the air at a time, five
|
|
2
|
+
// hits and it is over.
|
|
3
|
+
//
|
|
4
|
+
// Everything is on the grid and turned in quarter turns, because a tank you
|
|
5
|
+
// cannot line up is a tank you cannot aim, and lining up *is* the shot. Your
|
|
6
|
+
// keys are one action each — a press turns you or moves you one cell — so
|
|
7
|
+
// holding an arrow drives, and tapping it nudges.
|
|
8
|
+
import { acid, ash, bone, danger, dim } from "./ui.mjs";
|
|
9
|
+
|
|
10
|
+
export const TARGET = 5;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The yard. `#` is wall, and the outer ring is closed — a shell that leaves the
|
|
14
|
+
* board is a shell nobody saw stop.
|
|
15
|
+
*/
|
|
16
|
+
export const YARD = [
|
|
17
|
+
"##########################################",
|
|
18
|
+
"# #",
|
|
19
|
+
"# #### ###### #### #",
|
|
20
|
+
"# # # # #",
|
|
21
|
+
"# # #### # #### # #",
|
|
22
|
+
"# # # # # #",
|
|
23
|
+
"# ##### # # #### # # #### #",
|
|
24
|
+
"# # # # # #",
|
|
25
|
+
"# # #### # #### # #",
|
|
26
|
+
"# # # # #",
|
|
27
|
+
"# #### ###### #### #",
|
|
28
|
+
"# #",
|
|
29
|
+
"##########################################",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const isWall = (x, y) => (YARD[y]?.[x] ?? "#") === "#";
|
|
33
|
+
|
|
34
|
+
// Derived from the yard rather than declared beside it, so the two can never
|
|
35
|
+
// disagree about how big the board is.
|
|
36
|
+
export const WIDTH = YARD[0].length;
|
|
37
|
+
export const HEIGHT = YARD.length;
|
|
38
|
+
|
|
39
|
+
/** Quarter turns, and the glyph a tank wears pointing that way. */
|
|
40
|
+
export const HEADINGS = [
|
|
41
|
+
{ dx: 0, dy: -1, glyph: "▲" },
|
|
42
|
+
{ dx: 1, dy: 0, glyph: "▶" },
|
|
43
|
+
{ dx: 0, dy: 1, glyph: "▼" },
|
|
44
|
+
{ dx: -1, dy: 0, glyph: "◀" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SHELL_SPEED = 1; // cells per tick
|
|
48
|
+
const THEM_EVERY = 4; // the machine gets a move every this many ticks
|
|
49
|
+
|
|
50
|
+
const spawnYou = () => ({ x: 2, y: 6, dir: 1, cool: 0 });
|
|
51
|
+
const spawnThem = () => ({ x: WIDTH - 3, y: 6, dir: 3, cool: 0 });
|
|
52
|
+
|
|
53
|
+
/** Move a tank one cell if there is floor there. Walls simply refuse. */
|
|
54
|
+
export function drive(tank, sign) {
|
|
55
|
+
const { dx, dy } = HEADINGS[tank.dir];
|
|
56
|
+
const x = tank.x + dx * sign;
|
|
57
|
+
const y = tank.y + dy * sign;
|
|
58
|
+
if (isWall(x, y)) return false;
|
|
59
|
+
tank.x = x;
|
|
60
|
+
tank.y = y;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const fire = (tank, owner) => ({
|
|
65
|
+
x: tank.x + HEADINGS[tank.dir].dx,
|
|
66
|
+
y: tank.y + HEADINGS[tank.dir].dy,
|
|
67
|
+
dir: tank.dir,
|
|
68
|
+
owner,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a tank can see another down the barrel: same row or column, nothing
|
|
73
|
+
* but floor in between. This is both how the machine decides to shoot and the
|
|
74
|
+
* only thing it is good at.
|
|
75
|
+
*/
|
|
76
|
+
export function lineOfSight(from, to) {
|
|
77
|
+
const { dx, dy } = HEADINGS[from.dir];
|
|
78
|
+
let x = from.x + dx;
|
|
79
|
+
let y = from.y + dy;
|
|
80
|
+
for (let i = 0; i < Math.max(WIDTH, HEIGHT); i++) {
|
|
81
|
+
if (isWall(x, y)) return false;
|
|
82
|
+
if (x === to.x && y === to.y) return true;
|
|
83
|
+
x += dx;
|
|
84
|
+
y += dy;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The next cell on the shortest way from one point to another, and the heading
|
|
91
|
+
* that gets there.
|
|
92
|
+
*
|
|
93
|
+
* This is a breadth-first search of the whole yard on every decision, which
|
|
94
|
+
* sounds extravagant for 546 cells and is not: it is the difference between a
|
|
95
|
+
* tank that comes around the block after you and one that drives into the same
|
|
96
|
+
* wall forever, which is what "just turn towards the enemy" does the moment
|
|
97
|
+
* there is anything between the two of you.
|
|
98
|
+
*/
|
|
99
|
+
export function stepToward(from, to) {
|
|
100
|
+
if (from.x === to.x && from.y === to.y) return null;
|
|
101
|
+
const seen = new Set([`${from.x},${from.y}`]);
|
|
102
|
+
const queue = [{ x: from.x, y: from.y, first: null }];
|
|
103
|
+
for (let head = 0; head < queue.length; head++) {
|
|
104
|
+
const cur = queue[head];
|
|
105
|
+
for (let dir = 0; dir < HEADINGS.length; dir++) {
|
|
106
|
+
const x = cur.x + HEADINGS[dir].dx;
|
|
107
|
+
const y = cur.y + HEADINGS[dir].dy;
|
|
108
|
+
const key = `${x},${y}`;
|
|
109
|
+
if (isWall(x, y) || seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
const first = cur.first ?? { x, y, dir };
|
|
112
|
+
if (x === to.x && y === to.y) return first;
|
|
113
|
+
queue.push({ x, y, first });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One quarter turn from `dir` towards `want`, the short way round. */
|
|
120
|
+
export const quarterTurn = (dir, want) => (dir + ((want - dir + 4) % 4 === 3 ? 3 : 1)) % 4;
|
|
121
|
+
|
|
122
|
+
function hit(state, who) {
|
|
123
|
+
if (who === "you") state.yours++; else state.theirs++;
|
|
124
|
+
state.shells = [];
|
|
125
|
+
state.you = spawnYou();
|
|
126
|
+
state.them = spawnThem();
|
|
127
|
+
if (state.yours >= TARGET) state.over = `you take it ${state.yours}–${state.theirs} 🤘`;
|
|
128
|
+
else if (state.theirs >= TARGET) state.over = `the machine takes it ${state.theirs}–${state.yours}`;
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One tick: shells first, then the machine takes its turn. */
|
|
133
|
+
export function step(state) {
|
|
134
|
+
for (const shell of [...state.shells]) {
|
|
135
|
+
for (let i = 0; i < SHELL_SPEED; i++) {
|
|
136
|
+
const { dx, dy } = HEADINGS[shell.dir];
|
|
137
|
+
shell.x += dx;
|
|
138
|
+
shell.y += dy;
|
|
139
|
+
if (isWall(shell.x, shell.y)) { state.shells = state.shells.filter((s) => s !== shell); break; }
|
|
140
|
+
const target = shell.owner === "you" ? state.them : state.you;
|
|
141
|
+
if (shell.x === target.x && shell.y === target.y) {
|
|
142
|
+
state.shells = state.shells.filter((s) => s !== shell);
|
|
143
|
+
hit(state, shell.owner);
|
|
144
|
+
return state;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (state.over) return state;
|
|
149
|
+
|
|
150
|
+
if (state.you.cool > 0) state.you.cool--;
|
|
151
|
+
if (state.them.cool > 0) state.them.cool--;
|
|
152
|
+
|
|
153
|
+
state.clock++;
|
|
154
|
+
if (state.clock % THEM_EVERY) return state;
|
|
155
|
+
|
|
156
|
+
// The machine: shoot if it is looking at you, otherwise turn towards you, and
|
|
157
|
+
// drive when it is already pointed the right way. It is not clever, but it is
|
|
158
|
+
// relentless, and in a yard this size that is enough.
|
|
159
|
+
const them = state.them;
|
|
160
|
+
if (lineOfSight(them, state.you)) {
|
|
161
|
+
if (!them.cool && !state.shells.some((s) => s.owner === "them")) {
|
|
162
|
+
state.shells.push(fire(them, "them"));
|
|
163
|
+
them.cool = 6;
|
|
164
|
+
}
|
|
165
|
+
return state;
|
|
166
|
+
}
|
|
167
|
+
const next = stepToward(them, state.you);
|
|
168
|
+
if (!next) return state;
|
|
169
|
+
if (them.dir !== next.dir) them.dir = quarterTurn(them.dir, next.dir);
|
|
170
|
+
else drive(them, 1);
|
|
171
|
+
return state;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const TANK = {
|
|
175
|
+
key: "tank",
|
|
176
|
+
aliases: ["tanks", "combat"],
|
|
177
|
+
title: "TANK",
|
|
178
|
+
blurb: "two tanks, one yard, five hits — line it up and let go",
|
|
179
|
+
keys: "← → turn · ↑ drive · ↓ reverse · space fire · q quit",
|
|
180
|
+
tickMs: 60,
|
|
181
|
+
|
|
182
|
+
create() {
|
|
183
|
+
return {
|
|
184
|
+
you: spawnYou(),
|
|
185
|
+
them: spawnThem(),
|
|
186
|
+
shells: [],
|
|
187
|
+
yours: 0,
|
|
188
|
+
theirs: 0,
|
|
189
|
+
clock: 0,
|
|
190
|
+
over: null,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
tick: step,
|
|
195
|
+
|
|
196
|
+
onKey(state, key) {
|
|
197
|
+
const you = state.you;
|
|
198
|
+
if (key === "left") you.dir = (you.dir + 3) % 4;
|
|
199
|
+
else if (key === "right") you.dir = (you.dir + 1) % 4;
|
|
200
|
+
else if (key === "up") drive(you, 1);
|
|
201
|
+
else if (key === "down") drive(you, -1);
|
|
202
|
+
else if (key === "space" || key === "enter") {
|
|
203
|
+
// One shell of yours in the air at a time, same as the machine. Two would
|
|
204
|
+
// turn a duel into a hosepipe.
|
|
205
|
+
if (you.cool || state.shells.some((s) => s.owner === "you")) return state;
|
|
206
|
+
const shell = fire(you, "you");
|
|
207
|
+
if (!isWall(shell.x, shell.y)) state.shells.push(shell);
|
|
208
|
+
you.cool = 4;
|
|
209
|
+
}
|
|
210
|
+
return state;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
status(state) {
|
|
214
|
+
return state.over ? state.over : `you ${state.yours} · machine ${state.theirs}`;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
render(state) {
|
|
218
|
+
const grid = YARD.map((row) => [...row].map((c) => (c === "#" ? ash("█") : null)));
|
|
219
|
+
const put = (x, y, glyph) => {
|
|
220
|
+
if (!grid[y] || x < 0 || x >= grid[y].length) return;
|
|
221
|
+
grid[y][x] = glyph;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
for (const shell of state.shells) put(shell.x, shell.y, (shell.owner === "you" ? bone : danger)("•"));
|
|
225
|
+
put(state.you.x, state.you.y, acid(HEADINGS[state.you.dir].glyph));
|
|
226
|
+
put(state.them.x, state.them.y, danger(HEADINGS[state.them.dir].glyph));
|
|
227
|
+
|
|
228
|
+
return grid.map((row) => row.map((cell) => cell ?? dim("·")).join(""));
|
|
229
|
+
},
|
|
230
|
+
};
|
package/src/games.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The moshcode arcade — `/games` in the pit, `moshcode games` from a shell.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Twenty-two games, one frame. Every game here is the same shape (see GAME_SHAPE
|
|
4
4
|
// below) and is drawn by the same `frame()`, so they look like one arcade
|
|
5
|
-
// rather than
|
|
5
|
+
// rather than twenty-two weekend projects: a title, a status line, a boxed board, and
|
|
6
6
|
// one line of keys along the bottom. There is no menu, no options screen and no
|
|
7
7
|
// difficulty prompt — `/games tetris` is already playing by the time the frame
|
|
8
8
|
// lands, and `q` is always the way out.
|
|
@@ -18,6 +18,22 @@ import { PACMAN } from "./games-pacman.mjs";
|
|
|
18
18
|
import { TICTACTOE } from "./games-tictactoe.mjs";
|
|
19
19
|
import { HANGMAN } from "./games-hangman.mjs";
|
|
20
20
|
import { CHESS } from "./games-chess.mjs";
|
|
21
|
+
import { ASTEROIDS } from "./games-asteroids.mjs";
|
|
22
|
+
import { BLACKJACK } from "./games-blackjack.mjs";
|
|
23
|
+
import { STAGEDIVE } from "./games-stagedive.mjs";
|
|
24
|
+
import { INVADERS } from "./games-invaders.mjs";
|
|
25
|
+
import { BREAKOUT } from "./games-breakout.mjs";
|
|
26
|
+
import { PONG } from "./games-pong.mjs";
|
|
27
|
+
import { TANK } from "./games-tank.mjs";
|
|
28
|
+
import { SPYHUNTER } from "./games-spyhunter.mjs";
|
|
29
|
+
import { CENTIPEDE } from "./games-centipede.mjs";
|
|
30
|
+
import { FROGGER } from "./games-frogger.mjs";
|
|
31
|
+
import { DIGDUG } from "./games-digdug.mjs";
|
|
32
|
+
import { KONG } from "./games-kong.mjs";
|
|
33
|
+
import { PITFALL } from "./games-pitfall.mjs";
|
|
34
|
+
import { CHOPLIFTER } from "./games-choplifter.mjs";
|
|
35
|
+
import { EXCITEBIKE } from "./games-excitebike.mjs";
|
|
36
|
+
import { OUTRUN } from "./games-outrun.mjs";
|
|
21
37
|
|
|
22
38
|
/**
|
|
23
39
|
* @typedef {object} Game — the whole contract, so a seventh game is an import.
|
|
@@ -27,6 +43,8 @@ import { CHESS } from "./games-chess.mjs";
|
|
|
27
43
|
* @property {string} blurb one line, for /games list
|
|
28
44
|
* @property {string} keys the footer; the only place controls are ever explained
|
|
29
45
|
* @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
|
|
46
|
+
* @property {boolean} [vim] false when a game wants h/j/k/l as letters, not arrows
|
|
47
|
+
* @property {boolean} [restartable] false when `r` is only a restart once the game is over
|
|
30
48
|
* @property {Function} create ({ rng }) => state
|
|
31
49
|
* @property {Function} onKey (state, key, { rng }) => state
|
|
32
50
|
* @property {Function} [tick] (state, { rng }) => state
|
|
@@ -35,7 +53,11 @@ import { CHESS } from "./games-chess.mjs";
|
|
|
35
53
|
*/
|
|
36
54
|
|
|
37
55
|
/** The cabinet. Order is the order `/games` lists them. */
|
|
38
|
-
export const GAMES = [
|
|
56
|
+
export const GAMES = [
|
|
57
|
+
TETRIS, SNAKE, PACMAN, INVADERS, CENTIPEDE, ASTEROIDS, BREAKOUT, PONG, TANK, DIGDUG,
|
|
58
|
+
FROGGER, KONG, PITFALL, CHOPLIFTER, SPYHUNTER, OUTRUN, EXCITEBIKE, STAGEDIVE,
|
|
59
|
+
TICTACTOE, BLACKJACK, CHESS, HANGMAN,
|
|
60
|
+
];
|
|
39
61
|
|
|
40
62
|
/** Games by name, following aliases. Case- and slash-insensitive. */
|
|
41
63
|
export function resolveGame(name) {
|
|
@@ -98,8 +120,12 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
|
|
|
98
120
|
* Games never see an escape sequence; they see "up", "enter", "a". A chunk can
|
|
99
121
|
* hold several keypresses (hold an arrow key down and they arrive in batches),
|
|
100
122
|
* which is why this returns a list.
|
|
123
|
+
*
|
|
124
|
+
* `vim: false` is for the games that read letters — hangman cannot ask for a
|
|
125
|
+
* word with an `h` in it while `h` means left, and blackjack wants `h` to be
|
|
126
|
+
* hit. Arrows are unaffected either way; they arrive as escape sequences.
|
|
101
127
|
*/
|
|
102
|
-
export function decodeKeys(chunk) {
|
|
128
|
+
export function decodeKeys(chunk, { vim = true } = {}) {
|
|
103
129
|
const input = String(chunk);
|
|
104
130
|
const keys = [];
|
|
105
131
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -119,10 +145,10 @@ export function decodeKeys(chunk) {
|
|
|
119
145
|
if (c === "\x03" || c === "\x04") { keys.push("quit"); continue; }
|
|
120
146
|
if (c === "\x7f" || c === "\b") { keys.push("backspace"); continue; }
|
|
121
147
|
if (c === "\t") { keys.push("tab"); continue; }
|
|
122
|
-
// vim keys, everywhere, for free — every game reads arrows
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
148
|
+
// vim keys, everywhere, for free — every game that reads arrows gets them
|
|
149
|
+
// without knowing about them. A game that reads letters opts out.
|
|
150
|
+
const vimKey = vim ? { h: "left", j: "down", k: "up", l: "right" }[c] : null;
|
|
151
|
+
if (vimKey) { keys.push(vimKey); continue; }
|
|
126
152
|
if (c >= " " && c <= "~") keys.push(c.toLowerCase());
|
|
127
153
|
}
|
|
128
154
|
return keys;
|
|
@@ -241,7 +267,7 @@ export async function runGame(game, deps = {}) {
|
|
|
241
267
|
const onSignal = () => { restore(); process.exit(130); };
|
|
242
268
|
|
|
243
269
|
function onData(chunk) {
|
|
244
|
-
for (const key of decodeKeys(chunk)) {
|
|
270
|
+
for (const key of decodeKeys(chunk, { vim: game.vim !== false })) {
|
|
245
271
|
if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
|
|
246
272
|
if (key === "r" && (state.over || game.restartable !== false)) {
|
|
247
273
|
state = game.create(ctx);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Where `/news` gets its headlines when nobody has subscribed to anything yet.
|
|
2
|
+
//
|
|
3
|
+
// These are not invented: they are the sources the two profullstack properties
|
|
4
|
+
// that already do this in production use, lifted so the pit answers the same
|
|
5
|
+
// way they do.
|
|
6
|
+
//
|
|
7
|
+
// brisk.news — apps/web/src/lib/news/fetch-feed.ts builds Google News feeds,
|
|
8
|
+
// top stories for the front page and one per category, and
|
|
9
|
+
// scripts/import-opml-feeds.ts seeds its publisher table from four public
|
|
10
|
+
// OPML lists. Both are represented here: the Google feeds as the defaults,
|
|
11
|
+
// the OPML lists as named bundles `/news add` accepts by name.
|
|
12
|
+
//
|
|
13
|
+
// advis0r.com — src/providers/news/rss.ts pairs a Google News query feed with
|
|
14
|
+
// a Bing one (Google's links are interstitials, Bing's carry the publisher
|
|
15
|
+
// URL in a `url=` parameter), and reads two newswires directly. The search
|
|
16
|
+
// pairing is why `/news <keyword>` queries both, and the wires are here
|
|
17
|
+
// under `markets`.
|
|
18
|
+
//
|
|
19
|
+
// Deliberately small. A default list is a claim that every entry works, so it
|
|
20
|
+
// holds feeds with stable well-known URLs and defers everything else to the
|
|
21
|
+
// OPML bundles, where the list is somebody else's to maintain.
|
|
22
|
+
|
|
23
|
+
/** Google News locale. One place, because every builder below needs it. */
|
|
24
|
+
const GOOGLE_LOCALE = "hl=en-US&gl=US&ceid=US:en";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Google News category feeds, keyed the way brisk.news keys them.
|
|
28
|
+
*
|
|
29
|
+
* `general` is null there and means "top stories", which is a different URL
|
|
30
|
+
* rather than a search for the word "general" — the same distinction is kept.
|
|
31
|
+
*/
|
|
32
|
+
export const GOOGLE_NEWS_CATEGORIES = {
|
|
33
|
+
general: null,
|
|
34
|
+
science: "science",
|
|
35
|
+
sports: "sports",
|
|
36
|
+
business: "business",
|
|
37
|
+
health: "health",
|
|
38
|
+
entertainment: "entertainment",
|
|
39
|
+
tech: "technology",
|
|
40
|
+
politics: "politics",
|
|
41
|
+
food: "food",
|
|
42
|
+
travel: "travel",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Google News top stories, or one category from the map above. */
|
|
46
|
+
export function googleNewsFeed(category = null) {
|
|
47
|
+
const mapped = category ? GOOGLE_NEWS_CATEGORIES[category] ?? null : null;
|
|
48
|
+
if (!mapped) return `https://news.google.com/rss?${GOOGLE_LOCALE}`;
|
|
49
|
+
return `https://news.google.com/rss/search?q=${encodeURIComponent(mapped)}&${GOOGLE_LOCALE}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Google News query feed. `when:7d` style windows keep results recent —
|
|
54
|
+
* advis0r's googleNewsFeed() does the same, for the same reason.
|
|
55
|
+
*/
|
|
56
|
+
export function googleNewsSearch(query, { window = "7d" } = {}) {
|
|
57
|
+
const q = window ? `${query} when:${window}` : String(query);
|
|
58
|
+
return `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&${GOOGLE_LOCALE}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bing News query feed — the half of a search whose links are real articles. */
|
|
62
|
+
export function bingNewsSearch(query) {
|
|
63
|
+
return `https://www.bing.com/news/search?q=${encodeURIComponent(query)}&format=RSS`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a redirect wrapper to the publisher URL it carries.
|
|
68
|
+
*
|
|
69
|
+
* Lifted from advis0r's unwrapRedirect for the same reason it exists there:
|
|
70
|
+
* aggregator feeds link to themselves, and a reader that opens
|
|
71
|
+
* `news.google.com/rss/articles/CBM…` shows an interstitial instead of the
|
|
72
|
+
* story. Only decodes what is already in the link — it never follows a
|
|
73
|
+
* redirect, so it costs no request and cannot be led somewhere unexpected.
|
|
74
|
+
*/
|
|
75
|
+
export function unwrapRedirect(url) {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = new URL(url);
|
|
78
|
+
const inner = parsed.searchParams.get("url") ?? parsed.searchParams.get("u");
|
|
79
|
+
if (!inner) return url;
|
|
80
|
+
const decoded = decodeURIComponent(inner);
|
|
81
|
+
return /^https?:\/\//i.test(decoded) ? decoded : url;
|
|
82
|
+
} catch {
|
|
83
|
+
return url;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The feeds a fresh install reads.
|
|
89
|
+
*
|
|
90
|
+
* Google News carries the general desks because that is exactly what brisk.news
|
|
91
|
+
* serves its front page from, and it means one URL shape covers ten sections
|
|
92
|
+
* without ten publisher relationships to keep working. The named publishers are
|
|
93
|
+
* tier-1 hosts from advis0r's own tiering table that publish a stable feed, and
|
|
94
|
+
* they earn their place by being readable end to end — a Google News item is a
|
|
95
|
+
* headline behind an interstitial, theirs is the article.
|
|
96
|
+
*/
|
|
97
|
+
export const DEFAULT_FEEDS = [
|
|
98
|
+
{ name: "top-stories", title: "Google News — Top Stories", url: googleNewsFeed(), site: "https://news.google.com", category: "" },
|
|
99
|
+
{ name: "world", title: "Google News — World", url: googleNewsSearch("world news", { window: "2d" }), site: "https://news.google.com", category: "" },
|
|
100
|
+
|
|
101
|
+
{ name: "tech", title: "Google News — Technology", url: googleNewsFeed("tech"), site: "https://news.google.com", category: "tech" },
|
|
102
|
+
{ name: "ars-technica", title: "Ars Technica", url: "https://feeds.arstechnica.com/arstechnica/index", site: "https://arstechnica.com", category: "tech" },
|
|
103
|
+
{ name: "techcrunch", title: "TechCrunch", url: "https://techcrunch.com/feed/", site: "https://techcrunch.com", category: "tech" },
|
|
104
|
+
{ name: "the-register", title: "The Register", url: "https://www.theregister.com/headlines.atom", site: "https://www.theregister.com", category: "tech" },
|
|
105
|
+
{ name: "hacker-news", title: "Hacker News — Front Page", url: "https://hnrss.org/frontpage", site: "https://news.ycombinator.com", category: "tech" },
|
|
106
|
+
|
|
107
|
+
{ name: "business", title: "Google News — Business", url: googleNewsFeed("business"), site: "https://news.google.com", category: "markets" },
|
|
108
|
+
{ name: "marketwatch", title: "MarketWatch — Top Stories", url: "https://feeds.content.dowjones.io/public/rss/mw_topstories", site: "https://www.marketwatch.com", category: "markets" },
|
|
109
|
+
// The two newswires advis0r reads directly. Tier 0 there: issuers speaking
|
|
110
|
+
// for themselves rather than a publication speaking about them.
|
|
111
|
+
{ 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" },
|
|
112
|
+
{ 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" },
|
|
113
|
+
|
|
114
|
+
{ name: "science", title: "Google News — Science", url: googleNewsFeed("science"), site: "https://news.google.com", category: "science" },
|
|
115
|
+
{ name: "politics", title: "Google News — Politics", url: googleNewsFeed("politics"), site: "https://news.google.com", category: "politics" },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The public OPML lists brisk.news imports its publisher table from.
|
|
120
|
+
*
|
|
121
|
+
* Offered by name (`/news add journalists`) rather than only by URL because
|
|
122
|
+
* these are long, exact raw.githubusercontent paths that nobody is going to
|
|
123
|
+
* retype, and importing one is the fastest way from an empty reader to a real
|
|
124
|
+
* one. They are somebody else's lists, and that is the point: the feeds inside
|
|
125
|
+
* them stay current without moshcode shipping a release.
|
|
126
|
+
*/
|
|
127
|
+
export const OPML_BUNDLES = [
|
|
128
|
+
{
|
|
129
|
+
name: "journalists",
|
|
130
|
+
description: "Dave Winer's feedsForJournalists — mainstream desks",
|
|
131
|
+
url: "https://raw.githubusercontent.com/scripting/feedsForJournalists/master/list.opml",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "web3",
|
|
135
|
+
description: "ChainFeeds RSSAggregatorforWeb3 — crypto and web3",
|
|
136
|
+
url: "https://raw.githubusercontent.com/chainfeeds/RSSAggregatorforWeb3/main/RAW.opml",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "blockchain",
|
|
140
|
+
description: "CoinFabrik decentralized-and-blockchain-feeds",
|
|
141
|
+
url: "https://raw.githubusercontent.com/CoinFabrik/resources/master/decentralized-and-blockchain-feeds.opml",
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/** Resolve a bundle name to its OPML URL, or null. */
|
|
146
|
+
export function resolveBundle(name) {
|
|
147
|
+
const wanted = String(name ?? "").trim().toLowerCase();
|
|
148
|
+
return OPML_BUNDLES.find((b) => b.name === wanted) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A fresh copy of the defaults — callers mutate feed lists. */
|
|
152
|
+
export function defaultFeeds() {
|
|
153
|
+
return DEFAULT_FEEDS.map((feed) => ({ ...feed }));
|
|
154
|
+
}
|