├── src ├── pages │ ├── News │ │ ├── index.tsx │ │ ├── News.module.css │ │ ├── NewsSection.module.css │ │ ├── News.tsx │ │ ├── NewsItem.module.css │ │ ├── NewsItem.tsx │ │ └── NewsSection.tsx │ ├── Settings.module.css │ └── Settings.tsx ├── react-app-env.d.ts ├── components │ ├── Layout │ │ ├── index.tsx │ │ ├── Layout.module.css │ │ └── Layout.tsx │ ├── Navigation │ │ ├── index.tsx │ │ ├── Navigation.module.css │ │ └── Navigation.tsx │ ├── IconCheck.tsx │ ├── IconGrid.tsx │ ├── IconCode.tsx │ ├── IconCalendar.tsx │ ├── IconUser.tsx │ ├── IconOptions.tsx │ ├── IconLoading.tsx │ ├── IconArticle.tsx │ ├── IconMoon.tsx │ ├── IconSun.tsx │ └── App.tsx ├── setupTests.ts ├── styles │ ├── scrollbar.css │ ├── variables.css │ ├── index.css │ └── reset.css ├── index.tsx ├── hooks │ └── useMedia.tsx ├── store.tsx └── serviceWorker.ts ├── public ├── robots.txt ├── manifest.json └── index.html ├── .prettierrc.json ├── ChromeWebStore_Badge_v2_206x58.png ├── manifest.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── api.yml │ └── publish.yml ├── package.json ├── README.md └── fetchData.js /src/pages/News/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './News'; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Layout'; 2 | -------------------------------------------------------------------------------- /src/components/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Navigation'; 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /ChromeWebStore_Badge_v2_206x58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidpaulsson/glyf/HEAD/ChromeWebStore_Badge_v2_206x58.png -------------------------------------------------------------------------------- /src/pages/Settings.module.css: -------------------------------------------------------------------------------- 1 | .info { 2 | max-width: 30rem; 3 | } 4 | 5 | .info a { 6 | text-decoration: underline; 7 | } 8 | .info a:hover { 9 | text-decoration: none; 10 | } 11 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Glyf", 3 | "name": "Glyf―A new tab with news", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.css: -------------------------------------------------------------------------------- 1 | .grid { 2 | display: grid; 3 | 4 | height: 100vh; 5 | max-height: 100vh; 6 | padding: var(--spacing); 7 | 8 | gap: var(--spacing); 9 | grid-template-rows: auto 1fr; 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/News/News.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | display: grid; 3 | overflow: hidden; 4 | 5 | max-height: 100%; 6 | 7 | grid-template-columns: repeat(3, minmax(0px, 1fr)); 8 | grid-template-rows: repeat(2, minmax(0px, 1fr)); 9 | gap: var(--spacing); 10 | } 11 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/styles/scrollbar.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: calc(var(--spacing) / 2); 3 | } 4 | 5 | ::-webkit-scrollbar-thumb { 6 | position: relative; 7 | z-index: 999; 8 | 9 | border-radius: calc(var(--spacing) / 2); 10 | background-color: var(--color-text-3); 11 | } 12 | 13 | ::-webkit-scrollbar-track { 14 | background-color: var(--color-inverse); 15 | } 16 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "David Paulsson ", 3 | "name": "Glyf", 4 | "version": "1.0.8", 5 | "manifest_version": 2, 6 | "description": "A new tab with news", 7 | "permissions": ["management"], 8 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 9 | "chrome_url_overrides": { 10 | "newtab": "index.html" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | app.zip 26 | public/data.json 27 | .cache 28 | build.zip -------------------------------------------------------------------------------- /src/components/IconCheck.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconCheck(props: React.SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | 14 | const MemoIconCheck = React.memo(IconCheck); 15 | export default MemoIconCheck; 16 | -------------------------------------------------------------------------------- /src/components/IconGrid.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconGrid(props: React.SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | 14 | const MemoIconGrid = React.memo(IconGrid); 15 | export default MemoIconGrid; 16 | -------------------------------------------------------------------------------- /src/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --spacing: 8px; 3 | --color-text: #000; 4 | --color-text-1: #333; 5 | --color-text-2: #666; 6 | --color-text-3: #999; 7 | --color-inverse: #fff; 8 | --color-inverse-1: #eee; 9 | --color-inverse-2: #ddd; 10 | --color-inverse-3: #ccc; 11 | } 12 | 13 | .darkMode { 14 | --color-text: #fff; 15 | --color-text-1: #ddd; 16 | --color-text-2: #ccc; 17 | --color-text-3: #aaa; 18 | --color-inverse: #000; 19 | --color-inverse-1: #333; 20 | --color-inverse-2: #666; 21 | --color-inverse-3: #999; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, { useContext } from 'react'; 3 | import { store } from '../../store'; 4 | import styles from './Layout.module.css'; 5 | 6 | const Layout: React.FC = ({ children }) => { 7 | const { state } = useContext(store); 8 | 9 | return ( 10 |
16 | {children} 17 |
18 | ); 19 | }; 20 | 21 | export default Layout; 22 | -------------------------------------------------------------------------------- /src/components/IconCode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconCode(props: React.SVGProps) { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | 14 | const MemoIconCode = React.memo(IconCode); 15 | export default MemoIconCode; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/IconCalendar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconCalendar(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ); 15 | } 16 | 17 | const MemoIconCalendar = React.memo(IconCalendar); 18 | export default MemoIconCalendar; 19 | -------------------------------------------------------------------------------- /src/components/IconUser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconUser(props: React.SVGProps) { 4 | return ( 5 | 6 | 12 | 16 | 17 | ); 18 | } 19 | 20 | const MemoIconUser = React.memo(IconUser); 21 | export default MemoIconUser; 22 | -------------------------------------------------------------------------------- /src/components/IconOptions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconOptions(props: React.SVGProps) { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | 16 | const MemoIconOptions = React.memo(IconOptions); 17 | export default MemoIconOptions; 18 | -------------------------------------------------------------------------------- /src/pages/News/NewsSection.module.css: -------------------------------------------------------------------------------- 1 | .section { 2 | overflow-y: scroll; 3 | } 4 | 5 | .title { 6 | position: sticky; 7 | z-index: 1; 8 | top: 0; 9 | 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | 14 | margin-bottom: var(--spacing); 15 | padding-right: var(--spacing); 16 | padding-bottom: var(--spacing); 17 | 18 | border-bottom: 1px solid var(--color-inverse-2); 19 | background-color: var(--color-inverse); 20 | } 21 | 22 | .title h2 span { 23 | color: var(--color-text-3); 24 | } 25 | 26 | .title button { 27 | display: flex; 28 | 29 | color: var(--color-text-1); 30 | } 31 | .title button:hover { 32 | display: flex; 33 | 34 | color: var(--color-text); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/IconLoading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconLoading(props: React.SVGProps) { 4 | return ( 5 | 6 | 13 | 17 | 18 | ); 19 | } 20 | 21 | const MemoIconLoading = React.memo(IconLoading); 22 | export default MemoIconLoading; 23 | -------------------------------------------------------------------------------- /src/pages/News/News.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { ISource, store } from '../../store'; 3 | import styles from './News.module.css'; 4 | import NewsSection from './NewsSection'; 5 | 6 | const News = () => { 7 | const { state } = useContext(store); 8 | 9 | return ( 10 |
11 | {state.settings.selectedSources.map((domain, index) => { 12 | const source: ISource | undefined = state.sources.sources.find( 13 | (source) => source.domain === domain 14 | ); 15 | 16 | if (!source) return false; 17 | 18 | return ; 19 | })} 20 |
21 | ); 22 | }; 23 | 24 | export default News; 25 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | *, 5 | *:before, 6 | *:after { 7 | box-sizing: inherit; 8 | } 9 | 10 | body { 11 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 12 | Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 13 | font-size: 1.125rem; /* 18px */ 14 | line-height: 1.2; 15 | } 16 | 17 | .darkMode { 18 | color: var(--color-text); 19 | background-color: var(--color-inverse); 20 | } 21 | 22 | hr { 23 | height: 1px; 24 | 25 | border: 0; 26 | background-color: var(--color-inverse-2); 27 | } 28 | 29 | a { 30 | text-decoration: none; 31 | 32 | color: var(--color-text); 33 | } 34 | 35 | label { 36 | display: flex; 37 | align-items: center; 38 | } 39 | 40 | label input { 41 | margin-right: var(--spacing); 42 | } 43 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './styles/reset.css'; 4 | import 'react-tippy/dist/tippy.css'; 5 | import './styles/variables.css'; 6 | import './styles/scrollbar.css'; 7 | import './styles/index.css'; 8 | import App from './components/App'; 9 | import * as serviceWorker from './serviceWorker'; 10 | import { StateProvider } from './store'; 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ); 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister(); 25 | -------------------------------------------------------------------------------- /src/components/IconArticle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconArticle(props: React.SVGProps) { 4 | return ( 5 | 6 | 10 | 16 | 17 | ); 18 | } 19 | 20 | const MemoIconArticle = React.memo(IconArticle); 21 | export default MemoIconArticle; 22 | -------------------------------------------------------------------------------- /src/components/Navigation/Navigation.module.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | 6 | padding-bottom: calc(var(--spacing) * 2); 7 | } 8 | 9 | .nav > button:first-child { 10 | margin-left: unset; 11 | 12 | color: var(--color-text); 13 | } 14 | 15 | .nav h1, 16 | .nav h1 span { 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .nav h1 span svg { 22 | margin: 0 calc(var(--spacing) / 2) 0 var(--spacing); 23 | 24 | animation: rotation 1s infinite ease; 25 | } 26 | 27 | @keyframes rotation { 28 | from { 29 | transform: rotate(0deg); 30 | } 31 | to { 32 | transform: rotate(359deg); 33 | } 34 | } 35 | 36 | button { 37 | margin-left: var(--spacing); 38 | 39 | color: var(--color-text-3); 40 | } 41 | 42 | button:hover { 43 | color: var(--color-text-2); 44 | } 45 | 46 | button.active { 47 | color: var(--color-text); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/IconMoon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconMoon(props: React.SVGProps) { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | 16 | const MemoIconMoon = React.memo(IconMoon); 17 | export default MemoIconMoon; 18 | -------------------------------------------------------------------------------- /src/components/IconSun.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | function IconSun(props: React.SVGProps) { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | 16 | const MemoIconSun = React.memo(IconSun); 17 | export default MemoIconSun; 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 David Paulsson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/api.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | schedule: 5 | - cron: '0 * * * *' 6 | 7 | jobs: 8 | build: 9 | name: Build and deploy to GitHub Pages 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 12 20 | 21 | - name: Use cache 22 | uses: actions/cache@v2 23 | with: 24 | path: '**/node_modules' 25 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 26 | 27 | - name: Install packages 28 | if: ${{ success() }} 29 | run: yarn install 30 | 31 | - name: Generate json 32 | if: ${{ success() }} 33 | run: yarn api 34 | 35 | - name: Generate build 36 | if: ${{ success() }} 37 | run: yarn build 38 | 39 | - name: 🚀 Deploy 40 | uses: JamesIves/github-pages-deploy-action@releases/v3 41 | with: 42 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 43 | BRANCH: gh-pages 44 | FOLDER: build 45 | SINGLE_COMMIT: true 46 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | branches: [release] 6 | 7 | jobs: 8 | build: 9 | name: Build and publish to Chrome Web Store 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 12 20 | 21 | - name: Use cache 22 | uses: actions/cache@v2 23 | with: 24 | path: '**/node_modules' 25 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 26 | 27 | - name: Install packages 28 | run: yarn install 29 | 30 | - name: Generate build 31 | run: yarn build 32 | 33 | - name: Package .zip 34 | uses: montudor/action-zip@v0.1.0 35 | with: 36 | args: zip -qq -r ./app.zip ./build 37 | 38 | - name: 🚀 Upload & release 39 | uses: mnao305/chrome-extension-upload@1.1.0 40 | with: 41 | file-path: app.zip 42 | extension-id: kklidjoiedcocpkddfnknenpkpcdalnp 43 | client-id: ${{ secrets.CLIENT_ID }} 44 | client-secret: ${{ secrets.CLIENT_SECRET }} 45 | refresh-token: ${{ secrets.REFRESH_TOKEN }} 46 | -------------------------------------------------------------------------------- /src/pages/News/NewsItem.module.css: -------------------------------------------------------------------------------- 1 | .link { 2 | display: block; 3 | } 4 | 5 | .link:hover .title { 6 | text-decoration: line-through; 7 | } 8 | .link:hover .preamble { 9 | color: var(--color-text); 10 | } 11 | 12 | .link:visited .title, 13 | .link:visited .preamble, 14 | .link:visited .published { 15 | text-decoration: line-through; 16 | 17 | color: var(--color-text-3); 18 | } 19 | 20 | .item { 21 | margin-bottom: calc(var(--spacing) * 3); 22 | } 23 | 24 | .preamble { 25 | display: inherit; 26 | overflow: hidden; 27 | 28 | text-overflow: ellipsis; 29 | 30 | color: var(--color-text-2); 31 | 32 | font-size: 1rem; 33 | } 34 | 35 | .published { 36 | display: flex; 37 | align-items: center; 38 | 39 | margin-top: var(--spacing); 40 | 41 | color: var(--color-text-2); 42 | 43 | font-size: 0.8rem; 44 | } 45 | 46 | .published svg { 47 | margin-right: calc(var(--spacing) / 2); 48 | } 49 | 50 | .star { 51 | position: relative; 52 | z-index: 0; 53 | 54 | margin-right: calc(var(--spacing) / 3); 55 | 56 | transform: scale(0.7); 57 | } 58 | 59 | .spacer { 60 | display: block; 61 | 62 | width: var(--spacing); 63 | } 64 | 65 | .image { 66 | float: right; 67 | 68 | width: 5rem; 69 | height: 4rem; 70 | margin-left: var(--spacing); 71 | 72 | border-radius: var(--spacing); 73 | background-repeat: no-repeat; 74 | background-position: center center; 75 | background-size: cover; 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glyf-v3", 3 | "version": "1.0.8", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^10.4.9", 8 | "@testing-library/user-event": "^12.1.3", 9 | "@types/classnames": "^2.2.10", 10 | "@types/jest": "^26.0.10", 11 | "@types/node": "^14.6.0", 12 | "@types/react": "^16.9.46", 13 | "@types/react-dom": "^16.9.0", 14 | "axios": "^0.20.0", 15 | "cheerio": "^1.0.0-rc.3", 16 | "classnames": "^2.2.6", 17 | "lodash.orderby": "^4.6.0", 18 | "moment": "^2.27.0", 19 | "react": "^16.13.1", 20 | "react-dom": "^16.13.1", 21 | "react-scripts": "3.4.3", 22 | "react-tippy": "^1.4.0", 23 | "remove-markdown": "^0.3.0", 24 | "rss-parser": "^3.9.0", 25 | "sanitize-html": "^1.27.2", 26 | "typescript": "~4.0.2" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "sh ./build.sh", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "api": "node fetchData.js" 34 | }, 35 | "eslintConfig": { 36 | "extends": "react-app" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/hooks/useMedia.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | //alternate hook that accepts a single query 4 | export default function useMedia( 5 | queries: any[], 6 | values: boolean[], 7 | defaultValue: boolean 8 | ) { 9 | // state and setter for matched value 10 | const [value, setValue] = useState(defaultValue); 11 | 12 | //array containing a media query list for each query 13 | const mediaQueryLists = queries.map((q: string) => window.matchMedia(q)); 14 | 15 | //state update function 16 | const getValue = () => { 17 | const index = mediaQueryLists.findIndex( 18 | (mql: { matches: any }) => mql.matches 19 | ); 20 | 21 | return typeof values[index] !== 'undefined' ? values[index] : defaultValue; 22 | }; 23 | 24 | useEffect( 25 | () => { 26 | //set the initial value 27 | setValue(getValue); 28 | 29 | const handler = () => setValue(getValue); 30 | 31 | mediaQueryLists.forEach( 32 | (mql: { addListener: (arg0: () => void) => any }) => 33 | mql.addListener(handler) 34 | ); 35 | 36 | //remove listeners on cleanup 37 | return () => 38 | mediaQueryLists.forEach( 39 | (mql: { removeListener: (arg0: () => void) => any }) => 40 | mql.removeListener(handler) 41 | ); 42 | }, 43 | // eslint-disable-next-line react-hooks/exhaustive-deps 44 | [] //empty array ensures effect is only run on mount and unmount 45 | ); 46 | 47 | return value; 48 | } 49 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | Glyf 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import News from '../pages/News'; 3 | import Settings from '../pages/Settings'; 4 | import { routes, store, actions } from '../store'; 5 | import Layout from './Layout'; 6 | import Navigation from './Navigation'; 7 | import useMedia from '../hooks/useMedia'; 8 | 9 | const usePrefersdarkMode = () => 10 | useMedia(['(prefers-color-scheme: dark)'], [true], false); 11 | 12 | function App() { 13 | const { state, dispatch } = useContext(store); 14 | 15 | useEffect(() => { 16 | dispatch({ type: actions.SET_LOADING_SOURCES }); 17 | 18 | const fetchArticles = async () => { 19 | const response = await fetch( 20 | 'https://davidpaulsson.github.io/glyf/data.json' 21 | ); 22 | if (response.status >= 200 && response.status <= 299) { 23 | const jsonResponse = await response.json(); 24 | dispatch({ 25 | type: actions.SET_SOURCES, 26 | payload: jsonResponse, 27 | }); 28 | } else { 29 | // Handle errors 30 | console.log(response.status, response.statusText); 31 | } 32 | }; 33 | 34 | fetchArticles(); 35 | }, [dispatch]); 36 | 37 | const prefersDarkMode = usePrefersdarkMode(); 38 | useEffect(() => { 39 | if (state.settings.useSystemPreferenceDarkMode) { 40 | dispatch({ type: actions.SET_IS_DARK_MODE, payload: prefersDarkMode }); 41 | } 42 | }, [prefersDarkMode, dispatch, state.settings.useSystemPreferenceDarkMode]); 43 | 44 | return ( 45 | 46 | 47 | {state.navigation.currentRoute === routes.NEWS && } 48 | {state.navigation.currentRoute === routes.SETTINGS && } 49 | 50 | ); 51 | } 52 | 53 | export default App; 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Chrome Web Store](https://img.shields.io/chrome-web-store/v/kklidjoiedcocpkddfnknenpkpcdalnp.svg) 2 | ![Chrome Web Store](https://img.shields.io/chrome-web-store/rating/kklidjoiedcocpkddfnknenpkpcdalnp.svg) 3 | ![license](https://img.shields.io/github/license/davidpaulsson/alla-nyheter-extension.svg) 4 | 5 | # Glyf 6 | 7 | [![Available in the Chrome Web Store](ChromeWebStore_Badge_v2_206x58.png)](https://chrome.google.com/webstore/detail/alla-nyheter/kklidjoiedcocpkddfnknenpkpcdalnp) 8 | 9 | ## Sources 10 | 11 | Currently this extension load articles from the following news sources 12 | 13 | - [Aftonbladet](https://www.aftonbladet.se/) 🇸🇪 14 | - [Al Jazeera](https://www.aljazeera.com/) 🌏 15 | - [BBC News](https://www.bbc.com/news) 🇬🇧 16 | - [Breakit](https://www.breakit.se/) 🇸🇪 17 | - [Dagens Industri](https://www.di.se/) 🇸🇪 18 | - [Dagens Media](https://www.dagensmedia.se/) 🇸🇪 19 | - [Dagens Nyheter](https://www.dn.se/) 🇸🇪 20 | - [Designer News](https://www.designernews.co/) 🇺🇸 21 | - [Expressen](https://www.expressen.se/) 🇸🇪 22 | - [Github (Trending repositories)](https://github.com/trending) 🇺🇸 23 | - [Hacker News](https://news.ycombinator.com/) 🇺🇸 24 | - [New York Times](https://www.nytimes.com/) 🇺🇸 25 | - [Product Hunt](https://www.producthunt.com/) 🇺🇸 26 | - [Resumé](https://www.resume.se/) 🇸🇪 27 | - [Svenska Dagbladet](https://www.svd.se/) 🇸🇪 28 | - [Sveriges Radio Ekot](http://sverigesradio.se/ekot) 🇸🇪 29 | - [SVT Nyheter](https://www.svt.se/) 🇸🇪 30 | 31 | ### Issues 32 | 33 | Feel free to submit issues and enhancement requests. 34 | 35 | ### Contributing 36 | 37 | In general, we follow the "fork-and-pull" Git workflow. 38 | 39 | 1. **Fork** the repo on GitHub 40 | 2. **Clone** the project to your own machine 41 | 3. **Commit** changes to your own branch 42 | 4. **Push** your work back up to your fork 43 | 5. Submit a **Pull request** so that we can review your changes 44 | 45 | NOTE: Be sure to merge the latest from "upstream" before making a pull request! 46 | -------------------------------------------------------------------------------- /src/pages/News/NewsItem.tsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import React, { useContext } from 'react'; 3 | import IconCalendar from '../../components/IconCalendar'; 4 | import IconCode from '../../components/IconCode'; 5 | import IconUser from '../../components/IconUser'; 6 | import { IItem, store } from '../../store'; 7 | import styles from './NewsItem.module.css'; 8 | 9 | const NewsItem = ({ item }: { item: IItem }) => { 10 | const { state } = useContext(store); 11 | 12 | return ( 13 |
  • 14 | 22 | {item.image && ( 23 |
  • 63 | ); 64 | }; 65 | 66 | export default NewsItem; 67 | -------------------------------------------------------------------------------- /src/pages/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { actions, store } from '../store'; 3 | import newsSectionStyles from '../pages/News/NewsSection.module.css'; 4 | import styles from './Settings.module.css'; 5 | import packageJson from '../../package.json'; 6 | 7 | const Settings: React.FC = () => { 8 | const { state, dispatch } = useContext(store); 9 | 10 | return ( 11 | 62 | ); 63 | }; 64 | 65 | export default Settings; 66 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | 90 | vertical-align: baseline; 91 | 92 | border: 0; 93 | 94 | font: inherit; 95 | font-size: 100%; 96 | } 97 | /* HTML5 display-role reset for older browsers */ 98 | article, 99 | aside, 100 | details, 101 | figcaption, 102 | figure, 103 | footer, 104 | header, 105 | hgroup, 106 | menu, 107 | nav, 108 | section { 109 | display: block; 110 | } 111 | body { 112 | line-height: 1; 113 | } 114 | ol, 115 | ul { 116 | list-style: none; 117 | } 118 | blockquote, 119 | q { 120 | quotes: none; 121 | } 122 | blockquote:before, 123 | blockquote:after, 124 | q:before, 125 | q:after { 126 | content: ''; 127 | content: none; 128 | } 129 | table { 130 | border-spacing: 0; 131 | 132 | border-collapse: collapse; 133 | } 134 | 135 | /** RESET BUTTONS */ 136 | 137 | button { 138 | overflow: visible; 139 | 140 | width: auto; 141 | margin: 0; 142 | padding: 0; 143 | /* inherit font & color from ancestor */ 144 | 145 | color: inherit; 146 | border: none; 147 | background: transparent; 148 | 149 | font: inherit; 150 | /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */ 151 | line-height: normal; 152 | /* Corrects font smoothing for webkit */ 153 | 154 | -webkit-font-smoothing: inherit; 155 | -moz-osx-font-smoothing: inherit; 156 | /* Corrects inability to style clickable `input` types in iOS */ 157 | -webkit-appearance: none; 158 | } 159 | 160 | button:hover { 161 | cursor: pointer; 162 | } 163 | 164 | button:focus { 165 | outline: none; 166 | } 167 | -------------------------------------------------------------------------------- /src/pages/News/NewsSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import IconGrid from '../../components/IconGrid'; 3 | import { ISource, store, actions } from '../../store'; 4 | import NewsItem from './NewsItem'; 5 | import styles from './NewsSection.module.css'; 6 | import IconCheck from '../../components/IconCheck'; 7 | import { Tooltip } from 'react-tippy'; 8 | const NewsSection = ({ source, index }: { source: ISource; index: number }) => { 9 | const { state, dispatch } = useContext(store); 10 | const [showSourceSelection, setShowSourceSelection] = useState(false); 11 | 12 | return ( 13 |
    14 | {showSourceSelection ? ( 15 | <> 16 |
    17 |

    18 | Select News Source ({source.title}) 19 |

    20 | 26 | 29 | 30 |
    31 |
      32 | {state.sources.sources.map((s) => ( 33 |
    • 34 | 52 |
    • 53 | ))} 54 |
    55 | 56 | ) : ( 57 | <> 58 |
    59 |

    {source.title}

    60 | 66 | 69 | 70 |
    71 |
      72 | {source.items.map((item, index) => ( 73 | 74 | ))} 75 |
    76 | 77 | )} 78 |
    79 | ); 80 | }; 81 | 82 | export default NewsSection; 83 | -------------------------------------------------------------------------------- /src/components/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, { useContext } from 'react'; 3 | import { Tooltip } from 'react-tippy'; 4 | import { actions, routes, store } from '../../store'; 5 | import IconArticle from '../IconArticle'; 6 | import IconLoading from '../IconLoading'; 7 | import IconMoon from '../IconMoon'; 8 | import IconOptions from '../IconOptions'; 9 | import IconSun from '../IconSun'; 10 | import styles from './Navigation.module.css'; 11 | 12 | const Navigation: React.FC = () => { 13 | const { state, dispatch } = useContext(store); 14 | 15 | const goToNews = () => 16 | dispatch({ type: actions.NAVIGATE, payload: routes.NEWS }); 17 | 18 | return ( 19 | 107 | ); 108 | }; 109 | 110 | export default Navigation; 111 | -------------------------------------------------------------------------------- /src/store.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, Dispatch, useReducer, useEffect } from 'react'; 2 | 3 | export enum routes { 4 | NEWS, 5 | SETTINGS, 6 | } 7 | 8 | export enum actions { 9 | NAVIGATE, 10 | SET_OPEN_LINKS_IN_NEW_TAB, 11 | SET_IS_DARK_MODE, 12 | SET_SYSTEM_DARK_MODE, 13 | SET_LOADING_SOURCES, 14 | SET_SOURCES, 15 | SET_SOURCE, 16 | } 17 | 18 | export interface IItem { 19 | title: string; 20 | description: string; 21 | preamble: string; 22 | published?: string; 23 | sortDate: string; 24 | url: string; 25 | github?: boolean; 26 | language?: string; 27 | author?: string; 28 | stars?: number; 29 | image?: string; 30 | } 31 | 32 | export interface ISource { 33 | domain: string; 34 | items: IItem[]; 35 | title: string; 36 | } 37 | 38 | interface IState { 39 | navigation: { 40 | currentRoute: routes; 41 | }; 42 | settings: { 43 | openLinksInNewTab: boolean; 44 | isDarkMode: boolean; 45 | useSystemPreferenceDarkMode: boolean; 46 | selectedSources: string[]; 47 | }; 48 | sources: { 49 | sources: ISource[]; 50 | updatedAt: string | null; 51 | loading: boolean; 52 | }; 53 | } 54 | 55 | interface IContext { 56 | dispatch: Dispatch; 57 | state: IState; 58 | } 59 | 60 | const initialState = JSON.parse( 61 | localStorage.getItem('GlyfStore') as string 62 | ) || { 63 | navigation: { 64 | currentRoute: routes.NEWS, 65 | }, 66 | settings: { 67 | openLinksInNewTab: false, 68 | isDarkMode: false, 69 | useSystemPreferenceDarkMode: true, 70 | selectedSources: [], 71 | }, 72 | sources: { 73 | sources: [], 74 | updatedAt: null, 75 | loading: false, 76 | }, 77 | }; 78 | 79 | const store = createContext({ 80 | dispatch: () => {}, 81 | state: initialState, 82 | }); 83 | 84 | const { Provider } = store; 85 | 86 | const take = (arr: ISource[], qty = 1) => [...arr].splice(0, qty); 87 | 88 | const StateProvider: React.FC = ({ children }) => { 89 | const [state, dispatch] = useReducer( 90 | (state: IState, action: { type: actions; payload: any }) => { 91 | switch (action.type) { 92 | case actions.NAVIGATE: 93 | return { 94 | ...state, 95 | navigation: { 96 | ...state.navigation, 97 | currentRoute: action.payload, 98 | }, 99 | }; 100 | case actions.SET_OPEN_LINKS_IN_NEW_TAB: 101 | return { 102 | ...state, 103 | settings: { 104 | ...state.settings, 105 | openLinksInNewTab: action.payload, 106 | }, 107 | }; 108 | case actions.SET_IS_DARK_MODE: 109 | return { 110 | ...state, 111 | settings: { 112 | ...state.settings, 113 | isDarkMode: action.payload, 114 | }, 115 | }; 116 | case actions.SET_SYSTEM_DARK_MODE: 117 | return { 118 | ...state, 119 | settings: { 120 | ...state.settings, 121 | useSystemPreferenceDarkMode: action.payload, 122 | }, 123 | }; 124 | case actions.SET_LOADING_SOURCES: 125 | return { 126 | ...state, 127 | sources: { 128 | ...state.sources, 129 | loading: true, 130 | }, 131 | }; 132 | case actions.SET_SOURCES: 133 | return { 134 | ...state, 135 | sources: { 136 | ...action.payload, 137 | loading: false, 138 | }, 139 | settings: { 140 | ...state.settings, 141 | selectedSources: 142 | state.settings.selectedSources.length > 0 143 | ? state.settings.selectedSources 144 | : take(action.payload.sources, 6).map( 145 | (source: { domain: string }) => source.domain 146 | ), 147 | }, 148 | }; 149 | case actions.SET_SOURCE: 150 | return { 151 | ...state, 152 | settings: { 153 | ...state.settings, 154 | selectedSources: state.settings.selectedSources.map( 155 | (source, index) => 156 | index === action.payload.index 157 | ? action.payload.source 158 | : source 159 | ), 160 | }, 161 | }; 162 | default: 163 | return state; 164 | } 165 | }, 166 | initialState 167 | ); 168 | 169 | useEffect(() => { 170 | localStorage.setItem('GlyfStore', JSON.stringify(state)); 171 | }, [state]); 172 | 173 | return {children}; 174 | }; 175 | 176 | export { store, StateProvider }; 177 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /fetchData.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const axios = require('axios'); 3 | const orderBy = require('lodash.orderby'); 4 | 5 | const moment = require('moment'); 6 | 7 | const Parser = require('rss-parser'); 8 | const parser = new Parser(); 9 | 10 | const sanitizeHtml = require('sanitize-html'); 11 | const removeMd = require('remove-markdown'); 12 | 13 | const cheerio = require('cheerio'); 14 | 15 | const sources = [ 16 | { 17 | title: 'Github', 18 | domain: 'github.com', 19 | api: 'https://github.com/trending', 20 | }, 21 | { 22 | title: 'Product Hunt', 23 | domain: 'producthunt.com', 24 | api: 'https://www.producthunt.com/feed', 25 | }, 26 | { 27 | title: 'Designer News', 28 | domain: 'designernews.co', 29 | api: 'https://www.designernews.co/?format=rss', 30 | }, 31 | { 32 | title: 'Hacker News', 33 | domain: 'hnrss.org', 34 | api: 'https://hnrss.org/newest', 35 | }, 36 | { 37 | title: 'Dagens Media', 38 | domain: 'dagensmedia.se', 39 | api: 'https://www.dagensmedia.se/rss.xml', 40 | }, 41 | { 42 | title: 'Resume', 43 | domain: 'resume.se', 44 | api: 'https://www.resume.se/rss.xml', 45 | }, 46 | { 47 | title: 'Breakit', 48 | domain: 'breakit.se', 49 | api: 'https://www.breakit.se/feed/artiklar', 50 | }, 51 | { 52 | title: 'Dagens Industri', 53 | domain: 'di.se', 54 | api: 'https://www.di.se/rss', 55 | }, 56 | { 57 | title: 'Aftonbladet', 58 | domain: 'aftonbladet.se', 59 | api: 'https://rss.aftonbladet.se/rss2/small/pages/sections/senastenytt/', 60 | }, 61 | { 62 | title: 'Dagens Nyheter', 63 | domain: 'dn.se', 64 | api: 'https://www.dn.se/nyheter/m/rss', 65 | }, 66 | { 67 | title: 'Expressen', 68 | domain: 'expressen.se', 69 | api: 'https://feeds.expressen.se', 70 | }, 71 | { 72 | title: 'SVT Nyheter', 73 | domain: 'svt.se', 74 | api: 'https://www.svt.se/nyheter/rss.xml', 75 | }, 76 | { 77 | title: 'Svenska Dagbladet', 78 | domain: 'svd.se', 79 | api: 'https://www.svd.se/?service=rss', 80 | }, 81 | { 82 | title: 'Sveriges Radio Ekot', 83 | domain: 'sverigesradio.se/ekot', 84 | api: 'https://api.sr.se/api/rss/program/83?format=145', 85 | }, 86 | { 87 | title: 'BBC News', 88 | domain: 'bbc.com/news', 89 | api: 'http://feeds.bbci.co.uk/news/rss.xml', 90 | }, 91 | { 92 | title: 'New York Times', 93 | domain: 'nytimes.com', 94 | api: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', 95 | }, 96 | { 97 | title: 'Al Jazeera', 98 | domain: 'aljazeera.com', 99 | api: 'https://www.aljazeera.com/xml/rss/all.xml', 100 | }, 101 | ]; 102 | 103 | const data = { 104 | updatedAt: moment().toDate(), 105 | sources: [], 106 | }; 107 | 108 | const sortByDate = (array) => orderBy(array, 'sortDate', 'desc'); 109 | 110 | const stripHtml = (str) => 111 | !!str 112 | ? removeMd( 113 | sanitizeHtml(str.length && str[0] === '“' ? str.slice(1) : str, { 114 | allowedTags: [], 115 | }).trim() 116 | ) 117 | : null; 118 | 119 | const fixTitle = (str) => (!!str ? str.replace(/ *\([^)]*\) */g, '') : null); 120 | 121 | const extractImageUri = (item) => { 122 | if (item.content) { 123 | return item.content.url; 124 | } 125 | 126 | if (!item.description) { 127 | return null; 128 | } 129 | 130 | const img = sanitizeHtml(item.description, { 131 | allowedTags: ['img'], 132 | allowedAttributes: { 133 | img: ['src'], 134 | }, 135 | textFilter() { 136 | return ''; 137 | }, 138 | }); 139 | 140 | const re = /"(.*?[^\\])"/; 141 | return re.exec(img) && 142 | re.exec(img)[1] !== 'https://static.breakit.se/article/default.png' 143 | ? re.exec(img)[1] 144 | : null; 145 | }; 146 | 147 | const truncateString = (str) => { 148 | if (str === null) { 149 | return null; 150 | } 151 | const num = 150; 152 | if (str.length <= num) { 153 | return str; 154 | } 155 | return str.slice(0, num) + '…'; 156 | }; 157 | 158 | const normalize = ({ data }) => 159 | data.map((d) => ({ 160 | title: fixTitle(d.title), 161 | url: d.link, 162 | preamble: truncateString(stripHtml(d.content)), 163 | published: moment(d.pubDate).calendar(), 164 | sortDate: moment(d.pubDate).toDate(), 165 | image: d.enclosure ? d.enclosure.url : extractImageUri(d), 166 | description: d.content 167 | ? removeMd( 168 | sanitizeHtml(d.content, { 169 | allowedTags: [], 170 | }).trim() 171 | ) 172 | : null, 173 | })); 174 | 175 | const asyncForEach = async (array, callback) => { 176 | for (let index = 0; index < array.length; index++) { 177 | try { 178 | await callback(array[index], index, array); 179 | } catch (error) { 180 | console.log(array[index], error); 181 | } 182 | } 183 | }; 184 | 185 | const start = async () => { 186 | try { 187 | await asyncForEach( 188 | orderBy(sources, 'domain'), 189 | async ({ title, domain, api }) => { 190 | if (title === 'Github') { 191 | // We need to scrape... 192 | const { data: html } = await axios.get(api); 193 | const $ = cheerio.load(html); 194 | 195 | const items = $('.Box-row') 196 | .get() 197 | // eslint-disable-next-line array-callback-return 198 | .map((row) => { 199 | console.log('row', row); 200 | try { 201 | const repoLink = $(row).find('h2 a').text(); 202 | const repoLinkSplit = repoLink.split('/'); 203 | const author = repoLinkSplit[0].trim(); 204 | const repoName = repoLinkSplit[1].trim(); 205 | const url = $(row).find('h2 a').attr('href'); 206 | const desc = $(row).find('h2 + p').text().trim(); 207 | const language = $(row) 208 | .find('.repo-language-color + span') 209 | .text() 210 | .trim(); 211 | 212 | const stars = $(row) 213 | .find('.repo-language-color') 214 | .parent() 215 | .next() 216 | .text() 217 | .trim(); 218 | 219 | const title = fixTitle(repoName); 220 | const preamble = truncateString(stripHtml(desc)); 221 | 222 | return { 223 | title: title !== '' ? title : null, 224 | url: 'https://github.com' + url, 225 | preamble: preamble !== '' ? preamble : null, 226 | language: language !== '' ? language : null, 227 | stars: stars !== '' ? stars : null, 228 | author: author !== '' ? author : null, 229 | github: true, 230 | }; 231 | } catch (err) { 232 | console.error('parse error', err); 233 | } 234 | }) 235 | .filter(Boolean); 236 | 237 | data.sources.push({ 238 | title, 239 | domain, 240 | items, 241 | }); 242 | return; 243 | } 244 | 245 | if (title === 'Resume') { 246 | const xml = await axios.get(api); 247 | const resp = await parser.parseString(xml.data); 248 | data.sources.push({ 249 | title, 250 | domain, 251 | items: sortByDate(normalize({ title, data: resp.items })), 252 | }); 253 | return; 254 | } 255 | 256 | if (title === 'Dagens Media') { 257 | const xml = await axios.get(api); 258 | const resp = await parser.parseString(xml.data.value); 259 | data.sources.push({ 260 | title, 261 | domain, 262 | items: sortByDate(normalize({ title, data: resp.items })), 263 | }); 264 | return; 265 | } 266 | 267 | const resp = await parser.parseURL(api); 268 | data.sources.push({ 269 | title, 270 | domain, 271 | items: sortByDate(normalize({ title, data: resp.items })), 272 | }); 273 | return; 274 | } 275 | ); 276 | } catch (error) { 277 | console.log(error); 278 | } 279 | 280 | fs.writeFile( 281 | './public/data.json', 282 | JSON.stringify(data), 283 | function (err, result) { 284 | if (err) { 285 | console.log('error', err); 286 | } else { 287 | console.log('Done!', data); 288 | } 289 | } 290 | ); 291 | }; 292 | 293 | start(); 294 | --------------------------------------------------------------------------------