diff --git a/README.md b/README.md
index 6ecef41..9c9b0b1 100644
--- a/README.md
+++ b/README.md
@@ -16,19 +16,42 @@ A lightweight JavaScript library for fetching and displaying RSS feeds from a Fr
- **Styling Customization**: Modify appearance using CSS variables or custom CSS.
## Setup FreshRSS Proxy
-**Blogroller** requires a FreshRSS proxy to securely access your FreshRSS instance's API and protect your credentials. The [FreshProxy](https://github.com/hstct/FreshProxy) is a Python Flask application that you need to host separately. Check the repository for detailed instructions.
-**Note:** Deploy the Flask app to a hosting service (e.g. Heroku, AWS, DigitalOcean) to make it accessible via a public URL.
+**Blogroller** requires a FreshRSS proxy to securely access your FreshRSS instance's API and protect your credentials. We recommend [FreshProxy](https://github.com/hstct/FreshProxy), a Python Flask application you can host separately. By default, FreshProxy now provides a single `/digest` endpoint that returns JSON data shaped like:
+```json
+{
+ "items": [
+ {
+ "title": "Some Post",
+ "published": 1697000000,
+ "feedId": "feed/123",
+ "feedTitle": "Example Feed",
+ "feedHtmlUrl": "https://example.com",
+ "feedIconUrl": "https://example.com/icon.png",
+ "alternate": [{ "href": "https://example.com/post" }]
+ }
+ // ...
+ ],
+ "page": 1,
+ "limit": 50,
+ "totalItems": 123
+}
+```
+
+**Note**: Deploy the Flask app to a hosting service (e.g. Heroku, AWS, DigitalOcean) to make it accessible via a public URL. Please check the [FreshProxy repo](https://github.com/hstct/FreshProxy) for detailed instructions on configuration and environment variables.
## Installation
### Via NPM
+
Install **Blogroller** using npm:
+
```bash
npm install blogroller
```
Then import in your code:
+
```js
import { Blogroll } from 'blogroller';
```
@@ -36,9 +59,13 @@ import { Blogroll } from 'blogroller';
### CDN / Script Tag
You can also load **Blogroller** from a CDN like unpkg:
+
```html
-
+
@@ -61,7 +88,9 @@ This exposes a global `Blogroller` object (i.e., `window.Blogroller`).
**Blogroller** provides default styling via the `blogroller.css` file, which you can customize to match your site's design. You can override the default styles using CSS variables or by adding your own CSS rules.
### Customizing Styles
+
You can override the default CSS variables to customize colors. For example:
+
```css
:root {
--blogroller-border-color: #3c3836;
@@ -80,7 +109,9 @@ Alternatively, you can write your own CSS rules targeting the `.blogroller-*` cl
## Usage
### Basic Example
+
Assume you have a `
` in your HTML. Then:
+
```js
import { Blogroll } from 'https://unpkg.com/blogroller@latest/dist/blogroller.esm.js';
@@ -92,7 +123,9 @@ blogroll.initialize({
containerId: 'rss-feed',
});
```
+
**Parameters:**
+
- `proxyUrl` _(string, required)_: The URL to your FreshRSS proxy.
- `categoryLabel` _(string, required)_: The label (or category) of the feeds in your FreshRSS instance you want to display.
- `batchSize` _(number, optional)_: How many items to display initially (optional, default 10).
diff --git a/jest.config.ts b/jest.config.ts
new file mode 100644
index 0000000..fb06847
--- /dev/null
+++ b/jest.config.ts
@@ -0,0 +1,19 @@
+/** @type {import('jest').Config} */
+module.exports = {
+ testEnvironment: 'jsdom',
+ transform: {
+ '^.+\\.ts$': [
+ 'ts-jest',
+ {
+ tsconfig: 'tsconfig.json',
+ isolatedModules: true,
+ },
+ ],
+ },
+ moduleFileExtensions: ['ts', 'js', 'json', 'node'],
+ moduleNameMapper: {
+ '^@/(.*)$': '/src/$1',
+ '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
+ },
+ roots: ['/tests', '/src'],
+};
diff --git a/package-lock.json b/package-lock.json
index 3c50f2c..f13b69c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,8 @@
"@eslint/js": "^9.17.0",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
+ "@rollup/plugin-typescript": "^12.1.2",
+ "@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
@@ -29,7 +31,10 @@
"prettier": "^3.4.2",
"rollup": "^2.79.2",
"rollup-plugin-postcss": "^4.0.2",
- "rollup-plugin-terser": "^7.0.2"
+ "rollup-plugin-terser": "^7.0.2",
+ "ts-jest": "^29.2.5",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.7.3"
}
},
"node_modules/@ampproject/remapping": {
@@ -1793,6 +1798,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",
@@ -2495,6 +2524,33 @@
}
}
},
+ "node_modules/@rollup/plugin-typescript": {
+ "version": "12.1.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.1.2.tgz",
+ "integrity": "sha512-cdtSp154H5sv637uMr1a8OTWB0L1SWDSm1rDGiyfcGcvQ6cuTs4MDk2BVEBGysUWago4OJN4EQZqOTl/QY3Jgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.14.0||^3.0.0||^4.0.0",
+ "tslib": "*",
+ "typescript": ">=3.7.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ },
+ "tslib": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
@@ -2585,6 +2641,34 @@
"node": ">=10.13.0"
}
},
+ "node_modules/@tsconfig/node10": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+ "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node12": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node16": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2674,6 +2758,17 @@
"@types/istanbul-lib-report": "*"
}
},
+ "node_modules/@types/jest": {
+ "version": "29.5.14",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
+ "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
"node_modules/@types/jsdom": {
"version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
@@ -2886,6 +2981,13 @@
"node": ">= 8"
}
},
+ "node_modules/arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -3015,6 +3117,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3267,6 +3376,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bs-logger": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
+ "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-json-stable-stringify": "2.x"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/bser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
@@ -3585,6 +3707,13 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/create-require": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3963,6 +4092,16 @@
"node": ">=8"
}
},
+ "node_modules/diff": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@@ -4084,6 +4223,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.76",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
@@ -4866,6 +5021,39 @@
"node": ">=16.0.0"
}
},
+ "node_modules/filelist": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
+ "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -6048,6 +6236,25 @@
"node": ">=8"
}
},
+ "node_modules/jake": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
+ "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.3",
+ "chalk": "^4.0.2",
+ "filelist": "^1.0.4",
+ "minimatch": "^3.1.2"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -7181,6 +7388,13 @@
"node": ">=10"
}
},
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -9579,6 +9793,112 @@
"node": ">=18"
}
},
+ "node_modules/ts-jest": {
+ "version": "29.2.5",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz",
+ "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bs-logger": "^0.2.6",
+ "ejs": "^3.1.10",
+ "fast-json-stable-stringify": "^2.1.0",
+ "jest-util": "^29.0.0",
+ "json5": "^2.2.3",
+ "lodash.memoize": "^4.1.2",
+ "make-error": "^1.3.6",
+ "semver": "^7.6.3",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "ts-jest": "cli.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": ">=7.0.0-beta.0 <8",
+ "@jest/transform": "^29.0.0",
+ "@jest/types": "^29.0.0",
+ "babel-jest": "^29.0.0",
+ "jest": "^29.0.0",
+ "typescript": ">=4.3 <6"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "@jest/transform": {
+ "optional": true
+ },
+ "@jest/types": {
+ "optional": true
+ },
+ "babel-jest": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ts-jest/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ts-node": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "^0.8.0",
+ "@tsconfig/node10": "^1.0.7",
+ "@tsconfig/node12": "^1.0.7",
+ "@tsconfig/node14": "^1.0.0",
+ "@tsconfig/node16": "^1.0.2",
+ "acorn": "^8.4.1",
+ "acorn-walk": "^8.1.1",
+ "arg": "^4.1.0",
+ "create-require": "^1.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "v8-compile-cache-lib": "^3.0.1",
+ "yn": "3.1.1"
+ },
+ "bin": {
+ "ts-node": "dist/bin.js",
+ "ts-node-cwd": "dist/bin-cwd.js",
+ "ts-node-esm": "dist/bin-esm.js",
+ "ts-node-script": "dist/bin-script.js",
+ "ts-node-transpile-only": "dist/bin-transpile.js",
+ "ts-script": "dist/bin-script-deprecated.js"
+ },
+ "peerDependencies": {
+ "@swc/core": ">=1.2.50",
+ "@swc/wasm": ">=1.2.50",
+ "@types/node": "*",
+ "typescript": ">=2.7"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "@swc/wasm": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -9736,6 +10056,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typescript": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
+ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -9875,6 +10209,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/v8-compile-cache-lib": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -10208,6 +10549,16 @@
"node": ">=12"
}
},
+ "node_modules/yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 4f3c0e0..266fc2d 100644
--- a/package.json
+++ b/package.json
@@ -27,26 +27,14 @@
"format": "prettier --write .",
"build": "rollup -c rollup.config.mjs"
},
- "jest": {
- "testEnvironment": "jsdom",
- "transform": {
- "^.+\\.js$": "babel-jest"
- },
- "moduleNameMapper": {
- "^@/(.*)$": "/src/$1",
- "\\.(css|less|scss|sass)$": "identity-obj-proxy"
- },
- "roots": [
- "/tests",
- "/src"
- ]
- },
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@eslint/js": "^9.17.0",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
+ "@rollup/plugin-typescript": "^12.1.2",
+ "@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
@@ -62,6 +50,9 @@
"prettier": "^3.4.2",
"rollup": "^2.79.2",
"rollup-plugin-postcss": "^4.0.2",
- "rollup-plugin-terser": "^7.0.2"
+ "rollup-plugin-terser": "^7.0.2",
+ "ts-jest": "^29.2.5",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.7.3"
}
}
diff --git a/rollup.config.mjs b/rollup.config.mjs
index 7ce806c..0a1aa7c 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -2,9 +2,10 @@ import { nodeResolve } from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
import commonjs from '@rollup/plugin-commonjs';
import postcss from 'rollup-plugin-postcss';
+import typescript from '@rollup/plugin-typescript';
export default {
- input: 'src/blogroll.js',
+ input: 'src/blogroll.ts',
output: [
{
file: 'dist/blogroller.umd.js',
@@ -27,5 +28,6 @@ export default {
extract: 'blogroller.css',
minimize: true,
}),
+ typescript({ tsconfig: './tsconfig.json' }),
],
};
diff --git a/src/api.js b/src/api.js
deleted file mode 100644
index 5d00fc6..0000000
--- a/src/api.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import { MESSAGES, PREFIX } from './constants.js';
-import { calculateReadingTime } from './utils/common.js';
-
-/**
- * A unified fetch wrapper that applies a standard referrer policy,
- * handles errors, and returns parsed JSON.
- *
- * @param {string} url - The URL to fetch.
- * @param {string} [errorMessage] - Custom error message for failures.
- * @param {string} [referrerPolicy] - Referrer policy for the request.
- * @returns {Promise} - Parsed JSON from the fetch.
- */
-async function customFetch(
- url,
- errorMessage = 'Error fetching data',
- referrerPolicy = 'strict-origin-when-cross-origin'
-) {
- const response = await fetch(url, { referrerPolicy });
- if (!response.ok) {
- throw new Error(`${PREFIX} ${errorMessage}: ${response.statusText}`);
- }
- return response.json();
-}
-
-/**
- * Fetch subscription feeds filtered by a specific category label.
- *
- * @param {Object} config - The configuration object.
- * @param {string} config.subscriptionUrl - URL for fetching subscription feeds.
- * @param {string} categoryLabel - The category label to filter subscriptions by.
- * @returns {Promise} - A promise that resolves to an array of subscription feeds.
- */
-export async function fetchSubscriptions({ subscriptionUrl, categoryLabel }) {
- let missingParams = [];
- if (!subscriptionUrl) missingParams.push('subscriptionUrl');
- if (!categoryLabel) missingParams.push('categoryLabel');
-
- if (missingParams.length > 0) {
- throw new Error(
- MESSAGES.ERROR.MISSING_PARAMETER_DETAIL(
- missingParams.join(', '),
- 'fetchSubscriptions'
- )
- );
- }
-
- const data = await customFetch(
- subscriptionUrl,
- 'Failed to fetch subscriptions'
- );
-
- return data.subscriptions.filter((feed) =>
- feed.categories.some((category) => category.label === categoryLabel)
- );
-}
-
-/**
- * Fetch the latest post data for a specific feed.
- *
- * @param {string} feedId - The ID of the feed.
- * @param {Object} config - The configuration object.
- * @param {string} config.proxUrl - Base URL for fetching feed content.
- * @return {Promise} - A promise that resolves to the latest post data or null.
- */
-export async function fetchLatestPost(feedId, { proxyUrl }) {
- let missingParams = [];
- if (!feedId) missingParams.push('feedId');
- if (!proxyUrl) missingParams.push('proxyUrl');
-
- if (missingParams.length > 0) {
- throw new Error(
- MESSAGES.ERROR.MISSING_PARAMETER_DETAIL(
- missingParams.join(', '),
- 'fetchLatestPost'
- )
- );
- }
-
- const feedUrl = `${proxyUrl}${feedId}?n=1`;
-
- const feedData = await customFetch(
- feedUrl,
- `Failed to fetch latest post for feed ID: ${feedId}`
- );
-
- if (!feedData.items || !feedData.items[0]) {
- console.warn(MESSAGES.WARN.NO_POSTS_FOR_ID(feedId));
- return null;
- }
-
- const latestPost = feedData.items[0];
- const pubDate = new Date(latestPost.published * 1000);
-
- if (isNaN(pubDate.getTime())) {
- console.warn(MESSAGES.WARN.INVALID_DATE_FOR_ID(feedId));
- return null;
- }
-
- return {
- postTitle: latestPost.title,
- postUrl: latestPost.alternate[0]?.href,
- pubDate: pubDate,
- readingTime: calculateReadingTime(latestPost.summary?.content || ''),
- };
-}
-
-/**
- * Fetch detailed data for a list of feeds.
- *
- * @param {Array} feeds - An array of feed objects.
- * @param {Object} config - The configuration object.
- * @return {Promise} - A promise that resolves to an array of feed data objets.
- **/
-export async function fetchFeedsData(feeds, config) {
- const failedFeeds = []; // Track failed feed IDs
-
- const results = await Promise.all(
- feeds.map(async (feed) => {
- try {
- const latestPost = await fetchLatestPost(feed.id, config);
- if (!latestPost) {
- failedFeeds.push({
- id: feed.id,
- title: feed.title,
- error: 'No posts found',
- });
- return null;
- }
-
- return {
- feedTitle: feed.title,
- feedUrl: feed.htmlUrl,
- feedIcon: feed.iconUrl,
- ...latestPost,
- };
- } catch (error) {
- console.error(MESSAGES.ERROR.FETCH_FAILED, error);
- failedFeeds.push({
- id: feed.id,
- title: feed.title,
- error: error.message || 'Unknown error',
- });
- return null;
- }
- })
- );
-
- return {
- feedsData: results.filter(Boolean),
- failedFeeds,
- };
-}
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..623d32f
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,44 @@
+import { PREFIX } from './constants';
+import { AggregatorResponse } from './types';
+
+interface FetchDigestOptions {
+ proxyUrl: string;
+ categoryLabel?: string;
+ page?: number;
+ limit?: number;
+ n?: number;
+}
+
+/**
+ * Fetches aggregated latest posts from the proxy's /digest endpoint.
+ */
+export async function fetchDigest({
+ proxyUrl,
+ categoryLabel,
+ page = 1,
+ limit = 10,
+ n = 1,
+}: FetchDigestOptions): Promise {
+ const url = new URL(`${proxyUrl}digest`);
+
+ if (categoryLabel) {
+ url.searchParams.set('label', categoryLabel);
+ }
+
+ url.searchParams.set('page', page.toString());
+ url.searchParams.set('limit', limit.toString());
+ url.searchParams.set('n', n.toString());
+
+ const response = await fetch(url.toString(), {
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `${PREFIX} Aggregator error: HTTP ${response.status} - ${response.statusText}`
+ );
+ }
+
+ const data: AggregatorResponse = await response.json();
+ return data;
+}
diff --git a/src/blogroll.js b/src/blogroll.js
deleted file mode 100644
index 5976b4b..0000000
--- a/src/blogroll.js
+++ /dev/null
@@ -1,179 +0,0 @@
-import { fetchSubscriptions, fetchFeedsData } from './api.js';
-import { createFeedItem, createShowMoreLink } from './utils/dom.js';
-import { constructApiUrl, sortFeedsByDate } from './utils/common.js';
-import { CONFIG } from './config.js';
-import { DEFAULT_CONTAINER_ID, MESSAGES } from './constants.js';
-import '../styles/blogroller.css';
-
-/**
- * Class to handle Blogroll functionality.
- */
-export class Blogroll {
- constructor() {
- this.config = null;
- this.showMoreLink = null;
- }
-
- /**
- * Initialize the Blogroll with a configuration object.
- *
- * @param {Object} config - Configuration object.
- */
- initialize(config) {
- this.config = { ...CONFIG.defaults, ...config };
-
- // Validate the configuration
- this.validateConfig(this.config);
-
- if (!this.config.proxyUrl.endsWith('/')) {
- this.config.proxyUrl += '/';
- }
-
- // Construct derived URLs
- this.config.subscriptionUrl = constructApiUrl(
- this.config.proxyUrl,
- this.config.subscriptionEndpoint,
- { output: 'json' }
- );
-
- this.loadFeeds();
- }
-
- /**
- * Validate the configuration object.
- *
- * @param {Object} config - Configuration object.
- */
- validateConfig(config) {
- const requireParams = CONFIG.validation.requiredParams;
- const missingParams = requireParams.filter((param) => !config[param]);
-
- if (missingParams.length > 0) {
- throw new Error(
- MESSAGES.ERROR.MISSING_PARAMETER(missingParams.join(', '))
- );
- }
-
- if (
- typeof config.proxyUrl !== 'string' ||
- !config.proxyUrl.startsWith('http')
- ) {
- throw new Error(MESSAGES.ERROR.INVALID_PROXY_URL);
- }
-
- if (typeof config.categoryLabel !== 'string') {
- throw new Error(MESSAGES.ERROR.INVALID_CATEGORY_LABEL);
- }
- }
-
- /**
- * Dynamically fetch the container element.
- *
- * @returns {HTMLElment} - The feed container element.
- */
- getFeedContainer() {
- const containerId = this.config.containerId || DEFAULT_CONTAINER_ID;
- const container = document.getElementById(containerId);
-
- if (!container) {
- throw new Error(MESSAGES.ERROR.MISSING_CONTAINER(containerId));
- }
-
- if (!container.classList.contains('blogroller-feed-container')) {
- container.classList.add('blogroller-feed-container');
- }
-
- return container;
- }
-
- /**
- * Initialize the Blogroll component by fetching and displaying feeds.
- */
- async loadFeeds() {
- const feedContainer = this.getFeedContainer();
- feedContainer.innerHTML = MESSAGES.LOADING;
-
- try {
- // Fetch and filter subscriptions by category
- const subscriptions = await fetchSubscriptions(this.config);
- const { feedsData, failedFeeds } = await fetchFeedsData(
- subscriptions,
- this.config
- );
- const sortedFeeds = sortFeedsByDate(feedsData);
-
- if (failedFeeds.length > 0) {
- console.warn(MESSAGES.ERROR.FETCH_FAILED, failedFeeds);
- }
-
- this.displayFeeds(sortedFeeds);
- } catch (error) {
- console.error(MESSAGES.ERROR.LOAD_FEEDS_FAILED, error);
- feedContainer.innerHTML = MESSAGES.LOAD_FAILED;
- }
- }
-
- /**
- * Render feeds into the container in a paginated way.
- *
- * @param {Array} feeds - Array of feed data objects.
- * @param {number} startIndex - Starting index of feeds to render.
- **/
- renderFeeds(feeds, startIndex = 0) {
- const feedContainer = this.getFeedContainer();
- const fragment = document.createDocumentFragment();
- const batch = feeds.slice(startIndex, startIndex + this.config.batchSize);
-
- batch.forEach((feed) => {
- const feedItem = createFeedItem(feed);
- fragment.appendChild(feedItem);
- });
-
- feedContainer.appendChild(fragment);
- }
-
- /**
- * Attach a "Show More" link to dynamically load more feeds.
- *
- * @param {Array} feeds - Array of feed data objects.
- **/
- attachShowMoreHandler(feeds) {
- this.showMoreLink.addEventListener('click', (event) => {
- event.preventDefault();
-
- const feedContainer = this.getFeedContainer();
- const currentCount = feedContainer.querySelectorAll(
- '.blogroller-feed-item'
- ).length;
- this.renderFeeds(feeds, currentCount);
-
- if (currentCount + this.config.batchSize >= feeds.length) {
- this.showMoreLink.style.display = 'none';
- }
- });
- }
-
- /**
- * Display the feeds in the container and manage "Show More" functionality.
- *
- * @param {Array} feeds - Array of sorted feed data objects.
- **/
- displayFeeds(feeds) {
- const feedContainer = this.getFeedContainer();
- feedContainer.innerHTML = ''; // Clear loading indicator
-
- if (feeds.length === 0) {
- feedContainer.innerHTML = MESSAGES.NO_POSTS;
- return;
- }
-
- this.renderFeeds(feeds);
-
- if (feeds.length > this.config.batchSize) {
- this.showMoreLink = createShowMoreLink();
- this.showMoreLink.style.display = 'block';
- feedContainer.parentElement.appendChild(this.showMoreLink);
- this.attachShowMoreHandler(feeds);
- }
- }
-}
diff --git a/src/blogroll.ts b/src/blogroll.ts
new file mode 100644
index 0000000..242c032
--- /dev/null
+++ b/src/blogroll.ts
@@ -0,0 +1,209 @@
+import { fetchDigest } from './api';
+import { createFeedItem, createShowMoreLink } from './utils/dom';
+import { calculateReadingTime } from './utils/common';
+import { CONFIG } from './config';
+import { DEFAULT_CONTAINER_ID, MESSAGES } from './constants';
+import '../styles/blogroller.css';
+import { AggregatorItem, AggregatorResponse, TransformedFeed } from './types';
+
+interface BlogrollUserConfig {
+ proxyUrl: string;
+ categoryLabel: string;
+ containerId?: string;
+ batchSize?: number;
+ documentClass?: string;
+}
+
+/**
+ * Class to handle Blogroll functionality.
+ */
+export class Blogroll {
+ // Exposed for testing
+ public config!: Required;
+ private showMoreLink: HTMLAnchorElement | null = null;
+ private currentPage = 1;
+ private hasMoreFeeds = false;
+
+ /**
+ * Initializes the Blogroll with a user config object.
+ */
+ public initialize(config: BlogrollUserConfig): void {
+ const mergedConfig = { ...CONFIG.defaults, ...config };
+
+ // Validate the configuration
+ this.validateConfig(mergedConfig);
+
+ this.config = {
+ proxyUrl: mergedConfig.proxyUrl.endsWith('/')
+ ? mergedConfig.proxyUrl
+ : mergedConfig.proxyUrl + '/',
+ categoryLabel: mergedConfig.categoryLabel,
+ containerId: mergedConfig.containerId || DEFAULT_CONTAINER_ID,
+ batchSize: mergedConfig.batchSize,
+ documentClass: mergedConfig.documentClass,
+ };
+
+ void this.loadFeeds();
+ }
+
+ /**
+ * Checks for missing or invalid parameters in the user config.
+ */
+ private validateConfig(config: Partial): void {
+ const missingParams = CONFIG.validation.requiredParams.filter(
+ (param) => !config[param as keyof BlogrollUserConfig]
+ );
+ if (missingParams.length > 0) {
+ throw new Error(
+ MESSAGES.ERROR.MISSING_PARAMETER(missingParams.join(', '))
+ );
+ }
+
+ if (
+ typeof config.proxyUrl !== 'string' ||
+ !/^https?:\/\//.test(config.proxyUrl!)
+ ) {
+ throw new Error(MESSAGES.ERROR.INVALID_PROXY_URL);
+ }
+
+ if (typeof config.categoryLabel !== 'string') {
+ throw new Error(MESSAGES.ERROR.INVALID_CATEGORY_LABEL);
+ }
+ }
+
+ /**
+ * Retrieves the container element by ID and applies default styling.
+ */
+ private getFeedContainer(): HTMLElement {
+ const container = document.getElementById(this.config.containerId);
+
+ if (!container) {
+ throw new Error(
+ MESSAGES.ERROR.MISSING_CONTAINER(this.config.containerId)
+ );
+ }
+
+ if (!container.classList.contains('blogroller-feed-container')) {
+ container.classList.add('blogroller-feed-container');
+ }
+
+ return container;
+ }
+
+ /**
+ * Fetches and renders feeds based on the current scope.
+ */
+ private async loadFeeds(): Promise {
+ const feedContainer = this.getFeedContainer();
+
+ if (this.currentPage === 1) {
+ feedContainer.innerHTML = MESSAGES.LOADING;
+ }
+
+ try {
+ const aggregatorData: AggregatorResponse = await fetchDigest({
+ proxyUrl: this.config.proxyUrl,
+ categoryLabel: this.config.categoryLabel,
+ page: this.currentPage,
+ limit: this.config.batchSize,
+ n: 1,
+ });
+
+ const aggregatorItems = aggregatorData.items;
+
+ if (!aggregatorItems || aggregatorItems.length === 0) {
+ if (this.currentPage === 1) {
+ feedContainer.innerHTML = MESSAGES.NO_POSTS;
+ }
+ return;
+ }
+
+ const transformed = this.transformFeeds(aggregatorItems);
+
+ if (this.currentPage === 1) {
+ feedContainer.innerHTML = '';
+ }
+
+ this.renderFeeds(transformed, aggregatorData);
+
+ const { page, limit, totalItems } = aggregatorData;
+ this.hasMoreFeeds =
+ aggregatorItems.length === this.config.batchSize &&
+ page * limit < totalItems;
+
+ if (this.hasMoreFeeds) {
+ this.ensureShowMoreLink(feedContainer);
+ } else if (this.showMoreLink) {
+ this.showMoreLink.style.display = 'none';
+ }
+ } catch (error) {
+ console.error(MESSAGES.ERROR.LOAD_FEEDS_FAILED, error);
+ feedContainer.innerHTML = MESSAGES.LOAD_FAILED;
+ }
+ }
+
+ /**
+ * Converts aggregator data into an array of TransformedFeed objects.
+ */
+ private transformFeeds(items: AggregatorItem[]): TransformedFeed[] {
+ return items.map((item) => {
+ const publishedMs = item.published ? item.published * 1000 : NaN;
+ const postContent = item.summary?.content || '';
+ return {
+ feedTitle: item.feedTitle || 'Untitled Feed',
+ feedUrl: item.feedHtmlUrl ?? '#',
+ feedIcon: item.feedIconUrl,
+ postTitle: item.title || 'Untitled Post',
+ postUrl: item.alternate?.[0]?.href || '#',
+ pubDate: isNaN(publishedMs) ? null : new Date(publishedMs),
+ readingTime: calculateReadingTime(postContent),
+ };
+ });
+ }
+
+ /**
+ * Renders feed items into the DOM.
+ **/
+ private renderFeeds(
+ feeds: TransformedFeed[],
+ aggregatorData: AggregatorResponse
+ ): void {
+ const feedContainer = this.getFeedContainer();
+ const fragment = document.createDocumentFragment();
+
+ feeds.forEach((feed) => {
+ const feedItem = createFeedItem(feed);
+ fragment.appendChild(feedItem);
+ });
+
+ feedContainer.appendChild(fragment);
+
+ const { page, limit, items, totalItems } = aggregatorData;
+
+ this.hasMoreFeeds =
+ items.length === this.config.batchSize && page * limit < totalItems;
+
+ if (this.hasMoreFeeds) {
+ this.ensureShowMoreLink(feedContainer);
+ } else if (this.showMoreLink) {
+ this.showMoreLink.style.display = 'none';
+ }
+ }
+
+ /**
+ * Ensures a "Show More" link is present and wired up to load more feeds.
+ */
+ private ensureShowMoreLink(feedContainer: HTMLElement): void {
+ if (!this.showMoreLink) {
+ this.showMoreLink = createShowMoreLink();
+ feedContainer.parentElement?.appendChild(this.showMoreLink);
+
+ this.showMoreLink.addEventListener('click', (event) => {
+ event.preventDefault();
+ this.currentPage++;
+ this.loadFeeds();
+ });
+ }
+ this.showMoreLink.style.display = 'block';
+ }
+}
diff --git a/src/config.js b/src/config.js
deleted file mode 100644
index dc5dcb1..0000000
--- a/src/config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export const CONFIG = {
- defaults: {
- documentClass: 'blogroll',
- subscriptionEndpoint: 'subscriptions',
- batchSize: 10,
- },
- validation: {
- requiredParams: ['proxyUrl', 'categoryLabel'],
- },
-};
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..660dc73
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,23 @@
+interface BlogrollDefaults {
+ documentClass: string;
+ batchSize: number;
+}
+
+interface BlogrollValidation {
+ requiredParams: string[];
+}
+
+export interface BlogrollConfig {
+ defaults: BlogrollDefaults;
+ validation: BlogrollValidation;
+}
+
+export const CONFIG: BlogrollConfig = {
+ defaults: {
+ documentClass: 'blogroll',
+ batchSize: 10,
+ },
+ validation: {
+ requiredParams: ['proxyUrl', 'categoryLabel'],
+ },
+};
diff --git a/src/constants.js b/src/constants.ts
similarity index 68%
rename from src/constants.js
rename to src/constants.ts
index b4dd770..49e4274 100644
--- a/src/constants.js
+++ b/src/constants.ts
@@ -1,23 +1,24 @@
export const DEFAULT_CONTAINER_ID = 'rss-feed';
export const PREFIX = '[Blogroll]';
+
export const MESSAGES = {
LOADING: 'Loading latest posts...
',
LOAD_FAILED: 'Failed to load posts. Please try again later.
',
NO_POSTS: 'No latest posts available at this time.
',
ERROR: {
- MISSING_CONTAINER: (id) =>
+ MISSING_CONTAINER: (id: string) =>
`Feed container with ID '${id}' not found in the DOM.`,
- MISSING_PARAMETER: (param) => `Missing required parameter(s): ${param}`,
- MISSING_PARAMETER_DETAIL: (param, fn) =>
- `${PREFIX} Missing required parameter(s) for ${fn}: ${param}`,
+ MISSING_PARAMETER: (param: string) =>
+ `Missing required parameter(s): ${param}`,
INVALID_PROXY_URL: "Invalid 'proxyUrl'. Must be a valid URL string.",
INVALID_CATEGORY_LABEL: "Invalid 'categoryLabel'. Must be a string.",
LOAD_FEEDS_FAILED: 'Error initializing blogroll.',
FETCH_FAILED: `${PREFIX} Failed to fetch data for some feeds:`,
},
WARN: {
- NO_POSTS_FOR_ID: (id) => `${PREFIX} No posts found for feed ID: ${id}`,
- INVALID_DATE_FOR_ID: (id) =>
+ NO_POSTS_FOR_ID: (id: string) =>
+ `${PREFIX} No posts found for feed ID: ${id}`,
+ INVALID_DATE_FOR_ID: (id: string) =>
`${PREFIX} Invalid publish date for feed ID: ${id}`,
},
};
diff --git a/src/types.d.ts b/src/types.d.ts
new file mode 100644
index 0000000..14a3b65
--- /dev/null
+++ b/src/types.d.ts
@@ -0,0 +1,32 @@
+export interface AggregatorItem {
+ title: string;
+ published?: number;
+ alternate?: Array<{ href: string }>;
+ summary?: { content: string };
+ feedId?: string;
+ feedTitle?: string;
+ feedHtmlUrl?: string;
+ feedIconUrl?: string;
+ author?: string;
+}
+
+export interface AggregatorResponse {
+ items: AggregatorItem[];
+ page: number;
+ limit: number;
+ totalItems: number;
+}
+
+export interface SortableFeed {
+ pubDate: Date | null;
+ [key: string]: unknown;
+}
+
+export interface TransformedFeed extends SortableFeed {
+ feedTitle: string;
+ feedUrl: string;
+ feedIcon?: string;
+ postTitle: string;
+ postUrl: string;
+ readingTime: string | null;
+}
diff --git a/src/utils/common.js b/src/utils/common.js
deleted file mode 100644
index ffcfd93..0000000
--- a/src/utils/common.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * Estimates the average reading time of a text based on the word count.
- *
- * @param {string} content - The content to estimate reading time for (e.g., HTML or plain text).
- * @param {number} [wordsPerMinute=250] - The average reading speed in words per minute.
- * @returns {string} - A string representing the estimated reading time (e.g., "3 min read").
- */
-export function calculateReadingTime(content, wordsPerMinute = 250) {
- if (typeof content !== 'string') {
- throw new Error(
- "Invalid content provided to 'calculateReadingTime'. Expected a string."
- );
- }
- if (typeof wordsPerMinute !== 'number' || wordsPerMinute <= 0) {
- throw new Error(
- "Invalid 'wordsPerMinute' value. Expected a positive number."
- );
- }
-
- // Remove HTML tags and count words
- const plainText = content
- .replace(/<\/?[^>]+(>|$)/g, '')
- .replace(/&[^;]+;/g, ' ');
- const wordCount = plainText
- .trim()
- .split(/\s+/)
- .filter((word) => word.length > 0).length;
-
- if (wordCount === 0) {
- return '0 min read';
- }
-
- // Calculate and format reading time
- const minutes = Math.ceil(wordCount / wordsPerMinute);
- return `${minutes} min read`;
-}
-
-/**
- * Helper function to construct a URL with optional query parameters.
- *
- * @param {string} baseUrl - The base URL.
- * @param {string} endpoint - The endpoint to append to the base URL.
- * @param {Object} [queryParams] - Optional query parameters as key-value pairs.
- * @returns {string} - The constructed URL.
- */
-export function constructApiUrl(baseUrl, endpoint, queryParams = {}) {
- const url = new URL(endpoint, baseUrl);
- Object.keys(queryParams).forEach((key) =>
- url.searchParams.append(key, queryParams[key])
- );
- return url.toString();
-}
-
-/**
- * Sort feeds by publication date in descending order.
- *
- * @param {Array} feeds - Array of feed data objects.
- * @returns {Array} - Sorted feeds data.
- */
-export function sortFeedsByDate(feeds) {
- return feeds
- .filter((feed) => {
- if (!(feed.pubDate instanceof Date) || isNaN(feed.pubDate)) {
- console.warn(`Invalid pubDate for feed: ${feed}`);
- return false;
- }
- return true;
- })
- .sort((a, b) => b.pubDate - a.pubDate);
-}
diff --git a/src/utils/common.ts b/src/utils/common.ts
new file mode 100644
index 0000000..33cb3ff
--- /dev/null
+++ b/src/utils/common.ts
@@ -0,0 +1,33 @@
+/**
+ * Estimates the average reading time of a text based on the word count.
+ * @param content - The text content (e.g., HTML or plain text).
+ * @param wordsPerMinute - The average reading speed in words per minute (defaults to 250).
+ * @returns A string representing the estimated reading time (e.g., "3 min read").
+ */
+export function calculateReadingTime(
+ content: string,
+ wordsPerMinute: number = 250
+): string {
+ if (wordsPerMinute <= 0) {
+ throw new Error(
+ "Invalid 'wordsPerMinute' value. Expected a positive number."
+ );
+ }
+
+ // Remove HTML tags and count words
+ const plainText = content
+ .replace(/<\/?[^>]+(>|$)/g, '')
+ .replace(/&[^;]+;/g, ' ');
+ const wordCount = plainText
+ .trim()
+ .split(/\s+/)
+ .filter((word) => word.length > 0).length;
+
+ if (wordCount === 0) {
+ return '0 min read';
+ }
+
+ // Calculate and format reading time
+ const minutes = Math.ceil(wordCount / wordsPerMinute);
+ return `${minutes} min read`;
+}
diff --git a/src/utils/date.js b/src/utils/date.js
deleted file mode 100644
index 67d1fcb..0000000
--- a/src/utils/date.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Calulates the time elapsed since a given date and formats it as a human-readable string.
- *
- * @param {Date} date - The date to calculate the elapsed time from.
- * @param {Object} [labels] - Custom labels for intervals (e.g., { year: "año", second: "segundo" }).
- * @param {number} [threshold=5] - Number of seconds under which to return "just now".
- * @returns {string} - A string representing the relative time (e.g., "2 days ago").
- */
-function timeSince(date, labels = {}, threshold = 5) {
- if (!(date instanceof Date)) {
- throw new Error(
- "Invalid date provided to 'timeSince'. Expected a Date object."
- );
- }
-
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
-
- if (seconds < 0) {
- return 'in the future';
- }
-
- if (seconds < threshold) {
- return 'just now';
- }
-
- const defaultLabels = {
- year: 'year',
- month: 'month',
- day: 'day',
- hour: 'hour',
- minute: 'minute',
- second: 'second',
- };
-
- const intervals = [
- { label: 'year', seconds: 31536000 },
- { label: 'month', seconds: 2592000 },
- { label: 'day', seconds: 86400 },
- { label: 'hour', seconds: 3600 },
- { label: 'minute', seconds: 60 },
- { label: 'second', seconds: 1 },
- ];
-
- const finalLabels = { ...defaultLabels, ...labels };
-
- for (const interval of intervals) {
- const count = Math.floor(seconds / interval.seconds);
- if (count >= 1) {
- const label = finalLabels[interval.label] || interval.label;
- return `${count} ${label}${count > 1 ? 's' : ''} ago`;
- }
- }
- return 'just now';
-}
-
-/**
- * Get the relative date string.
- *
- * @param {string|Date} pubDate - Publication date.
- * @param {Object} [labels] - Custom labels for intervals (optional).
- * @param {number} [threshold=5] - Seconds threshold for returning "just now".
- * @returns {string} - Relative date string.
- */
-export function getRelativeDate(pubDate, labels = {}, threshold = 5) {
- if (pubDate) {
- const parsedDate = new Date(pubDate);
- if (!isNaN(parsedDate.getTime())) {
- return timeSince(parsedDate, labels, threshold);
- }
- }
- return 'Unknown Date';
-}
diff --git a/src/utils/date.ts b/src/utils/date.ts
new file mode 100644
index 0000000..ee268d8
--- /dev/null
+++ b/src/utils/date.ts
@@ -0,0 +1,82 @@
+type TimeIntervalLabel =
+ | 'year'
+ | 'month'
+ | 'day'
+ | 'hour'
+ | 'minute'
+ | 'second';
+
+interface Interval {
+ label: TimeIntervalLabel;
+ seconds: number;
+}
+
+const defaultLabels: Record = {
+ year: 'year',
+ month: 'month',
+ day: 'day',
+ hour: 'hour',
+ minute: 'minute',
+ second: 'second',
+};
+
+const intervals: Interval[] = [
+ { label: 'year', seconds: 31536000 },
+ { label: 'month', seconds: 2592000 },
+ { label: 'day', seconds: 86400 },
+ { label: 'hour', seconds: 3600 },
+ { label: 'minute', seconds: 60 },
+ { label: 'second', seconds: 1 },
+];
+
+/**
+ * Calulates the time elapsed since a given date and formats it as a human-readable string.
+ * @param date - The Date to compare with now.
+ * @param labels - Optional custom labels for intervals.
+ * @param threshold - Number of seconds under which to return "just now".
+ */
+function timeSince(
+ date: Date,
+ labels: Partial> = {},
+ threshold: number = 5
+): string {
+ const mergedLabels: Record = {
+ ...defaultLabels,
+ ...labels,
+ };
+
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
+ if (seconds < 0) return 'in the future';
+ if (seconds < threshold) return 'just now';
+
+ for (const interval of intervals) {
+ const count = Math.floor(seconds / interval.seconds);
+ if (count >= 1) {
+ const label = mergedLabels[interval.label] || interval.label;
+ return `${count} ${label}${count > 1 ? 's' : ''} ago`;
+ }
+ }
+ return 'just now';
+}
+
+/**
+ * Returns a string like "2 days ago" or "just now", given a date.
+ * @param pubDate - The publication date (Date, string, or null).
+ * @param labels - Optional custom labels for intervals.
+ * @param threshold - Number of seconds under which to return "just now".
+ * @returns The formatted relative date, or "Unknown Date" if invalid.
+ */
+export function getRelativeDate(
+ pubDate: string | Date | null | undefined,
+ labels: Record = {},
+ threshold: number = 5
+): string {
+ if (!pubDate) return 'Unknown Date';
+
+ const dateObj = pubDate instanceof Date ? pubDate : new Date(pubDate);
+
+ if (isNaN(dateObj.getTime())) {
+ return 'Unknown Date';
+ }
+ return timeSince(dateObj, labels, threshold);
+}
diff --git a/src/utils/dom.js b/src/utils/dom.ts
similarity index 57%
rename from src/utils/dom.js
rename to src/utils/dom.ts
index f6115ba..e5a8a40 100644
--- a/src/utils/dom.js
+++ b/src/utils/dom.ts
@@ -1,18 +1,12 @@
-import { getRelativeDate } from './date.js';
+import { TransformedFeed } from '../types';
+import { getRelativeDate } from './date';
/**
* Escapes HTML special characters in a string.
- *
- * @param {string} input - The input string to sanitize.
- * @returns {string} - A sanitized string safe for embedding in HTML.
+ * @param input - The input string to sanitize.
+ * @returns A sanitized string safe for embedding in HTML.
*/
-function escapeHTML(input) {
- if (typeof input !== 'string') {
- console.warn(
- 'escapeHTML: Non-string input received. Returning empty string.'
- );
- return '';
- }
+function escapeHTML(input: string): string {
const div = document.createElement('div');
div.textContent = input;
return div.innerHTML;
@@ -20,12 +14,10 @@ function escapeHTML(input) {
/**
* Validates a URL and ensures it uses a safe protocol.
- *
- * @param {string} url - The URL to validate.
- * @param {string} [fallback="#"] - The fallback value for invalid URLs.
- * @returns {string} - The validated URL or a placeholder.
+ * @param url - The URL to validate.
+ * @param fallback - Fallback if invalid (default '#').
*/
-function validateUrl(url, fallback = '#') {
+function validateUrl(url: string, fallback = '#'): string {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
@@ -38,14 +30,13 @@ function validateUrl(url, fallback = '#') {
}
/**
- * Create the feed header element.
- *
- * @param {string} feedTitle - The title of the feed.
- * @param {string} feedUrl - The URL of the feed.
- * @param {string} feedIconUrl - The URL of the feed icon.
- * @returns {HTMLElement} - The feed header element.
+ * Create the feed header element for a feed item.
*/
-function createFeedHeader(feedTitle, feedUrl, feedIconUrl) {
+function createFeedHeader(
+ feedTitle: string,
+ feedUrl: string,
+ feedIconUrl?: string
+): HTMLDivElement {
const feedHeader = document.createElement('div');
feedHeader.className = 'blogroller-feed-header';
@@ -54,13 +45,17 @@ function createFeedHeader(feedTitle, feedUrl, feedIconUrl) {
feedLink.target = '_blank';
feedLink.className = 'blogroller-feed-title-link';
- const feedIcon = document.createElement('img');
- feedIcon.src = feedIconUrl || '';
- feedIcon.alt = `${feedTitle} icon`;
- feedIcon.className = 'blogroller-feed-icon';
- feedIcon.setAttribute('referrerpolicy', 'no-referrer');
+ if (feedIconUrl) {
+ const feedIcon = document.createElement('img');
+ feedIcon.src = feedIconUrl || '';
+ feedIcon.alt = `${feedTitle} icon`;
+ feedIcon.className = 'blogroller-feed-icon';
+ feedIcon.loading = 'lazy';
+ feedIcon.setAttribute('referrerpolicy', 'no-referrer');
+
+ feedLink.appendChild(feedIcon);
+ }
- feedLink.appendChild(feedIcon);
feedLink.appendChild(document.createTextNode(feedTitle));
feedHeader.appendChild(feedLink);
@@ -68,30 +63,25 @@ function createFeedHeader(feedTitle, feedUrl, feedIconUrl) {
}
/**
- * Create the post link element.
- *
- * @param {string} postTitle - The title of the post.
- * @param {string} postUrl - The URL of the post.
- * @returns {HTMLElement} - The post link element.
+ * Creates a clickable post link element.
*/
-function createPostLink(postTitle, postUrl) {
+function createPostLink(postTitle: string, postUrl: string): HTMLAnchorElement {
const postLink = document.createElement('a');
postLink.href = postUrl || '#';
postLink.target = '_blank';
postLink.className = 'blogroller-post-title-link';
- postLink.textContent = postTitle;
+ postLink.innerHTML = postTitle;
return postLink;
}
/**
- * Create the feed meta element.
- *
- * @param {string} readingTime - The reading time of the post.
- * @param {string} relativeDate - The relative date of the post.
- * @returns {HTMLElement} - The feed meta element.
+ * Creates the "metadata" section showing reading time and relative date.
*/
-function createFeedMeta(readingTime, relativeDate) {
+function createFeedMeta(
+ readingTime: string | null,
+ relativeDate: string
+): HTMLDivElement {
const feedMeta = document.createElement('div');
feedMeta.className = 'blogroller-feed-meta';
@@ -115,12 +105,9 @@ function createFeedMeta(readingTime, relativeDate) {
}
/**
- * Create a single feed item element.
- *
- * @param {Object} feed - Feed data object.
- * @returns {HTMLElement} - DOM element for the feed item.
+ * Creates a single feed item element in the DOM, representing a feed's latest post.
**/
-export function createFeedItem(feed) {
+export function createFeedItem(feed: TransformedFeed): HTMLDivElement {
const feedTitle = escapeHTML(feed.feedTitle) || 'Untitled Feed';
const postTitle = escapeHTML(feed.postTitle) || 'Untitled Post';
const relativeDate = getRelativeDate(feed.pubDate);
@@ -142,11 +129,9 @@ export function createFeedItem(feed) {
}
/**
- * Create a "Show More" link element.
- *
- * @returns {HTMLElement} - DOM element for the "Show More" link.
+ * Creates an initially hidden "Show More" link element for pagination.
**/
-export function createShowMoreLink() {
+export function createShowMoreLink(): HTMLAnchorElement {
const showMoreLink = document.createElement('a');
showMoreLink.id = 'blogroller-show-more';
showMoreLink.href = '#';
diff --git a/tests/api.test.js b/tests/api.test.js
deleted file mode 100644
index f26c6a6..0000000
--- a/tests/api.test.js
+++ /dev/null
@@ -1,354 +0,0 @@
-import {
- fetchSubscriptions,
- fetchLatestPost,
- fetchFeedsData,
-} from '../src/api.js';
-
-global.fetch = jest.fn();
-
-const mockResponses = {
- validSubscriptions: {
- subscriptions: [
- { categories: [{ label: 'favs' }], title: 'Feed 1' },
- { categories: [{ label: 'other' }], title: 'Feed 2' },
- ],
- },
- validPost: {
- items: [
- {
- title: 'Post Title',
- alternate: [{ href: 'https://post-url' }],
- published: 1672444800,
- summary: { content: 'This is a test summary.' },
- },
- ],
- },
- emptyPost: { items: [] },
-};
-
-const mockFetch = (response, ok = true, statusText = 'Error') => {
- fetch.mockResolvedValueOnce({
- ok,
- statusText,
- json: async () => response,
- });
-};
-
-beforeEach(() => {
- jest.clearAllMocks();
- jest.spyOn(console, 'warn').mockImplementation(() => {});
-});
-
-afterEach(() => {
- jest.restoreAllMocks();
-});
-
-describe('API Tests', () => {
- describe('fetchSubscriptions', () => {
- test('should fetch and filter subscriptions by category', async () => {
- mockFetch(mockResponses.validSubscriptions);
-
- const result = await fetchSubscriptions({
- subscriptionUrl: 'https://test-url',
- categoryLabel: 'favs',
- });
- expect(result).toEqual([
- { categories: [{ label: 'favs' }], title: 'Feed 1' },
- ]);
- expect(fetch).toHaveBeenCalledWith('https://test-url', {
- referrerPolicy: 'strict-origin-when-cross-origin',
- });
- });
-
- test('should throw an error for invalid subscriptionUrl', async () => {
- await expect(fetchSubscriptions({}, 'favs')).rejects.toThrow(
- '[Blogroll] Missing required parameter(s) for fetchSubscriptions: subscriptionUrl'
- );
- });
-
- test('should handle API errors', async () => {
- mockFetch(null, false, 'Internal Server Error');
-
- await expect(
- fetchSubscriptions({
- subscriptionUrl: 'https://test-url',
- categoryLabel: 'favs',
- })
- ).rejects.toThrow('Failed to fetch subscriptions: Internal Server Error');
- });
-
- test('should throw error for empty category label', async () => {
- await expect(
- fetchSubscriptions({
- subscriptionUrl: 'https://test-url',
- categoryLabel: '',
- })
- ).rejects.toThrow(
- '[Blogroll] Missing required parameter(s) for fetchSubscriptions: categoryLabel'
- );
- });
- });
-
- describe('fetchLatestPost', () => {
- test('should fetch the latest post for a valid feed ID', async () => {
- mockFetch(mockResponses.validPost);
-
- const result = await fetchLatestPost('feed/123', {
- proxyUrl: 'https://test-url/',
- });
- expect(result).toEqual({
- postTitle: 'Post Title',
- postUrl: 'https://post-url',
- pubDate: new Date(1672444800000),
- readingTime: '1 min read',
- });
- expect(fetch).toHaveBeenCalledWith('https://test-url/feed/123?n=1', {
- referrerPolicy: 'strict-origin-when-cross-origin',
- });
- });
-
- test('should throw an error for invalid feedBaseUrl', async () => {
- await expect(fetchLatestPost('feed/123', {})).rejects.toThrow(
- '[Blogroll] Missing required parameter(s) for fetchLatestPost: proxyUrl'
- );
- });
-
- test('should handle API errors', async () => {
- mockFetch(null, false, 'Not Found');
-
- await expect(
- fetchLatestPost('feed/123', { proxyUrl: 'https://test-url/' })
- ).rejects.toThrow('Failed to fetch latest post for feed ID: feed/123');
- });
-
- test('should throw error for empty feed ID', async () => {
- await expect(
- fetchLatestPost('', { proxyUrl: 'https://test-url/' })
- ).rejects.toThrow(
- '[Blogroll] Missing required parameter(s) for fetchLatestPost: feedId'
- );
- });
-
- test('should handle feed with no posts', async () => {
- mockFetch(mockResponses.emptyPost);
-
- const result = await fetchLatestPost('feed/123', {
- proxyUrl: 'https://test-url/',
- });
- expect(result).toBeNull();
- });
-
- test('should handle feed with invalid post data', async () => {
- mockFetch({ items: [{ invalid: 'data' }] });
-
- const result = await fetchLatestPost('feed/123', {
- proxyUrl: 'https://test-url/',
- });
- expect(result).toBeNull();
- });
- });
-
- describe('fetchFeedsData', () => {
- const feeds = [
- {
- id: 'feed1',
- title: 'Feed 1',
- htmlUrl: 'https://feed1-url',
- iconUrl: 'https://icon1-url',
- },
- {
- id: 'feed2',
- title: 'Feed 2',
- htmlUrl: 'https://feed2-url',
- iconUrl: 'https://icon2-url',
- },
- ];
-
- test('should fetch data for valid feeds', async () => {
- mockFetch(mockResponses.validPost);
- mockFetch(mockResponses.validPost);
-
- const result = await fetchFeedsData(feeds, {
- proxyUrl: 'https://test-url/',
- });
-
- expect(result.feedsData).toHaveLength(2);
- expect(result.feedsData[0]).toEqual(
- expect.objectContaining({ feedTitle: 'Feed 1' })
- );
- expect(result.failedFeeds).toHaveLength(0);
- });
-
- test('should handle errors for some feeds', async () => {
- const consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(() => {});
- mockFetch(mockResponses.validPost);
- mockFetch(null, false, 'Not Found');
-
- const result = await fetchFeedsData(feeds, {
- proxyUrl: 'https://test-url/',
- });
-
- expect(result.feedsData).toHaveLength(1);
- expect(result.failedFeeds).toHaveLength(1);
- expect(result.failedFeeds[0]).toEqual(
- expect.objectContaining({
- id: 'feed2',
- title: 'Feed 2',
- error:
- '[Blogroll] Failed to fetch latest post for feed ID: feed2: Not Found',
- })
- );
-
- expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- '[Blogroll] Failed to fetch data for some feeds:',
- expect.any(Error)
- );
-
- consoleErrorSpy.mockRestore();
- });
-
- test('should handle an empty feeds array', async () => {
- const result = await fetchFeedsData([], {
- proxyUrl: 'https://test-url/',
- });
-
- expect(result.feedsData).toHaveLength(0);
- expect(result.failedFeeds).toHaveLength(0);
- });
-
- test('should handle feeds with mixed valid and invalid content', async () => {
- const consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(() => {});
- mockFetch(mockResponses.validPost);
- mockFetch({ items: [{ invalid: 'data' }] });
- mockFetch(null, false, 'Not Found');
-
- const mixedFeeds = [
- ...feeds,
- {
- id: 'feed3',
- title: 'Feed 3',
- htmlUrl: 'https://feed3-url',
- iconUrl: 'https://icon3-url',
- },
- ];
-
- const result = await fetchFeedsData(mixedFeeds, {
- proxyUrl: 'https://test-url/',
- });
-
- expect(result.feedsData).toHaveLength(1);
- expect(result.failedFeeds).toHaveLength(2);
-
- expect(result.failedFeeds).toContainEqual(
- expect.objectContaining({
- id: 'feed2',
- title: 'Feed 2',
- error: 'No posts found',
- })
- );
- expect(result.failedFeeds).toContainEqual(
- expect.objectContaining({
- id: 'feed3',
- title: 'Feed 3',
- error:
- '[Blogroll] Failed to fetch latest post for feed ID: feed3: Not Found',
- })
- );
-
- expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- '[Blogroll] Failed to fetch data for some feeds:',
- expect.any(Error)
- );
-
- consoleErrorSpy.mockRestore();
- });
- });
-});
-
-describe('Edge Cases', () => {
- test('fetchSubscriptions should handle no feeds', async () => {
- mockFetch({ subscriptions: [] }); // Empty subscription
-
- const result = await fetchSubscriptions({
- subscriptionUrl: 'https://test-url',
- categoryLabel: 'favs',
- });
- expect(result).toEqual([]);
- });
-
- test('fetchLatestPost should handle missing data gracefully', async () => {
- mockFetch({ items: [{}] }); // Missing expected fields
-
- const result = await fetchLatestPost('feed/123', {
- proxyUrl: 'https://test-url/',
- });
- expect(result).toBeNull();
- });
-
- test('fetchFeedsData should handle a mix of valid and invalid feeds', async () => {
- const consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(() => {});
- mockFetch(mockResponses.validPost);
- mockFetch(mockResponses.emptyPost);
- mockFetch(null, false, 'Not Found');
-
- const feeds = [
- {
- id: 'feed1',
- title: 'Feed 1',
- htmlUrl: 'https://feed1-url',
- iconUrl: 'https://icon1-url',
- },
- {
- id: 'feed2',
- title: 'Feed 2',
- htmlUrl: 'https://feed2-url',
- iconUrl: 'https://icon2-url',
- },
- {
- id: 'feed3',
- title: 'Feed 3',
- htmlUrl: 'https://feed3-url',
- iconUrl: 'https://icon3-url',
- },
- ];
-
- const result = await fetchFeedsData(feeds, {
- proxyUrl: 'https://test-url/',
- });
-
- expect(result.feedsData).toHaveLength(1);
- expect(result.failedFeeds).toHaveLength(2);
-
- expect(result.failedFeeds).toContainEqual(
- expect.objectContaining({
- id: 'feed2',
- title: 'Feed 2',
- error: 'No posts found',
- })
- );
- expect(result.failedFeeds).toContainEqual(
- expect.objectContaining({
- id: 'feed3',
- title: 'Feed 3',
- error:
- '[Blogroll] Failed to fetch latest post for feed ID: feed3: Not Found',
- })
- );
-
- expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- '[Blogroll] Failed to fetch data for some feeds:',
- expect.any(Error)
- );
-
- consoleErrorSpy.mockRestore();
- });
-});
diff --git a/tests/blogroll.aggregator.test.ts b/tests/blogroll.aggregator.test.ts
new file mode 100644
index 0000000..8487d32
--- /dev/null
+++ b/tests/blogroll.aggregator.test.ts
@@ -0,0 +1,163 @@
+import { Blogroll } from '../src/blogroll';
+import { fetchDigest } from '../src/api';
+import { MESSAGES } from '../src/constants';
+
+const mockFetchDigest = fetchDigest as jest.Mock;
+
+jest.mock('../src/api', () => {
+ return {
+ fetchDigest: jest.fn(),
+ };
+});
+
+describe('Blogroll Aggregator Tests', () => {
+ let container: HTMLElement;
+ let blogroll: Blogroll;
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ container = document.getElementById('rss-feed')!;
+ blogroll = new Blogroll();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ test('should call aggregator on load and render returned feeds', async () => {
+ mockFetchDigest.mockResolvedValueOnce({
+ items: [
+ {
+ title: 'Post 100A',
+ published: 1697000000,
+ feedId: 'feed/100',
+ feedTitle: 'Feed 100',
+ feedHtmlUrl: 'https://feed100.example.com',
+ feedIconUrl: 'https://feed100.example.com/icon.png',
+ alternate: [{ href: 'https://feed100.example.com/postA' }],
+ },
+ {
+ title: 'Post 200A',
+ published: 1697100000,
+ feedId: 'feed/200',
+ feedTitle: 'Feed 200',
+ feedHtmlUrl: 'https://feed200.example.com',
+ feedIconUrl: 'https://feed200.example.com/icon.png',
+ alternate: [{ href: 'https://feed200.example.com/postA' }],
+ },
+ ],
+ page: 1,
+ limit: 5,
+ totalItems: 2,
+ });
+
+ blogroll.initialize({
+ proxyUrl: 'https://proxy.test.com/',
+ categoryLabel: 'test',
+ batchSize: 5,
+ });
+
+ await Promise.resolve();
+
+ expect(fetchDigest).toHaveBeenCalledTimes(1);
+
+ const feedItems = container.querySelectorAll('.blogroller-feed-item');
+ expect(feedItems.length).toBe(2);
+
+ const feedTitles = Array.from(feedItems).map((item) => {
+ const header = item.querySelector('.blogroller-feed-header');
+ return header!.textContent;
+ });
+
+ expect(feedTitles).toEqual(
+ expect.arrayContaining(['Feed 100', 'Feed 200'])
+ );
+ });
+
+ test('should display NO_POSTS message if aggregator returns no feeds', async () => {
+ mockFetchDigest.mockResolvedValueOnce({
+ items: [],
+ page: 1,
+ limit: 5,
+ totalItems: 0,
+ });
+
+ blogroll.initialize({
+ proxyUrl: 'https://proxy.test.com/',
+ categoryLabel: 'empty',
+ batchSize: 5,
+ });
+
+ await Promise.resolve();
+
+ expect(fetchDigest).toHaveBeenCalledTimes(1);
+ expect(container.innerHTML).toBe(MESSAGES.NO_POSTS);
+ });
+
+ test('should handle pagination with Show More link', async () => {
+ mockFetchDigest.mockResolvedValueOnce({
+ items: [
+ {
+ title: 'Post 300A',
+ published: 1697000000,
+ feedId: 'feed/300',
+ feedTitle: 'Feed 300',
+ feedHtmlUrl: 'https://feed300.example.com',
+ alternate: [{ href: 'https://feed300.example.com/postA' }],
+ },
+ ],
+ page: 1,
+ limit: 1,
+ totalItems: 2,
+ });
+
+ mockFetchDigest.mockResolvedValueOnce({
+ items: [
+ {
+ title: 'Post 400A',
+ published: 1697100000,
+ feedId: 'feed/400',
+ feedTitle: 'Feed 400',
+ feedHtmlUrl: 'https://feed400.example.com',
+ alternate: [{ href: 'https://feed400.example.com/postA' }],
+ },
+ ],
+ page: 2,
+ limit: 1,
+ totalItems: 2,
+ });
+
+ blogroll.initialize({
+ proxyUrl: 'https://proxy.test.com/',
+ categoryLabel: 'favs',
+ batchSize: 1,
+ });
+
+ await Promise.resolve();
+ let feedItems = container.querySelectorAll('.blogroller-feed-item');
+ expect(feedItems.length).toBe(1);
+
+ let showMoreLink = document.getElementById('blogroller-show-more');
+ expect(showMoreLink).not.toBeNull();
+ expect(showMoreLink!.style.display).toBe('block');
+
+ showMoreLink!.click();
+ await Promise.resolve();
+
+ expect(fetchDigest).toHaveBeenCalledTimes(2);
+
+ // container should now show 2 feed items total
+ feedItems = container.querySelectorAll('.blogroller-feed-item');
+ expect(feedItems.length).toBe(2);
+
+ // The second feed item
+ const feedHeader2 = feedItems[1].querySelector('.blogroller-feed-header');
+ expect(feedHeader2!.textContent).toContain('Feed 400');
+
+ // After second page, totalFeeds=2 -> we have 2 feeds rendered,
+ // so "Show More" should now be hidden
+ showMoreLink = document.getElementById('blogroller-show-more');
+ expect(showMoreLink!.style.display).toBe('none');
+ });
+});
diff --git a/tests/blogroll.showMore.test.js b/tests/blogroll.showMore.test.js
deleted file mode 100644
index af97d94..0000000
--- a/tests/blogroll.showMore.test.js
+++ /dev/null
@@ -1,187 +0,0 @@
-import { Blogroll } from '../src/blogroll.js';
-
-jest.mock('../src/api.js', () => {
- return {
- fetchSubscriptions: jest.fn(),
- fetchFeedsData: jest.fn(),
- };
-});
-
-import { fetchSubscriptions, fetchFeedsData } from '../src/api.js';
-import { MESSAGES } from '../src/constants.js';
-
-describe('Blogroll Show More Tests', () => {
- let container;
- let blogroll;
-
- beforeEach(() => {
- document.body.innerHTML = '';
- container = document.getElementById('rss-feed');
-
- fetchSubscriptions.mockResolvedValueOnce([
- { id: 'feed1', title: 'Feed 1' },
- { id: 'feed2', title: 'Feed 2' },
- { id: 'feed3', title: 'Feed 3' },
- ]);
-
- fetchFeedsData.mockResolvedValueOnce({
- feedsData: [
- {
- feedTitle: 'Feed 1',
- feedUrl: 'https://feed1-url',
- feedIcon: 'https://icon1-url',
- postTitle: 'Post 1',
- postUrl: 'https://post1-url',
- pubDate: new Date(),
- },
- {
- feedTitle: 'Feed 2',
- feedUrl: 'https://feed2-url',
- feedIcon: 'https://icon2-url',
- postTitle: 'Post 2',
- postUrl: 'https://post2-url',
- pubDate: new Date(),
- },
- {
- feedTitle: 'Feed 3',
- feedUrl: 'https://feed3-url',
- feedIcon: 'https://icon3-url',
- postTitle: 'Post 3',
- postUrl: 'https://post3-url',
- pubDate: new Date(),
- },
- ],
- failedFeeds: [],
- });
-
- blogroll = new Blogroll();
- blogroll.initialize({
- proxyUrl: 'https://proxy.test.com/',
- categoryLabel: 'test',
- batchSize: 5,
- });
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- document.body.innerHTML = '';
- });
-
- test('should NOT show "Show More" link if total feeds <= batchSize', async () => {
- await Promise.resolve();
-
- const showMoreLink = document.getElementById('blogroller-show-more');
- expect(showMoreLink).toBeNull();
- });
-
- test('should show "Show More" link if total feeds > batchSize, then load more feeds on click', async () => {
- blogroll.config.batchSize = 2;
-
- // Suppose we have 5 feeds from fetchSubscriptions
- fetchSubscriptions.mockResolvedValueOnce([
- { id: 'feed1', title: 'Feed 1' },
- { id: 'feed2', title: 'Feed 2' },
- { id: 'feed3', title: 'Feed 3' },
- { id: 'feed4', title: 'Feed 4' },
- { id: 'feed5', title: 'Feed 5' },
- ]);
-
- // feedsData: an array of 5 feed objects
- fetchFeedsData.mockResolvedValueOnce({
- feedsData: [
- {
- feedTitle: 'Feed 1',
- postTitle: 'Post 1',
- feedUrl: 'https://feed1-url',
- postUrl: 'https://feed1-post',
- pubDate: new Date(),
- },
- {
- feedTitle: 'Feed 2',
- postTitle: 'Post 2',
- feedUrl: 'https://feed2-url',
- postUrl: 'https://feed2-post',
- pubDate: new Date(),
- },
- {
- feedTitle: 'Feed 3',
- postTitle: 'Post 3',
- feedUrl: 'https://feed3-url',
- postUrl: 'https://feed3-post',
- pubDate: new Date(),
- },
- {
- feedTitle: 'Feed 4',
- postTitle: 'Post 4',
- feedUrl: 'https://feed4-url',
- postUrl: 'https://feed4-post',
- pubDate: new Date(),
- },
- {
- feedTitle: 'Feed 5',
- postTitle: 'Post 5',
- feedUrl: 'https://feed5-url',
- postUrl: 'https://feed5-post',
- pubDate: new Date(),
- },
- ],
- failedFeeds: [],
- });
-
- await blogroll.loadFeeds();
-
- // We should now have 2 items rendered (batchSize = 2)
- let feedItems = container.querySelectorAll('.blogroller-feed-item');
- expect(feedItems.length).toBe(2);
-
- // The "Show More" link should be visible
- const showMoreLink = document.getElementById('blogroller-show-more');
- expect(showMoreLink).not.toBeNull();
- expect(showMoreLink.style.display).toBe('block');
-
- // Simulate a click on "Show More"
- showMoreLink.click();
-
- // Now we expect 4 items (the next 2 in the list)
- feedItems = container.querySelectorAll('.blogroller-feed-item');
- expect(feedItems.length).toBe(4);
-
- // Click again → we should get the final 5th item
- showMoreLink.click();
- feedItems = container.querySelectorAll('.blogroller-feed-item');
- expect(feedItems.length).toBe(5);
-
- // After the last click, there are no more items left to show, so it should hide
- expect(showMoreLink.style.display).toBe('none');
- });
-
- test('should display error message if loadFeeds throws an error', async () => {
- const consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(() => {});
- fetchSubscriptions.mockRejectedValueOnce(new Error('Network fail'));
-
- await blogroll.loadFeeds();
-
- expect(container.innerHTML).toBe(MESSAGES.LOAD_FAILED);
-
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- MESSAGES.ERROR.LOAD_FEEDS_FAILED,
- expect.any(Error)
- );
-
- consoleErrorSpy.mockRestore();
- });
-
- test('should display MESSAGES.NO_POSTS if feedsData is empty', async () => {
- // Subscriptions are found but feed data is empty
- fetchSubscriptions.mockResolvedValueOnce([
- { id: 'feed1', title: 'Feed 1' },
- ]);
- fetchFeedsData.mockResolvedValueOnce({ feedsData: [], failedFeeds: [] });
-
- await blogroll.loadFeeds();
-
- expect(container.innerHTML).toBe(MESSAGES.NO_POSTS);
- });
-});
diff --git a/tests/blogroll.test.js b/tests/blogroll.test.ts
similarity index 68%
rename from tests/blogroll.test.js
rename to tests/blogroll.test.ts
index 71b8fb0..c1e45f8 100644
--- a/tests/blogroll.test.js
+++ b/tests/blogroll.test.ts
@@ -1,10 +1,9 @@
-import { Blogroll } from '../src/blogroll.js';
+import { Blogroll } from '../src/blogroll';
-jest.mock('../src/config.js', () => ({
+jest.mock('../src/config', () => ({
CONFIG: {
defaults: {
documentClass: 'test-blogroll',
- subscriptionEndpoint: 'test/subscription',
batchSize: 5,
},
validation: {
@@ -24,12 +23,15 @@ afterEach(() => {
});
describe('Blogroll Configuration Tests', () => {
- let container;
+ let container: HTMLElement;
+ let blogroll: Blogroll;
beforeEach(() => {
container = document.createElement('div');
container.id = 'rss-feed';
document.body.appendChild(container);
+
+ blogroll = new Blogroll();
});
afterEach(() => {
@@ -45,7 +47,6 @@ describe('Blogroll Configuration Tests', () => {
expect(blogroll.config).toMatchObject({
documentClass: 'test-blogroll',
- subscriptionEndpoint: 'test/subscription',
batchSize: 5,
proxyUrl: 'https://proxy.test.com/',
categoryLabel: 'test',
@@ -53,34 +54,15 @@ describe('Blogroll Configuration Tests', () => {
});
test('should throw error for missing required parameters', () => {
- const blogroll = new Blogroll();
-
- expect(() => blogroll.initialize({ categoryLabel: 'Favorites' })).toThrow(
- 'Missing required parameter(s): proxyUrl'
- );
+ expect(() =>
+ blogroll.initialize({ categoryLabel: 'Favorites' } as any)
+ ).toThrow('Missing required parameter(s): proxyUrl');
expect(() =>
- blogroll.initialize({ proxyUrl: 'https://proxy.test.com' })
+ blogroll.initialize({ proxyUrl: 'https://proxy.test.com' } as any)
).toThrow('Missing required parameter(s): categoryLabel');
});
- test.skip('should log an error for invalid container ID', () => {
- const blogroll = new Blogroll();
- const invalidContainerId = 'non-existent-id';
-
- expect(() => {
- blogroll.initialize({
- proxyUrl: 'https://proxy.test.com',
- categoryLabel: 'test',
- containerId: invalidContainerId,
- });
- }).toThrow(
- new Error(
- `Feed container with ID '${invalidContainerId}' not found in the DOM.`
- )
- );
- });
-
test('should merge custom configuration with defaults', () => {
const blogroll = new Blogroll();
blogroll.initialize({
@@ -106,7 +88,7 @@ describe('Blogroll Configuration Tests', () => {
});
describe('Blogroll Tests', () => {
- let blogroll;
+ let blogroll: Blogroll;
beforeEach(() => {
document.body.innerHTML = '';
@@ -127,17 +109,6 @@ describe('Blogroll Tests', () => {
expect(blogroll.config.proxyUrl).toBe('https://proxy.test.com/');
});
- test('should throw error if feed container is missing', () => {
- blogroll.initialize({
- proxyUrl: 'https://proxy.test.com',
- categoryLabel: 'test',
- });
- document.body.innerHTML = '';
- expect(() => blogroll.getFeedContainer()).toThrow(
- "Feed container with ID 'rss-feed' not found in the DOM."
- );
- });
-
test('should initialize multiple instances of Blogroll independently', () => {
const blogroll1 = new Blogroll();
const blogroll2 = new Blogroll();
@@ -167,19 +138,7 @@ describe('Blogroll Tests', () => {
blogroll.initialize({
proxyUrl: 'https://proxy.test.com/',
categoryLabel: null,
- })
+ } as any)
).toThrow('Missing required parameter(s): categoryLabel');
});
-
- test('should initialize with a custom container ID', () => {
- document.body.innerHTML = '
';
- blogroll.initialize({
- proxyUrl: 'https://proxy.test.com/',
- categoryLabel: 'test',
- containerId: 'custom-container',
- });
-
- const container = blogroll.getFeedContainer();
- expect(container.id).toBe('custom-container');
- });
});
diff --git a/tests/utils/common.test.js b/tests/utils/common.test.js
deleted file mode 100644
index e004d5c..0000000
--- a/tests/utils/common.test.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import {
- calculateReadingTime,
- sortFeedsByDate,
-} from '../../src/utils/common.js';
-
-describe('calculateReadingTime', () => {
- const content = 'This is a sample text for testing';
-
- test('should calculate reading time for plain text', () => {
- expect(calculateReadingTime(content)).toBe('1 min read');
- });
-
- test('should calculate reading time for HTML content', () => {
- const html = 'This is a test sentence with ten words.
';
- expect(calculateReadingTime(html)).toBe('1 min read');
- });
-
- test('should throw error for invalid input', () => {
- expect(() => calculateReadingTime(12345)).toThrow(
- "Invalid content provided to 'calculateReadingTime'. Expected a string."
- );
- });
-
- test('should throw an error for invalid wordsPerMinute value', () => {
- expect(() => calculateReadingTime(content, 'invalid')).toThrow(
- "Invalid 'wordsPerMinute' value. Expected a positive number."
- );
-
- expect(() => calculateReadingTime(content, -100)).toThrow(
- "Invalid 'wordsPerMinute' value. Expected a positive number."
- );
-
- expect(() => calculateReadingTime(content, 0)).toThrow(
- "Invalid 'wordsPerMinute' value. Expected a positive number."
- );
- });
-
- test('should calculate reading time for empty string', () => {
- expect(calculateReadingTime('')).toBe('0 min read');
- });
-
- test('should calculate reading time for very long content', () => {
- const longContent = 'word '.repeat(1000);
- expect(calculateReadingTime(longContent)).toBe('4 min read');
- });
-
- test('should calculate reading time for HTML with nested elements', () => {
- const nestedHtml =
- 'This is a test sentence with ten words.
';
- expect(calculateReadingTime(nestedHtml)).toBe('1 min read');
- });
-
- test('should calculate reading time with different wordsPerMinue values', () => {
- const longContent = 'word '.repeat(1000);
- expect(calculateReadingTime(longContent, 200)).toBe('5 min read');
- expect(calculateReadingTime(longContent, 300)).toBe('4 min read');
- });
-});
-
-describe('sortFeedsByDate', () => {
- test('should sort feeds in descending order by pubDate', () => {
- const feeds = [
- { pubDate: new Date('2022-01-01') },
- { pubDate: new Date('2023-01-01') },
- { pubDate: new Date('2021-01-01') },
- ];
-
- const sorted = sortFeedsByDate(feeds);
-
- expect(sorted).toEqual([
- { pubDate: new Date('2023-01-01') },
- { pubDate: new Date('2022-01-01') },
- { pubDate: new Date('2021-01-01') },
- ]);
- });
-
- test('should ignore null or invalid dates', () => {
- const feeds = [
- { pubDate: new Date('2022-01-01') },
- { pubDate: null },
- { pubDate: new Date('2021-01-01') },
- { pubDate: 'invalid-date' },
- ];
-
- const sorted = sortFeedsByDate(feeds);
-
- expect(sorted).toEqual([
- { pubDate: new Date('2022-01-01') },
- { pubDate: new Date('2021-01-01') },
- ]);
- });
-
- test('should handle feeds with same pubDate', () => {
- const feeds = [
- { pubDate: new Date('2022-01-01') },
- { pubDate: new Date('2022-01-01') },
- ];
-
- const sorted = sortFeedsByDate(feeds);
-
- expect(sorted).toEqual([
- { pubDate: new Date('2022-01-01') },
- { pubDate: new Date('2022-01-01') },
- ]);
- });
-
- test('should handle empty feeds array', () => {
- const feeds = [];
- const sorted = sortFeedsByDate(feeds);
-
- expect(sorted).toEqual([]);
- });
-
- test('should handle feeds with future dates', () => {
- const feeds = [
- { pubDate: new Date('2045-01-01') },
- { pubDate: new Date('2023-01-01') },
- ];
-
- const sorted = sortFeedsByDate(feeds);
-
- expect(sorted).toEqual([
- { pubDate: new Date('2045-01-01') },
- { pubDate: new Date('2023-01-01') },
- ]);
- });
-});
diff --git a/tests/utils/common.test.ts b/tests/utils/common.test.ts
new file mode 100644
index 0000000..be9ac49
--- /dev/null
+++ b/tests/utils/common.test.ts
@@ -0,0 +1,45 @@
+import { calculateReadingTime } from '../../src/utils/common';
+
+describe('calculateReadingTime', () => {
+ const content = 'This is a sample text for testing';
+
+ test('should calculate reading time for plain text', () => {
+ expect(calculateReadingTime(content)).toBe('1 min read');
+ });
+
+ test('should calculate reading time for HTML content', () => {
+ const html = 'This is a test sentence with ten words.
';
+ expect(calculateReadingTime(html)).toBe('1 min read');
+ });
+
+ test('should throw an error for invalid wordsPerMinute value', () => {
+ expect(() => calculateReadingTime(content, -100)).toThrow(
+ "Invalid 'wordsPerMinute' value. Expected a positive number."
+ );
+
+ expect(() => calculateReadingTime(content, 0)).toThrow(
+ "Invalid 'wordsPerMinute' value. Expected a positive number."
+ );
+ });
+
+ test('should calculate reading time for empty string', () => {
+ expect(calculateReadingTime('')).toBe('0 min read');
+ });
+
+ test('should calculate reading time for very long content', () => {
+ const longContent = 'word '.repeat(1000);
+ expect(calculateReadingTime(longContent)).toBe('4 min read');
+ });
+
+ test('should calculate reading time for HTML with nested elements', () => {
+ const nestedHtml =
+ 'This is a test sentence with ten words.
';
+ expect(calculateReadingTime(nestedHtml)).toBe('1 min read');
+ });
+
+ test('should calculate reading time with different wordsPerMinue values', () => {
+ const longContent = 'word '.repeat(1000);
+ expect(calculateReadingTime(longContent, 200)).toBe('5 min read');
+ expect(calculateReadingTime(longContent, 300)).toBe('4 min read');
+ });
+});
diff --git a/tests/utils/date.test.js b/tests/utils/date.test.ts
similarity index 97%
rename from tests/utils/date.test.js
rename to tests/utils/date.test.ts
index c08c353..c4cd7bd 100644
--- a/tests/utils/date.test.js
+++ b/tests/utils/date.test.ts
@@ -1,4 +1,4 @@
-import { getRelativeDate } from '../../src/utils/date.js';
+import { getRelativeDate } from '../../src/utils/date';
describe('date.js', () => {
describe('getRelativeDate', () => {
diff --git a/tests/utils/dom.test.js b/tests/utils/dom.test.ts
similarity index 62%
rename from tests/utils/dom.test.js
rename to tests/utils/dom.test.ts
index a81709e..7f179e8 100644
--- a/tests/utils/dom.test.js
+++ b/tests/utils/dom.test.ts
@@ -1,62 +1,66 @@
-import { createFeedItem } from '../../src/utils/dom.js';
+import { TransformedFeed } from '../../src/types';
+import { createFeedItem } from '../../src/utils/dom';
describe('createFeedItem', () => {
test('should create a valid DOM element for a feed item', () => {
- const mockData = {
+ const mockData: TransformedFeed = {
feedTitle: 'Test Feed Item',
postTitle: 'Sample Post',
feedUrl: 'https://test.com',
postUrl: 'https://test.com/post',
- pubData: new Date('2024-01-01').toISOString(),
+ pubDate: new Date('2024-01-01'),
feedIcon: 'https://test.com/icon.png',
readingTime: '5 min read',
};
const feedItem = createFeedItem(mockData);
-
- // Check that the returned element is a DOM node
expect(feedItem).toBeInstanceOf(HTMLElement);
- // Validate structure
const titleElement = feedItem.querySelector('.blogroller-feed-title-link');
- const linkElement = feedItem.querySelector('.blogroller-post-title-link');
-
expect(titleElement).not.toBeNull();
- expect(titleElement.textContent.trim()).toBe(mockData.feedTitle);
- expect(linkElement.href).toBe(mockData.postUrl);
+ expect(titleElement!.textContent!.trim()).toBe(mockData.feedTitle);
+
+ const linkElement = feedItem.querySelector('.blogroller-post-title-link');
+ expect(linkElement).not.toBeNull();
+ expect(linkElement!.getAttribute('href')).toBe(mockData.postUrl);
});
test('should sanitize potentially harmful scripts', () => {
- const mockData = {
+ const mockData: TransformedFeed = {
feedTitle: '',
postTitle: 'Sample Post',
feedUrl: 'https://test.com',
postUrl: 'javascript:alert("XSS")',
- pubData: new Date().toISOString(),
+ pubDate: new Date(),
feedIcon: 'https://test.com/icon.png',
+ readingTime: null,
};
const feedItem = createFeedItem(mockData);
// Ensure the title is sanitized
const titleElement = feedItem.querySelector('.blogroller-feed-title-link');
- expect(titleElement.innerHTML).not.toContain('