Skip to content

Commit 67aeba3

Browse files
ralyodioclaude
andauthored
sync: carry news.opml, and stop hand-maintaining the profullstack defaults (#383)
Two things `/save` and the feed list got wrong. `/save` did not carry your subscriptions. SYNCED_FILES had `feeds.opml` in it, which is written by `tcfeed rss add` and read by nothing here; `/news` and `/rss` keep their subscriptions in `news.opml`, which was in the allowlist zero times. The two names sat one line apart and were easy to mistake for each other, so the feeds you actually chose were the one thing that stayed on one machine. Both are carried now, with a comment saying which belongs to what. No size cap change. A subscription list past MAX_FILE_BYTES is already reported as skipped rather than failing the snapshot, which is the right answer for a list that got big by importing somebody else's. The fourteen profullstack blogs were typed into DEFAULT_FEEDS by hand, which made a second copy of a list that is already published at profullstack.com/feeds.opml. Two hand-maintained copies of the same list is one more than can be kept in step, and the one that would go stale is ours — nobody editing the published OPML has a reason to think about moshcode. So the published file is vendored at src/profullstack-feeds.opml and parsed into the defaults. Read synchronously at module load, because defaultFeeds() is synchronous and a fresh install must not wait on the network to show anything; `files` includes `src`, so it travels in both the npm and install.sh channels (verified against `npm pack`). A missing file degrades to no profullstack defaults rather than throwing, which would take `/news` down over a packaging mistake. It is parsed by a small matcher rather than parseOpml, because news.mjs imports this module and taking the import back the other way makes a cycle. Same reason slugify() is copied as slug(). Both shortcuts are held in place by tests that run the real parseOpml and the real slugify over the vendored file and require the same answers — the shortcut is allowed to be small, not allowed to differ. Drift against the live URL is checked by test/profullstack-feeds.test.mjs under MOSHCODE_CHECK_FEED_DRIFT=1. Opt-in on purpose: a suite that fails whenever profullstack.com blinks is a suite people learn to ignore. Everything checkable without a network runs always. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0c1ce82 commit 67aeba3

5 files changed

Lines changed: 209 additions & 20 deletions

File tree

src/news-sources.mjs

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
// holds feeds with stable well-known URLs and defers everything else to the
2121
// published lists, where the list is somebody else's to maintain.
2222

23+
import fs from "node:fs";
24+
2325
/**
2426
* Bing News query feed — the only search feed left.
2527
*
@@ -86,6 +88,53 @@ export function isDeadEndLink(url) {
8688
}
8789
}
8890

91+
/** The vendored copy of what profullstack.com/feeds.opml serves. */
92+
const PROFULLSTACK_OPML = new URL("./profullstack-feeds.opml", import.meta.url);
93+
94+
/** slugify() from news.mjs, kept in step by a test rather than imported. */
95+
function slug(label) {
96+
return String(label ?? "")
97+
.toLowerCase()
98+
.replace(/[']/g, "")
99+
.replace(/[^a-z0-9]+/g, "-")
100+
.replace(/^-+|-+$/g, "")
101+
.slice(0, 32);
102+
}
103+
104+
/**
105+
* The profullstack blogs, read from the vendored OPML rather than typed out.
106+
*
107+
* Read synchronously and at module load because defaultFeeds() is synchronous —
108+
* a fresh install must not wait on a network call, or on a promise, to show
109+
* anything at all. The file ships with the package (`files` includes `src`), so
110+
* it is there in both the npm and the install.sh channel.
111+
*
112+
* Parsed here with a small matcher instead of news.mjs's parseOpml, because
113+
* news.mjs imports this module and taking the import back the other way makes a
114+
* cycle. The matcher can afford to be small: this is our own file, flat, and a
115+
* test asserts the two agree on every feed it contains.
116+
*
117+
* A missing or unreadable file degrades to no profullstack defaults rather than
118+
* throwing, which would take `/news` down entirely over a packaging mistake.
119+
*/
120+
function profullstackFeeds() {
121+
let xml;
122+
try { xml = fs.readFileSync(PROFULLSTACK_OPML, "utf8"); }
123+
catch { return []; }
124+
125+
const feeds = [];
126+
const seen = new Set();
127+
for (const tag of xml.match(/<outline\b[^>]*>/gi) ?? []) {
128+
const attr = (name) => (new RegExp(`\\b${name}="([^"]*)"`, "i").exec(tag) ?? [])[1] ?? "";
129+
const url = attr("xmlUrl");
130+
if (!/^https?:\/\//i.test(url) || seen.has(url)) continue;
131+
seen.add(url);
132+
const title = attr("title") || attr("text") || url;
133+
feeds.push({ name: slug(title), title, url, site: attr("htmlUrl"), category: "profullstack" });
134+
}
135+
return feeds;
136+
}
137+
89138
/**
90139
* The feeds a fresh install reads.
91140
*
@@ -120,25 +169,18 @@ export const DEFAULT_FEEDS = [
120169

121170
{ name: "npr-politics", title: "NPR — Politics", url: "https://feeds.npr.org/1014/rss.xml", site: "https://www.npr.org", category: "politics" },
122171

123-
// The profullstack blogs, read out of the box. This is the one published list
124-
// small enough to be a default: profullstack.com/feeds.opml is 14 feeds, where
125-
// smallweb is 33,000 and could only ever be searched. Kept in step with that
126-
// file by hand rather than fetched, because defaultFeeds() is synchronous and
127-
// a fresh install must not wait on a network call to show anything at all.
128-
{ name: "bittorrented-blog", title: "BitTorrented Blog", url: "https://bittorrented.com/blog/rss.xml", site: "https://bittorrented.com/blog", category: "profullstack" },
129-
{ name: "bl0ggers-blog", title: "bl0ggers Blog", url: "https://bl0ggers.com/blog/rss.xml", site: "https://bl0ggers.com/blog", category: "profullstack" },
130-
{ name: "c0mpute-blog", title: "c0mpute Blog", url: "https://c0mpute.com/blog/rss.xml", site: "https://c0mpute.com/blog", category: "profullstack" },
131-
{ name: "c0upons-blog", title: "c0upons Blog", url: "https://c0upons.com/blog/rss.xml", site: "https://c0upons.com/blog", category: "profullstack" },
132-
{ name: "coinpay-blog", title: "CoinPay Blog", url: "https://coinpayportal.com/blog/rss.xml", site: "https://coinpayportal.com/blog", category: "profullstack" },
133-
{ name: "crawlproof-blog", title: "CrawlProof Blog", url: "https://crawlproof.com/blog/rss.xml", site: "https://crawlproof.com/blog", category: "profullstack" },
134-
{ name: "d0rz-blog", title: "d0rz Blog", url: "https://d0rz.com/blog/rss.xml", site: "https://d0rz.com/blog", category: "profullstack" },
135-
{ name: "logicsrc-blog", title: "LogicSRC Blog", url: "https://logicsrc.com/blog/rss.xml", site: "https://logicsrc.com/blog", category: "profullstack" },
136-
{ name: "pairux-blog", title: "PairUX Blog", url: "https://pairux.com/blog/rss.xml", site: "https://pairux.com/blog", category: "profullstack" },
137-
{ name: "qryptchat-blog", title: "QryptChat Blog", url: "https://qrypt.chat/blog/rss.xml", site: "https://qrypt.chat/blog", category: "profullstack" },
138-
{ name: "saasrow-blog", title: "SaaSRow Blog", url: "https://www.saasrow.com/blog/rss.xml", site: "https://www.saasrow.com/blog", category: "profullstack" },
139-
{ name: "sh1pt-blog", title: "sh1pt Blog", url: "https://sh1pt.com/blog/rss.xml", site: "https://sh1pt.com/blog", category: "profullstack" },
140-
{ name: "threatcrush-blog", title: "ThreatCrush Blog", url: "https://threatcrush.com/blog/rss.xml", site: "https://threatcrush.com/blog", category: "profullstack" },
141-
{ name: "ugig-blog", title: "ugig Blog", url: "https://ugig.net/blog/rss.xml", site: "https://ugig.net/blog", category: "profullstack" },
172+
// The profullstack blogs, read out of the box. Not typed out here: they are
173+
// parsed from src/profullstack-feeds.opml, which is a copy of what
174+
// profullstack.com/feeds.opml actually serves. Two hand-maintained lists of
175+
// the same fourteen blogs is one more than can be kept in step, and the copy
176+
// that would go stale is this one — nobody editing the published OPML has a
177+
// reason to think about moshcode. Refresh it with:
178+
//
179+
// curl -sL https://profullstack.com/feeds.opml -o src/profullstack-feeds.opml
180+
//
181+
// test/profullstack-feeds.test.mjs checks that against the live file when
182+
// MOSHCODE_CHECK_FEED_DRIFT=1 is set.
183+
...profullstackFeeds(),
142184
];
143185

144186
/**

src/profullstack-feeds.opml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<opml version="2.0">
3+
<head>
4+
<title>Profullstack Blogs</title>
5+
<ownerName>Profullstack</ownerName>
6+
<ownerId>https://profullstack.com</ownerId>
7+
<dateCreated>Thu, 13 Aug 2026 00:00:00 +0000</dateCreated>
8+
</head>
9+
<body>
10+
<outline text="BitTorrented Blog" title="BitTorrented Blog" type="rss" xmlUrl="https://bittorrented.com/blog/rss.xml" htmlUrl="https://bittorrented.com/blog" />
11+
<outline text="bl0ggers Blog" title="bl0ggers Blog" type="rss" xmlUrl="https://bl0ggers.com/blog/rss.xml" htmlUrl="https://bl0ggers.com/blog" />
12+
<outline text="c0mpute Blog" title="c0mpute Blog" type="rss" xmlUrl="https://c0mpute.com/blog/rss.xml" htmlUrl="https://c0mpute.com/blog" />
13+
<outline text="c0upons Blog" title="c0upons Blog" type="rss" xmlUrl="https://c0upons.com/blog/rss.xml" htmlUrl="https://c0upons.com/blog" />
14+
<outline text="CoinPay Blog" title="CoinPay Blog" type="rss" xmlUrl="https://coinpayportal.com/blog/rss.xml" htmlUrl="https://coinpayportal.com/blog" />
15+
<outline text="CrawlProof Blog" title="CrawlProof Blog" type="rss" xmlUrl="https://crawlproof.com/blog/rss.xml" htmlUrl="https://crawlproof.com/blog" />
16+
<outline text="d0rz Blog" title="d0rz Blog" type="rss" xmlUrl="https://d0rz.com/blog/rss.xml" htmlUrl="https://d0rz.com/blog" />
17+
<outline text="LogicSRC Blog" title="LogicSRC Blog" type="rss" xmlUrl="https://logicsrc.com/blog/rss.xml" htmlUrl="https://logicsrc.com/blog" />
18+
<outline text="PairUX Blog" title="PairUX Blog" type="rss" xmlUrl="https://pairux.com/blog/rss.xml" htmlUrl="https://pairux.com/blog" />
19+
<outline text="QryptChat Blog" title="QryptChat Blog" type="rss" xmlUrl="https://qrypt.chat/blog/rss.xml" htmlUrl="https://qrypt.chat/blog" />
20+
<outline text="SaaSRow Blog" title="SaaSRow Blog" type="rss" xmlUrl="https://www.saasrow.com/blog/rss.xml" htmlUrl="https://www.saasrow.com/blog" />
21+
<outline text="sh1pt Blog" title="sh1pt Blog" type="rss" xmlUrl="https://sh1pt.com/blog/rss.xml" htmlUrl="https://sh1pt.com/blog" />
22+
<outline text="ThreatCrush Blog" title="ThreatCrush Blog" type="rss" xmlUrl="https://threatcrush.com/blog/rss.xml" htmlUrl="https://threatcrush.com/blog" />
23+
<outline text="ugig Blog" title="ugig Blog" type="rss" xmlUrl="https://ugig.net/blog/rss.xml" htmlUrl="https://ugig.net/blog" />
24+
</body>
25+
</opml>

src/settings-sync.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ export const SYNCED_FILES = [
6464
// and read by nothing here — moshcode's interest in it begins and ends with
6565
// moving it, and a file this does not parse cannot be broken by this.
6666
{ path: "feeds.opml", json: false, label: "rss feeds" },
67+
// `/news` and `/rss` keep their subscriptions here — see opmlFile() in
68+
// news.mjs. A near-identical name sat in this list above it for a while and
69+
// the two were easy to mistake for each other, so: feeds.opml belongs to
70+
// tcfeed, news.opml belongs to this. Carrying only the first meant the feeds
71+
// you actually subscribed to were the one thing `/save` left behind.
72+
//
73+
// Deliberately no cap change. A subscription list past MAX_FILE_BYTES is
74+
// reported as skipped rather than failing the snapshot, which is the right
75+
// answer for a list that got big by importing somebody else's.
76+
{ path: "news.opml", json: false, label: "news subscriptions" },
6777
];
6878

6979
/**

test/profullstack-feeds.test.mjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// The vendored profullstack feed list, against the published one.
2+
//
3+
// src/profullstack-feeds.opml is a copy of what profullstack.com/feeds.opml
4+
// serves. It is a copy on purpose: defaultFeeds() is synchronous, so a fresh
5+
// install cannot fetch the list before it shows anything. The cost of that
6+
// choice is drift, and the point of this file is to make drift loud.
7+
//
8+
// Everything that can be checked without a network runs always — that the file
9+
// is there, that it parses, that the small matcher in news-sources.mjs agrees
10+
// with the real parseOpml, and that its private slug() still matches
11+
// slugify(). Those are the failures a refactor actually causes.
12+
//
13+
// The one check that needs the network — vendored copy against the live URL —
14+
// is opt-in, because a suite that fails when profullstack.com is briefly down
15+
// is a suite people learn to ignore. Run it deliberately:
16+
//
17+
// MOSHCODE_CHECK_FEED_DRIFT=1 node --test test/profullstack-feeds.test.mjs
18+
import assert from "node:assert/strict";
19+
import fs from "node:fs";
20+
import path from "node:path";
21+
import test from "node:test";
22+
import { fileURLToPath } from "node:url";
23+
24+
import { DEFAULT_FEEDS } from "../src/news-sources.mjs";
25+
import { parseOpml, slugify } from "../src/news.mjs";
26+
27+
const OPML = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src", "profullstack-feeds.opml");
28+
const LIVE = "https://profullstack.com/feeds.opml";
29+
30+
const vendored = fs.readFileSync(OPML, "utf8");
31+
const mine = DEFAULT_FEEDS.filter((f) => f.category === "profullstack");
32+
33+
test("the vendored OPML ships and is read", () => {
34+
// `files` in package.json includes `src`, so this travels with the package.
35+
// If it ever stops, profullstackFeeds() degrades to [] rather than throwing —
36+
// which is the right runtime behaviour and exactly why it needs asserting
37+
// here instead: silence is the failure mode.
38+
assert.ok(vendored.includes("<opml"), "not an OPML document");
39+
assert.ok(mine.length > 0, "no profullstack feeds in the defaults");
40+
});
41+
42+
test("every feed in the file is a default, and nothing else is", () => {
43+
// parseOpml is the real parser; news-sources.mjs cannot import it without a
44+
// cycle, so it has a small matcher of its own. This is the assertion that the
45+
// shortcut did not change the answer.
46+
const parsed = parseOpml(vendored);
47+
assert.deepEqual(
48+
mine.map((f) => f.url).sort(),
49+
parsed.map((f) => f.url).sort(),
50+
);
51+
assert.deepEqual(
52+
mine.map((f) => f.title).sort(),
53+
parsed.map((f) => f.title).sort(),
54+
);
55+
});
56+
57+
test("the private slug() still agrees with slugify()", () => {
58+
// news-sources.mjs copies slugify() rather than importing it, for the same
59+
// cycle reason. A feed named differently by the two would be reachable as
60+
// `--feed <name>` under one name and listed under another.
61+
for (const feed of mine) assert.equal(feed.name, slugify(feed.title), feed.title);
62+
});
63+
64+
test("the feeds are usable — https, unique, and pointed at a site", () => {
65+
const urls = mine.map((f) => f.url);
66+
assert.equal(new Set(urls).size, urls.length, "duplicate feed URL");
67+
for (const feed of mine) {
68+
assert.match(feed.url, /^https:\/\//, `${feed.title} is not https`);
69+
assert.ok(feed.title.trim(), "a feed with no title");
70+
assert.match(feed.site, /^https:\/\//, `${feed.title} has no site`);
71+
}
72+
});
73+
74+
test("vendored copy matches profullstack.com/feeds.opml", {
75+
skip: process.env.MOSHCODE_CHECK_FEED_DRIFT === "1"
76+
? false
77+
: "set MOSHCODE_CHECK_FEED_DRIFT=1 to check against the live file",
78+
}, async () => {
79+
const res = await fetch(LIVE);
80+
assert.equal(res.ok, true, `${LIVE} answered ${res.status}`);
81+
const live = parseOpml(await res.text());
82+
83+
// Compared by what a reader would act on, not byte for byte: the published
84+
// file carries a dateCreated that changes without any feed changing.
85+
assert.deepEqual(
86+
mine.map((f) => f.url).sort(),
87+
live.map((f) => f.url).sort(),
88+
`vendored list is stale — refresh with:\n curl -sL ${LIVE} -o src/profullstack-feeds.opml`,
89+
);
90+
});

test/settings-sync.test.mjs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const DIGEST_FIXTURE = {
4141
"herd/rules.json": { content: "{}" },
4242
};
4343

44-
function home({ aliases = null, rules = null, credentials = true, marker = null, feeds = null } = {}) {
44+
function home({ aliases = null, rules = null, credentials = true, marker = null, feeds = null, news = null } = {}) {
4545
const dir = mkdtempSync(path.join(tmpdir(), "moshcode-sync-"));
4646
const moshcode = path.join(dir, ".moshcode");
4747
fs.mkdirSync(moshcode, { recursive: true });
@@ -51,6 +51,7 @@ function home({ aliases = null, rules = null, credentials = true, marker = null,
5151
}
5252
if (aliases) fs.writeFileSync(path.join(moshcode, "aliases.json"), aliases);
5353
if (feeds) fs.writeFileSync(path.join(moshcode, "feeds.opml"), feeds);
54+
if (news) fs.writeFileSync(path.join(moshcode, "news.opml"), news);
5455
if (rules) {
5556
fs.mkdirSync(path.join(moshcode, "herd"), { recursive: true });
5657
fs.writeFileSync(path.join(moshcode, "herd", "rules.json"), rules);
@@ -275,6 +276,27 @@ test("/save carries the feed list, and does not try to parse it", async () => {
275276
assert.equal(sent["feeds.opml"].content, OPML, "OPML travels byte for byte");
276277
});
277278

279+
test("/save carries the news subscriptions too, not only tcfeed's list", async () => {
280+
// These two files sat one line apart in SYNCED_FILES with nearly the same
281+
// name, and only tcfeed's was in it. `/news` and `/rss` keep their
282+
// subscriptions in news.opml, so the feeds you actually chose were the one
283+
// thing `/save` left behind.
284+
const dir = home({ aliases: "{}", feeds: OPML, news: OPML });
285+
const fetchImpl = stubFetch([[200, { revision: 1 }]]);
286+
287+
const code = await saveCommand([], { home: dir, creds: CREDS, fetchImpl, write: lines(), installed: INSTALLED });
288+
assert.equal(code, 0);
289+
const sent = fetchImpl.calls[0].body.snapshot.files;
290+
assert.equal(sent["news.opml"].content, OPML, "the subscription list did not travel");
291+
assert.ok(sent["feeds.opml"], "tcfeed's list still travels");
292+
});
293+
294+
test("news.opml is in the allowlist and is not parsed", () => {
295+
const entry = SYNCED_FILES.find((f) => f.path === "news.opml");
296+
assert.ok(entry, "news.opml is not synced");
297+
assert.equal(entry.json, false, "OPML must not be JSON-parsed");
298+
});
299+
278300
test("a feed list that is not XML still syncs — nothing here reads it", async () => {
279301
// The contrast with aliases.json is the point: that one is `json: true` and
280302
// a broken one is held back rather than copied to every machine. OPML is

0 commit comments

Comments
 (0)