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('