webring 1.0.1 → 1.0.3

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,20 @@ 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.0.3](https://github.com/shepherdjerred/webring/compare/v1.0.2...v1.0.3) (2024-06-24)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * create dir to cache ([a30645c](https://github.com/shepherdjerred/webring/commit/a30645c11d2afe91f7802c91b2c82eef9a97c717))
14
+
15
+ ## [1.0.2](https://github.com/shepherdjerred/webring/compare/v1.0.1...v1.0.2) (2024-06-21)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * disable style parsing ([0999f02](https://github.com/shepherdjerred/webring/commit/0999f025b4e9970a20bde8c1ffce1248ce38f3b7))
21
+
8
22
  ## [1.0.1](https://github.com/shepherdjerred/webring/compare/v1.0.0...v1.0.1) (2024-06-03)
9
23
 
10
24
 
package/README.md CHANGED
@@ -17,13 +17,13 @@ npm i webring
17
17
 
18
18
  ## Quick Start
19
19
 
20
- This library is meant to be used with static site generators. It is framework agnostic.
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).
21
21
 
22
22
  ```typescript
23
- import { type Configuration, run } from "webring";
23
+ import { run } from "webring";
24
+ import { type Configuration, type Result } from "webring";
24
25
 
25
- // create a configuration object
26
- const config: Configuration = {
26
+ export const config: Configuration = {
27
27
  sources: [
28
28
  {
29
29
  url: "https://drewdevault.com/blog/index.xml",
@@ -38,18 +38,39 @@ const config: Configuration = {
38
38
  title: "Jake Lazaroff",
39
39
  },
40
40
  ],
41
+ // the output will return the three most recent posts from the above sources
41
42
  number: 3,
42
- cache_duration_minutes: 60,
43
+ // the output will return santized HTML truncated to 300 characters
43
44
  truncate: 300,
45
+ // if this is defined, we'll cache the results
46
+ cache: {
47
+ // the file to use as a cache
48
+ cache_file: "webring.json",
49
+ // how long the cache should remain valid for
50
+ cache_duration_minutes: 60,
51
+ },
44
52
  };
45
53
 
46
- // run the application
47
- const result = await run(config);
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
+ export const result: Result = await run(config);
48
66
 
49
- // do something with the results
50
67
  result.map((entry) => {
51
- console.log(entry);
68
+ // do something with the results
69
+ // for example, you might render each item as an HTML block
52
70
  });
53
71
  ```
54
72
 
55
- I use this with Astro on my [personal website](https://github.com/shepherdjerred/sjer.red/blob/main/src/components/BlogWebring.astro#L17-L22).
73
+ Here's what I do for my blog:
74
+
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)).
package/dist/cache.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { Configuration, Cache, Result, ResultEntry, Source } from "./types.js";
2
- export declare function runWithCache(config: Configuration, cache: Cache): Promise<[Result, Cache]>;
3
- export declare function fetchWithCache(source: Source, cache: Cache, config: Configuration): Promise<ResultEntry | undefined>;
1
+ import { type Cache, type Result, type ResultEntry, type Source, type CachedConfiguration } from "./types.js";
2
+ export declare function fetchAllCached(config: CachedConfiguration): Promise<Result>;
3
+ export declare function fetchWithCache(source: Source, cache: Cache, config: CachedConfiguration): Promise<ResultEntry | undefined>;
package/dist/cache.js CHANGED
@@ -1,25 +1,54 @@
1
1
  import * as R from "remeda";
2
+ import { CacheSchema, } from "./types.js";
2
3
  import { fetch } from "./fetch.js";
