webring 1.0.3 → 1.1.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.1](https://github.com/shepherdjerred/webring/compare/v1.1.0...v1.1.1) (2024-07-07)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **deps:** update dependency astro to v4.11.5 ([a61f733](https://github.com/shepherdjerred/webring/commit/a61f733cbd3f7bd659d4a608f58473885a5bcc61))
14
+ * **deps:** update dependency remeda to v2.2.2 ([97a6c06](https://github.com/shepherdjerred/webring/commit/97a6c064138e0c204e231efb2ca45defd82b61f9))
15
+ * **deps:** update dependency remeda to v2.3.0 ([7a4c8d5](https://github.com/shepherdjerred/webring/commit/7a4c8d51ad5722be0a8dced10eb0b14a279b0bf6))
16
+ * update snapshot ([c73cdf7](https://github.com/shepherdjerred/webring/commit/c73cdf779433e6e7e8c5f2beee3fb2aefec9a0e0))
17
+ * use import instead of triple-slash ([971a77e](https://github.com/shepherdjerred/webring/commit/971a77ecd0c612850faeb9d16f7775d3e7ca7253))
18
+
19
+ ## [1.1.0](https://github.com/shepherdjerred/webring/compare/v1.0.3...v1.1.0) (2024-06-28)
20
+
21
+
22
+ ### Features
23
+
24
+ * source mappings ([095fe2b](https://github.com/shepherdjerred/webring/commit/095fe2be44e25547271730a5611d4710609cdf8d))
25
+
8
26
  ## [1.0.3](https://github.com/shepherdjerred/webring/compare/v1.0.2...v1.0.3) (2024-06-24)
9
27
 
10
28
 
package/README.md CHANGED
@@ -1,13 +1,17 @@
1
- # webring
1
+ <div align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://cdn.rawgit.com/shepherdjerred/webring/main/assets/logo-dark.png">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://cdn.rawgit.com/shepherdjerred/webring/main/assets/logo-light.png">
5
+ <img alt="webring logo" src="https://cdn.rawgit.com/shepherdjerred/webring/main/assets/logo-light.png" height=150>
6
+ </picture>
2
7
 
3
8
  [![webring](https://img.shields.io/npm/v/webring.svg)](https://www.npmjs.com/package/webring)
4
9
 
5
- `webring` gathers the latest posts from your favorite RSS feeds so that you can embed them on your site.
10
+ `webring` fetches the latest updates from your favorite RSS feeds.
6
11
 
7
- Inspired by:
12
+ This project is actively maintained. If you have a feature request or need help, please [create an issue](https://github.com/shepherdjerred/webring/issues/new).
8
13
 
9
- - https://github.com/lukehsiao/openring-rs
10
- - https://git.sr.ht/~sircmpwn/openring
14
+ </div>
11
15
 
12
16
  ## Installation
13
17
 
@@ -15,13 +19,92 @@ Inspired by:
15
19
  npm i webring
16
20
  ```
17
21
 
22
+ ## Features
23
+
24
+ - Written in TypeScript
25
+ - Caching
26
+ - HTML sanitization and truncation
27
+
18
28
  ## Quick Start
19
29
 
20
- This library is meant to be used with static site generators. It is framework agnostic. I use this with [Astro](https://astro.build/) on my [personal website](https://github.com/shepherdjerred/sjer.red/blob/main/src/components/BlogWebring.astro#L17-L22).
30
+ This library is intended to be used with a static site generator. I use this with [Astro](https://astro.build/) on my [personal website](https://github.com/shepherdjerred/sjer.red/blob/1220ebef2e43956ba385402ed8529870e9084de8/src/components/BlogWebring.astro#L17-L22).
21
31
 
22
32
  ```typescript
23
33
  import { run } from "webring";
24
- import { type Configuration, type Result } from "webring";
34
+
35
+ const result = await run({
36
+ sources: [
37
+ {
38
+ url: "https://drewdevault.com/blog/index.xml",
39
+ title: "Drew DeVault",
40
+ },
41
+ {
42
+ url: "https://danluu.com/atom.xml",
43
+ title: "Dan Luu",
44
+ },
45
+ {
46
+ url: "https://jakelazaroff.com/rss.xml",
47
+ title: "Jake Lazaroff",
48
+ },
49
+ ],
50
+ });
51
+
52
+ console.log(result);
53
+ // [
54
+ // {
55
+ // title: 'A discussion of discussions on AI bias',
56
+ // url: 'https://danluu.com/ai-bias/',
57
+ // date: 2024-06-16T00:00:00.000Z,
58
+ // source: { url: 'https://danluu.com/atom.xml', title: 'Dan Luu' },
59
+ // preview: `There've been regular viral stories about ML/AI bias with LLMs and generative AI for the past couple years. One thing I find interesting about discussions of bias is how different the reaction is in the LLM and generative AI case when compared to "classical" bugs in cases where there's a clear bug. ...`
60
+ // },
61
+ // {
62
+ // title: 'Writing a Unix clone in about a month',
63
+ // url: 'https://drewdevault.com/2024/05/24/2024-05-24-Bunnix.html',
64
+ // date: 2024-05-24T00:00:00.000Z,
65
+ // source: {
66
+ // url: 'https://drewdevault.com/blog/index.xml',
67
+ // title: 'Drew DeVault'
68
+ // },
69
+ // preview: 'I needed a bit of a break from “real work” recently, so I started a new programming project that was low-stakes and purely recreational. On April 21st, I set out to see how much of a Unix-like operating system for x86_64 targets that I could put together in about a month. The result is Bunnix. Not i...'
70
+ // },
71
+ // {
72
+ // title: 'The Web Component Success Story',
73
+ // url: 'https://jakelazaroff.com/words/the-web-component-success-story/',
74
+ // date: 2024-01-29T00:00:00.000Z,
75
+ // source: { url: 'https://jakelazaroff.com/rss.xml', title: 'Jake Lazaroff' },
76
+ // preview: "Web components won't take web development by storm, or show us the One True Way to build websites. What they will do is let us collectively build a rich ecosystem of dynamic components that work with any web stack."
77
+ // }
78
+ // ]
79
+ ```
80
+
81
+ ## Configuration
82
+
83
+ `webring` is configured by passing in a `Configuration` object into the `run` method.
84
+
85
+ All possible configuration values can be seen by looking at the [`typedoc` site](https://shepherdjerred.github.io/webring/types/Configuration.html).
86
+
87
+ ## Example
88
+
89
+ An example of using this project with Astro is located in `example`. The relevant file is [`src/pages/blog/[...slug].astro`](https://github.com/shepherdjerred/webring/blob/971a77ecd0c612850faeb9d16f7775d3e7ca7253/example/src/pages/blog/%5B...slug%5D.astro#L18).
90
+
91
+ ```typescript
92
+ ---
93
+ import { type CollectionEntry, getCollection } from "astro:content";
94
+ import BlogPost from "../../layouts/BlogPost.astro";
95
+ import { type Configuration, type Result, run } from "webring";
96
+
97
+ export async function getStaticPaths() {
98
+ const posts = await getCollection("blog");
99
+ return posts.map((post) => ({
100
+ params: { slug: post.slug },
101
+ props: post,
102
+ }));
103
+ }
104
+ type Props = CollectionEntry<"blog">;
105
+
106
+ const post = Astro.props;
107
+ const { Content } = await post.render();
25
108
 
26
109
  export const config: Configuration = {
27
110
  sources: [
@@ -38,39 +121,33 @@ export const config: Configuration = {
38
121
  title: "Jake Lazaroff",
39
122
  },
40
123
  ],
41
- // the output will return the three most recent posts from the above sources
42
124
  number: 3,
43
- // the output will return santized HTML truncated to 300 characters
44
125
  truncate: 300,
45
- // if this is defined, we'll cache the results
46
126
  cache: {
47
- // the file to use as a cache
48
127
  cache_file: "webring.json",
49
- // how long the cache should remain valid for
50
128
  cache_duration_minutes: 60,
51
129
  },
52
130
  };
53
131
 
54
- // type Result = {
55
- // title: string,
56
- // url: string,
57
- // date: Date,
58
- // source: {
59
- // url: string,
60
- // title: string,
61
- // },
62
- // // this will be undefined if the RSS feed is empty
63
- // preview?: string
64
- // }[]
65
132
  export const result: Result = await run(config);
133
+ ---
66
134
 
67
- result.map((entry) => {
68
- // do something with the results
69
- // for example, you might render each item as an HTML block
70
- });
135
+ <BlogPost {...post.data}>
136
+ <Content />
137
+ <h2>Posts from blogs I read</h2>
138
+ <ul>
139
+ {
140
+ result.map((post) => (
141
+ <li>
142
+ <a href={post.url}>{post.title}</a>
143
+ </li>
144
+ ))
145
+ }
146
+ </ul>
147
+ </BlogPost>
71
148
  ```
72
149
 
73
- Here's what I do for my blog:
150
+ ## Inspiration
74
151
 
75
- 1. Run `map` against the resulting array ([code](https://github.com/shepherdjerred/sjer.red/blob/f72b2b75bf0722ba8ff0fdd45f31c02c2ee5089d/src/components/BlogWebring.astro#L17-L22)).
76
- 1. Render a component for each entry ([code](https://github.com/shepherdjerred/sjer.red/blob/f72b2b75bf0722ba8ff0fdd45f31c02c2ee5089d/src/components/WebringEntry.astro)).
152
+ - https://github.com/lukehsiao/openring-rs
153
+ - https://git.sr.ht/~sircmpwn/openring
package/dist/cache.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { type Cache, type Result, type ResultEntry, type Source, type CachedConfiguration } from "./types.js";
2
2
  export declare function fetchAllCached(config: CachedConfiguration): Promise<Result>;
3
3
  export declare function fetchWithCache(source: Source, cache: Cache, config: CachedConfiguration): Promise<ResultEntry | undefined>;
4
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,KAAK,EACV,KAAK,MAAM,EACX,KAAK,WAAW,EAEhB,KAAK,MAAM,EACX,KAAK,mBAAmB,EAGzB,MAAM,YAAY,CAAC;AAyCpB,wBAAsB,cAAc,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAQjF;AAED,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,mBAAmB,GAC1B,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAgBlC"}
package/dist/cache.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as R from "remeda";
2
2
  import { CacheSchema, } from "./types.js";
3
3
  import { fetch } from "./fetch.js";
4
- import fs from "fs/promises";
5
4
  import { asyncMapFilterUndefined } from "./util.js";
5
+ import fs from "fs/promises";
6
6
  async function loadCache({ cache_file }) {
7
7
  try {
8
8
  await fs.access(cache_file);
@@ -14,7 +14,10 @@ async function loadCache({ cache_file }) {
14
14
  }
15
15
  }
16
16
  async function saveCache({ cache_file }, cache) {
17
- await fs.mkdir(cache_file.split("/").slice(0, -1).join("/"), { recursive: true });
17
+ const dir = cache_file.split("/").slice(0, -1).join("/");
18
+ if (dir !== "") {
19
+ await fs.mkdir(cache_file.split("/").slice(0, -1).join("/"), { recursive: true });
20
+ }
18
21
  await fs.writeFile(cache_file, JSON.stringify(cache));
19
22
  }
20
23
  function toCacheEntry(result, now) {
@@ -52,3 +55,4 @@ export async function fetchWithCache(source, cache, config) {
52
55
  console.log(`Fetching ${source.url}`);
53
56
  return fetch(source, config.truncate);
54
57
  }
58
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"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"}
package/dist/fetch.d.ts CHANGED
@@ -10,3 +10,4 @@ export declare function fetchAll(config: Configuration): Promise<{
10
10
  preview?: string | undefined;
11
11
  }[]>;
12
12
  export declare function fetch(source: Source, length: number): Promise<ResultEntry | undefined>;
13
+ //# sourceMappingURL=fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,MAAM,EAAE,KAAK,WAAW,EAAmB,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAOhG,wBAAsB,QAAQ,CAAC,MAAM,EAAE,aAAa;;;;;;;;;KAEnD;AAED,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAsC5F"}
package/dist/fetch.js CHANGED
@@ -35,3 +35,4 @@ export async function fetch(source, length) {
35
35
  return undefined;
36
36
  }
37
37
  }
38
+ //# sourceMappingURL=fetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch.js","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,YAAY,CAAC;AAChC,OAAO,YAAY,MAAM,eAAe,CAAC;AACzC,OAAO,QAAQ,MAAM,eAAe,CAAC;AACrC,OAAO,EAAiC,eAAe,EAAsB,MAAM,YAAY,CAAC;AAChG,OAAO,KAAK,CAAC,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAEpD,sFAAsF;AACtF,MAAM,UAAU,GAA4B,QAA8C,CAAC;AAE3F,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,MAAqB;IAClD,OAAO,MAAM,uBAAuB,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;AACnG,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,MAAc,EAAE,MAAc;IACxD,MAAM,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;IAC5B,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAE/C,MAAM,SAAS,GAAG,CAAC,CAAC,IAAI,CACtB,IAAI,CAAC,KAAK,EACV,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAC5C,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC,EACjD,CAAC,CAAC,OAAO,EAAE,EACX,CAAC,CAAC,KAAK,EAAE,CACV,CAAC;QAEF,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;QAC5C,CAAC;QAED,MAAM,OAAO,GACX,SAAS,CAAC,cAAc,IAAI,SAAS,CAAC,OAAO,IAAI,SAAS,CAAC,WAAW,IAAI,SAAS,CAAC,iBAAiB,CAAC,CAAC;QAEzG,OAAO;YACL,KAAK,EAAE,SAAS,CAAC,KAAK;YACtB,GAAG,EAAE,SAAS,CAAC,IAAI;YACnB,IAAI,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;YAC9B,MAAM;YACN,OAAO,EAAE,OAAO;gBACd,CAAC,CAAC,UAAU,CACR,YAAY,CAAC,OAAO,EAAE;oBACpB,oBAAoB,EAAE,KAAK;iBAC5B,CAAC,EACF,MAAM,CACP;gBACH,CAAC,CAAC,SAAS;SACd,CAAC;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,KAAK,CAAC,kBAAkB,MAAM,CAAC,GAAG,KAAK,CAAW,EAAE,CAAC,CAAC;QAC9D,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { type Configuration, type Result } from "./types.js";
2
2
  export declare function run(config: Configuration): Promise<Result>;
3
3
  export * from "./types.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,MAAM,EAA6B,MAAM,YAAY,CAAC;AAExF,wBAAsB,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAoBhE;AAED,cAAc,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -17,3 +17,4 @@ export async function run(config) {
17
17
  return topResults;
18
18
  }
19
19
  export * from "./types.js";
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,cAAc,IAAI,cAAc,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,QAAQ,IAAI,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC1D,OAAO,EAAmC,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAExF,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,MAAqB;IAC7C,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,yBAAyB,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAEtE,IAAI,MAAc,CAAC;IACnB,IAAI,OAAO,EAAE,CAAC;QACZ,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,CAAC,CAAC;QACxD,MAAM,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;QAC/B,MAAM,GAAG,MAAM,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAC1C,CAAC;IAED,MAAM,UAAU,GAAG,CAAC,CAAC,IAAI,CACvB,MAAM,EACN,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,EAC3C,CAAC,CAAC,OAAO,EAAE,EACX,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CACtB,CAAC;IAEF,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,cAAc,YAAY,CAAC"}
@@ -1 +1,2 @@
1
1
  export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -4,7 +4,7 @@ import { tmpdir } from "os";
4
4
  import { mkdtemp } from "fs/promises";
5
5
  import { join } from "path";
6
6
  // TODO: intercept network requests
7
- test("it should fetch an RSS feed without caching", async () => {
7
+ test("it should fetch an RSS feed without caching", { timeout: 30000 }, async () => {
8
8
  const config = {
9
9
  sources: [
10
10
  {
@@ -18,7 +18,7 @@ test("it should fetch an RSS feed without caching", async () => {
18
18
  const result = await run(config);
19
19
  expect(result).toMatchSnapshot();
20
20
  });
21
- test("it should fetch several RSS feeds", async () => {
21
+ test("it should fetch several RSS feeds", { timeout: 30000 }, async () => {
22
22
  const config = {
23
23
  sources: [
24
24
  {
@@ -29,58 +29,14 @@ test("it should fetch several RSS feeds", async () => {
29
29
  url: "https://danluu.com/atom.xml",
30
30
  title: "Dan Luu",
31
31
  },
32
- {
33
- url: "https://jakelazaroff.com/rss.xml",
34
- title: "Jake Lazaroff",
35
- },
36
- {
37
- url: "https://awesomekling.github.io/feed.xml",
38
- title: "Andreas Kling",
39
- },
40
- {
41
- url: "https://xeiaso.net/blog.rss",
42
- title: "Xe Iaso",
43
- },
44
- {
45
- url: "https://ciechanow.ski/atom.xml",
46
- title: "Bartosz Ciechanowski",
47
- },
48
- {
49
- url: "https://explained-from-first-principles.com/feed.xml",
50
- title: "Explained From First Principles",
51
- },
52
- {
53
- url: "http://www.aaronsw.com/2002/feeds/pgessays.rss",
54
- title: "Paul Graham",
55
- },
56
- {
57
- url: "https://samwho.dev/rss.xml",
58
- title: "Sam Rose",
59
- },
60
- {
61
- url: "https://rachelbythebay.com/w/atom.xml",
62
- title: "Rachel Kroll",
63
- },
64
- {
65
- url: "https://brr.fyi/feed.xml",
66
- title: "brr.fyi",
67
- },
68
- {
69
- url: "https://devblogs.microsoft.com/oldnewthing/feed",
70
- title: "The Old New Thing",
71
- },
72
- {
73
- url: "https://ludic.mataroa.blog/rss/",
74
- title: "Ludicity",
75
- },
76
32
  ],
77
- number: 20,
33
+ number: 3,
78
34
  truncate: 300,
79
35
  };
80
36
  const result = await run(config);
81
37
  expect(result).toMatchSnapshot();
82
38
  });
83
- test("it should fetch an RSS feed with caching", async () => {
39
+ test("it should fetch an RSS feed with caching", { timeout: 30000 }, async () => {
84
40
  const config = {
85
41
  sources: [
86
42
  {
@@ -104,3 +60,4 @@ async function createTempDir() {
104
60
  const dir = join(ostmpdir, "unit-test-");
105
61
  return await mkdtemp(dir);
106
62
  }
63
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AACjC,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,mCAAmC;AACnC,IAAI,CAAC,6CAA6C,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,IAAI,EAAE;IACjF,MAAM,MAAM,GAAkB;QAC5B,OAAO,EAAE;YACP;gBACE,KAAK,EAAE,iBAAiB;gBACxB,GAAG,EAAE,0BAA0B;aAChC;SACF;QACD,MAAM,EAAE,CAAC;QACT,QAAQ,EAAE,GAAG;KACd,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,MAAM,CAAC,CAAC,eAAe,EAAE,CAAC;AACnC,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,mCAAmC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,IAAI,EAAE;IACvE,MAAM,MAAM,GAAkB;QAC5B,OAAO,EAAE;YACP;gBACE,GAAG,EAAE,wCAAwC;gBAC7C,KAAK,EAAE,cAAc;aACtB;YACD;gBACE,GAAG,EAAE,6BAA6B;gBAClC,KAAK,EAAE,SAAS;aACjB;SACF;QACD,MAAM,EAAE,CAAC;QACT,QAAQ,EAAE,GAAG;KACd,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,MAAM,CAAC,CAAC,eAAe,EAAE,CAAC;AACnC,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,0CAA0C,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,IAAI,EAAE;IAC9E,MAAM,MAAM,GAAkB;QAC5B,OAAO,EAAE;YACP;gBACE,KAAK,EAAE,iBAAiB;gBACxB,GAAG,EAAE,0BAA0B;aAChC;SACF;QACD,MAAM,EAAE,CAAC;QACT,QAAQ,EAAE,GAAG;QACb,KAAK,EAAE;YACL,UAAU,EAAE,GAAG,MAAM,aAAa,EAAE,aAAa;YACjD,sBAAsB,EAAE,CAAC;SAC1B;KACF,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,MAAM,CAAC,CAAC,eAAe,EAAE,CAAC;AACnC,CAAC,CAAC,CAAC;AAEH,oDAAoD;AACpD,KAAK,UAAU,aAAa;IAC1B,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC;IAC1B,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;IACzC,OAAO,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC;AAC5B,CAAC"}
package/dist/types.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { z } from "zod";
2
2
  export type Source = z.infer<typeof SourceSchema>;
3
+ /** An RSS source */
3
4
  declare const SourceSchema: z.ZodObject<{
5
+ /** The URL of an RSS feed */
4
6
  url: z.ZodString;
7
+ /** A title to describe the feed */
5
8
  title: z.ZodString;
6
9
  }, "strip", z.ZodTypeAny, {
7
10
  url: string;
@@ -11,8 +14,11 @@ declare const SourceSchema: z.ZodObject<{
11
14
  title: string;
12
15
  }>;
13
16
  export type CacheConfiguration = z.infer<typeof CacheConfigurationSchema>;
17
+ /** Configuration for the cache */
14
18
  declare const CacheConfigurationSchema: z.ZodObject<{
19
+ /** How long to cache a result for */
15
20
  cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
21
+ /** The location of a file to use as a cache */
16
22
  cache_file: z.ZodDefault<z.ZodString>;
17
23
  }, "strip", z.ZodTypeAny, {
18
24
  cache_duration_minutes: number;
@@ -22,9 +28,13 @@ declare const CacheConfigurationSchema: z.ZodObject<{
22
28
  cache_file?: string | undefined;
23
29
  }>;
24
30
  export type Configuration = z.infer<typeof ConfigurationSchema>;
31
+ /** A configuration object with caching possibly configured */
25
32
  declare const ConfigurationSchema: z.ZodObject<{
33
+ /** A list of sources to fetch */
26
34
  sources: z.ZodArray<z.ZodObject<{
35
+ /** The URL of an RSS feed */
27
36
  url: z.ZodString;
37
+ /** A title to describe the feed */
28
38
  title: z.ZodString;
29
39
  }, "strip", z.ZodTypeAny, {
30
40
  url: string;
@@ -33,10 +43,15 @@ declare const ConfigurationSchema: z.ZodObject<{
33
43
  url: string;
34
44
  title: string;
35
45
  }>, "many">;
46
+ /** Return the n latest updates from the source list. */
36
47
  number: z.ZodDefault<z.ZodNumber>;
48
+ /** How many words the preview field should be truncated to in characters after HTML has been sanitized and parsed. */
37
49
  truncate: z.ZodDefault<z.ZodNumber>;
50
+ /** Configuration for the cache */
38
51
  cache: z.ZodOptional<z.ZodObject<{
52
+ /** How long to cache a result for */
39
53
  cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
54
+ /** The location of a file to use as a cache */
40
55
  cache_file: z.ZodDefault<z.ZodString>;
41
56
  }, "strip", z.ZodTypeAny, {
42
57
  cache_duration_minutes: number;
@@ -69,9 +84,13 @@ declare const ConfigurationSchema: z.ZodObject<{
69
84
  } | undefined;
70
85
  }>;
71
86
  export type CachedConfiguration = z.infer<typeof CachedConfigurationSchema>;
87
+ /** A configuration object with caching definitely configured */
72
88
  export declare const CachedConfigurationSchema: z.ZodObject<z.objectUtil.extendShape<{
89
+ /** A list of sources to fetch */
73
90
  sources: z.ZodArray<z.ZodObject<{
91
+ /** The URL of an RSS feed */
74
92
  url: z.ZodString;
93
+ /** A title to describe the feed */
75
94
  title: z.ZodString;
76
95
  }, "strip", z.ZodTypeAny, {
77
96
  url: string;
@@ -80,10 +99,15 @@ export declare const CachedConfigurationSchema: z.ZodObject<z.objectUtil.extendS
80
99
  url: string;
81
100
  title: string;
82
101
  }>, "many">;
102
+ /** Return the n latest updates from the source list. */
83
103
  number: z.ZodDefault<z.ZodNumber>;
104
+ /** How many words the preview field should be truncated to in characters after HTML has been sanitized and parsed. */
84
105
  truncate: z.ZodDefault<z.ZodNumber>;
106
+ /** Configuration for the cache */
85
107
  cache: z.ZodOptional<z.ZodObject<{
108
+ /** How long to cache a result for */
86
109
  cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
110
+ /** The location of a file to use as a cache */
87
111
  cache_file: z.ZodDefault<z.ZodString>;
88
112
  }, "strip", z.ZodTypeAny, {
89
113
  cache_duration_minutes: number;
@@ -93,8 +117,11 @@ export declare const CachedConfigurationSchema: z.ZodObject<z.objectUtil.extendS
93
117
  cache_file?: string | undefined;
94
118
  }>>;
95
119
  }, {
120
+ /** Configuration for the cache */
96
121
  cache: z.ZodObject<{
122
+ /** How long to cache a result for */
97
123
  cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
124
+ /** The location of a file to use as a cache */
98
125
  cache_file: z.ZodDefault<z.ZodString>;
99
126
  }, "strip", z.ZodTypeAny, {
100
127
  cache_duration_minutes: number;
@@ -127,12 +154,19 @@ export declare const CachedConfigurationSchema: z.ZodObject<z.objectUtil.extendS
127
154
  truncate?: number | undefined;
128
155
  }>;
129
156
  export type ResultEntry = z.infer<typeof ResultEntrySchema>;
157
+ /** A single entry from an RSS feed */
130
158
  declare const ResultEntrySchema: z.ZodObject<{
159
+ /** The title of the entry */
131
160
  title: z.ZodString;
161
+ /** A direct link to the entry */
132
162
  url: z.ZodString;
163
+ /** The date of the entry */
133
164
  date: z.ZodDate;
165
+ /** The source the entry is from */
134
166
  source: z.ZodObject<{
167
+ /** The URL of an RSS feed */
135
168
  url: z.ZodString;
169
+ /** A title to describe the feed */
136
170
  title: z.ZodString;
137
171
  }, "strip", z.ZodTypeAny, {
138
172
  url: string;
@@ -141,6 +175,7 @@ declare const ResultEntrySchema: z.ZodObject<{
141
175
  url: string;
142
176
  title: string;
143
177
  }>;
178
+ /** A preview of the entry. This may contain sanitized HTML. */
144
179
  preview: z.ZodOptional<z.ZodString>;
145
180
  }, "strip", z.ZodTypeAny, {
146
181
  url: string;
@@ -162,12 +197,19 @@ declare const ResultEntrySchema: z.ZodObject<{
162
197
  preview?: string | undefined;
163
198
  }>;
164
199
  export type Result = z.infer<typeof ResultSchema>;
200
+ /** A list of results */
165
201
  declare const ResultSchema: z.ZodArray<z.ZodObject<{
202
+ /** The title of the entry */
166
203
  title: z.ZodString;
204
+ /** A direct link to the entry */
167
205
  url: z.ZodString;
206
+ /** The date of the entry */
168
207
  date: z.ZodDate;
208
+ /** The source the entry is from */
169
209
  source: z.ZodObject<{
210
+ /** The URL of an RSS feed */
170
211
  url: z.ZodString;
212
+ /** A title to describe the feed */
171
213
  title: z.ZodString;
172
214
  }, "strip", z.ZodTypeAny, {
173
215
  url: string;
@@ -176,6 +218,7 @@ declare const ResultSchema: z.ZodArray<z.ZodObject<{
176
218
  url: string;
177
219
  title: string;
178
220
  }>;
221
+ /** A preview of the entry. This may contain sanitized HTML. */
179
222
  preview: z.ZodOptional<z.ZodString>;
180
223
  }, "strip", z.ZodTypeAny, {
181
224
  url: string;
@@ -197,14 +240,23 @@ declare const ResultSchema: z.ZodArray<z.ZodObject<{
197
240
  preview?: string | undefined;
198
241
  }>, "many">;
199
242
  export type CacheEntry = z.infer<typeof CacheEntrySchema>;
243
+ /** A single cache entry */
200
244
  export declare const CacheEntrySchema: z.ZodObject<{
245
+ /** The time a source was last checked */
201
246
  timestamp: z.ZodDate;
247
+ /** The data from the source */
202
248
  data: z.ZodObject<{
249
+ /** The title of the entry */
203
250
  title: z.ZodString;
251
+ /** A direct link to the entry */
204
252
  url: z.ZodString;
253
+ /** The date of the entry */
205
254
  date: z.ZodDate;
255
+ /** The source the entry is from */
206
256
  source: z.ZodObject<{
257
+ /** The URL of an RSS feed */
207
258
  url: z.ZodString;
259
+ /** A title to describe the feed */
208
260
  title: z.ZodString;
209
261
  }, "strip", z.ZodTypeAny, {
210
262
  url: string;
@@ -213,6 +265,7 @@ export declare const CacheEntrySchema: z.ZodObject<{
213
265
  url: string;
214
266
  title: string;
215
267
  }>;
268
+ /** A preview of the entry. This may contain sanitized HTML. */
216
269
  preview: z.ZodOptional<z.ZodString>;
217
270
  }, "strip", z.ZodTypeAny, {
218
271
  url: string;
@@ -259,14 +312,23 @@ export declare const CacheEntrySchema: z.ZodObject<{
259
312
  };
260
313
  }>;
261
314
  export type Cache = z.infer<typeof CacheSchema>;
315
+ /** A mapping of source URLs to cache entries */
262
316
  export declare const CacheSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
317
+ /** The time a source was last checked */
263
318
  timestamp: z.ZodDate;
319
+ /** The data from the source */
264
320
  data: z.ZodObject<{
321
+ /** The title of the entry */
265
322
  title: z.ZodString;
323
+ /** A direct link to the entry */
266
324
  url: z.ZodString;
325
+ /** The date of the entry */
267
326
  date: z.ZodDate;
327
+ /** The source the entry is from */
268
328
  source: z.ZodObject<{
329
+ /** The URL of an RSS feed */
269
330
  url: z.ZodString;
331
+ /** A title to describe the feed */
270
332
  title: z.ZodString;
271
333
  }, "strip", z.ZodTypeAny, {
272
334
  url: string;
@@ -275,6 +337,7 @@ export declare const CacheSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
275
337
  url: string;
276
338
  title: string;
277
339
  }>;
340
+ /** A preview of the entry. This may contain sanitized HTML. */
278
341
  preview: z.ZodOptional<z.ZodString>;
279
342
  }, "strip", z.ZodTypeAny, {
280
343
  url: string;
@@ -320,6 +383,7 @@ export declare const CacheSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
320
383
  preview?: string | undefined;
321
384
  };
322
385
  }>>;
386
+ /** The expected format fetched RSS feed entries */
323
387
  export declare const FeedEntrySchema: z.ZodEffects<z.ZodObject<{
324
388
  title: z.ZodString;
325
389
  link: z.ZodString;
@@ -366,3 +430,4 @@ export declare const FeedEntrySchema: z.ZodEffects<z.ZodObject<{
366
430
  description?: string | undefined;
367
431
  }>;
368
432
  export {};
433
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js CHANGED
@@ -1,40 +1,59 @@
1
1
  import { z } from "zod";
2
+ /** An RSS source */
2
3
  const SourceSchema = z.object({
3
- // the url of the feed
4
+ /** The URL of an RSS feed */
4
5
  url: z.string(),
5
- // a title for the feed
6
- title: z.string(),
6
+ /** A title to describe the feed */
7
+ title: z.string().describe("A title for the feed"),
7
8
  });
9
+ /** Configuration for the cache */
8
10
  const CacheConfigurationSchema = z.object({
9
- // how long to cache a results for
11
+ /** How long to cache a result for */
10
12
  cache_duration_minutes: z.number().default(60),
13
+ /** The location of a file to use as a cache */
11
14
  cache_file: z.string().default("cache.json"),
12
15
  });
16
+ /** A configuration object with caching possibly configured */
13
17
  const ConfigurationSchema = z.object({
14
- // list of sources to fetch
18
+ /** A list of sources to fetch */
15
19
  sources: SourceSchema.array(),
16
- // how many entries to return
20
+ /** Return the n latest updates from the source list. */
17
21
  number: z.number().default(3),
18
- // how many words to truncate the preview to
22
+ /** How many words the preview field should be truncated to in characters after HTML has been sanitized and parsed. */
19
23
  truncate: z.number().default(300),
24
+ /** Configuration for the cache */
20
25
  cache: CacheConfigurationSchema.optional(),
21
26
  });
27
+ /** A configuration object with caching definitely configured */
22
28
  export const CachedConfigurationSchema = ConfigurationSchema.extend({
29
+ /** Configuration for the cache */
23
30
  cache: CacheConfigurationSchema,
24
31
  });
32
+ /** A single entry from an RSS feed */
25
33
  const ResultEntrySchema = z.object({
34
+ /** The title of the entry */
26
35
  title: z.string(),
36
+ /** A direct link to the entry */
27
37
  url: z.string(),
38
+ /** The date of the entry */
28
39
  date: z.coerce.date(),
40
+ /** The source the entry is from */
29
41
  source: SourceSchema,
42
+ /** A preview of the entry. This may contain sanitized HTML. */
30
43
  preview: z.string().optional(),
31
44
  });
45
+ /** A list of results */
32
46
  const ResultSchema = z.array(ResultEntrySchema);
47
+ /** A single cache entry */
33
48
  export const CacheEntrySchema = z.object({
49
+ /** The time a source was last checked */
34
50
  timestamp: z.coerce.date(),
51
+ /** The data from the source */
35
52
  data: ResultEntrySchema,
36
53
  });
54
+ /** A mapping of source URLs to cache entries */
37
55
  export const CacheSchema = z.record(CacheEntrySchema);
56
+ /** The expected format fetched RSS feed entries */
38
57
  export const FeedEntrySchema = z
39
58
  .object({
40
59
  title: z.string(),
@@ -61,3 +80,4 @@ export const FeedEntrySchema = z
61
80
  "content:encoded": entry["content:encoded"],
62
81
  };
63
82
  });
83
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/util.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export declare function asyncMapFilterUndefined<T, U>(input: T[], fn: (x: T) => Promise<U | undefined>): Promise<U[]>;
2
2
  export declare function asyncMap<T, U>(input: T[], fn: (x: T) => Promise<U>): Promise<U[]>;
3
3
  export declare function filterUndefined<T>(input: (T | undefined)[]): T[];
4
+ //# sourceMappingURL=util.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAGA,wBAAsB,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAGlH;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAOvF;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,GAAG,SAAS,CAAC,EAAE,GAAG,CAAC,EAAE,CAKhE"}
package/dist/util.js CHANGED
@@ -11,3 +11,4 @@ export async function asyncMap(input, fn) {
11
11
  export function filterUndefined(input) {
12
12
  return R.pipe(input, R.filter((result) => result !== undefined));
13
13
  }
14
+ //# sourceMappingURL=util.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,QAAQ,CAAC;AAE5B,8DAA8D;AAC9D,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAAO,KAAU,EAAE,EAAoC;IAClG,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAC1C,OAAO,eAAe,CAAC,OAAO,CAAC,CAAC;AAClC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAO,KAAU,EAAE,EAAwB;IACvE,MAAM,QAAQ,GAAG,CAAC,CAAC,IAAI,CACrB,KAAK,EACL,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAC1B,CAAC;IAEF,OAAO,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AAC/B,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,KAAwB;IACzD,OAAO,CAAC,CAAC,IAAI,CACX,KAAK,EACL,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,KAAK,SAAS,CAAC,CACpC,CAAC;AACX,CAAC"}
package/package.json CHANGED
@@ -1,12 +1,29 @@
1
1
  {
2
2
  "name": "webring",
3
+ "description": "Collect the latest RSS items from your favorite feeds.",
4
+ "author": {
5
+ "name": "Jerred Shepherd",
6
+ "email": "npm@sjer.red",
7
+ "url": "https://sjer.red"
8
+ },
9
+ "homepage": "https://github.com/shepherdjerred/webring",
10
+ "license": "GPL-3.0-only",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/shepherdjerred/webring.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/shepherdjerred/webring/issues"
17
+ },
3
18
  "type": "module",
4
- "version": "1.0.3",
19
+ "version": "1.1.1",
5
20
  "scripts": {
6
21
  "prepare": "husky",
7
22
  "lint": "eslint src",
8
23
  "build": "tsc",
9
- "test": "vitest --disable-console-intercept"
24
+ "watch": "tsc -w",
25
+ "test": "vitest --disable-console-intercept",
26
+ "typedoc": "typedoc src/index.ts"
10
27
  },
11
28
  "main": "dist/index.js",
12
29
  "types": "dist/index.d.ts",
@@ -23,7 +40,6 @@
23
40
  "@eslint/js": "^9.4.0",
24
41
  "@tsconfig/node20": "^20.1.4",
25
42
  "@tsconfig/strictest": "^2.0.5",
26
- "@types/eslint__js": "^8.42.3",
27
43
  "@types/node": "^20.14.0",
28
44
  "@types/sanitize-html": "^2.11.0",
29
45
  "@typescript-eslint/eslint-plugin": "^7.11.0",
@@ -32,6 +48,8 @@
32
48
  "husky": "^9.0.11",
33
49
  "lint-staged": "^15.2.5",
34
50
  "prettier": "^3.3.0",
51
+ "typedoc": "^0.26.3",
52
+ "typedoc-plugin-zod": "^1.2.0",
35
53
  "typescript": "^5.4.5",
36
54
  "typescript-eslint": "^7.11.0",
37
55
  "vitest": "^1.6.0"
@@ -50,6 +68,7 @@
50
68
  },
51
69
  "files": [
52
70
  "dist",
71
+ "src",
53
72
  "package.json",
54
73
  "README.md",
55
74
  "LICENSE",
@@ -0,0 +1,56 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`it should fetch an RSS feed with caching 1`] = `
4
+ [
5
+ {
6
+ "date": 2024-07-01T00:00:00.000Z,
7
+ "preview": "TIL: Using Twoslash with Shiki and Astro",
8
+ "source": {
9
+ "title": "Jerred Shepherd",
10
+ "url": "https://sjer.red/rss.xml",
11
+ },
12
+ "title": "TIL: Using Twoslash with Shiki and Astro",
13
+ "url": "https://sjer.red/blog/til/2024-07-01/",
14
+ },
15
+ ]
16
+ `;
17
+
18
+ exports[`it should fetch an RSS feed without caching 1`] = `
19
+ [
20
+ {
21
+ "date": 2024-07-01T00:00:00.000Z,
22
+ "preview": "TIL: Using Twoslash with Shiki and Astro",
23
+ "source": {
24
+ "title": "Jerred Shepherd",
25
+ "url": "https://sjer.red/rss.xml",
26
+ },
27
+ "title": "TIL: Using Twoslash with Shiki and Astro",
28
+ "url": "https://sjer.red/blog/til/2024-07-01/",
29
+ },
30
+ ]
31
+ `;
32
+
33
+ exports[`it should fetch several RSS feeds 1`] = `
34
+ [
35
+ {
36
+ "date": 2024-06-16T00:00:00.000Z,
37
+ "preview": "There've been regular viral stories about ML/AI bias with LLMs and generative AI for the past couple years. One thing I find interesting about discussions of bias is how different the reaction is in the LLM and generative AI case when compared to "classical" bugs in cases where there's a clear bug. ...",
38
+ "source": {
39
+ "title": "Dan Luu",
40
+ "url": "https://danluu.com/atom.xml",
41
+ },
42
+ "title": "A discussion of discussions on AI bias",
43
+ "url": "https://danluu.com/ai-bias/",
44
+ },
45
+ {
46
+ "date": 2024-05-24T00:00:00.000Z,
47
+ "preview": "I needed a bit of a break from “real work” recently, so I started a new programming project that was low-stakes and purely recreational. On April 21st, I set out to see how much of a Unix-like operating system for x86_64 targets that I could put together in about a month. The result is Bunnix. Not i...",
48
+ "source": {
49
+ "title": "Drew DeVault",
50
+ "url": "https://drewdevault.com/blog/index.xml",
51
+ },
52
+ "title": "Writing a Unix clone in about a month",
53
+ "url": "https://drewdevault.com/2024/05/24/2024-05-24-Bunnix.html",
54
+ },
55
+ ]
56
+ `;
package/src/cache.ts ADDED
@@ -0,0 +1,82 @@
1
+ import * as R from "remeda";
2
+ import {
3
+ type Cache,
4
+ type Result,
5
+ type ResultEntry,
6
+ type CacheEntry,
7
+ type Source,
8
+ type CachedConfiguration,
9
+ CacheSchema,
10
+ type CacheConfiguration,
11
+ } from "./types.js";
12
+ import { fetch } from "./fetch.js";
13
+ import { asyncMapFilterUndefined } from "./util.js";
14
+ import fs from "fs/promises";
15
+
16
+ async function loadCache({ cache_file }: CacheConfiguration): Promise<Cache> {
17
+ try {
18
+ await fs.access(cache_file);
19
+ const cacheFile = await fs.readFile(cache_file);
20
+ return CacheSchema.parse(JSON.parse(cacheFile.toString()));
21
+ } catch (e) {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ async function saveCache({ cache_file }: CacheConfiguration, cache: Cache) {
27
+ const dir = cache_file.split("/").slice(0, -1).join("/");
28
+ if (dir !== "") {
29
+ await fs.mkdir(cache_file.split("/").slice(0, -1).join("/"), { recursive: true });
30
+ }
31
+ await fs.writeFile(cache_file, JSON.stringify(cache));
32
+ }
33
+
34
+ function toCacheEntry(result: ResultEntry, now: Date): [string, CacheEntry] {
35
+ return [result.source.url, { timestamp: now, data: result }];
36
+ }
37
+
38
+ function toCache(results: ResultEntry[], now: Date): Cache {
39
+ return R.pipe(
40
+ results,
41
+ R.map((result) => toCacheEntry(result, now)),
42
+ R.fromEntries(),
43
+ );
44
+ }
45
+
46
+ function updateCache(results: ResultEntry[], config: CachedConfiguration): Promise<void> {
47
+ const now = new Date();
48
+ const updatedCache = toCache(results, now);
49
+ return saveCache(config.cache, updatedCache);
50
+ }
51
+
52
+ export async function fetchAllCached(config: CachedConfiguration): Promise<Result> {
53
+ const cache = await loadCache(config.cache);
54
+
55
+ const results = await asyncMapFilterUndefined(config.sources, (source) => fetchWithCache(source, cache, config));
56
+
57
+ await updateCache(results, config);
58
+
59
+ return results;
60
+ }
61
+
62
+ export async function fetchWithCache(
63
+ source: Source,
64
+ cache: Cache,
65
+ config: CachedConfiguration,
66
+ ): Promise<ResultEntry | undefined> {
67
+ const cacheEntry = cache[source.url];
68
+ if (cacheEntry) {
69
+ const now = new Date();
70
+ if (now.getTime() - cacheEntry.timestamp.getTime() < config.cache.cache_duration_minutes * 60 * 1000) {
71
+ console.log(`Cache entry found for ${source.url}.`);
72
+ return Promise.resolve(cacheEntry.data);
73
+ } else {
74
+ console.log(`Cache entry for ${source.url} is too old.`);
75
+ }
76
+ } else {
77
+ console.log(`No cache entry for ${source.url}.`);
78
+ }
79
+
80
+ console.log(`Fetching ${source.url}`);
81
+ return fetch(source, config.truncate);
82
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,53 @@
1
+ import Parser from "rss-parser";
2
+ import sanitizeHtml from "sanitize-html";
3
+ import truncate from "truncate-html";
4
+ import { type Source, type ResultEntry, FeedEntrySchema, type Configuration } from "./types.js";
5
+ import * as R from "remeda";
6
+ import { asyncMapFilterUndefined } from "./util.js";
7
+
8
+ // for some reason, TypeScript does not infer the type of the default export correctly
9
+ const truncateFn: typeof truncate.default = truncate as unknown as typeof truncate.default;
10
+
11
+ export async function fetchAll(config: Configuration) {
12
+ return await asyncMapFilterUndefined(config.sources, (source) => fetch(source, config.truncate));
13
+ }
14
+
15
+ export async function fetch(source: Source, length: number): Promise<ResultEntry | undefined> {
16
+ const parser = new Parser();
17
+ try {
18
+ const feed = await parser.parseURL(source.url);
19
+
20
+ const firstItem = R.pipe(
21
+ feed.items,
22
+ R.map((item) => FeedEntrySchema.parse(item)),
23
+ R.sortBy((item) => new Date(item.date).getTime()),
24
+ R.reverse(),
25
+ R.first(),
26
+ );
27
+
28
+ if (!firstItem) {
29
+ throw new Error("no items found in feed");
30
+ }
31
+
32
+ const preview =
33
+ firstItem.contentSnippet ?? firstItem.content ?? firstItem.description ?? firstItem["content:encoded"];
34
+
35
+ return {
36
+ title: firstItem.title,
37
+ url: firstItem.link,
38
+ date: new Date(firstItem.date),
39
+ source,
40
+ preview: preview
41
+ ? truncateFn(
42
+ sanitizeHtml(preview, {
43
+ parseStyleAttributes: false,
44
+ }),
45
+ length,
46
+ )
47
+ : undefined,
48
+ };
49
+ } catch (e) {
50
+ console.error(`Error fetching ${source.url}: ${e as string}`);
51
+ return undefined;
52
+ }
53
+ }
@@ -0,0 +1,70 @@
1
+ import { expect, test } from "vitest";
2
+ import type { Configuration } from "./types.js";
3
+ import { run } from "./index.js";
4
+ import { tmpdir } from "os";
5
+ import { mkdtemp } from "fs/promises";
6
+ import { join } from "path";
7
+
8
+ // TODO: intercept network requests
9
+ test("it should fetch an RSS feed without caching", { timeout: 30000 }, async () => {
10
+ const config: Configuration = {
11
+ sources: [
12
+ {
13
+ title: "Jerred Shepherd",
14
+ url: "https://sjer.red/rss.xml",
15
+ },
16
+ ],
17
+ number: 1,
18
+ truncate: 300,
19
+ };
20
+
21
+ const result = await run(config);
22
+ expect(result).toMatchSnapshot();
23
+ });
24
+
25
+ test("it should fetch several RSS feeds", { timeout: 30000 }, async () => {
26
+ const config: Configuration = {
27
+ sources: [
28
+ {
29
+ url: "https://drewdevault.com/blog/index.xml",
30
+ title: "Drew DeVault",
31
+ },
32
+ {
33
+ url: "https://danluu.com/atom.xml",
34
+ title: "Dan Luu",
35
+ },
36
+ ],
37
+ number: 3,
38
+ truncate: 300,
39
+ };
40
+
41
+ const result = await run(config);
42
+ expect(result).toMatchSnapshot();
43
+ });
44
+
45
+ test("it should fetch an RSS feed with caching", { timeout: 30000 }, async () => {
46
+ const config: Configuration = {
47
+ sources: [
48
+ {
49
+ title: "Jerred Shepherd",
50
+ url: "https://sjer.red/rss.xml",
51
+ },
52
+ ],
53
+ number: 1,
54
+ truncate: 300,
55
+ cache: {
56
+ cache_file: `${await createTempDir()}/cache.json`,
57
+ cache_duration_minutes: 1,
58
+ },
59
+ };
60
+
61
+ const result = await run(config);
62
+ expect(result).toMatchSnapshot();
63
+ });
64
+
65
+ // https://sdorra.dev/posts/2024-02-12-vitest-tmpdir
66
+ async function createTempDir() {
67
+ const ostmpdir = tmpdir();
68
+ const dir = join(ostmpdir, "unit-test-");
69
+ return await mkdtemp(dir);
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import * as R from "remeda";
2
+ import { fetchAllCached as fetchAllCached } from "./cache.js";
3
+ import { fetchAll as fetchAllUncached } from "./fetch.js";
4
+ import { type Configuration, type Result, CachedConfigurationSchema } from "./types.js";
5
+
6
+ export async function run(config: Configuration): Promise<Result> {
7
+ const { success, data } = CachedConfigurationSchema.safeParse(config);
8
+
9
+ let result: Result;
10
+ if (success) {
11
+ console.log(`Using cache at ${data.cache.cache_file}.`);
12
+ result = await fetchAllCached(data);
13
+ } else {
14
+ console.log("Cache disabled.");
15
+ result = await fetchAllUncached(config);
16
+ }
17
+
18
+ const topResults = R.pipe(
19
+ result,
20
+ R.sortBy((result) => result.date.getTime()),
21
+ R.reverse(),
22
+ R.take(config.number),
23
+ );
24
+
25
+ return topResults;
26
+ }
27
+
28
+ export * from "./types.js";
package/src/types.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+
3
+ export type Source = z.infer<typeof SourceSchema>;
4
+ /** An RSS source */
5
+ const SourceSchema = z.object({
6
+ /** The URL of an RSS feed */
7
+ url: z.string(),
8
+ /** A title to describe the feed */
9
+ title: z.string().describe("A title for the feed"),
10
+ });
11
+
12
+ export type CacheConfiguration = z.infer<typeof CacheConfigurationSchema>;
13
+ /** Configuration for the cache */
14
+ const CacheConfigurationSchema = z.object({
15
+ /** How long to cache a result for */
16
+ cache_duration_minutes: z.number().default(60),
17
+ /** The location of a file to use as a cache */
18
+ cache_file: z.string().default("cache.json"),
19
+ });
20
+
21
+ export type Configuration = z.infer<typeof ConfigurationSchema>;
22
+ /** A configuration object with caching possibly configured */
23
+ const ConfigurationSchema = z.object({
24
+ /** A list of sources to fetch */
25
+ sources: SourceSchema.array(),
26
+ /** Return the n latest updates from the source list. */
27
+ number: z.number().default(3),
28
+ /** How many words the preview field should be truncated to in characters after HTML has been sanitized and parsed. */
29
+ truncate: z.number().default(300),
30
+ /** Configuration for the cache */
31
+ cache: CacheConfigurationSchema.optional(),
32
+ });
33
+
34
+ export type CachedConfiguration = z.infer<typeof CachedConfigurationSchema>;
35
+ /** A configuration object with caching definitely configured */
36
+ export const CachedConfigurationSchema = ConfigurationSchema.extend({
37
+ /** Configuration for the cache */
38
+ cache: CacheConfigurationSchema,
39
+ });
40
+
41
+ export type ResultEntry = z.infer<typeof ResultEntrySchema>;
42
+ /** A single entry from an RSS feed */
43
+ const ResultEntrySchema = z.object({
44
+ /** The title of the entry */
45
+ title: z.string(),
46
+ /** A direct link to the entry */
47
+ url: z.string(),
48
+ /** The date of the entry */
49
+ date: z.coerce.date(),
50
+ /** The source the entry is from */
51
+ source: SourceSchema,
52
+ /** A preview of the entry. This may contain sanitized HTML. */
53
+ preview: z.string().optional(),
54
+ });
55
+
56
+ export type Result = z.infer<typeof ResultSchema>;
57
+ /** A list of results */
58
+ const ResultSchema = z.array(ResultEntrySchema);
59
+
60
+ export type CacheEntry = z.infer<typeof CacheEntrySchema>;
61
+ /** A single cache entry */
62
+ export const CacheEntrySchema = z.object({
63
+ /** The time a source was last checked */
64
+ timestamp: z.coerce.date(),
65
+ /** The data from the source */
66
+ data: ResultEntrySchema,
67
+ });
68
+
69
+ export type Cache = z.infer<typeof CacheSchema>;
70
+ /** A mapping of source URLs to cache entries */
71
+ export const CacheSchema = z.record(CacheEntrySchema);
72
+
73
+ /** The expected format fetched RSS feed entries */
74
+ export const FeedEntrySchema = z
75
+ .object({
76
+ title: z.string(),
77
+ link: z.string(),
78
+ isoDate: z.coerce.date().optional(),
79
+ pubDate: z.coerce.date().optional(),
80
+ content: z.string().optional(),
81
+ contentSnippet: z.string().optional(),
82
+ "content:encoded": z.string().optional(),
83
+ description: z.string().optional(),
84
+ })
85
+ .transform((entry) => {
86
+ const date = entry.isoDate ?? entry.pubDate;
87
+ if (!date) {
88
+ throw new Error("no date found in feed entry");
89
+ }
90
+ return {
91
+ title: entry.title,
92
+ link: entry.link,
93
+ date,
94
+ content: entry.content,
95
+ contentSnippet: entry.contentSnippet,
96
+ description: entry.description,
97
+ "content:encoded": entry["content:encoded"],
98
+ };
99
+ });
package/src/util.ts ADDED
@@ -0,0 +1,23 @@
1
+ import * as R from "remeda";
2
+
3
+ // run an async map operation, filtering out undefined results
4
+ export async function asyncMapFilterUndefined<T, U>(input: T[], fn: (x: T) => Promise<U | undefined>): Promise<U[]> {
5
+ const results = await asyncMap(input, fn);
6
+ return filterUndefined(results);
7
+ }
8
+
9
+ export async function asyncMap<T, U>(input: T[], fn: (x: T) => Promise<U>): Promise<U[]> {
10
+ const promises = R.pipe(
11
+ input,
12
+ R.map((item) => fn(item)),
13
+ );
14
+
15
+ return Promise.all(promises);
16
+ }
17
+
18
+ export function filterUndefined<T>(input: (T | undefined)[]): T[] {
19
+ return R.pipe(
20
+ input,
21
+ R.filter((result) => result !== undefined),
22
+ ) as T[];
23
+ }