@isomoes/iread 0.1.0 → 0.2.2
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 +8 -2
- package/dist/server/cli.js +10 -1
- package/dist/server/db.js +1 -2
- package/dist/server/feed-service.js +97 -33
- package/dist/server/fetch-feed.js +0 -1
- package/dist/server/index.js +2 -1
- package/dist/server/opml-sync.js +41 -0
- package/dist/server/opml.js +1 -1
- package/dist/server/routes/feeds.js +0 -1
- package/dist/server/routes/helpers.js +0 -1
- package/dist/server/routes/items.js +0 -1
- package/dist/server/routes/opml.js +0 -1
- package/dist/server/sanitize.js +0 -1
- package/dist/server/ssrf.js +0 -1
- package/dist/shared/types.js +0 -1
- package/dist/web/assets/index-BzVL9Rsl.js +16 -0
- package/dist/web/assets/index-CW5g_eWm.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/server/cli.js.map +0 -1
- package/dist/server/db.js.map +0 -1
- package/dist/server/feed-service.js.map +0 -1
- package/dist/server/fetch-feed.js.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/server/opml.js.map +0 -1
- package/dist/server/routes/feeds.js.map +0 -1
- package/dist/server/routes/helpers.js.map +0 -1
- package/dist/server/routes/items.js.map +0 -1
- package/dist/server/routes/opml.js.map +0 -1
- package/dist/server/sanitize.js.map +0 -1
- package/dist/server/ssrf.js.map +0 -1
- package/dist/shared/types.js.map +0 -1
- package/dist/web/assets/index-BI1j2sXf.css +0 -2
- package/dist/web/assets/index-HhCr0pHx.js +0 -17
- package/dist/web/assets/index-HhCr0pHx.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A concise, local, single-user RSS/Atom reader inspired by newsboat. Keyboard-fir
|
|
|
4
4
|
|
|
5
5
|
There is no auth, no multi-user, no cloud sync, and no background scheduler. Refresh is always user-initiated. All data lives in a single local SQLite file — by default `~/.config/iread/iread.db` — which is the trust boundary.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
7
9
|
## Quick start
|
|
8
10
|
|
|
9
11
|
```sh
|
|
@@ -19,6 +21,9 @@ Options:
|
|
|
19
21
|
-p, --port <port> Port to listen on (default: $PORT or 8787)
|
|
20
22
|
--db <path> SQLite database file
|
|
21
23
|
(default: $DB_PATH or ~/.config/iread/iread.db)
|
|
24
|
+
--opml <path> OPML file auto-saved on every subscription change
|
|
25
|
+
(default: $OPML_PATH or feeds.opml next to the database;
|
|
26
|
+
pass "" to disable)
|
|
22
27
|
-v, --version Print the version and exit
|
|
23
28
|
-h, --help Show this help and exit
|
|
24
29
|
```
|
|
@@ -35,7 +40,7 @@ Options:
|
|
|
35
40
|
- Star and unstar.
|
|
36
41
|
- Live case-insensitive search over title plus summary in the current view.
|
|
37
42
|
- Full keyboard navigation as the primary interaction model.
|
|
38
|
-
- OPML import (bulk add) and OPML export.
|
|
43
|
+
- OPML import (bulk add) and OPML export, plus an always-current `feeds.opml` auto-saved next to the database on every change for quick sharing and backup. The auto-saved file is a one-way snapshot of the database (overwritten on every change and at startup), so use it for backup and sharing — to bring feeds in, use OPML import.
|
|
39
44
|
- Light, dark, and system theme, persisted to localStorage.
|
|
40
45
|
|
|
41
46
|
## Requirements (development)
|
|
@@ -78,7 +83,7 @@ pnpm start
|
|
|
78
83
|
|
|
79
84
|
Open http://localhost:8787
|
|
80
85
|
|
|
81
|
-
`PORT` (default 8787)
|
|
86
|
+
`PORT` (default 8787), `DB_PATH` (default `~/.config/iread/iread.db`), and `OPML_PATH` (default `feeds.opml` next to the database; set empty to disable the auto-saved mirror) are read from the environment. See `config/.env.example`.
|
|
82
87
|
|
|
83
88
|
## Publish to npm
|
|
84
89
|
|
|
@@ -114,6 +119,7 @@ Keys are case-sensitive (Shift matters). Bindings fire only when you are not typ
|
|
|
114
119
|
| `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
|
|
115
120
|
| `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
|
|
116
121
|
| `v` | Open original | Open the article link in a new tab. |
|
|
122
|
+
| `#` then N | Open link by number | Links in the article body are numbered inline `[N]`; press `#`, type the number, and it opens in a new tab — instantly once the number is unambiguous, otherwise `Enter` confirms; `Esc` cancels. |
|
|
117
123
|
| `/` | Focus search | Focus and select the search input. |
|
|
118
124
|
| `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
|
|
119
125
|
| `?` | Help overlay | Toggle the keybinding overlay. |
|
package/dist/server/cli.js
CHANGED
|
@@ -30,6 +30,9 @@ Options:
|
|
|
30
30
|
-p, --port <port> Port to listen on (default: $PORT or 8787)
|
|
31
31
|
--db <path> SQLite database file
|
|
32
32
|
(default: $DB_PATH or ~/.config/iread/iread.db)
|
|
33
|
+
--opml <path> OPML file auto-saved on every subscription change
|
|
34
|
+
(default: $OPML_PATH or feeds.opml next to the database;
|
|
35
|
+
pass "" to disable)
|
|
33
36
|
-v, --version Print the version and exit
|
|
34
37
|
-h, --help Show this help and exit
|
|
35
38
|
`;
|
|
@@ -74,6 +77,13 @@ for (let i = 0; i < args.length; i++) {
|
|
|
74
77
|
process.env.DB_PATH = value;
|
|
75
78
|
break;
|
|
76
79
|
}
|
|
80
|
+
case '--opml': {
|
|
81
|
+
const value = args[++i];
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
fail('--opml requires a file path (pass "" to disable)');
|
|
84
|
+
process.env.OPML_PATH = value;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
77
87
|
default:
|
|
78
88
|
fail(`unknown option: ${arg}`);
|
|
79
89
|
}
|
|
@@ -81,4 +91,3 @@ for (let i = 0; i < args.length; i++) {
|
|
|
81
91
|
// Serve the prebuilt web bundle unless the caller explicitly set NODE_ENV.
|
|
82
92
|
process.env.NODE_ENV ??= 'production';
|
|
83
93
|
await import('./index.js');
|
|
84
|
-
//# sourceMappingURL=cli.js.map
|
package/dist/server/db.js
CHANGED
|
@@ -21,7 +21,7 @@ function defaultDbPath() {
|
|
|
21
21
|
const DB_PATH = process.env.DB_PATH ?? defaultDbPath();
|
|
22
22
|
// Resolve to an absolute path and ensure the containing directory exists
|
|
23
23
|
// (DatabaseSync will not create intermediate directories on its own).
|
|
24
|
-
const dbPath = resolve(process.cwd(), DB_PATH);
|
|
24
|
+
export const dbPath = resolve(process.cwd(), DB_PATH);
|
|
25
25
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
26
26
|
export const db = new DatabaseSync(dbPath);
|
|
27
27
|
// Connection PRAGMAs. WAL keeps readers from erroring during writes;
|
|
@@ -155,4 +155,3 @@ export function totalChanges() {
|
|
|
155
155
|
const row = db.prepare('SELECT total_changes() AS n').get();
|
|
156
156
|
return row.n;
|
|
157
157
|
}
|
|
158
|
-
//# sourceMappingURL=db.js.map
|
|
@@ -9,6 +9,7 @@ import { db, transaction, totalChanges, queryAll, queryGet, run, } from './db.js
|
|
|
9
9
|
import { fetchFeed } from './fetch-feed.js';
|
|
10
10
|
import { assertSafeFeedUrl } from './ssrf.js';
|
|
11
11
|
import { sanitizeArticleHtml, toPlainText } from './sanitize.js';
|
|
12
|
+
import { writeOpmlSnapshot } from './opml-sync.js';
|
|
12
13
|
const SUMMARY_MAX = 280;
|
|
13
14
|
const LIMIT_DEFAULT = 50;
|
|
14
15
|
const LIMIT_MAX = 200;
|
|
@@ -211,7 +212,12 @@ const UPDATE_FEED_ON_SUCCESS = `
|
|
|
211
212
|
last_fetched_at = ?, fetch_error = NULL
|
|
212
213
|
WHERE id = ?
|
|
213
214
|
`;
|
|
214
|
-
async
|
|
215
|
+
// Phase 1 (async, no DB, runs concurrently across feeds): fetch + parse + sanitize.
|
|
216
|
+
// Never throws — a failed fetch/parse becomes an 'error' plan so the caller's loop
|
|
217
|
+
// always completes. CRITICAL (review fix #13): the new etag/last_modified are
|
|
218
|
+
// carried in the plan and persisted only by applyRefresh, after a fully
|
|
219
|
+
// successful parse+upsert.
|
|
220
|
+
async function planRefresh(row) {
|
|
215
221
|
const now = Date.now();
|
|
216
222
|
try {
|
|
217
223
|
const result = await fetchFeed({
|
|
@@ -219,30 +225,56 @@ async function refreshFeedRow(row) {
|
|
|
219
225
|
etag: row.etag,
|
|
220
226
|
lastModified: row.last_modified,
|
|
221
227
|
});
|
|
222
|
-
if (result.kind === 'notModified')
|
|
228
|
+
if (result.kind === 'notModified')
|
|
229
|
+
return { kind: 'notModified', now };
|
|
230
|
+
const { feedMeta, commit } = await parseAndPrepare(row.feed_url, result.xml);
|
|
231
|
+
return {
|
|
232
|
+
kind: 'ok',
|
|
233
|
+
now,
|
|
234
|
+
feedMeta,
|
|
235
|
+
commit,
|
|
236
|
+
etag: result.etag,
|
|
237
|
+
lastModified: result.lastModified,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
return { kind: 'error', now, message: err instanceof Error ? err.message : String(err) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Record a failed refresh on the feed row. Shared by the error-plan branch and the
|
|
245
|
+
// catch fallback so the UPDATE + error outcome live in exactly one place.
|
|
246
|
+
function recordFeedError(row, now, message) {
|
|
247
|
+
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
|
|
248
|
+
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
|
|
249
|
+
}
|
|
250
|
+
// Phase 2 (synchronous DB writes): apply one plan. Fully synchronous — it never
|
|
251
|
+
// awaits — so even when called from concurrent fetch workers its writes can't
|
|
252
|
+
// overlap and node:sqlite never awaits mid-transaction. Only the commit + feed-row
|
|
253
|
+
// update run inside the transaction.
|
|
254
|
+
function applyRefresh(row, plan) {
|
|
255
|
+
try {
|
|
256
|
+
if (plan.kind === 'notModified') {
|
|
223
257
|
// 304: nothing changed. Update last_fetched_at and clear any prior error.
|
|
224
|
-
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(now, row.id);
|
|
258
|
+
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(plan.now, row.id);
|
|
225
259
|
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: true };
|
|
226
260
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return inserted;
|
|
237
|
-
});
|
|
238
|
-
return { feed: getFeedWithCounts(row.id), newItems, ok: true };
|
|
261
|
+
if (plan.kind === 'ok') {
|
|
262
|
+
const newItems = transaction(() => {
|
|
263
|
+
const inserted = plan.commit(row.id);
|
|
264
|
+
db.prepare(UPDATE_FEED_ON_SUCCESS).run(plan.feedMeta.title, plan.feedMeta.siteUrl, plan.feedMeta.description, plan.etag, plan.lastModified, plan.now, row.id);
|
|
265
|
+
return inserted;
|
|
266
|
+
});
|
|
267
|
+
return { feed: getFeedWithCounts(row.id), newItems, ok: true };
|
|
268
|
+
}
|
|
269
|
+
return recordFeedError(row, plan.now, plan.message);
|
|
239
270
|
}
|
|
240
271
|
catch (err) {
|
|
241
|
-
|
|
242
|
-
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
|
|
243
|
-
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
|
|
272
|
+
return recordFeedError(row, plan.now, err instanceof Error ? err.message : String(err));
|
|
244
273
|
}
|
|
245
274
|
}
|
|
275
|
+
async function refreshFeedRow(row) {
|
|
276
|
+
return applyRefresh(row, await planRefresh(row));
|
|
277
|
+
}
|
|
246
278
|
function getFeedRow(id) {
|
|
247
279
|
return queryGet(`SELECT * FROM feeds WHERE id = ?`, id);
|
|
248
280
|
}
|
|
@@ -253,32 +285,59 @@ export async function refreshFeed(id) {
|
|
|
253
285
|
if (!row)
|
|
254
286
|
throw new ServiceError('NOT_FOUND', 'Feed not found.');
|
|
255
287
|
const outcome = await refreshFeedRow(row);
|
|
288
|
+
syncOpmlMirror();
|
|
256
289
|
return { feed: outcome.feed, newItems: outcome.newItems };
|
|
257
290
|
}
|
|
258
|
-
|
|
259
|
-
|
|
291
|
+
// Max feeds fetched+parsed at once in refreshAll. Bounded so we never open
|
|
292
|
+
// hundreds of sockets or buffer hundreds of 10 MB bodies simultaneously.
|
|
293
|
+
const REFRESH_CONCURRENCY = 8;
|
|
294
|
+
// Map items through an async worker with at most `limit` in flight, preserving
|
|
295
|
+
// input order in the result. Used to overlap the per-feed fetch/parse/apply work.
|
|
296
|
+
async function mapWithConcurrency(items, limit, worker) {
|
|
297
|
+
const results = new Array(items.length);
|
|
298
|
+
let next = 0;
|
|
299
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, () => (async () => {
|
|
300
|
+
for (;;) {
|
|
301
|
+
const i = next++;
|
|
302
|
+
if (i >= items.length)
|
|
303
|
+
return;
|
|
304
|
+
results[i] = await worker(items[i], i);
|
|
305
|
+
}
|
|
306
|
+
})());
|
|
307
|
+
await Promise.all(runners);
|
|
308
|
+
return results;
|
|
309
|
+
}
|
|
310
|
+
/** Refresh every feed. Each feed's fetch + parse runs concurrently (bounded by
|
|
311
|
+
* REFRESH_CONCURRENCY) and its synchronous apply runs in the same worker, so the
|
|
312
|
+
* DB writes serialize naturally and only ~concurrency feeds' parsed content is held
|
|
313
|
+
* at once. Never fails because one feed errored; tallies refreshed/failed/newItems
|
|
314
|
+
* and returns the full updated list. */
|
|
260
315
|
export async function refreshAll() {
|
|
261
316
|
const rows = queryAll(`SELECT * FROM feeds ORDER BY id ASC`);
|
|
262
317
|
let refreshed = 0;
|
|
263
318
|
let failed = 0;
|
|
264
319
|
let newItems = 0;
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
320
|
+
// Each worker fetches+parses (concurrent) then applies its own plan (synchronous,
|
|
321
|
+
// so applies never overlap and node:sqlite stays happy). The try/catch guarantees
|
|
322
|
+
// the batch always completes: even if applyRefresh's own error path throws (a
|
|
323
|
+
// DB-level fault), that feed is tallied as failed rather than aborting the request.
|
|
324
|
+
const outcomes = await mapWithConcurrency(rows, REFRESH_CONCURRENCY, async (row) => {
|
|
268
325
|
try {
|
|
269
|
-
const outcome = await
|
|
270
|
-
|
|
271
|
-
refreshed += 1;
|
|
272
|
-
else
|
|
273
|
-
failed += 1;
|
|
274
|
-
newItems += outcome.newItems;
|
|
326
|
+
const outcome = applyRefresh(row, await planRefresh(row));
|
|
327
|
+
return { ok: outcome.ok, newItems: outcome.newItems };
|
|
275
328
|
}
|
|
276
329
|
catch {
|
|
277
|
-
|
|
278
|
-
// unexpected throw so the loop always completes.
|
|
279
|
-
failed += 1;
|
|
330
|
+
return { ok: false, newItems: 0 };
|
|
280
331
|
}
|
|
332
|
+
});
|
|
333
|
+
for (const outcome of outcomes) {
|
|
334
|
+
if (outcome.ok)
|
|
335
|
+
refreshed += 1;
|
|
336
|
+
else
|
|
337
|
+
failed += 1;
|
|
338
|
+
newItems += outcome.newItems;
|
|
281
339
|
}
|
|
340
|
+
syncOpmlMirror();
|
|
282
341
|
return { feeds: listFeeds().feeds, refreshed, failed, newItems };
|
|
283
342
|
}
|
|
284
343
|
// ---------------------------------------------------------------------------
|
|
@@ -336,6 +395,7 @@ export async function addFeed(url) {
|
|
|
336
395
|
const feed = getFeedWithCounts(feedId);
|
|
337
396
|
if (!feed)
|
|
338
397
|
throw new ServiceError('NOT_FOUND', 'Feed disappeared after insert.');
|
|
398
|
+
syncOpmlMirror();
|
|
339
399
|
return feed;
|
|
340
400
|
}
|
|
341
401
|
// ---------------------------------------------------------------------------
|
|
@@ -345,6 +405,7 @@ export function deleteFeed(id) {
|
|
|
345
405
|
const res = run(`DELETE FROM feeds WHERE id = ?`, id);
|
|
346
406
|
if (res.changes === 0)
|
|
347
407
|
throw new ServiceError('NOT_FOUND', 'Feed not found.');
|
|
408
|
+
syncOpmlMirror();
|
|
348
409
|
}
|
|
349
410
|
// ---------------------------------------------------------------------------
|
|
350
411
|
// listItems (PLAN 7 GET /api/items) — dynamic WHERE built from fixed fragments
|
|
@@ -513,6 +574,7 @@ export async function importFeeds(urls) {
|
|
|
513
574
|
}
|
|
514
575
|
}
|
|
515
576
|
}
|
|
577
|
+
syncOpmlMirror();
|
|
516
578
|
return { added, skipped, failed, feeds: listFeeds().feeds };
|
|
517
579
|
}
|
|
518
580
|
/** All feeds as plain Feed DTOs (for OPML export). */
|
|
@@ -520,4 +582,6 @@ export function listFeedsForExport() {
|
|
|
520
582
|
const rows = queryAll(`SELECT * FROM feeds ORDER BY title COLLATE NOCASE ASC, id ASC`);
|
|
521
583
|
return rows.map(mapFeed);
|
|
522
584
|
}
|
|
523
|
-
|
|
585
|
+
export function syncOpmlMirror() {
|
|
586
|
+
writeOpmlSnapshot(listFeedsForExport());
|
|
587
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -17,6 +17,8 @@ const IS_PROD = process.env.NODE_ENV === 'production';
|
|
|
17
17
|
// Importing ./db has the side effect of opening the DB and running migrations.
|
|
18
18
|
// Do it explicitly so startup failures surface immediately.
|
|
19
19
|
import './db.js';
|
|
20
|
+
import { syncOpmlMirror } from './feed-service.js';
|
|
21
|
+
syncOpmlMirror();
|
|
20
22
|
const app = new Hono();
|
|
21
23
|
// --- API routes (registered FIRST) -----------------------------------------
|
|
22
24
|
const api = new Hono();
|
|
@@ -59,4 +61,3 @@ serve({ fetch: app.fetch, port: PORT }, (info) => {
|
|
|
59
61
|
console.log(`[iread] listening on ${url}`);
|
|
60
62
|
});
|
|
61
63
|
export { app };
|
|
62
|
-
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { dbPath } from './db.js';
|
|
4
|
+
import { exportOpml } from './opml.js';
|
|
5
|
+
function resolveMirrorPath() {
|
|
6
|
+
const override = process.env.OPML_PATH;
|
|
7
|
+
if (override !== undefined) {
|
|
8
|
+
const trimmed = override.trim();
|
|
9
|
+
return trimmed === '' ? null : resolve(process.cwd(), trimmed);
|
|
10
|
+
}
|
|
11
|
+
return join(dirname(dbPath), 'feeds.opml');
|
|
12
|
+
}
|
|
13
|
+
export const opmlMirrorPath = resolveMirrorPath();
|
|
14
|
+
function withoutDateCreated(xml) {
|
|
15
|
+
return xml.replace(/[ \t]*<dateCreated>.*?<\/dateCreated>\r?\n?/, '');
|
|
16
|
+
}
|
|
17
|
+
export function writeOpmlSnapshot(feeds) {
|
|
18
|
+
if (!opmlMirrorPath)
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
const xml = exportOpml(feeds);
|
|
22
|
+
let existing = null;
|
|
23
|
+
try {
|
|
24
|
+
existing = readFileSync(opmlMirrorPath, 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
existing = null;
|
|
28
|
+
}
|
|
29
|
+
if (existing !== null && withoutDateCreated(existing) === withoutDateCreated(xml)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(dirname(opmlMirrorPath), { recursive: true });
|
|
33
|
+
const tmp = `${opmlMirrorPath}.tmp`;
|
|
34
|
+
writeFileSync(tmp, xml, 'utf-8');
|
|
35
|
+
renameSync(tmp, opmlMirrorPath);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
console.warn(`[iread] could not write OPML mirror at ${opmlMirrorPath}: ${message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/server/opml.js
CHANGED
|
@@ -102,6 +102,7 @@ export function importOpml(xml) {
|
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
103
|
function escapeXmlAttr(value) {
|
|
104
104
|
return value
|
|
105
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
105
106
|
.replace(/&/g, '&')
|
|
106
107
|
.replace(/</g, '<')
|
|
107
108
|
.replace(/>/g, '>')
|
|
@@ -134,4 +135,3 @@ export function exportOpml(feeds) {
|
|
|
134
135
|
lines.push('');
|
|
135
136
|
return lines.join('\n');
|
|
136
137
|
}
|
|
137
|
-
//# sourceMappingURL=opml.js.map
|
package/dist/server/sanitize.js
CHANGED
package/dist/server/ssrf.js
CHANGED
package/dist/shared/types.js
CHANGED