3
- export async function runWithCache(config, cache) {
4
- const promises = R.pipe(config.sources, R.map((source) => fetchWithCache(source, cache, config)));
5
- const results = await Promise.all(promises);
6
- const definedResults = results.filter((result) => result !== undefined);
7
- const updatedCache = R.pipe(definedResults, R.map((result) => [result.source.url, { timestamp: new Date(), data: result }]), R.fromEntries());
8
- const topResults = R.pipe(definedResults, R.sortBy((result) => result.date.getTime()), R.reverse(), R.take(config.number));
9
- return [topResults, updatedCache];
4
+ import fs from "fs/promises";
5
+ import { asyncMapFilterUndefined } from "./util.js";
6
+ async function loadCache({ cache_file }) {
7
+ try {
8
+ await fs.access(cache_file);
9
+ const cacheFile = await fs.readFile(cache_file);
10
+ return CacheSchema.parse(JSON.parse(cacheFile.toString()));
11
+ }
12
+ catch (e) {
13
+ return {};
14
+ }
15
+ }
16
+ async function saveCache({ cache_file }, cache) {
17
+ await fs.mkdir(cache_file.split("/").slice(0, -1).join("/"), { recursive: true });
18
+ await fs.writeFile(cache_file, JSON.stringify(cache));
19
+ }
20
+ function toCacheEntry(result, now) {
21
+ return [result.source.url, { timestamp: now, data: result }];
22
+ }
23
+ function toCache(results, now) {
24
+ return R.pipe(results, R.map((result) => toCacheEntry(result, now)), R.fromEntries());
25
+ }
26
+ function updateCache(results, config) {
27
+ const now = new Date();
28
+ const updatedCache = toCache(results, now);
29
+ return saveCache(config.cache, updatedCache);
30
+ }
31
+ export async function fetchAllCached(config) {
32
+ const cache = await loadCache(config.cache);
33
+ const results = await asyncMapFilterUndefined(config.sources, (source) => fetchWithCache(source, cache, config));
34
+ await updateCache(results, config);
35
+ return results;
10
36
  }
11
37
  export async function fetchWithCache(source, cache, config) {
12
38
  const cacheEntry = cache[source.url];
13
39
  if (cacheEntry) {
14
40
  const now = new Date();
15
- if (now.getTime() - cacheEntry.timestamp.getTime() < config.cache_duration_minutes * 60 * 1000) {
16
- console.log(`Cache entry found for ${source.url}`);
41
+ if (now.getTime() - cacheEntry.timestamp.getTime() < config.cache.cache_duration_minutes * 60 * 1000) {
42
+ console.log(`Cache entry found for ${source.url}.`);
17
43
  return Promise.resolve(cacheEntry.data);
18
44
  }
19
45
  else {
20
- console.log(`Cache entry for ${source.url} is too old`);
46
+ console.log(`Cache entry for ${source.url} is too old.`);
21
47
  }
22
48
  }
23
- console.log(`No cache entry for ${source.url}`);
49
+ else {
50
+ console.log(`No cache entry for ${source.url}.`);
51
+ }
52
+ console.log(`Fetching ${source.url}`);
24
53
  return fetch(source, config.truncate);
25
54
  }
package/dist/fetch.d.ts CHANGED
@@ -1,2 +1,12 @@
1
- import { type Source, type ResultEntry } from "./types.js";
1
+ import { type Source, type ResultEntry, type Configuration } from "./types.js";
2
+ export declare function fetchAll(config: Configuration): Promise<{
3
+ url: string;
4
+ title: string;
5
+ date: Date;
6
+ source: {
7
+ url: string;
8
+ title: string;
9
+ };
10
+ preview?: string | undefined;
11
+ }[]>;
2
12
  export declare function fetch(source: Source, length: number): Promise<ResultEntry | undefined>;
package/dist/fetch.js CHANGED
@@ -3,8 +3,12 @@ import sanitizeHtml from "sanitize-html";
3
3
  import truncate from "truncate-html";
4
4
  import { FeedEntrySchema } from "./types.js";
5
5
  import * as R from "remeda";
6
+ import { asyncMapFilterUndefined } from "./util.js";
6
7
  // for some reason, TypeScript does not infer the type of the default export correctly
7
8
  const truncateFn = truncate;
9
+ export async function fetchAll(config) {
10
+ return await asyncMapFilterUndefined(config.sources, (source) => fetch(source, config.truncate));
11
+ }
8
12
  export async function fetch(source, length) {
9
13
  const parser = new Parser();
10
14
  try {
@@ -19,7 +23,11 @@ export async function fetch(source, length) {
19
23
  url: firstItem.link,
20
24
  date: new Date(firstItem.date),
21
25
  source,
22
- preview: preview ? truncateFn(sanitizeHtml(preview), length) : undefined,
26
+ preview: preview
27
+ ? truncateFn(sanitizeHtml(preview, {
28
+ parseStyleAttributes: false,
29
+ }), length)
30
+ : undefined,
23
31
  };
24
32
  }
25
33
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,3 @@
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
- export * from "./cache.js";
5
- export * from "./fetch.js";
package/dist/index.js CHANGED
@@ -1,29 +1,19 @@
1
- import { runWithCache } from "./cache.js";
2
- import { CacheSchema } from "./types.js";
3
- import fs from "fs/promises";
1
+ import * as R from "remeda";
2
+ import { fetchAllCached as fetchAllCached } from "./cache.js";
3
+ import { fetchAll as fetchAllUncached } from "./fetch.js";
4
+ import { CachedConfigurationSchema } from "./types.js";
4
5
  export async function run(config) {
5
- const cacheFilename = config.cache_file;
6
- let cacheObject = {};
7
- // check if cache.json exists
8
- try {
9
- await fs.access(cacheFilename);
6
+ const { success, data } = CachedConfigurationSchema.safeParse(config);
7
+ let result;
8
+ if (success) {
9
+ console.log(`Using cache at ${data.cache.cache_file}.`);
10
+ result = await fetchAllCached(data);
10
11
  }
11
- catch (e) {
12
- await fs.writeFile(cacheFilename, JSON.stringify({}));
12
+ else {
13
+ console.log("Cache disabled.");
14
+ result = await fetchAllUncached(config);
13
15
  }
14
- try {
15
- const cacheFile = await fs.readFile(cacheFilename);
16
- cacheObject = CacheSchema.parse(JSON.parse(cacheFile.toString()));
17
- }
18
- catch (e) {
19
- console.error("Error reading cache file:", e);
20
- throw e;
21
- }
22
- const [result, updatedCache] = await runWithCache(config, cacheObject);
23
- // write the updated cache to cache.json
24
- await fs.writeFile(cacheFilename, JSON.stringify(updatedCache));
25
- return result;
16
+ const topResults = R.pipe(result, R.sortBy((result) => result.date.getTime()), R.reverse(), R.take(config.number));
17
+ return topResults;
26
18
  }
27
19
  export * from "./types.js";
28
- export * from "./cache.js";
29
- export * from "./fetch.js";
@@ -1,7 +1,10 @@
1
- // sum.test.js
2
1
  import { expect, test } from "vitest";
3
2
  import { run } from "./index.js";
4
- test("it should work", async () => {
3
+ import { tmpdir } from "os";
4
+ import { mkdtemp } from "fs/promises";
5
+ import { join } from "path";
6
+ // TODO: intercept network requests
7
+ test("it should fetch an RSS feed without caching", async () => {
5
8
  const config = {
6
9
  sources: [
7
10
  {
@@ -10,10 +13,94 @@ test("it should work", async () => {
10
13
  },
11
14
  ],
12
15
  number: 1,
13
- cache_duration_minutes: 0,
14
16
  truncate: 300,
15
- cache_file: "cache.json",
16
17
  };
17
18
  const result = await run(config);
18
19
  expect(result).toMatchSnapshot();
19
20
  });
21
+ test("it should fetch several RSS feeds", async () => {
22
+ const config = {
23
+ sources: [
24
+ {
25
+ url: "https://drewdevault.com/blog/index.xml",
26
+ title: "Drew DeVault",
27
+ },
28
+ {
29
+ url: "https://danluu.com/atom.xml",
30
+ title: "Dan Luu",
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
+ ],
77
+ number: 20,
78
+ truncate: 300,
79
+ };
80
+ const result = await run(config);
81
+ expect(result).toMatchSnapshot();
82
+ });
83
+ test("it should fetch an RSS feed with caching", async () => {
84
+ const config = {
85
+ sources: [
86
+ {
87
+ title: "Jerred Shepherd",
88
+ url: "https://sjer.red/rss.xml",
89
+ },
90
+ ],
91
+ number: 1,
92
+ truncate: 300,
93
+ cache: {
94
+ cache_file: `${await createTempDir()}/cache.json`,
95
+ cache_duration_minutes: 1,
96
+ },
97
+ };
98
+ const result = await run(config);
99
+ expect(result).toMatchSnapshot();
100
+ });
101
+ // https://sdorra.dev/posts/2024-02-12-vitest-tmpdir
102
+ async function createTempDir() {
103
+ const ostmpdir = tmpdir();
104
+ const dir = join(ostmpdir, "unit-test-");
105
+ return await mkdtemp(dir);
106
+ }
package/dist/types.d.ts CHANGED
@@ -10,6 +10,17 @@ declare const SourceSchema: z.ZodObject<{
10
10
  url: string;
11
11
  title: string;
12
12
  }>;
13
+ export type CacheConfiguration = z.infer<typeof CacheConfigurationSchema>;
14
+ declare const CacheConfigurationSchema: z.ZodObject<{
15
+ cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
16
+ cache_file: z.ZodDefault<z.ZodString>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ cache_duration_minutes: number;
19
+ cache_file: string;
20
+ }, {
21
+ cache_duration_minutes?: number | undefined;
22
+ cache_file?: string | undefined;
23
+ }>;
13
24
  export type Configuration = z.infer<typeof ConfigurationSchema>;
14
25
  declare const ConfigurationSchema: z.ZodObject<{
15
26
  sources: z.ZodArray<z.ZodObject<{
@@ -23,27 +34,97 @@ declare const ConfigurationSchema: z.ZodObject<{
23
34
  title: string;
24
35
  }>, "many">;
25
36
  number: z.ZodDefault<z.ZodNumber>;
26
- cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
27
37
  truncate: z.ZodDefault<z.ZodNumber>;
28
- cache_file: z.ZodDefault<z.ZodString>;
38
+ cache: z.ZodOptional<z.ZodObject<{
39
+ cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
40
+ cache_file: z.ZodDefault<z.ZodString>;
41
+ }, "strip", z.ZodTypeAny, {
42
+ cache_duration_minutes: number;
43
+ cache_file: string;
44
+ }, {
45
+ cache_duration_minutes?: number | undefined;
46
+ cache_file?: string | undefined;
47
+ }>>;
29
48
  }, "strip", z.ZodTypeAny, {
30
49
  number: number;
31
50
  sources: {
32
51
  url: string;
33
52
  title: string;
34
53
  }[];
35
- cache_duration_minutes: number;
36
54
  truncate: number;
37
- cache_file: string;
55
+ cache?: {
56
+ cache_duration_minutes: number;
57
+ cache_file: string;
58
+ } | undefined;
38
59
  }, {
39
60
  sources: {
40
61
  url: string;
41
62
  title: string;
42
63
  }[];
43
64
  number?: number | undefined;
44
- cache_duration_minutes?: number | undefined;
45
65
  truncate?: number | undefined;
46
- cache_file?: string | undefined;
66
+ cache?: {
67
+ cache_duration_minutes?: number | undefined;
68
+ cache_file?: string | undefined;
69
+ } | undefined;
70
+ }>;
71
+ export type CachedConfiguration = z.infer<typeof CachedConfigurationSchema>;
72
+ export declare const CachedConfigurationSchema: z.ZodObject<z.objectUtil.extendShape<{
73
+ sources: z.ZodArray<z.ZodObject<{
74
+ url: z.ZodString;
75
+ title: z.ZodString;
76
+ }, "strip", z.ZodTypeAny, {
77
+ url: string;
78
+ title: string;
79
+ }, {
80
+ url: string;
81
+ title: string;
82
+ }>, "many">;
83
+ number: z.ZodDefault<z.ZodNumber>;
84
+ truncate: z.ZodDefault<z.ZodNumber>;
85
+ cache: z.ZodOptional<z.ZodObject<{
86
+ cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
87
+ cache_file: z.ZodDefault<z.ZodString>;
88
+ }, "strip", z.ZodTypeAny, {
89
+ cache_duration_minutes: number;
90
+ cache_file: string;
91
+ }, {
92
+ cache_duration_minutes?: number | undefined;
93
+ cache_file?: string | undefined;
94
+ }>>;
95
+ }, {
96
+ cache: z.ZodObject<{
97
+ cache_duration_minutes: z.ZodDefault<z.ZodNumber>;
98
+ cache_file: z.ZodDefault<z.ZodString>;
99
+ }, "strip", z.ZodTypeAny, {
100
+ cache_duration_minutes: number;
101
+ cache_file: string;
102
+ }, {
103
+ cache_duration_minutes?: number | undefined;
104
+ cache_file?: string | undefined;
105
+ }>;
106
+ }>, "strip", z.ZodTypeAny, {
107
+ number: number;
108
+ sources: {
109
+ url: string;
110
+ title: string;
111
+ }[];
112
+ truncate: number;
113
+ cache: {
114
+ cache_duration_minutes: number;
115
+ cache_file: string;
116
+ };
117
+ }, {
118
+ sources: {
119
+ url: string;
120
+ title: string;
121
+ }[];
122
+ cache: {
123
+ cache_duration_minutes?: number | undefined;
124
+ cache_file?: string | undefined;
125
+ };
126
+ number?: number | undefined;
127
+ truncate?: number | undefined;
47
128
  }>;
48
129
  export type ResultEntry = z.infer<typeof ResultEntrySchema>;
49
130
  declare const ResultEntrySchema: z.ZodObject<{
package/dist/types.js CHANGED
@@ -5,16 +5,22 @@ const SourceSchema = z.object({
5
5
  // a title for the feed
6
6
  title: z.string(),
7
7
  });
8
+ const CacheConfigurationSchema = z.object({
9
+ // how long to cache a results for
10
+ cache_duration_minutes: z.number().default(60),
11
+ cache_file: z.string().default("cache.json"),
12
+ });
8
13
  const ConfigurationSchema = z.object({
9
14
  // list of sources to fetch
10
15
  sources: SourceSchema.array(),
11
16
  // how many entries to return
12
17
  number: z.number().default(3),
13
- // how long to cache a results for
14
- cache_duration_minutes: z.number().default(60),
15
18
  // how many words to truncate the preview to
16
19
  truncate: z.number().default(300),
17
- cache_file: z.string().default("cache.json"),
20
+ cache: CacheConfigurationSchema.optional(),
21
+ });
22
+ export const CachedConfigurationSchema = ConfigurationSchema.extend({
23
+ cache: CacheConfigurationSchema,
18
24
  });
19
25
  const ResultEntrySchema = z.object({
20
26
  title: z.string(),
package/dist/util.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function asyncMapFilterUndefined<T, U>(input: T[], fn: (x: T) => Promise<U | undefined>): Promise<U[]>;
2
+ export declare function asyncMap<T, U>(input: T[], fn: (x: T) => Promise<U>): Promise<U[]>;
3
+ export declare function filterUndefined<T>(input: (T | undefined)[]): T[];
package/dist/util.js ADDED
@@ -0,0 +1,13 @@
1
+ import * as R from "remeda";
2
+ // run an async map operation, filtering out undefined results
3
+ export async function asyncMapFilterUndefined(input, fn) {
4
+ const results = await asyncMap(input, fn);
5
+ return filterUndefined(results);
6
+ }
7
+ export async function asyncMap(input, fn) {
8
+ const promises = R.pipe(input, R.map((item) => fn(item)));
9
+ return Promise.all(promises);
10
+ }
11
+ export function filterUndefined(input) {
12
+ return R.pipe(input, R.filter((result) => result !== undefined));
13
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "webring",
3
3
  "type": "module",
4
- "version": "1.0.1",
4
+ "version": "1.0.3",
5
5
  "scripts": {
6
6
  "prepare": "husky",
7
7
  "lint": "eslint src",
8
8
  "build": "tsc",
9
- "test": "vitest"
9
+ "test": "vitest --disable-console-intercept"
10
10
  },
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",