├── .editorconfig ├── .github └── workflows │ ├── node-publish.js.yml │ └── node-test.js.yml ├── .gitignore ├── CHANGELOG.md ├── CREDITS.md ├── LICENSE.md ├── README.md ├── _locales └── en │ └── messages.json ├── biome.json ├── build.bash ├── icons ├── icon-16.png ├── icon-32.png ├── icon-48.png ├── icon-512.png └── icon-64.png ├── manifest.json ├── package-lock.json ├── package.json ├── screencast.gif ├── src └── popup │ ├── components │ ├── failed.css │ ├── failed.html │ ├── failed.ts │ ├── feed.css │ ├── feed.html │ ├── feed.ts │ ├── index.ts │ ├── notification.css │ ├── notification.ts │ ├── settings.css │ ├── settings.html │ └── settings.ts │ ├── extractors │ ├── __inject-page-state.ts │ ├── bitchute │ │ ├── __inject.ts │ │ └── index.ts │ ├── derived-extractors.ts │ ├── direct │ │ ├── __inject.ts │ │ └── index.ts │ ├── index.ts │ ├── reddit │ │ ├── __inject.ts │ │ └── index.ts │ ├── substack │ │ ├── __inject.ts │ │ └── index.ts │ ├── wordpress │ │ ├── __inject.ts │ │ └── index.ts │ └── youtube │ │ ├── __inject.ts │ │ └── index.ts │ ├── init.ts │ ├── launchpad-popup.html │ ├── openers │ └── index.ts │ ├── settings │ ├── impl.ts │ └── index.ts │ ├── shared.css │ ├── types.ts │ └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/node-publish.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI publish 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | build-and-publish: 11 | runs-on: ubuntu-latest 12 | if: github.ref == 'refs/heads/master' 13 | 14 | strategy: 15 | matrix: 16 | node-version: [22.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Build and lint with ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: "npm" 26 | - run: npm install 27 | - run: npm run build:ext 28 | - run: npm run lint 29 | 30 | - name: Get version from package.json 31 | id: get_version 32 | run: | 33 | version=$(jq -r '.version' package.json) 34 | echo "Version is $version" 35 | echo "version=$version" >> $GITHUB_ENV 36 | 37 | - name: Create GitHub Release 38 | uses: softprops/action-gh-release@v2 39 | with: 40 | tag_name: "v${{ env.version }}" 41 | name: "Release v${{ env.version }}" 42 | files: web-ext-artifacts/*.zip 43 | body_path: CHANGELOG.md 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.github/workflows/node-test.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI test 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | build-and-lint: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [22.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: "npm" 25 | - run: npm install 26 | - run: npm run build 27 | - run: npm run lint 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Log files 4 | *.log 5 | 6 | # Generated TypeScript files and build data 7 | tsconfig.tsbuildinfo 8 | 9 | # Dist files 10 | dist 11 | dist_ext 12 | 13 | .gp.md 14 | web-ext-artifacts 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ## Changelog 4 | 5 | ### Version 0.14.0 6 | 7 | [v0.14.0](+https://github.com/ilya-m32/rss-launchpad/releases/tag/0.14.0) 8 | 9 | ### Version 0.12.0 10 | 11 | [v0.12.0](+https://github.com/ilya-m32/rss-launchpad/releases/tag/0.12.0) 12 | 13 | #### Commits 14 | 15 | [7265ce6a89cba6d51b92c541afcf15a25981c287](https://github.com/ilya-m32/rss-launchpad/commit/7265ce6a89cba6d51b92c541afcf15a25981c287) Merge pull request #24 from ilya-m32/feat/wordpress-and-fixes 16 | [e432e48a5d06aea44228620b6cea96008fb52315](https://github.com/ilya-m32/rss-launchpad/commit/e432e48a5d06aea44228620b6cea96008fb52315) fix: import order 17 | [6ea18c81c79886eac5126b751271bd222498abd3](https://github.com/ilya-m32/rss-launchpad/commit/6ea18c81c79886eac5126b751271bd222498abd3) fix: README link 18 | [779ac5bbe41813d40f3d908a81e42d3bcdd97224](https://github.com/ilya-m32/rss-launchpad/commit/779ac5bbe41813d40f3d908a81e42d3bcdd97224) fix: enable text elipsis for landscape viewports 19 | [418d7a5367c6d0fe26b98941dad755a7e9f35b7c](https://github.com/ilya-m32/rss-launchpad/commit/418d7a5367c6d0fe26b98941dad755a7e9f35b7c) fix: remove redundant tag sanitization (only safe methods are used) 20 | [114b0cea5224df45430522d4deb33b633bc62d48](https://github.com/ilya-m32/rss-launchpad/commit/114b0cea5224df45430522d4deb33b633bc62d48) feat: add wordpress generic extractor 21 | 22 | # 0.11.0 23 | 24 | Fixed 3rd party opener URL settings restoration. 25 | 26 | # 0.3 27 | 28 | First public release. 29 | 30 | Features: 31 | 32 | - RSS/Atom feeds inspection 33 | - 3rd party openers 34 | - i18n support (but no translations besides English) 35 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | RSS icons by Maciej Aniśko ([Creative Commons Attribution 3.0 Unported](https://creativecommons.org/licenses/by/3.0/)) 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Ilya Malyavin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RSS Launchpad Extension 2 | 3 | Install from [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/rss-launchpad-find-feeds-easy/). 4 | 5 | ![](screencast.gif) 6 | 7 | ## What is it? 8 | 9 | RSS Launchpad is a minimalist extension for Firefox and Firefox mobile (and should be compatible with other browsers that support the Web Extensions format). 10 | It provides a list of RSS/Atom feeds defined by websites as alternative sources in the document. 11 | 12 | In total: 13 | 14 | - The extension will extract all feeds from the active tab 15 | - You can open these feeds with a RSS reader app or through a custom RSS reader web service 16 | 17 | ## Why use it? 18 | 19 | First of all, I believe RSS is cool: it offers an independent, low-distraction way of reading and staying up-to-date 20 | with interesting topics without the downsides of social media and modern algo-driven feeds, which often prioritize engagement and clickbait. 21 | 22 | Sadly, RSS integration was removed long ago from all major browsers. Many websites (especially tech-related) still support it, though! 23 | Sometimes, you may stumble upon an interesting blog post or newsletter - this extension will highlight whether that 24 | site has an official RSS/Atom feed, allowing you to subscribe to more content later in your standalone 25 | reader app. I personally enjoy using [Thunderbird](https://www.thunderbird.net), but it will work anywhere. 26 | 27 | ## Goals 28 | 29 | - Indicate whether a website offers RSS/Atom feeds 30 | - Should look nice & native to Firefox (including light/dark mode) 31 | - Basic integration with 3rd-party readers via buildable URLs 32 | 33 | ## Non-Goals 34 | 35 | - RSS reader capabilities 36 | - Deeper integration with 3rd-party services 37 | - Non-open subscription formats 38 | 39 | ## Technical Principles 40 | 41 | - Less code is better, make it sound and statically typed 42 | - Prefer native browser API 43 | - Avoid adding dependencies unless necessary 44 | - Minimize build step and code transformations 45 | - Don't be invasive and use minimal required permissions 46 | 47 | ## Derived (unofficial) feeds 48 | 49 | This extension also supports unofficial feeds for some popular websites, like YouTube. 50 | It means that the feeds are not listed directly on the page but are instead derived from page content using official APIs. 51 | 52 | Unlike official feeds, unofficial ones may break if there are changes to the website or its RSS API interface. 53 | 54 | Supported sites: 55 | - YouTube 56 | - Reddit 57 | - Wordpress-based blogs 58 | 59 | If you wish to contribute by adding a new website, you need to create a new extractor 60 | (similar to [youtube](https://github.com/ilya-m32/rss-launchpad/tree/master/src/popup/extractors/youtube/) extractor) 61 | and add it to the [list](https://github.com/ilya-m32/rss-launchpad/blob/master/src/popup/extractors/derived-extractors.ts). 62 | 63 | ## How to contribute? 64 | 65 | Feel free to send PRs. To start developing this extension you need: 66 | 67 | - Up to date Firefox or compatible browser 68 | - nodejs >= 20.x, npm >= 10.x, POSIX-compatible environment with bash 69 | - run `npm install` and then `npm run start` to get started! 70 | 71 | ## TODOs 72 | 73 | - [x] i18n support 74 | - [x] Setup CI/CD, for now thigns are pretty manual 75 | - [x] Add basic integration 3rd party openers? (Open via URL + query params) 76 | - [x] Add popular infer cases, like youtube with SPA navigation 77 | - [ ] Maybe add support for in-addons settings menu, too 78 | 79 | ## Similar projects 80 | 81 | - [Awesome RSS](https://github.com/shgysk8zer0/awesome-rss) - similar goal but abandoned, last commit in 2018 82 | 83 | # Credits 84 | 85 | Refer to CREDITS.md. 86 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "RSS Launchpad: find feeds easily", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "Lets you find RSS or Atom subscriptions in web pages easily", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "feed.openFeedLink": { 13 | "message": "Open feed", 14 | "description": "Text of button which opens feed in a new tab, directly or via 3rd party service" 15 | }, 16 | 17 | "feed.copyLinkButton": { 18 | "message": "Copy link", 19 | "description": "Label for the button that copies the link feed." 20 | }, 21 | 22 | "feed.noFeedsAvailable": { 23 | "message": "This tab has no RSS or ATOM feeds available", 24 | "description": "This text is shown if there are no RSS or ATOM tabs" 25 | }, 26 | 27 | "failed.fetchFeedsFailed": { 28 | "message": "Failed to fetch feeds. Please check permissions.", 29 | "description": "Error message displayed when the application cannot retrieve the feed list" 30 | }, 31 | 32 | "failed.fetchFeedsFailedReloadButton": { 33 | "message": "Try again", 34 | "description": "Button text for reloading feeds after a failed fetch attempt" 35 | }, 36 | 37 | "failed.fetchFeedsNoAccess": { 38 | "message": "No access to this tab", 39 | "description": "Disclaimer when there's no access to the current tab (related to permissions or a special page)" 40 | }, 41 | 42 | "feed.onSuccessFeedLinkCopy": { 43 | "message": "Link copied!", 44 | "description": "Successfully copied the link to the clipboard" 45 | }, 46 | 47 | "feed.onFailedFeedLinkCopy": { 48 | "message": "Failed to copy selected link. Check if Clipboard access is enabled in Firefox extension permissions", 49 | "description": "Failed to copy the link to the clipboard" 50 | }, 51 | 52 | "feed.derivedFeedDisclaimer": { 53 | "message": "This feed was derived from page content and is not guaranteed to function.", 54 | "description": "Derived feed disclaimer" 55 | }, 56 | 57 | "settings.title": { 58 | "message": "Settings", 59 | "description": "The title of the settings dialog." 60 | }, 61 | "settings.closeButtonAriaLabel": { 62 | "message": "Close", 63 | "description": "The aria-label for the button that closes the settings dialog." 64 | }, 65 | "settings.themeSelectLabel": { 66 | "message": "Theme:", 67 | "description": "The label for the theme selection dropdown." 68 | }, 69 | "settings.themeOptionAuto": { 70 | "message": "Auto (system preference)", 71 | "description": "An option in the theme selection dropdown for automatic theme selection based on system preferences." 72 | }, 73 | "settings.themeOptionLight": { 74 | "message": "Light", 75 | "description": "An option in the theme selection dropdown for a light theme." 76 | }, 77 | "settings.themeOptionDark": { 78 | "message": "Dark", 79 | "description": "An option in the theme selection dropdown for a dark theme." 80 | }, 81 | "settings.defaultOpenerLabel": { 82 | "message": "Default opener:", 83 | "description": "A label describing select menu to choose default opener" 84 | }, 85 | 86 | "settings.defaultOpenerNewTab": { 87 | "message": "New tab" 88 | }, 89 | "settings.defaultOpenerFeedly": { 90 | "message": "feedly" 91 | }, 92 | "settings.defaultOpenerInoreader": { 93 | "message": "inoreader" 94 | }, 95 | "settings.defaultOpenerTinyTinyRss": { 96 | "message": "tinyTinyRss" 97 | }, 98 | "settings.defaultOpenerNextcloud": { 99 | "message": "nextcloud" 100 | }, 101 | "settings.defaultOpenerFreshRss": { 102 | "message": "freshRss" 103 | }, 104 | "settings.hintUnblock": { 105 | "message": "Fill settings below to unblock disabled options.", 106 | "description": "A hint on how to unblock disabled options" 107 | }, 108 | "settings.thirdPartyOpenersTitle": { 109 | "message": "3rd-party openers", 110 | "description": "Title for section with 3rd party openers config" 111 | }, 112 | "settings.thirdPartyOpenersHint": { 113 | "message": "Configure alternative feed openers to handle specific feed types or keep empty if not needed.", 114 | "description": "Hint about the 3rd party openers section" 115 | }, 116 | 117 | "settings.saveAndApplyButton": { 118 | "message": "Save & Apply", 119 | "description": "The text on the button to save and apply the selected settings." 120 | }, 121 | "settings.tinyTinyRssUrlLabel": { 122 | "message": "TinyTinyRSS URL:", 123 | "description": "The label for the input field to enter the URL for TinyTinyRSS." 124 | }, 125 | "settings.nextcloudUrlLabel": { 126 | "message": "Nextcloud News URL:", 127 | "description": "The label for the input field to enter the URL for Nextcloud News." 128 | }, 129 | "settings.freshRssUrlLabel": { 130 | "message": "FreshRSS URL:", 131 | "description": "The label for the input field to enter the URL for FreshRSS." 132 | }, 133 | "settings.errorMessageInvalidUrl": { 134 | "message": "Please enter a valid URL.", 135 | "description": "An error message displayed when the user enters an invalid URL in any of the URL input fields." 136 | }, 137 | "settings.useOpenerLinksToCopy": { 138 | "message": "Use opener link to copy:", 139 | "description": "Label for checkbox setting if original or opener link should be copied" 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "linter": { 3 | "rules": { 4 | "complexity": { 5 | "useArrowFunction": "off", 6 | "noForEach": "off" 7 | }, 8 | "style": { 9 | "noNonNullAssertion": "off" 10 | } 11 | } 12 | }, 13 | "formatter": { 14 | "lineWidth": 120, 15 | "indentStyle": "space", 16 | "indentWidth": 2 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /build.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | mkdir -p dist/ 6 | 7 | # clean up old build if exist 8 | rm -rf dist/* 9 | 10 | # TS Build 11 | npx tsc 12 | 13 | # Html template 14 | cp -r src/popup/*.html dist/ 15 | 16 | # Combine all CSS files from src/components and src/ directories into one style.css file 17 | # Sort files numerically first (if they start with numbers), then alphabetically 18 | cat $(find src/popup/ -name "*.css" | sort -t '/' -k2,2n -k2,2) > dist/styles.css 19 | 20 | # ... and include templates for components 21 | content=$(cat $(find src/popup/components -name "*.html" | sort -t '/' -k2,2n -k2,2) | tr -d '\n') 22 | sed -i "s//$(echo "$content" | sed 's/[&/\]/\\&/g')/" dist/launchpad-popup.html 23 | 24 | # Clean folder for extension 25 | mkdir -p dist_ext/ 26 | cp -r dist manifest.json icons _locales dist_ext/ 27 | 28 | # Re-run format for easier manual review by extension reviewers 29 | npx biome format . --fix 30 | -------------------------------------------------------------------------------- /icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-m32/rss-launchpad/a8ac0e3cb875e67166a587640cccf8a2ab27a8bb/icons/icon-16.png -------------------------------------------------------------------------------- /icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-m32/rss-launchpad/a8ac0e3cb875e67166a587640cccf8a2ab27a8bb/icons/icon-32.png -------------------------------------------------------------------------------- /icons/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-m32/rss-launchpad/a8ac0e3cb875e67166a587640cccf8a2ab27a8bb/icons/icon-48.png -------------------------------------------------------------------------------- /icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-m32/rss-launchpad/a8ac0e3cb875e67166a587640cccf8a2ab27a8bb/icons/icon-512.png -------------------------------------------------------------------------------- /icons/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-m32/rss-launchpad/a8ac0e3cb875e67166a587640cccf8a2ab27a8bb/icons/icon-64.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_extensionName__", 3 | "description": "__MSG_extensionDescription__", 4 | "manifest_version": 2, 5 | "author": "Ilya Malyavin", 6 | "version": "0.14", 7 | "homepage_url": "https://github.com/ilya-m32/rss-launchpad", 8 | "default_locale": "en", 9 | "icons": { 10 | "16": "icons/icon-16.png", 11 | "32": "icons/icon-32.png", 12 | "48": "icons/icon-48.png" 13 | }, 14 | "content_scripts": [], 15 | "permissions": ["activeTab", "storage"], 16 | "optional_permissions": ["clipboardWrite"], 17 | "browser_action": { 18 | "default_icon": "icons/icon-48.png", 19 | "theme_icons": [ 20 | { 21 | "light": "icons/icon-48.png", 22 | "dark": "icons/icon-48.png", 23 | "size": 48 24 | }, 25 | { 26 | "light": "icons/icon-32.png", 27 | "dark": "icons/icon-32.png", 28 | "size": 32 29 | }, 30 | { 31 | "light": "icons/icon-16.png", 32 | "dark": "icons/icon-16.png", 33 | "size": 16 34 | } 35 | ], 36 | "default_title": "RSS Launchpad", 37 | "default_popup": "dist/launchpad-popup.html" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rss-lauchpad", 3 | "version": "0.14.0", 4 | "description": "RSS Launchpad extension: quickly add new subscriptions from websites", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/ilya-m32/rss-launchpad.git" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "build": "tsc && ./build.bash", 13 | "build:ext": "npm run build && web-ext build -s dist_ext", 14 | "start": "npm run build && web-ext run --devtools -u \"https://en.wikipedia.org/wiki/Main_Page\"", 15 | "lint:ext": "web-ext lint -s dist_ext", 16 | "lint:src": "biome check .", 17 | "lint:fix": "biome check --write .", 18 | "lint": "npm run lint:src && npm run lint:ext" 19 | }, 20 | "keywords": ["extension", "browser", "rss"], 21 | "author": "Ilya Malyavin", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@biomejs/biome": "1.9.4", 25 | "@types/chrome": "^0.0.299", 26 | "@types/firefox-webext-browser": "^120.0.4", 27 | "@types/node": "^22.10.7", 28 | "typescript": "^5.7.3", 29 | "web-ext": "^8.4.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-m32/rss-launchpad/a8ac0e3cb875e67166a587640cccf8a2ab27a8bb/screencast.gif -------------------------------------------------------------------------------- /src/popup/components/failed.css: -------------------------------------------------------------------------------- 1 | failed-state { 2 | height: 100%; 3 | } 4 | 5 | .failed-state { 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | flex-direction: column; 10 | height: 100%; 11 | 12 | padding: 16px 0; 13 | 14 | min-height: 320px; 15 | width: 90%; 16 | margin: 0 auto; 17 | text-align: center; 18 | } 19 | -------------------------------------------------------------------------------- /src/popup/components/failed.html: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /src/popup/components/failed.ts: -------------------------------------------------------------------------------- 1 | import { createByTemplate } from "../utils.js"; 2 | 3 | class FailedStateComponentImpl extends HTMLElement { 4 | public onRefresh?: () => void; 5 | public setReason(reason: typeof this.reason) { 6 | this.reason = reason; 7 | 8 | if (this.isConnected) { 9 | this.render(); 10 | } 11 | } 12 | 13 | private reason: "permission" | "other" = "other"; 14 | private onClick = (event: Event) => { 15 | const target = event.target; 16 | if (!(target instanceof HTMLButtonElement)) { 17 | return; 18 | } 19 | 20 | this.onRefresh?.(); 21 | target.disabled = true; 22 | }; 23 | 24 | connectedCallback() { 25 | this.render(); 26 | this.addEventListener("click", this.onClick); 27 | } 28 | 29 | render() { 30 | let template = "failed-state"; 31 | if (this.reason === "permission") { 32 | template = "failed-state_type_no-access"; 33 | } 34 | 35 | this.replaceChildren(createByTemplate(template)); 36 | } 37 | } 38 | 39 | export type FailedStateComponent = FailedStateComponentImpl; 40 | 41 | customElements.define("failed-state", FailedStateComponentImpl); 42 | -------------------------------------------------------------------------------- /src/popup/components/feed.css: -------------------------------------------------------------------------------- 1 | feed-list { 2 | height: 100%; 3 | display: flex; 4 | 5 | justify-content: center; 6 | overflow: scroll; 7 | } 8 | 9 | .feeds { 10 | height: 100%; 11 | display: flex; 12 | } 13 | 14 | .feeds__group { 15 | width: 100%; 16 | display: flex; 17 | flex-direction: column; 18 | overflow: scroll; 19 | 20 | margin: 0; 21 | padding: 12px; 22 | } 23 | 24 | .feed { 25 | display: flex; 26 | position: relative; 27 | 28 | flex-direction: column; 29 | background-color: var(--list-item-bg); 30 | border-radius: 4px; 31 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 32 | padding: 8px; 33 | 34 | min-width: 280px; 35 | } 36 | 37 | .feed .feed__disclaimer { 38 | display: none; 39 | 40 | position: absolute; 41 | cursor: pointer; 42 | right: 12px; 43 | } 44 | 45 | .feed.feed_type_derived .feed__disclaimer { 46 | display: inline; 47 | } 48 | 49 | .feed + .feed { 50 | margin-top: 16px; 51 | } 52 | 53 | .feed__control { 54 | display: flex; 55 | justify-content: space-between; 56 | align-items: center; 57 | margin-top: 16px; 58 | } 59 | 60 | .feed_state_empty { 61 | width: 90%; 62 | 63 | align-self: center; 64 | text-align: center; 65 | } 66 | 67 | .feed_state_empty pre { 68 | font-size: 32px; 69 | } 70 | 71 | .feed__name { 72 | max-width: 100%; 73 | text-overflow: ellipsis; 74 | overflow: hidden; 75 | white-space: nowrap; 76 | } 77 | 78 | .feed.feed_type_derived .feed__name { 79 | max-width: calc(100% - 32px); 80 | } 81 | -------------------------------------------------------------------------------- /src/popup/components/feed.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 32 | 33 | 41 | -------------------------------------------------------------------------------- /src/popup/components/feed.ts: -------------------------------------------------------------------------------- 1 | import { generateFeedUrl } from "../openers/index.js"; 2 | import { settings } from "../settings/index.js"; 3 | import type { Feed, ISettings } from "../types"; 4 | import { createByTemplate, getTranslation } from "../utils.js"; 5 | import type { NotificationComponent } from "./notification"; 6 | 7 | class FeedListComponentImpl extends HTMLElement { 8 | private settings = settings; 9 | 10 | onConnect = () => { 11 | const settingsState = this.settings.toJSON(); 12 | this.updateOpenLinks(settingsState); 13 | }; 14 | 15 | connectedCallback() { 16 | this.addEventListener("click", this.onCopyFeedLink); 17 | this.addEventListener("click", this.onDisclaimerClick); 18 | this.settings.subscribe(this.onConnect); 19 | this.onConnect(); 20 | } 21 | 22 | disconnectedCallback() { 23 | this.settings.unsubscribe(this.onConnect); 24 | } 25 | 26 | private onCopyFeedLink = (event: MouseEvent) => { 27 | const { target } = event; 28 | if (!(target instanceof HTMLButtonElement)) { 29 | return; 30 | } 31 | 32 | const link = target.dataset.link; 33 | if (!link) { 34 | return; 35 | } 36 | 37 | if (target.classList.contains("feed__copy")) { 38 | this.onCopyClick(link); 39 | } 40 | }; 41 | 42 | private sendNotification() { 43 | const notification = document.createElement("notification-component") as NotificationComponent; 44 | this.appendChild(notification); 45 | return notification; 46 | } 47 | 48 | private onDisclaimerClick = (event: MouseEvent) => { 49 | const { target } = event; 50 | if (!(target instanceof HTMLElement && target.classList.contains("feed__disclaimer"))) { 51 | return; 52 | } 53 | 54 | this.sendNotification().show(getTranslation("feed.derivedFeedDisclaimer"), void 0, 5000); 55 | }; 56 | 57 | private onCopyClick(link: string) { 58 | const notification = this.sendNotification(); 59 | navigator.clipboard.writeText(link).then( 60 | () => notification.show(getTranslation("feed.onSuccessFeedLinkCopy")), 61 | () => notification.show(getTranslation("feed.onFailedFeedLinkCopy")), 62 | ); 63 | } 64 | 65 | private updateOpenLinks(settingState: ISettings) { 66 | for (const elem of Array.from(this.querySelectorAll(".feed__open")) as Iterable) { 67 | elem.href = generateFeedUrl(settingState, elem.dataset.baseUrl!).toString(); 68 | } 69 | 70 | for (const elem of Array.from(this.querySelectorAll(".feed__copy")) as Iterable) { 71 | const baseUrl = elem.dataset.baseUrl!; 72 | 73 | elem.dataset.link = settingState.useOpenerLinksToCopy 74 | ? generateFeedUrl(settingState, baseUrl).toString() 75 | : baseUrl; 76 | } 77 | } 78 | 79 | setFeeds(feeds: Feed[]) { 80 | this.render(feeds); 81 | } 82 | 83 | render(feeds: Feed[]) { 84 | if (!feeds.length) { 85 | this.replaceChildren(createByTemplate("template-feed_state_empty")); 86 | return; 87 | } 88 | 89 | const feedsElem = createByTemplate("template-feeds"); 90 | const list = feedsElem.querySelector(".feeds__group")!; 91 | 92 | for (const feed of feeds) { 93 | const listItem = createByTemplate("template-feed__item").querySelector(".feed")!; 94 | 95 | listItem.querySelector(".feed__name")!.textContent = `[${feed.type.toUpperCase()}] ${feed.title}`; 96 | listItem.querySelector(".feed__copy")?.setAttribute("data-base-url", feed.href); 97 | listItem.querySelector(".feed__open")?.setAttribute("data-base-url", feed.href); 98 | listItem.classList.add(`feed_type_${feed.extractType}`); 99 | 100 | list.appendChild(listItem); 101 | } 102 | 103 | this.replaceChildren(list); 104 | this.updateOpenLinks(this.settings.toJSON()); 105 | } 106 | } 107 | 108 | export type FeedListComponent = FeedListComponentImpl; 109 | 110 | customElements.define("feed-list", FeedListComponentImpl); 111 | -------------------------------------------------------------------------------- /src/popup/components/index.ts: -------------------------------------------------------------------------------- 1 | // Reference in runtime to register components 2 | import "./feed.js"; 3 | import "./failed.js"; 4 | import "./notification.js"; 5 | import "./settings.js"; 6 | -------------------------------------------------------------------------------- /src/popup/components/notification.css: -------------------------------------------------------------------------------- 1 | .notification { 2 | position: fixed; 3 | top: 0; 4 | left: 50%; 5 | width: 80%; 6 | transform: translateX(-50%); 7 | transition: opacity 0.5s; 8 | opacity: 0; 9 | z-index: 1000; 10 | padding: 10px; 11 | background-color: var(--aux-accent-color); 12 | border-radius: 4px; 13 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 14 | } 15 | -------------------------------------------------------------------------------- /src/popup/components/notification.ts: -------------------------------------------------------------------------------- 1 | class NotificationComponentImpl extends HTMLElement { 2 | timeoutId: NodeJS.Timeout | number | null = null; 3 | 4 | connectedCallback() { 5 | this.addEventListener("click", () => this.clear()); 6 | this.className = "notification"; 7 | } 8 | 9 | show(text: string, onClear?: () => void, timeout = 3000) { 10 | this.textContent = text; 11 | this.style.opacity = "1"; 12 | this.style.transition = "opacity 0.5s, top 0.5s"; 13 | this.style.top = "24px"; 14 | if (this.timeoutId !== null) { 15 | clearTimeout(this.timeoutId); 16 | } 17 | 18 | this.timeoutId = setTimeout(() => { 19 | this.clear(); 20 | onClear?.(); 21 | this.remove(); 22 | }, timeout); 23 | } 24 | 25 | clear() { 26 | this.style.opacity = "0"; 27 | this.style.top = "0"; 28 | this.timeoutId = null; 29 | } 30 | 31 | disconnectedCallback() { 32 | if (this.timeoutId !== null) { 33 | clearTimeout(this.timeoutId); 34 | } 35 | } 36 | } 37 | 38 | export type NotificationComponent = NotificationComponentImpl; 39 | 40 | customElements.define("notification-component", NotificationComponentImpl); 41 | -------------------------------------------------------------------------------- /src/popup/components/settings.css: -------------------------------------------------------------------------------- 1 | .settings__dialog { 2 | background-color: var(--body-bg); 3 | color: var(--body-text); 4 | 5 | min-width: 80%; 6 | } 7 | 8 | .settings__title { 9 | margin: 0 0 16px 0; 10 | position: relative; 11 | } 12 | 13 | .settings__close-button { 14 | position: absolute; 15 | right: 0px; 16 | top: 0px; 17 | } 18 | 19 | .settings__footer { 20 | display: flex; 21 | justify-content: end; 22 | margin-top: 16px; 23 | } 24 | 25 | .settings__dialog label { 26 | display: block; 27 | } 28 | 29 | .settings__form { 30 | font-size: 0.75em; 31 | } 32 | 33 | .settings__error-message { 34 | font-weight: 700; 35 | } 36 | 37 | .settings__form h3 { 38 | margin: 8px 0 0 0; 39 | } 40 | 41 | .settings__group input { 42 | width: 80%; 43 | } 44 | 45 | .settings__hint { 46 | margin: 4px 0; 47 | } 48 | 49 | .settings__form label.settings__use-opener-links-label { 50 | display: inline-block; 51 | } 52 | -------------------------------------------------------------------------------- /src/popup/components/settings.html: -------------------------------------------------------------------------------- 1 | 240 | -------------------------------------------------------------------------------- /src/popup/components/settings.ts: -------------------------------------------------------------------------------- 1 | import { settings } from "../settings/index.js"; 2 | import type { ISettings, ThemeMode } from "../types"; 3 | import { createByTemplate } from "../utils.js"; 4 | 5 | const CHECKBOX_FIELDS = ["useOpenerLinksToCopy"] as const; 6 | const SELECT_FIELDS = ["themeMode", "defaultOpener"] as const; 7 | const INPUT_FIELDS = ["tinyTinyRssUrl", "nextcloudUrl", "freshRssUrl"] as const; 8 | const OPTION_TO_URL_NAME = { 9 | tinyTinyRss: "tinyTinyRssUrl", 10 | nextcloud: "nextcloudUrl", 11 | freshRss: "freshRssUrl", 12 | } as const; 13 | const URL_NAME_TO_OPTION = Object.fromEntries(Object.entries(OPTION_TO_URL_NAME).map(([k, v]) => [v, k])); 14 | 15 | class SettingsMenuImpl extends HTMLElement { 16 | private dialog: HTMLDialogElement | undefined; 17 | private settings = settings; 18 | 19 | onConnect = () => { 20 | const settingsState = this.settings.toJSON(); 21 | 22 | this.render(settingsState); 23 | const dialog = this.querySelector("dialog"); 24 | if (!dialog) { 25 | throw Error("no dialog element in the tree of settings"); 26 | } 27 | 28 | this.dialog = dialog; 29 | this.attachEventListeners(); 30 | }; 31 | 32 | connectedCallback() { 33 | this.settings.subscribe(this.onConnect); 34 | this.onConnect(); 35 | } 36 | 37 | disconnectedCallback() { 38 | this.settings.unsubscribe(this.onConnect); 39 | } 40 | 41 | private attachEventListeners() { 42 | this.querySelector(".settings__button")?.addEventListener("click", this.openDialog.bind(this)); 43 | this.querySelector(".settings__close-button")?.addEventListener("click", this.closeDialog.bind(this)); 44 | this.querySelector(".settings__form")?.addEventListener("formdata", this.onSubmit); 45 | this.querySelector(".settings__urls")?.addEventListener("change", this.onUrlUpdate); 46 | } 47 | 48 | private onSubmit = (event: Event) => { 49 | const { formData } = event as FormDataEvent; 50 | event.preventDefault(); 51 | 52 | // a bit ugly but should be good for now 53 | const themeMode = (formData.get("themeMode") ?? "auto") as ThemeMode; 54 | const defaultOpener = (formData.get("defaultOpener") ?? "newTab") as ISettings["defaultOpener"]; 55 | 56 | const useOpenerLinksToCopy = !!formData.get("useOpenerLinksToCopy"); 57 | 58 | const inputUpdates = Object.fromEntries( 59 | INPUT_FIELDS.map((key) => [key, formData.get(key)]).filter((pair) => pair[1]), 60 | ) as ISettings; 61 | 62 | this.settings 63 | .setSetting({ 64 | ...inputUpdates, 65 | themeMode, 66 | defaultOpener, 67 | useOpenerLinksToCopy, 68 | }) 69 | .then( 70 | () => void this.closeDialog(), 71 | () => { 72 | console.error("Could not save settings"); 73 | // todo: replace with notifications later 74 | alert("Could not save settings"); 75 | }, 76 | ); 77 | }; 78 | 79 | openDialog() { 80 | if (this.dialog) { 81 | this.dialog.showModal(); 82 | } 83 | } 84 | 85 | closeDialog() { 86 | if (this.dialog?.open) { 87 | this.dialog.close(); 88 | } 89 | } 90 | 91 | private onUrlUpdate = (event: Event) => { 92 | const target = event.target; 93 | if (!(target instanceof HTMLInputElement)) { 94 | return; 95 | } 96 | const optionName = URL_NAME_TO_OPTION[target.name]; 97 | const hasValidUrl = URL.canParse(target.value ?? ""); 98 | 99 | if (!optionName) { 100 | return; 101 | } 102 | 103 | const optionElem = this.querySelector(`option[name="${optionName}"]`); 104 | if (optionElem) { 105 | const invalidUrl = !hasValidUrl; 106 | optionElem.disabled = invalidUrl; 107 | if (invalidUrl) { 108 | optionElem.selected = false; 109 | } 110 | } 111 | }; 112 | 113 | private updateOpenerOptions(settingsState: ISettings) { 114 | // Enable openers if filled correctly 115 | for (const [option, urlKey] of Object.entries(OPTION_TO_URL_NAME)) { 116 | const optionElem = this.querySelector(`option[name="${option}"][disabled]`); 117 | 118 | if (optionElem && settingsState[urlKey]) { 119 | optionElem.disabled = false; 120 | } 121 | } 122 | } 123 | 124 | private updateSelectedElements(settingsState: ISettings) { 125 | for (const field of SELECT_FIELDS) { 126 | const selectElement = this.querySelector(`select[name="${field}"]`); 127 | const optionElement = selectElement?.options.namedItem(settingsState[field]); 128 | 129 | if (optionElement) { 130 | optionElement.selected = true; 131 | } 132 | } 133 | 134 | for (const field of INPUT_FIELDS) { 135 | const input = this.querySelector(`input[name="${field}"]`); 136 | if (input && settingsState[field]) { 137 | input.value = settingsState[field]; 138 | } 139 | } 140 | 141 | for (const field of CHECKBOX_FIELDS) { 142 | const checkbox = this.querySelector(`input[name="${field}"]`); 143 | if (checkbox) { 144 | checkbox.checked = settingsState[field]; 145 | } 146 | } 147 | } 148 | 149 | render(settingsState: ISettings) { 150 | this.replaceChildren(createByTemplate("template-settings")); 151 | 152 | this.updateOpenerOptions(settingsState); 153 | this.updateSelectedElements(settingsState); 154 | } 155 | } 156 | 157 | export type SettingsMenu = SettingsMenuImpl; 158 | 159 | customElements.define("settings-menu", SettingsMenuImpl); 160 | -------------------------------------------------------------------------------- /src/popup/extractors/__inject-page-state.ts: -------------------------------------------------------------------------------- 1 | import type { PageStateResult } from "../types"; 2 | 3 | (function (): PageStateResult { 4 | const generator = document.head.querySelector('meta[name="generator"]')?.content; 5 | 6 | return { 7 | url: window.location.href, 8 | siteType: { 9 | isWordpressBased: !!(generator && /wordpress/i.test(generator)), 10 | }, 11 | }; 12 | })(); 13 | -------------------------------------------------------------------------------- /src/popup/extractors/bitchute/__inject.ts: -------------------------------------------------------------------------------- 1 | import type { Feed, PageSyncResult } from "../../types"; 2 | 3 | (function (): PageSyncResult { 4 | function deriveFeeds(): Feed[] { 5 | const currentUrl = URL.parse(window.location.href); 6 | if (!currentUrl) { 7 | return []; 8 | } 9 | 10 | const channelNameMatch = /channel\/([^\/]+)(\/)?/.exec(currentUrl.pathname); 11 | const channelName = channelNameMatch ? channelNameMatch[1] : null; 12 | 13 | if (!channelName) { 14 | return []; 15 | } 16 | 17 | currentUrl.search = "?showall=1"; 18 | currentUrl.pathname = `/feeds/rss/channel/${channelName}`; 19 | 20 | return [ 21 | { 22 | type: "application/rss+xml", 23 | extractType: "derived", 24 | href: currentUrl.toString(), 25 | title: `${channelName} RSS`, 26 | }, 27 | ]; 28 | } 29 | 30 | return { 31 | feeds: deriveFeeds(), 32 | }; 33 | })(); 34 | -------------------------------------------------------------------------------- /src/popup/extractors/bitchute/index.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../../types"; 2 | 3 | const bitchuteDerivedExtractor: IFeedExtractor = { 4 | match(pageState) { 5 | const hostname = URL.parse(pageState.url)?.hostname; 6 | return hostname ? /^(.+\.)?bitchute\.com$/.test(hostname) : false; 7 | }, 8 | 9 | getScriptPath() { 10 | return "extractors/bitchute/__inject.js"; 11 | }, 12 | }; 13 | 14 | export default bitchuteDerivedExtractor; 15 | -------------------------------------------------------------------------------- /src/popup/extractors/derived-extractors.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../types"; 2 | import bitchuteDerivedExtractor from "./bitchute/index.js"; 3 | import redditDerivedExtractor from "./reddit/index.js"; 4 | import substackDerivedExtractor from "./substack/index.js"; 5 | import wordpressDerivedExtractor from "./wordpress/index.js"; 6 | import youtubeDerivedExtractor from "./youtube/index.js"; 7 | 8 | export const DERIVED_FEED_EXTRACTORS: IFeedExtractor[] = [ 9 | // Add more here 10 | youtubeDerivedExtractor, 11 | redditDerivedExtractor, 12 | wordpressDerivedExtractor, 13 | substackDerivedExtractor, 14 | bitchuteDerivedExtractor, 15 | ] as const; 16 | -------------------------------------------------------------------------------- /src/popup/extractors/direct/__inject.ts: -------------------------------------------------------------------------------- 1 | import type { Feed, PageSyncResult } from "../../types"; 2 | 3 | (function (): PageSyncResult { 4 | const MIME_TYPES = new Set(["application/rss+xml", "application/atom+xml"]); 5 | 6 | function getFeedFromElement(element: HTMLLinkElement): Feed { 7 | return { 8 | type: element.type, 9 | extractType: "direct", 10 | href: element.href, 11 | title: element.title, 12 | }; 13 | } 14 | 15 | function getFeedLinks() { 16 | const feeds = []; 17 | 18 | const elements: HTMLLinkElement[] = Array.from(document.querySelectorAll(`link[rel="alternate"]`)); 19 | for (const element of elements) { 20 | if (MIME_TYPES.has(element.type)) { 21 | feeds.push(getFeedFromElement(element)); 22 | } 23 | } 24 | 25 | return feeds; 26 | } 27 | 28 | return { 29 | feeds: getFeedLinks(), 30 | }; 31 | })(); 32 | -------------------------------------------------------------------------------- /src/popup/extractors/direct/index.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../../types"; 2 | 3 | const directFeedExtractor: IFeedExtractor = { 4 | match() { 5 | return true; 6 | }, 7 | 8 | getScriptPath() { 9 | return "extractors/direct/__inject.js"; 10 | }, 11 | }; 12 | 13 | export default directFeedExtractor; 14 | -------------------------------------------------------------------------------- /src/popup/extractors/index.ts: -------------------------------------------------------------------------------- 1 | import type { Browser, FeedResult, IFeedExtractor, PageStateResult, PageSyncResult } from "../types"; 2 | import { DERIVED_FEED_EXTRACTORS } from "./derived-extractors.js"; 3 | import directFeedExtractor from "./direct/index.js"; 4 | 5 | export async function getPageFeeds(browserObj: Browser): Promise { 6 | const feedsResult = await fetchFeedsByExecutor(browserObj, directFeedExtractor); 7 | // if the direct feed requests failed, whole workflow is likely to be broken 8 | if (feedsResult.error || !feedsResult.results) { 9 | return feedsResult; 10 | } 11 | 12 | const pageState = await getPageState(browserObj); 13 | const pageFeedResults = await Promise.all( 14 | getMatchingFeedExtractors(pageState).map(fetchFeedsByExecutor.bind(void 0, browserObj)), 15 | ); 16 | 17 | // Enrich feed output 18 | for (const item of pageFeedResults) { 19 | if (item.results?.feeds?.length) { 20 | feedsResult.results.feeds.push(...item.results.feeds); 21 | } 22 | } 23 | 24 | return feedsResult; 25 | } 26 | 27 | async function getPageState(browserObj: Browser): Promise { 28 | const output = await executeActiveTab(browserObj, { 29 | file: "extractors/__inject-page-state.js", 30 | }); 31 | 32 | return ( 33 | output.results?.[0] ?? { 34 | url: "https://unknown/", 35 | siteType: { 36 | isWordpressBased: false, 37 | }, 38 | } 39 | ); 40 | } 41 | 42 | function executeActiveTab(browserObj: Browser, opts: { file: string }) { 43 | return browserObj.tabs 44 | .executeScript(opts) 45 | .then((results) => { 46 | // This function is called after the script has been executed 47 | if (browserObj.runtime.lastError || !results || !results.length) { 48 | throw Error(String(browserObj.runtime.lastError)); 49 | } 50 | 51 | return { results: results as TResult[], error: undefined }; 52 | }) 53 | .catch((error) => { 54 | console.error("Error on script execution: ", error); 55 | return { error, results: undefined }; 56 | }); 57 | } 58 | 59 | async function fetchFeedsByExecutor(browserObj: Browser, extractor: IFeedExtractor): Promise { 60 | const output = await executeActiveTab(browserObj, { 61 | file: extractor.getScriptPath(), 62 | }); 63 | 64 | return output.results ? { results: output.results[0] } : output; 65 | } 66 | 67 | function getMatchingFeedExtractors(pageState: PageStateResult) { 68 | return DERIVED_FEED_EXTRACTORS.filter((extractor) => extractor.match(pageState)); 69 | } 70 | -------------------------------------------------------------------------------- /src/popup/extractors/reddit/__inject.ts: -------------------------------------------------------------------------------- 1 | import type { Feed, PageSyncResult } from "../../types"; 2 | 3 | (function (): PageSyncResult { 4 | function deriveFeeds(): Feed[] { 5 | const currentUrl = new URL(window.location.href, "https://reddit.com"); 6 | // ignoring search params 7 | currentUrl.search = ""; 8 | const pathParts = currentUrl.pathname.split("/").filter(Boolean); 9 | const derivedFeeds: Feed[] = []; 10 | 11 | // Is it a home page? 12 | if (!pathParts.length) { 13 | const rssUrl = new URL(currentUrl.toString()); 14 | rssUrl.pathname = `${rssUrl.pathname}.rss`; 15 | const href = rssUrl.toString(); 16 | 17 | derivedFeeds.push({ 18 | type: "application/rss+xml", 19 | extractType: "derived", 20 | href, 21 | title: `${document.title}`, 22 | }); 23 | } 24 | 25 | // is it a subreddit? 26 | if (pathParts[0] === "r" && pathParts[1]) { 27 | const rssUrl = new URL(currentUrl.toString()); 28 | rssUrl.pathname = `/${pathParts[0]}/${pathParts[1]}/.rss`; 29 | 30 | const href = rssUrl.toString(); 31 | derivedFeeds.push({ 32 | type: "application/rss+xml", 33 | extractType: "derived", 34 | href, 35 | title: `Subreddit ${pathParts[1]}`, 36 | }); 37 | } 38 | 39 | // is it a user profile 40 | if (pathParts[0] === "user" && pathParts[1]) { 41 | const rssUrl = new URL(currentUrl.toString()); 42 | rssUrl.pathname = `/${pathParts[0]}/${pathParts[1]}/.rss`; 43 | 44 | const href = rssUrl.toString(); 45 | derivedFeeds.push({ 46 | type: "application/rss+xml", 47 | extractType: "derived", 48 | href, 49 | title: `User ${pathParts[1]}`, 50 | }); 51 | } 52 | 53 | return derivedFeeds; 54 | } 55 | 56 | return { 57 | feeds: deriveFeeds(), 58 | }; 59 | })(); 60 | -------------------------------------------------------------------------------- /src/popup/extractors/reddit/index.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../../types"; 2 | 3 | const REDDIT_DOMAINS = ["reddit.com"] as const; 4 | 5 | const redditDerivedExtractor: IFeedExtractor = { 6 | match(pageState) { 7 | const { hostname } = new URL(pageState.url); 8 | return REDDIT_DOMAINS.some((domain) => hostname.endsWith(domain)); 9 | }, 10 | 11 | getScriptPath() { 12 | return "extractors/reddit/__inject.js"; 13 | }, 14 | }; 15 | 16 | export default redditDerivedExtractor; 17 | -------------------------------------------------------------------------------- /src/popup/extractors/substack/__inject.ts: -------------------------------------------------------------------------------- 1 | import type { Feed, PageSyncResult } from "../../types"; 2 | 3 | (function (): PageSyncResult { 4 | function deriveFeeds(): Feed[] { 5 | const currentUrl = URL.parse(window.location.href); 6 | if (!currentUrl) { 7 | return []; 8 | } 9 | 10 | currentUrl.search = ""; 11 | currentUrl.pathname = "/feed"; 12 | 13 | return [ 14 | { 15 | type: "application/rss+xml", 16 | extractType: "derived", 17 | href: currentUrl.toString(), 18 | title: `${document.title} RSS`, 19 | }, 20 | ]; 21 | } 22 | 23 | return { 24 | feeds: deriveFeeds(), 25 | }; 26 | })(); 27 | -------------------------------------------------------------------------------- /src/popup/extractors/substack/index.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../../types"; 2 | 3 | const substackDerivedExtractor: IFeedExtractor = { 4 | match(pageState) { 5 | const hostname = URL.parse(pageState.url)?.hostname; 6 | return hostname ? /^(.+\.)?substack\.com$/.test(hostname) : false; 7 | }, 8 | 9 | getScriptPath() { 10 | return "extractors/substack/__inject.js"; 11 | }, 12 | }; 13 | 14 | export default substackDerivedExtractor; 15 | -------------------------------------------------------------------------------- /src/popup/extractors/wordpress/__inject.ts: -------------------------------------------------------------------------------- 1 | import type { Feed, PageSyncResult } from "../../types"; 2 | 3 | (function (): PageSyncResult { 4 | function deriveFeeds(): Feed[] { 5 | const currentUrl = URL.parse(window.location.href); 6 | if (!currentUrl) { 7 | return []; 8 | } 9 | 10 | currentUrl.search = ""; 11 | // Often wordpress blogs expose "/rss" by default 12 | currentUrl.pathname = "/rss"; 13 | 14 | return [ 15 | { 16 | type: "application/rss+xml", 17 | extractType: "derived", 18 | href: currentUrl.toString(), 19 | title: `${document.title} RSS`, 20 | }, 21 | ]; 22 | } 23 | 24 | return { 25 | feeds: deriveFeeds(), 26 | }; 27 | })(); 28 | -------------------------------------------------------------------------------- /src/popup/extractors/wordpress/index.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../../types"; 2 | 3 | const wordpressDerivedExtractor: IFeedExtractor = { 4 | match(pageState) { 5 | return pageState.siteType.isWordpressBased; 6 | }, 7 | 8 | getScriptPath() { 9 | return "extractors/wordpress/__inject.js"; 10 | }, 11 | }; 12 | 13 | export default wordpressDerivedExtractor; 14 | -------------------------------------------------------------------------------- /src/popup/extractors/youtube/__inject.ts: -------------------------------------------------------------------------------- 1 | import type { Feed, PageSyncResult } from "../../types"; 2 | 3 | (function (): PageSyncResult { 4 | const BASE_URL = "https://www.youtube.com/feeds/videos.xml"; 5 | 6 | function deriveChannelFeed(): Feed[] { 7 | const hrefElements = Array.from(document.querySelectorAll("a[href]")); 8 | const derivedFeeds = new Map(); 9 | 10 | for (const elem of hrefElements) { 11 | const channelMatch = elem.href.match(/\/channel\/(.+)\/about/); 12 | if (!channelMatch || channelMatch.length < 2) continue; 13 | 14 | const rssUrl = new URL(BASE_URL); 15 | const [_, channelId] = channelMatch; 16 | rssUrl.searchParams.set("channel_id", channelId); 17 | const href = rssUrl.toString(); 18 | if (derivedFeeds.has(href)) continue; 19 | 20 | derivedFeeds.set(href, { 21 | type: "application/rss+xml", 22 | extractType: "derived", 23 | href, 24 | title: elem.textContent ?? "??", 25 | }); 26 | } 27 | 28 | return Array.from(derivedFeeds.values()); 29 | } 30 | 31 | return { 32 | feeds: deriveChannelFeed(), 33 | }; 34 | })(); 35 | -------------------------------------------------------------------------------- /src/popup/extractors/youtube/index.ts: -------------------------------------------------------------------------------- 1 | import type { IFeedExtractor } from "../../types"; 2 | 3 | const YOUTUBE_DOMAINS = ["youtube.com", "youtu.be", "m.youtube.com"] as const; 4 | const youtubeDerivedExtractor: IFeedExtractor = { 5 | match(pageState) { 6 | const { hostname } = new URL(pageState.url); 7 | return YOUTUBE_DOMAINS.some((domain) => hostname.endsWith(domain)); 8 | }, 9 | 10 | getScriptPath() { 11 | return "extractors/youtube/__inject.js"; 12 | }, 13 | }; 14 | 15 | export default youtubeDerivedExtractor; 16 | -------------------------------------------------------------------------------- /src/popup/init.ts: -------------------------------------------------------------------------------- 1 | import type { FailedStateComponent } from "./components/failed.js"; 2 | import type { FeedListComponent } from "./components/feed.js"; 3 | import { getPageFeeds } from "./extractors/index.js"; 4 | import { settings } from "./settings/index.js"; 5 | import type { Feed, FeedResult, ISettings } from "./types"; 6 | import { getBrowser, sanitizeFeeds } from "./utils.js"; 7 | 8 | // Reference in runtime to register components 9 | import "./components/index.js"; 10 | 11 | // Init 12 | initExtension(); 13 | 14 | function initExtension() { 15 | const browserObj = getBrowser(); 16 | 17 | // once during start-up 18 | onGlobalSettingsUpdate(settings.toJSON()); 19 | settings.subscribe(onGlobalSettingsUpdate); 20 | 21 | return getPageFeeds(browserObj).then(onResultReceived); 22 | } 23 | 24 | function onResultReceived(payload: FeedResult): void { 25 | const { results } = payload; 26 | 27 | let feeds: Feed[] = []; 28 | const contentElem = document.getElementById("content"); 29 | if (!contentElem) { 30 | console.error("No content element to render"); 31 | return; 32 | } 33 | 34 | if (!results) { 35 | const failedStateElem = document.createElement("failed-state") as FailedStateComponent; 36 | failedStateElem.setReason(String(payload.error).includes("permission") ? "permission" : "other"); 37 | failedStateElem.onRefresh = () => void initExtension(); 38 | contentElem.replaceChildren(failedStateElem); 39 | return; 40 | } 41 | 42 | const listElement = contentElem.querySelector("feed-list") as FeedListComponent | undefined; 43 | 44 | if (results) { 45 | feeds = sanitizeFeeds(results.feeds); 46 | } else { 47 | console.error("No result returned from the page"); 48 | } 49 | 50 | listElement?.setFeeds(feeds); 51 | } 52 | 53 | function onGlobalSettingsUpdate(state: ISettings) { 54 | document.querySelector("body")!.className = `theme theme-mode_${state.themeMode}`; 55 | } 56 | -------------------------------------------------------------------------------- /src/popup/launchpad-popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | RSS Content 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/popup/openers/index.ts: -------------------------------------------------------------------------------- 1 | import type { ISettings, KnownOpeners } from "../types"; 2 | 3 | type Opener = (feedUrl: string, settingsState: ISettings) => URL; 4 | 5 | function noopOpener(feedUrl: string): URL { 6 | return new URL(feedUrl); 7 | } 8 | 9 | const serviceUrlGenerators: Record = { 10 | feedly(feedUrl) { 11 | const feedly = new URL("https://feedly.com/i/subscription/feed/"); 12 | feedly.pathname += encodeURIComponent(feedUrl); 13 | return feedly; 14 | }, 15 | freshRss(feedUrl, settingsState) { 16 | const url = new URL("", settingsState.freshRssUrl); 17 | url.searchParams.set("c", "feed"); 18 | url.searchParams.set("a", "add"); 19 | url.searchParams.set("url_rss", feedUrl); 20 | return url; 21 | }, 22 | inoreader(feedUrl) { 23 | const url = new URL("https://www.inoreader.com"); 24 | url.searchParams.set("add_feed", feedUrl); 25 | return url; 26 | }, 27 | nextcloud(feedUrl, settingsState) { 28 | const url = new URL("apps/news", settingsState.nextcloudUrl); 29 | url.searchParams.set("subscribe_to", feedUrl); 30 | return url; 31 | }, 32 | newTab: noopOpener, 33 | tinyTinyRss(feedUrl, settingsState) { 34 | const url = new URL("public.php", settingsState.tinyTinyRssUrl); 35 | url.searchParams.set("op", "bookmarklets--subscribe"); 36 | url.searchParams.set("feed_url", feedUrl); 37 | return url; 38 | }, 39 | }; 40 | 41 | export function generateFeedUrl(settingsState: ISettings, feedUrl: string): URL { 42 | const opener = serviceUrlGenerators[settingsState.defaultOpener]; 43 | return opener(feedUrl, settingsState); 44 | } 45 | -------------------------------------------------------------------------------- /src/popup/settings/impl.ts: -------------------------------------------------------------------------------- 1 | import type { Browser, ISettings } from "../types"; 2 | 3 | type OnChange = (settingsState: ISettings, update: Partial) => void; 4 | 5 | export class Settings { 6 | private settings: ISettings = { 7 | themeMode: "auto", 8 | defaultOpener: "newTab", 9 | useOpenerLinksToCopy: false, 10 | // quickfix for settings restore: move to a schema-based settings approach 11 | tinyTinyRssUrl: "", 12 | nextcloudUrl: "", 13 | freshRssUrl: "", 14 | }; 15 | private inited = false; 16 | private subscribers: Set = new Set(); 17 | 18 | constructor(private browser: Browser) {} 19 | 20 | private invariantInit() { 21 | if (!this.inited) { 22 | throw Error("Class wasn't initalized properly"); 23 | } 24 | } 25 | 26 | init(): Promise { 27 | if (this.inited) { 28 | return Promise.resolve(this); 29 | } 30 | 31 | return this.browser.storage.local 32 | .get(this.settings) 33 | .then((result) => { 34 | this.inited = true; 35 | this.settings = { ...this.settings, ...result }; 36 | return this; 37 | }) 38 | .catch((error) => { 39 | console.error("Error retrieving settings from storage:", error); 40 | return Promise.reject(error); 41 | }); 42 | } 43 | 44 | getSetting(key: K) { 45 | this.invariantInit(); 46 | 47 | return this.settings[key]; 48 | } 49 | 50 | /** 51 | * Only subscribes one function once 52 | */ 53 | subscribe(fn: OnChange) { 54 | this.subscribers.add(fn); 55 | } 56 | 57 | unsubscribe(fn: OnChange): boolean { 58 | return this.subscribers.delete(fn); 59 | } 60 | 61 | private updateSettingsState(update: Partial) { 62 | this.settings = { ...this.settings, ...update }; 63 | this.subscribers.forEach((fn) => fn(this.settings, update)); 64 | } 65 | 66 | setSetting(update: Partial): Promise { 67 | // Save the setting to storage 68 | return this.browser.storage.local 69 | .set(update) 70 | .then(() => { 71 | this.updateSettingsState(update); 72 | }) 73 | .catch((error) => { 74 | console.error(`Error saving ${JSON.stringify(update)} update to storage:`, error); 75 | }); 76 | } 77 | 78 | toJSON(): ISettings { 79 | this.invariantInit(); 80 | 81 | return structuredClone(this.settings); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/popup/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { getBrowser } from "../utils.js"; 2 | import { Settings } from "./impl.js"; 3 | 4 | export const settings = await new Settings(getBrowser()).init(); 5 | -------------------------------------------------------------------------------- /src/popup/shared.css: -------------------------------------------------------------------------------- 1 | :root .theme.theme-mode_light, 2 | :root { 3 | /** light mode **/ 4 | --body-bg: #ffffff; 5 | --body-text: #0c0c0d; 6 | --button-bg: #e0e0e0; 7 | --button-text: #0c0c0d; 8 | --button-bg-hover: #d7d7db; 9 | --list-item-bg: #f5f5f5; 10 | --list-item-text: #0c0c0d; 11 | --link-text: #0060df; 12 | --link-text-hover: #003eaa; 13 | --aux-accent-color: #d7d7db; 14 | 15 | /** dark mode **/ 16 | --body-bg-dark: #0c0c0d; 17 | --body-text-dark: #f9f9fa; 18 | --button-bg-dark: #3c3c3d; 19 | --button-bg-dark-hover: #0a84ff; 20 | --button-text-dark: #f9f9fa; 21 | --list-item-bg-dark: #2e2e2e; 22 | --list-item-text-dark: #f9f9fa; 23 | --link-text-dark: #45a1ff; 24 | --link-text-dark-hover: #0a84ff; 25 | --link-text-dark-visited: #b200ff; 26 | --aux-accent-color-dark: #5f6670; 27 | 28 | --viewport-width: max(100vw, 420px); 29 | font-family: system-ui, ui-sans-serif; 30 | } 31 | 32 | :root .theme.theme-mode_dark { 33 | --body-bg: var(--body-bg-dark); 34 | --body-text: var(--body-text-dark); 35 | --button-bg: var(--button-bg-dark); 36 | --button-text: var(--button-text-dark); 37 | --button-bg-hover: var(--button-bg-dark-hover); 38 | --list-item-bg: var(--list-item-bg-dark); 39 | --list-item-text: var(--list-item-text-dark); 40 | --link-text: var(--link-text-dark); 41 | --link-text-hover: var(--link-text-dark-hover); 42 | --aux-accent-color: var(--aux-accent-color-dark); 43 | } 44 | 45 | @media (prefers-color-scheme: dark) { 46 | :root { 47 | --body-bg: var(--body-bg-dark); 48 | --body-text: var(--body-text-dark); 49 | --button-bg: var(--button-bg-dark); 50 | --button-bg-hover: var(--button-bg-dark-hover); 51 | --button-text: var(--button-text-dark); 52 | --list-item-bg: var(--list-item-bg-dark); 53 | --list-item-text: var(--list-item-text-dark); 54 | --link-text: var(--link-text-dark); 55 | --link-text-hover: var(--link-text-dark-hover); 56 | --aux-accent-color: var(--aux-accent-color-dark); 57 | } 58 | } 59 | 60 | /** 61 | * Popup default sizes for Desktop 62 | */ 63 | html { 64 | min-width: var(--viewport-width); 65 | min-height: 480px; 66 | height: 100%; 67 | 68 | display: flex; 69 | } 70 | 71 | @media (orientation: landscape) { 72 | body { 73 | min-width: auto; 74 | min-height: auto; 75 | } 76 | } 77 | 78 | ol, 79 | ul { 80 | list-style-type: none; 81 | padding: 0; 82 | margin-bottom: 8px; 83 | } 84 | 85 | body { 86 | background-color: var(--body-bg); 87 | color: var(--body-text); 88 | 89 | margin: 0; 90 | flex-grow: 1; 91 | } 92 | 93 | li { 94 | color: var(--list-item-text); 95 | } 96 | a { 97 | color: var(--link-text); 98 | text-decoration: none; 99 | } 100 | a:visited, 101 | a:hover, 102 | a:focus { 103 | color: var(--link-text-hover); 104 | } 105 | 106 | .as-button, 107 | button { 108 | border: none; 109 | padding: 8px 16px; 110 | border-radius: 4px; 111 | cursor: pointer; 112 | transition: background-color 0.3s ease; 113 | background-color: var(--button-bg); 114 | color: var(--button-text); 115 | } 116 | 117 | .as-button:hover, 118 | button:hover { 119 | background-color: var(--button-bg-hover); 120 | color: var(--button-text); 121 | } 122 | 123 | .as-button:active, 124 | button:active { 125 | background-color: var(--button-bg); 126 | color: var(--button-text); 127 | } 128 | 129 | li { 130 | border-top: 1px solid var(--aux-accent-color); 131 | } 132 | 133 | li + li { 134 | margin-top: 8px; 135 | } 136 | 137 | #content { 138 | display: flex; 139 | flex-direction: column; 140 | justify-content: space-between; 141 | 142 | height: 100%; 143 | } 144 | 145 | .app__footer { 146 | padding: 12px; 147 | border-top: 1px solid var(--aux-accent-color); 148 | 149 | display: flex; 150 | justify-content: space-between; 151 | flex-direction: row; 152 | align-items: center; 153 | } 154 | 155 | .app__content-wrapper { 156 | display: flex; 157 | height: 100%; 158 | 159 | flex-direction: column; 160 | justify-content: space-between; 161 | } 162 | 163 | @media (orientation: portrait) { 164 | body .app__content-wrapper { 165 | /** 166 | * Fix for firefox mobile: limit width to 100vw ho prevent horizontal scrolling 167 | */ 168 | width: 100vw; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/popup/types.ts: -------------------------------------------------------------------------------- 1 | export interface Feed { 2 | type: string; 3 | extractType: "direct" | "derived"; 4 | href: string; 5 | title: string; 6 | } 7 | 8 | export interface PageSyncResult { 9 | feeds: Feed[]; 10 | } 11 | 12 | export type Browser = typeof browser | typeof chrome; 13 | 14 | export type ThemeMode = "auto" | "light" | "dark"; 15 | 16 | export type KnownOpeners = "newTab" | "feedly" | "inoreader" | "tinyTinyRss" | "nextcloud" | "freshRss"; 17 | 18 | export type PageStateResult = { 19 | url: string; 20 | siteType: { 21 | isWordpressBased: boolean; 22 | }; 23 | }; 24 | 25 | export interface ISettings { 26 | themeMode: ThemeMode; 27 | defaultOpener: KnownOpeners; 28 | tinyTinyRssUrl?: string; 29 | nextcloudUrl?: string; 30 | freshRssUrl?: string; 31 | useOpenerLinksToCopy: boolean; 32 | } 33 | 34 | export interface IFeedExtractor { 35 | match(pageState: PageStateResult): boolean; 36 | getScriptPath(): string; 37 | } 38 | 39 | export type FeedResult = 40 | | { results: PageSyncResult; error?: undefined } 41 | | { results?: undefined; error: Error | string }; 42 | -------------------------------------------------------------------------------- /src/popup/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Browser, Feed } from "./types"; 2 | 3 | export function getBrowser(): Browser { 4 | if (typeof browser !== "undefined") { 5 | return browser; 6 | // Chrome is the only who does not support browser, ugh 7 | } 8 | if (typeof chrome !== "undefined") { 9 | return chrome; 10 | } 11 | throw new Error("Unsupported browser"); 12 | } 13 | 14 | export function sanitizeFeed(feed: Feed): Feed { 15 | const type = /application\/(rss|atom)\+\w/.exec(feed.type)?.[1]; 16 | 17 | return { 18 | extractType: feed.extractType, 19 | title: feed.title, 20 | type: type ?? "UNKWN", 21 | href: new URL(feed.href).toString(), 22 | }; 23 | } 24 | 25 | export function sanitizeFeeds(inputFeeds: Feed[]): Feed[] { 26 | const feeds: Feed[] = []; 27 | 28 | for (const inputFeed of inputFeeds) { 29 | try { 30 | feeds.push(sanitizeFeed(inputFeed)); 31 | } catch (error) { 32 | console.error("Error sanitizing feed:", error); 33 | } 34 | } 35 | return feeds; 36 | } 37 | 38 | export function getTranslation(tag: string, subs?: string | string[]) { 39 | const browser = getBrowser(); 40 | return browser.i18n.getMessage(tag, subs); 41 | } 42 | 43 | export function applyTranslations(element: T): T { 44 | for (const iter of Array.from( 45 | element.querySelectorAll("[data-trans-text], [data-trans-aria-label], [data-trans-attr-title]"), 46 | ) as HTMLElement[]) { 47 | if (iter.dataset.transText) { 48 | const newText = getTranslation(iter.dataset.transText); 49 | if (newText) { 50 | iter.textContent = newText; 51 | } else { 52 | console.warn("Missing translation tag", iter.dataset.transText); 53 | } 54 | } 55 | 56 | if (iter.dataset.transAriaLabel) { 57 | const newLabel = getTranslation(iter.dataset.transAriaLabel); 58 | if (newLabel) { 59 | iter.ariaLabel = newLabel; 60 | } else { 61 | console.warn("Missing translation tag", iter.dataset.transAriaLabel); 62 | } 63 | } 64 | 65 | for (const [key, value] of Object.entries(iter.dataset)) { 66 | if (key.startsWith("transAttr")) { 67 | const newAttrValue = value && getTranslation(value); 68 | 69 | if (!newAttrValue) { 70 | console.warn("Missing translation tag", { key, value }); 71 | continue; 72 | } 73 | const attrName = key.slice("transAttr".length).toLowerCase(); 74 | iter.setAttribute(attrName, newAttrValue); 75 | } 76 | } 77 | } 78 | 79 | return element; 80 | } 81 | 82 | export function createByTemplate(templateId: string): T { 83 | const template = document.getElementById(templateId); 84 | if (!(template instanceof HTMLTemplateElement)) { 85 | throw Error(`Could not find template tag for #${templateId}`); 86 | } 87 | return applyTranslations(template.content.cloneNode(true) as T); 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "preserve", 5 | "moduleResolution": "node", 6 | "declaration": false, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "strictFunctionTypes": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "importHelpers": true, 16 | "skipLibCheck": true, 17 | "esModuleInterop": false, 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "sourceMap": false, 21 | "outDir": "./dist/", 22 | "types": ["node", "firefox-webext-browser", "chrome"], 23 | "lib": ["es2015", "dom"] 24 | }, 25 | "include": ["src/**/*.ts"], 26 | "exclude": ["node_modules", "**/*.test.ts"] 27 | } 28 | --------------------------------------------------------------------------------