├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── actions │ ├── deploy │ │ └── action.yml │ └── init-node │ │ └── action.yml └── workflows │ ├── audit.yml │ ├── docs.yml │ ├── main.yml │ ├── preview-clean.yml │ ├── preview-close.yml │ ├── preview-deploy.yml │ ├── preview-prepare.yml │ ├── proxy.yml │ ├── server.yml │ ├── test-loaders.yml │ └── visual.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .remarkignore ├── .remarkrc ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── api ├── README.md ├── http │ ├── signin.ts │ ├── signout.ts │ ├── signup.ts │ └── utils.ts ├── index.ts ├── logux │ ├── password.ts │ └── utils.ts └── package.json ├── core ├── .c8rc.json ├── .gitignore ├── README.md ├── busy.ts ├── category.ts ├── client.ts ├── comfort.ts ├── current-page.ts ├── devtools.ts ├── download.ts ├── environment.ts ├── feed.ts ├── filter.ts ├── html.ts ├── i18n.ts ├── index.ts ├── lib │ ├── queue.ts │ └── stores.ts ├── loader │ ├── atom.ts │ ├── index.ts │ ├── json-feed.ts │ ├── rss.ts │ └── utils.ts ├── menu.ts ├── messages │ ├── common │ │ └── en.ts │ ├── export │ │ └── en.ts │ ├── fast │ │ └── en.ts │ ├── import │ │ └── en.ts │ ├── index.ts │ ├── navbar │ │ └── en.ts │ ├── organize │ │ └── en.ts │ ├── preview │ │ └── en.ts │ ├── refresh │ │ └── en.ts │ ├── settings │ │ └── en.ts │ ├── slow │ │ └── en.ts │ └── start │ │ └── en.ts ├── not-found.ts ├── opened-popups.ts ├── package.json ├── pages │ ├── add.ts │ ├── common.ts │ ├── export.ts │ ├── feeds-by-categories.ts │ ├── feeds.ts │ ├── home.ts │ ├── import.ts │ └── index.ts ├── pagination.ts ├── popups │ ├── common.ts │ ├── feed-url.ts │ ├── feed.ts │ ├── index.ts │ └── post.ts ├── post.ts ├── posts-list.ts ├── readers │ ├── common.ts │ ├── feed.ts │ ├── index.ts │ └── list.ts ├── refresh.ts ├── request.ts ├── router.ts ├── settings.ts └── test │ ├── busy.test.ts │ ├── comfort.test.ts │ ├── dom-parser.ts │ ├── download.test.ts │ ├── environment.ts │ ├── filter.test.ts │ ├── html.test.ts │ ├── loader │ ├── atom.test.ts │ ├── json-feed.test.ts │ ├── rss.test.ts │ └── utils.test.ts │ ├── menu.test.ts │ ├── not-found.test.ts │ ├── pages │ ├── add.test.ts │ ├── export.test.ts │ ├── feeds-by-categories.test.ts │ ├── feeds.test.ts │ ├── import.test.ts │ └── redirects.test.ts │ ├── popups │ ├── feed-url.test.ts │ ├── feed.test.ts │ └── post.test.ts │ ├── post.test.ts │ ├── production.test.ts │ ├── readers │ ├── feed.test.ts │ └── list.test.ts │ ├── refresh.test.ts │ ├── router.test.ts │ ├── settings.test.ts │ └── utils.ts ├── docs ├── CODE_OF_CONDUCT.md ├── SECURITY.md ├── new_page.md ├── onboarding.md └── pull_request_template.md ├── eslint.config.ts ├── extension ├── .env ├── .env.dev.example ├── .env.example ├── .gitignore ├── README.md ├── api.ts ├── background.ts ├── config.ts ├── manifest.config.ts ├── package.json ├── vite-env.d.ts └── vite.config.ts ├── loader-tests ├── README.md ├── check-opml.ts ├── dom-parser.ts ├── example.opml ├── feeds.yml ├── home.ts ├── package.json ├── test-loaders.ts ├── url.ts └── utils.ts ├── nano-staged.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── proxy ├── .c8rc.json ├── .dockerignore ├── .gitignore ├── .npmignore ├── Dockerfile ├── README.md ├── index.ts ├── package.json ├── scripts │ └── run-image.sh ├── server.ts └── test │ └── index.test.ts ├── scripts ├── check-focused-tests.ts ├── check-messages.ts ├── check-versions.ts ├── pin-docker.sh ├── prepare-google-cloud.sh ├── tsnode ├── update-env.ts └── utils.sh ├── server ├── .c8rc.json ├── .dockerignore ├── .gitignore ├── .npmignore ├── Dockerfile ├── README.md ├── db │ ├── index.ts │ ├── migrations │ │ ├── 0000_plpgsql.sql │ │ ├── 0001_low_eternity.sql │ │ ├── 0002_huge_zodiak.sql │ │ ├── 0003_lying_imperial_guard.sql │ │ ├── 0004_slippery_revanche.sql │ │ ├── 0005_faithful_punisher.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ └── _journal.json │ └── schema.ts ├── drizzle.config.ts ├── index.ts ├── lib │ ├── config.ts │ └── http.ts ├── modules │ ├── added.ts │ ├── assets.ts │ ├── auth.ts │ ├── passwords.ts │ ├── proxy.ts │ └── sync.ts ├── package.json ├── scripts │ └── run-image.sh └── test │ ├── assets.test.ts │ ├── auth.test.ts │ ├── config.test.ts │ ├── proxy.test.ts │ ├── sync.test.ts │ └── utils.ts ├── tsconfig.json ├── web-archive ├── .stylelintrc ├── pages │ ├── busy.svelte │ ├── fast.svelte │ ├── feeds │ │ ├── add.svelte │ │ ├── categories.svelte │ │ ├── edit.svelte │ │ ├── export.svelte │ │ ├── import.svelte │ │ ├── internal.svelte │ │ ├── list.svelte │ │ ├── opml.svelte │ │ └── posts.svelte │ ├── not-found.svelte │ ├── refresh.svelte │ ├── settings │ │ ├── about.svelte │ │ ├── download.svelte │ │ ├── interface.svelte │ │ └── profile.svelte │ ├── slow.svelte │ ├── start.svelte │ └── welcome.svelte ├── stories │ ├── assets │ │ ├── long_width_example.avif │ │ └── short_width_example.avif │ ├── environment.ts │ ├── pages │ │ ├── busy.stories.svelte │ │ ├── fast.stories.svelte │ │ ├── feeds │ │ │ ├── add.stories.svelte │ │ │ ├── categories.stories.svelte │ │ │ ├── export.stories.svelte │ │ │ └── import.stories.svelte │ │ ├── post-mocks.ts │ │ ├── refresh.stories.svelte │ │ ├── settings │ │ │ ├── download.stories.svelte │ │ │ └── interface.stories.svelte │ │ ├── slow.stories.svelte │ │ └── start.stories.svelte │ ├── section.svelte │ └── ui │ │ ├── button.stories.svelte │ │ ├── card-link.stories.svelte │ │ ├── card.stories.svelte │ │ ├── formatted-text.stories.svelte │ │ ├── navbar.stories.svelte │ │ ├── pagination-bar.stories.svelte │ │ ├── radio.stories.svelte │ │ ├── rich-translation.stories.svelte │ │ ├── select-field.stories.svelte │ │ └── text-field.stories.svelte └── ui │ ├── button.svelte │ ├── card-actions.svelte │ ├── card-link.svelte │ ├── card-links.svelte │ ├── card.svelte │ ├── formatted-text.svelte │ ├── hotkey.svelte │ ├── icon.svelte │ ├── navbar │ ├── category.svelte │ ├── fast.svelte │ ├── fireplace.svelte │ ├── index.svelte │ ├── item.svelte │ ├── other.svelte │ ├── progress.svelte │ └── slow.svelte │ ├── page-title.svelte │ ├── page.svelte │ ├── pagination-bar.svelte │ ├── paragraph.svelte │ ├── post-card.svelte │ ├── radio-field.svelte │ ├── rich-translation.svelte │ ├── row.svelte │ ├── select-field.svelte │ ├── text-field.svelte │ ├── two-steps-page.svelte │ └── under-construction.svelte └── web ├── .browserslistrc ├── .dockerignore ├── .gitignore ├── .size-limit.json ├── .storybook ├── main.ts ├── manager-head.html └── preview.ts ├── .stylelintrc ├── Dockerfile ├── README.md ├── index.html ├── main ├── browser.ts ├── colors.css ├── common.css ├── devtools.ts ├── environment.ts ├── global.d.ts ├── index.css ├── index.ts ├── loader.css ├── main.svelte └── reset.css ├── nginx.conf ├── package.json ├── pages └── busy.svelte ├── postcss.config.cts ├── postcss ├── html-keeper.cts ├── props-checker.ts ├── pseudo-classes.cts ├── svelte-nesting-css-fixer.cts └── theme-classes.cts ├── public ├── .well-known │ └── security.txt ├── 404.html ├── 500.html ├── apple-touch-icon.png ├── favicon.ico ├── icon-192.png ├── icon-512.png ├── icon-dev.svg ├── icon-staging.svg ├── icon.svg ├── manifest.webmanifest └── robots.txt ├── scripts ├── build-nginx-config.sh ├── check-css-props.ts ├── check-names.ts ├── export-routes.ts ├── generate-csp.ts └── run-image.sh ├── stores ├── locale.ts └── router.ts ├── stories ├── environment.ts ├── pages │ └── busy.stories.svelte ├── scene.svelte ├── section.svelte └── ui │ └── loader.stories.svelte ├── svelte.config.js ├── test └── theme-classes.test.ts ├── ui ├── icon.svelte └── loader.svelte └── vite.config.ts /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "dockerfile": "Dockerfile" 4 | }, 5 | "runArgs": [ 6 | "--ulimit=host", 7 | "-p", 8 | "31337:31337", 9 | "-p", 10 | "5173:5173", 11 | "-p", 12 | "6006:6006", 13 | "-p", 14 | "2222:22" 15 | ], 16 | "customizations": { 17 | "vscode": { 18 | "extensions": [ 19 | "connor4312.nodejs-testing", 20 | "dbaeumer.vscode-eslint", 21 | "esbenp.prettier-vscode", 22 | "stylelint.vscode-stylelint", 23 | "svelte.svelte-vscode", 24 | "webben.browserslist", 25 | "yoavbls.pretty-ts-errors", 26 | "streetsidesoftware.code-spell-checker", 27 | "yzhang.markdown-all-in-one", 28 | "davidlday.languagetool-linter", 29 | "felixfbecker.postgresql-syntax", 30 | "formulahendry.auto-rename-tag", 31 | "walnuts1018.oklch-vscode" 32 | ] 33 | }, 34 | "nodejs-testing.extensions": [ 35 | { 36 | "extensions": ["js"], 37 | "parameters": [] 38 | }, 39 | { 40 | "extensions": ["ts"], 41 | "parameters": ["--experimental-strip-types"] 42 | } 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Config to synchronize text editor settings. 2 | # For VS Code, we also duplicate them in .vscode/settings.json. 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/actions/init-node/action.yml: -------------------------------------------------------------------------------- 1 | name: Initialize Node.js, pnpm, and dependencies 2 | inputs: 3 | cache: 4 | required: false 5 | default: true 6 | install: 7 | required: false 8 | default: '' 9 | runs: 10 | using: composite 11 | steps: 12 | - name: Install pnpm 13 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 14 | - name: Install Node.js 15 | uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 16 | with: 17 | node-version-file: .node-version 18 | cache: ${{ inputs.cache && 'pnpm' || '' }} 19 | - name: Install pnpm dependencies 20 | if: ${{ inputs.install != 'false' }} 21 | shell: bash 22 | run: pnpm install ${{ inputs.install }} 23 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Vulnerability Audit 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - '.github/workflows/audit.yml' 8 | - '.github/actions/init-node/action.yml' 9 | pull_request: 10 | paths: 11 | - '.github/workflows/audit.yml' 12 | - '.github/actions/init-node/action.yml' 13 | schedule: 14 | - cron: '00 23 * * *' # Runs at midnight UTC every day 15 | jobs: 16 | audit: 17 | name: Audit 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout the repository 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | - name: Initialize Node.js 23 | uses: ./.github/actions/init-node 24 | with: 25 | install: false 26 | - name: Check Node.js version for vulnerabilities 27 | run: pnpm dlx is-my-node-vulnerable 28 | - name: Check dependencies for vulnerabilities 29 | run: node --run test:audit 30 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Markdown Linter 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - '**/*.md' 8 | - '.remarkrc' 9 | - '.remarkignore' 10 | - 'pnpm-lock.yaml' 11 | - '.github/workflows/docs.yml' 12 | - '.github/actions/init-node/action.yml' 13 | pull_request: 14 | paths: 15 | - '**/*.md' 16 | - '.remarkrc' 17 | - '.remarkignore' 18 | - 'pnpm-lock.yaml' 19 | - '.github/workflows/docs.yml' 20 | - '.github/actions/init-node/action.yml' 21 | schedule: 22 | - cron: '00 23 * * 1' # Runs at midnight every Monday 23 | permissions: 24 | contents: read 25 | jobs: 26 | test: 27 | name: Check Docs 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout the repository 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | - name: Initialize Node.js 33 | uses: ./.github/actions/init-node 34 | with: 35 | install: -F . 36 | - name: Check docs 37 | run: node --run test:markdown 38 | -------------------------------------------------------------------------------- /.github/workflows/preview-close.yml: -------------------------------------------------------------------------------- 1 | name: Close Preview 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | paths-ignore: 7 | - '**/*.md' 8 | - 'scripts/**' 9 | - '.vscode/**' 10 | - '.husky/**' 11 | - '.github/**' 12 | - 'core/test/**' 13 | - 'proxy/test/**' 14 | - 'server/test/**' 15 | - 'loader-tests/**' 16 | - '.devcontainer/**' 17 | - 'extension/**' 18 | jobs: 19 | prepare: 20 | name: Prepare 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Save PR number 24 | run: echo "${{ github.event.pull_request.number }}" > ./preview-id 25 | - name: Save data for another workflow 26 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 27 | with: 28 | name: preview-id 29 | retention-days: 1 30 | path: | 31 | ./preview-id 32 | -------------------------------------------------------------------------------- /.github/workflows/preview-prepare.yml: -------------------------------------------------------------------------------- 1 | name: Start Preview Deployment 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - '**/*.md' 6 | - 'scripts/**' 7 | - '.vscode/**' 8 | - '.husky/**' 9 | - '.github/**' 10 | - 'core/test/**' 11 | - 'proxy/test/**' 12 | - 'server/test/**' 13 | - 'loader-tests/**' 14 | - '.devcontainer/**' 15 | - 'extension/**' 16 | permissions: 17 | contents: read 18 | jobs: 19 | prepare: 20 | name: Prepare 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout the repository 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | - name: Initialize Node.js 26 | uses: ./.github/actions/init-node 27 | - name: Build assets 28 | run: cd web && node --run build 29 | env: 30 | STAGING: 1 31 | - name: Build server 32 | run: cd server && node --run build 33 | - name: Save PR number 34 | run: echo "${{ github.event.pull_request.number }}" > ./preview-id 35 | - name: Create archive to keep symlinks 36 | run: tar -cf server.tar server/dist/ 37 | - name: Save server for deploy 38 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 39 | with: 40 | name: preview-server 41 | retention-days: 1 42 | include-hidden-files: true 43 | path: | 44 | server/web/ 45 | server/Dockerfile 46 | server/.dockerignore 47 | preview-id 48 | server.tar 49 | -------------------------------------------------------------------------------- /.github/workflows/test-loaders.yml: -------------------------------------------------------------------------------- 1 | name: Loaders Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'loader-tests/test-loaders.ts' 8 | - 'loader-tests/utils.ts' 9 | - 'loader-tests/feeds.yml' 10 | - 'core/loader/*.ts' 11 | - 'pnpm-lock.yaml' 12 | - '.github/workflows/test-loaders.yml' 13 | - '.github/actions/init-node/action.yml' 14 | pull_request: 15 | paths: 16 | - 'loader-tests/test-loaders.ts' 17 | - 'loader-tests/utils.ts' 18 | - 'loader-tests/feeds.yml' 19 | - 'core/loader/*.ts' 20 | - 'pnpm-lock.yaml' 21 | - '.github/workflows/test-loaders.yml' 22 | - '.github/actions/init-node/action.yml' 23 | schedule: 24 | - cron: '00 23 * * *' # Runs at midnight UTC every day 25 | jobs: 26 | test: 27 | name: Loader Test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout the repository 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | - name: Initialize Node.js 33 | uses: ./.github/actions/init-node 34 | - name: Run tests 35 | run: node --run test-loaders 36 | -------------------------------------------------------------------------------- /.github/workflows/visual.yml: -------------------------------------------------------------------------------- 1 | name: Visual Test 2 | on: 3 | schedule: 4 | - cron: '00 23 * * *' # Runs at midnight UTC every day 5 | permissions: 6 | contents: read 7 | jobs: 8 | chromatic: 9 | name: Chromatic 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout the repository 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | with: 15 | fetch-depth: 0 16 | - name: Check for today commits 17 | id: today 18 | run: | 19 | today=$(date -u +"%Y-%m-%d") 20 | last_commit_date=$(git log -1 --format=%cd --date=short) 21 | if [ "$today" == "$last_commit_date" ]; then 22 | echo "has_commits=true" >> "$GITHUB_OUTPUT" 23 | else 24 | echo "has_commits=false" >> "$GITHUB_OUTPUT" 25 | echo "No commits today. Stopping the workflow." 26 | fi 27 | - name: Initialize Node.js 28 | if: steps.today.outputs.has_commits == 'true' 29 | uses: ./.github/actions/init-node 30 | - name: Publish to Chromatic 31 | if: steps.today.outputs.has_commits == 'true' 32 | uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # v1 33 | with: 34 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 35 | buildScriptName: build:visual 36 | workingDir: web/ 37 | exitZeroOnChanges: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/nano-staged --config ./nano-staged.json 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | inject-workspace-packages=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/ 3 | web/storybook-static/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-svelte"], 3 | "arrowParens": "avoid", 4 | "jsxSingleQuote": false, 5 | "quoteProps": "consistent", 6 | "semi": false, 7 | "singleQuote": true, 8 | "trailingComma": "none", 9 | "overrides": [ 10 | { 11 | "files": "*.svelte", 12 | "options": { 13 | "parser": "svelte" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | LICENSE.md 2 | */dist/ 3 | -------------------------------------------------------------------------------- /.remarkrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "remark-lint-no-dead-urls", 5 | { 6 | "skipLocalhost": true, 7 | "skipUrlPatterns": [ 8 | "^https://t.me/", 9 | "^https://github.com/settings/", 10 | "^https://discord.gg/", 11 | "^https://browsersl.ist/#q=", 12 | "^https://code.visualstudio.com/", 13 | "^mailto:", 14 | "^https://dev.slowreader.app/" 15 | ], 16 | "deadOrAliveOptions": { 17 | "maxRetries": 3 18 | } 19 | } 20 | ], 21 | "remark-validate-links", 22 | "remark-lint-heading-capitalization", 23 | "remark-lint-code-block-split-list", 24 | "remark-lint-fenced-code-flag", 25 | "remark-lint-first-heading-level", 26 | "remark-lint-heading-increment", 27 | "remark-lint-no-shell-dollars", 28 | [ 29 | "remark-lint-match-punctuation", 30 | [ 31 | "“”", 32 | "()" 33 | ] 34 | ], 35 | "remark-lint-check-toc", 36 | "remark-lint-smarty-pants-typography" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["ms-vscode-remote.remote-containers"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [80], 3 | "editor.tabSize": 2, 4 | "editor.detectIndentation": false, 5 | "editor.trimAutoWhitespace": true, 6 | "editor.formatOnSave": true, 7 | "files.insertFinalNewline": true, 8 | "files.trimTrailingWhitespace": true 9 | } 10 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Slow Reader API 2 | 3 | Types and constants shared between clients and server. 4 | 5 | ## Subprotocol Version 6 | 7 | “Subprotocol” is a client-server API both Logux and HTTP. The term has “sub” because it is inside [Logux sync protocol](https://logux.org/protocols/ws/spec/). 8 | 9 | Every time we have client-server API changes, we need to update subprotocol version to be able to handle old clients. For instance, mobile app could take months for update. 10 | 11 | Server can ask users to update client app if version is too old. Or we can add different handler for old clients. 12 | 13 | ## Logux Actions 14 | 15 | We define [Logux actions](https://logux.org/guide/concepts/action/) types and action creators here to be sure, that client and server have the same types. 16 | 17 | ## HTTP API 18 | 19 | For every [HTTP endpoint](./http/) we define here: 20 | 21 | - URL params and HTTP body types. 22 | - `fetch()` wrapper to use in client checking all types. 23 | - Endpoint definition to use in [server helper](../server/lib/http.ts). 24 | 25 | It allows us to verify that client and server API is compatible. 26 | -------------------------------------------------------------------------------- /api/http/signin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Endpoint, 3 | fetchJSON, 4 | type FetchOptions, 5 | hasStringKey 6 | } from './utils.ts' 7 | 8 | export interface SignInRequest { 9 | password: string 10 | userId: string 11 | } 12 | 13 | export interface SignInResponse { 14 | session: string 15 | } 16 | 17 | const METHOD = 'POST' 18 | 19 | export async function signIn( 20 | params: SignInRequest, 21 | opts?: FetchOptions 22 | ): Promise { 23 | return fetchJSON(METHOD, '/session', params, opts) 24 | } 25 | 26 | export const signInEndpoint: Endpoint = { 27 | checkBody(body) { 28 | if (hasStringKey(body, 'userId') && hasStringKey(body, 'password')) { 29 | return body 30 | } 31 | return false 32 | }, 33 | method: METHOD, 34 | parseUrl(url) { 35 | if (url === '/session') { 36 | return {} 37 | } else { 38 | return false 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /api/http/signout.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Endpoint, 3 | fetchJSON, 4 | type FetchOptions, 5 | hasStringKey, 6 | isEmptyObject 7 | } from './utils.ts' 8 | 9 | export type SignOutRequest = { 10 | session?: string 11 | } 12 | 13 | export type SignOutResponse = Record 14 | 15 | const METHOD = 'DELETE' 16 | 17 | export async function signOut( 18 | params: SignOutRequest, 19 | opts?: FetchOptions 20 | ): Promise { 21 | return fetchJSON(METHOD, '/session', params, opts) 22 | } 23 | 24 | export const signOutEndpoint: Endpoint = { 25 | checkBody(body) { 26 | if (isEmptyObject(body) || hasStringKey(body, 'session')) { 27 | return body 28 | } 29 | return false 30 | }, 31 | method: METHOD, 32 | parseUrl(url) { 33 | if (url === '/session') { 34 | return {} 35 | } else { 36 | return false 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/http/signup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Endpoint, 3 | fetchJSON, 4 | type FetchOptions, 5 | hasStringKey 6 | } from './utils.ts' 7 | 8 | export interface SignUpRequest { 9 | id: string 10 | password: string 11 | } 12 | 13 | export interface SignUpResponse { 14 | id: string 15 | session: string 16 | } 17 | 18 | const METHOD = 'PUT' 19 | 20 | export async function signUp( 21 | params: SignUpRequest, 22 | opts?: FetchOptions 23 | ): Promise { 24 | return fetchJSON(METHOD, `/users/${params.id}`, params, opts) 25 | } 26 | 27 | const URL_PATTERN = /^\/users\/([^/]+)$/ 28 | 29 | export const signUpEndpoint: Endpoint< 30 | SignUpResponse, 31 | SignUpRequest, 32 | { id: string } 33 | > = { 34 | checkBody(body, urlParams) { 35 | if (hasStringKey(body, 'id') && hasStringKey(body, 'password')) { 36 | if (body.id === urlParams.id) { 37 | return body 38 | } 39 | } 40 | return false 41 | }, 42 | method: METHOD, 43 | parseUrl(url) { 44 | let match = url.match(URL_PATTERN) 45 | if (match) { 46 | return { id: match[1]! } 47 | } else { 48 | return false 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /api/http/utils.ts: -------------------------------------------------------------------------------- 1 | export function isObject(body: unknown): body is object { 2 | return typeof body === 'object' && body !== null 3 | } 4 | 5 | export function isEmptyObject(body: unknown): body is Record { 6 | return isObject(body) && Object.keys(body).length === 0 7 | } 8 | 9 | export function hasKey( 10 | body: unknown, 11 | key: Key 12 | ): body is Record { 13 | return isObject(body) && key in body 14 | } 15 | 16 | export function hasStringKey( 17 | body: unknown, 18 | key: Key 19 | ): body is Record { 20 | return hasKey(body, key) && typeof body[key] === 'string' 21 | } 22 | 23 | export interface FetchOptions { 24 | fetch?: typeof fetch 25 | host?: string 26 | response?: (res: Response) => void 27 | } 28 | 29 | export async function fetchJSON( 30 | method: string, 31 | url: string, 32 | body: object, 33 | opts: FetchOptions | undefined = {} 34 | ): Promise { 35 | let host = opts.host ?? '' 36 | let request = opts.fetch ?? fetch 37 | let response = await request(host + url, { 38 | body: JSON.stringify(body), 39 | headers: { 40 | 'Content-Type': 'application/json' 41 | }, 42 | method 43 | }) 44 | if (!response.ok) { 45 | throw new Error(await response.text()) 46 | } 47 | if (opts.response) opts.response(response) 48 | return response.json() as Response 49 | } 50 | 51 | export interface Endpoint< 52 | // Need to put it inside type to pass all types to server in a single variable 53 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 54 | Response, 55 | Request, 56 | UrlParams extends Record = Record 57 | > { 58 | checkBody(body: unknown, urlParams: UrlParams): false | Request 59 | method: string 60 | parseUrl(url: string): false | UrlParams 61 | } 62 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Client’s protocol version 3 | */ 4 | export const SUBPROTOCOL = '0.0.0' 5 | 6 | export * from './http/signin.ts' 7 | export * from './http/signout.ts' 8 | export * from './http/signup.ts' 9 | export type { Endpoint } from './http/utils.ts' 10 | export * from './logux/password.ts' 11 | -------------------------------------------------------------------------------- /api/logux/password.ts: -------------------------------------------------------------------------------- 1 | import { defineAction } from './utils.ts' 2 | 3 | export interface SetPasswordAction { 4 | password: string 5 | type: 'passwords/set' 6 | userId?: string 7 | } 8 | 9 | export const setPassword = defineAction('passwords/set') 10 | 11 | export interface DeletePasswordAction { 12 | type: 'passwords/delete' 13 | } 14 | 15 | export const deletePassword = 16 | defineAction('passwords/delete') 17 | -------------------------------------------------------------------------------- /api/logux/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ActionCreator } from '@logux/actions' 2 | import type { Action } from '@logux/core' 3 | 4 | export function defineAction( 5 | type: CreatedAction['type'] 6 | ): ActionCreator]> { 7 | function creator(fields: Omit): CreatedAction { 8 | return { type, ...fields } as CreatedAction 9 | } 10 | creator.type = type 11 | creator.match = (action: Action): action is CreatedAction => { 12 | return action.type === type 13 | } 14 | return creator 15 | } 16 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slowreader/api", 3 | "private": true, 4 | "type": "module", 5 | "engines": { 6 | "node": "^22.16.0", 7 | "pnpm": "^10.0.0" 8 | }, 9 | "exports": { 10 | ".": "./index.ts", 11 | "./package.json": "./package.json" 12 | }, 13 | "devDependencies": { 14 | "@logux/core": "0.9.0" 15 | }, 16 | "dependencies": { 17 | "@logux/actions": "github:logux/actions#next" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /core/.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "check-coverage": true, 4 | "clean": true, 5 | "exclude": ["**/*.test.*", "test/*", "coverage/", "devtools.ts"], 6 | "lines": 100, 7 | "reporter": ["text-summary", "text", "lcov"], 8 | "skip-full": true 9 | } 10 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | -------------------------------------------------------------------------------- /core/busy.ts: -------------------------------------------------------------------------------- 1 | import { atom, computed } from 'nanostores' 2 | 3 | const $tasks = atom(0) 4 | 5 | export async function busyDuring(cb: () => Promise): Promise { 6 | $tasks.set($tasks.get() + 1) 7 | try { 8 | await cb() 9 | } finally { 10 | $tasks.set($tasks.get() - 1) 11 | } 12 | } 13 | 14 | export const busy = computed($tasks, tasks => tasks > 0) 15 | -------------------------------------------------------------------------------- /core/client.ts: -------------------------------------------------------------------------------- 1 | import { type ClientOptions, CrossTabClient } from '@logux/client' 2 | import { TestPair, TestTime } from '@logux/core' 3 | import { SUBPROTOCOL } from '@slowreader/api' 4 | import { atom, effect } from 'nanostores' 5 | 6 | import { onEnvironment } from './environment.ts' 7 | import { userId } from './settings.ts' 8 | 9 | let testTime: TestTime | undefined 10 | 11 | export function enableTestTime(): TestTime { 12 | testTime = new TestTime() 13 | return testTime 14 | } 15 | 16 | function getServer(): ClientOptions['server'] { 17 | return testTime ? new TestPair().left : 'ws://localhost:31338/' 18 | } 19 | 20 | let prevClient: CrossTabClient | undefined 21 | export const client = atom() 22 | 23 | onEnvironment(({ logStoreCreator }) => { 24 | let unbindUser = effect(userId, user => { 25 | prevClient?.destroy() 26 | 27 | if (user) { 28 | let logux = new CrossTabClient({ 29 | prefix: 'slowreader', 30 | server: getServer(), 31 | store: logStoreCreator(), 32 | subprotocol: SUBPROTOCOL, 33 | time: testTime, 34 | userId: user 35 | }) 36 | logux.start(false) 37 | prevClient = logux 38 | client.set(logux) 39 | } else { 40 | client.set(undefined) 41 | } 42 | }) 43 | return () => { 44 | unbindUser() 45 | prevClient?.destroy() 46 | } 47 | }) 48 | 49 | export function getClient(): CrossTabClient { 50 | let logux = client.get() 51 | if (!logux) { 52 | /* c8 ignore next 2 */ 53 | throw new Error('No Slow Reader client') 54 | } 55 | return logux 56 | } 57 | -------------------------------------------------------------------------------- /core/comfort.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'nanostores' 2 | 3 | import { router } from './router.ts' 4 | 5 | export const comfortMode = computed(router, route => route.route !== 'fast') 6 | -------------------------------------------------------------------------------- /core/current-page.ts: -------------------------------------------------------------------------------- 1 | import { computed, type ReadableAtom, type WritableStore } from 'nanostores' 2 | 3 | import { getEnvironment } from './environment.ts' 4 | import { type Page, pages } from './pages/index.ts' 5 | import { type Route, router } from './router.ts' 6 | 7 | function eachParam( 8 | page: Page, 9 | route: SomeRoute, 10 | iterator: ( 11 | store: WritableStore, 12 | value: SomeRoute['params'][Param] 13 | ) => void 14 | ): void { 15 | let params = route.params as SomeRoute['params'] 16 | for (let i in page.params) { 17 | let name = i as keyof SomeRoute['params'] 18 | let value = params[name] 19 | // @ts-expect-error Too complex types for TS 20 | let store = page.params[name] as WritableStore 21 | iterator(store, value) 22 | } 23 | } 24 | 25 | function getPageParams( 26 | page: Page 27 | ): SomeRoute['params'] { 28 | let params = {} as SomeRoute['params'] 29 | for (let i in page.params) { 30 | let name = i as keyof SomeRoute['params'] 31 | // @ts-expect-error Too complex types for TS 32 | // eslint-disable-next-line 33 | params[name] = page.params[name].get() 34 | } 35 | return params 36 | } 37 | 38 | let prevPage: Page | undefined 39 | let unbinds: (() => void)[] = [] 40 | 41 | export const currentPage: ReadableAtom = computed(router, route => { 42 | let page = pages[route.route]() as Page 43 | if (page !== prevPage) { 44 | if (prevPage) { 45 | for (let unbind of unbinds) unbind() 46 | prevPage.destroy() 47 | } 48 | prevPage = page 49 | 50 | eachParam(page, route, store => { 51 | unbinds.push( 52 | store.listen(() => { 53 | let currentRoute = router.get() 54 | if (currentRoute.route === page.route) { 55 | getEnvironment().openRoute({ 56 | ...route, 57 | params: getPageParams(page) 58 | } as Route) 59 | } 60 | }) 61 | ) 62 | }) 63 | } 64 | 65 | eachParam(page, route, (store, value) => { 66 | if (store.get() !== value) { 67 | store.set(value) 68 | } 69 | }) 70 | 71 | return page 72 | }) 73 | -------------------------------------------------------------------------------- /core/devtools.ts: -------------------------------------------------------------------------------- 1 | import { loadValue } from '@logux/client' 2 | 3 | import { busyDuring } from './busy.ts' 4 | import { createDownloadTask } from './download.ts' 5 | import { getFeedLatestPosts, getFeeds } from './feed.ts' 6 | import { loadFilters } from './filter.ts' 7 | import { addPost, deletePost, getPosts, processOriginPost } from './post.ts' 8 | 9 | export async function fillFeedsWithPosts(): Promise { 10 | await busyDuring(async () => { 11 | let task = createDownloadTask() 12 | let feeds = await loadValue(getFeeds()) 13 | await Promise.all( 14 | feeds.list.map(async feed => { 15 | let old = await loadValue(getPosts({ feedId: feed.id })) 16 | for (let post of old.list) { 17 | await deletePost(post.id) 18 | } 19 | let posts = await getFeedLatestPosts(feed, task).next() 20 | let filters = await loadFilters({ feedId: feed.id }) 21 | for (let origin of posts) { 22 | let reading = filters(origin) ?? feed.reading 23 | if (reading !== 'delete') { 24 | await addPost(processOriginPost(origin, feed.id, reading)) 25 | } 26 | } 27 | }) 28 | ) 29 | }) 30 | } 31 | 32 | let warnings = false 33 | 34 | export function enableWarnings(mode = true): void { 35 | warnings = mode 36 | } 37 | 38 | export function warning(...args: unknown[]): void { 39 | if (warnings) { 40 | // eslint-disable-next-line no-console 41 | console.warn(...args) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /core/html.ts: -------------------------------------------------------------------------------- 1 | import createDOMPurify from 'dompurify' 2 | 3 | const ALLOWED_TAGS = [ 4 | 'a', 5 | 'abbr', 6 | 'address', 7 | 'audio', 8 | 'b', 9 | 'bdi', 10 | 'bdo', 11 | 'blockquote', 12 | 'br', 13 | 'caption', 14 | 'center', 15 | 'cite', 16 | 'code', 17 | 'col', 18 | 'colgroup', 19 | 'data', 20 | 'datalist', 21 | 'dd', 22 | 'del', 23 | 'details', 24 | 'dfn', 25 | 'dir', 26 | 'div', 27 | 'dl', 28 | 'dt', 29 | 'em', 30 | 'figcaption', 31 | 'figure', 32 | 'h1', 33 | 'h2', 34 | 'h3', 35 | 'h4', 36 | 'h5', 37 | 'h6', 38 | 'hr', 39 | 'i', 40 | 'image', 41 | 'img', 42 | 'ins', 43 | 'kbd', 44 | 'li', 45 | 'mark', 46 | 'ol', 47 | 'p', 48 | 'pre', 49 | 'q', 50 | 'rp', 51 | 'rt', 52 | 'ruby', 53 | 's', 54 | 'samp', 55 | 'small', 56 | 'source', 57 | 'span', 58 | 'strong', 59 | 'sub', 60 | 'summary', 61 | 'sup', 62 | 'table', 63 | 'tbody', 64 | 'td', 65 | 'tfoot', 66 | 'th', 67 | 'thead', 68 | 'tr', 69 | 'ul', 70 | 'video' 71 | ] 72 | 73 | let DOMPurify: ReturnType | undefined 74 | 75 | export function sanitizeDOM(html: string): Node { 76 | // @ts-expect-error Window types is hard 77 | if (!DOMPurify) DOMPurify = createDOMPurify(window) 78 | return DOMPurify.sanitize(html, { 79 | ALLOWED_TAGS, 80 | RETURN_DOM: true 81 | }) 82 | } 83 | 84 | export function parseRichTranslation(text: string): string { 85 | // @ts-expect-error Window types is hard 86 | if (!DOMPurify) DOMPurify = createDOMPurify(window) 87 | let html = DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }) 88 | .replace(/^[*-][ .](.*)/gm, '
  • $1
') 89 | .replace(/<\/ul>\n
    /g, '\n') 90 | if (html.includes('\n\n')) { 91 | html = `

    ${html.replace(/\n\n/g, '

    ')}

    ` 92 | } 93 | return html 94 | } 95 | 96 | export function parseLink(text: string, url: string): string { 97 | return text.replace(/\[(.*?)\]/gm, `$1`) 98 | } 99 | -------------------------------------------------------------------------------- /core/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n, type TranslationLoader } from '@nanostores/i18n' 2 | import { atom } from 'nanostores' 3 | 4 | import { onEnvironment } from './environment.ts' 5 | 6 | let $locale = atom('en') 7 | 8 | let loader: TranslationLoader 9 | 10 | /* c8 ignore start */ 11 | // TODO: Until we will have real translations 12 | export const i18n = createI18n($locale, { 13 | get(...args) { 14 | return loader(...args) 15 | } 16 | }) 17 | 18 | onEnvironment(({ locale, translationLoader }) => { 19 | loader = translationLoader 20 | return locale.listen(value => { 21 | $locale.set(value) 22 | }) 23 | }) 24 | /* c8 ignore stop */ 25 | -------------------------------------------------------------------------------- /core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './busy.ts' 2 | export * from './category.ts' 3 | export * from './client.ts' 4 | export * from './comfort.ts' 5 | export * from './current-page.ts' 6 | export * from './devtools.ts' 7 | export * from './download.ts' 8 | export * from './environment.ts' 9 | export * from './feed.ts' 10 | export * from './filter.ts' 11 | export * from './filter.ts' 12 | export * from './html.ts' 13 | export * from './i18n.ts' 14 | export { waitLoading } from './lib/stores.ts' 15 | export * from './loader/index.ts' 16 | export * from './menu.ts' 17 | export * from './messages/index.ts' 18 | export * from './not-found.ts' 19 | export * from './opened-popups.ts' 20 | export * from './pages/index.ts' 21 | export * from './pagination.ts' 22 | export * from './popups/index.ts' 23 | export * from './post.ts' 24 | export * from './posts-list.ts' 25 | export * from './readers/index.ts' 26 | export * from './refresh.ts' 27 | export * from './request.ts' 28 | export * from './router.ts' 29 | export * from './settings.ts' 30 | -------------------------------------------------------------------------------- /core/lib/queue.ts: -------------------------------------------------------------------------------- 1 | type TaskTypes = object 2 | 3 | type Task = { 4 | [Key in keyof Types]: { 5 | payload: Types[Key] 6 | type: Key 7 | } 8 | }[keyof Types] 9 | 10 | type QueueProcessor = { 11 | [Key in keyof Types]: ( 12 | payload: Types[Key], 13 | tasks: Task[] 14 | ) => Promise 15 | } 16 | 17 | export interface Queue { 18 | start(workers: number, processors: QueueProcessor): Promise 19 | stop(): void 20 | tasks: Task[] 21 | } 22 | 23 | export function createQueue( 24 | tasks: Task[] = [] 25 | ): Queue { 26 | let stopped = false 27 | return { 28 | async start(workers, processors) { 29 | async function worker(): Promise { 30 | if (stopped) return 31 | let task = tasks.shift() 32 | if (!task) return 33 | await processors[task.type](task.payload, tasks) 34 | await worker() 35 | } 36 | 37 | let promises: Promise[] = [] 38 | for (let i = 0; i < workers; i++) { 39 | promises.push(worker()) 40 | } 41 | await Promise.all(promises) 42 | }, 43 | stop() { 44 | stopped = true 45 | }, 46 | tasks 47 | } 48 | } 49 | 50 | export async function retryOnError( 51 | cb: () => Promise, 52 | onFirstError: () => void, 53 | attempts = 3 54 | ): Promise<'abort' | 'error' | Result> { 55 | let result: Result | undefined 56 | try { 57 | result = await cb() 58 | return result 59 | } catch (e) { 60 | if (e instanceof Error) { 61 | if (e.name === 'AbortError') { 62 | return 'abort' 63 | } else { 64 | attempts -= 1 65 | if (attempts === 0) { 66 | return 'error' 67 | } else { 68 | onFirstError() 69 | return retryOnError(cb, () => {}, attempts) 70 | } 71 | } 72 | } 73 | /* c8 ignore next 2 */ 74 | throw e 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /core/loader/index.ts: -------------------------------------------------------------------------------- 1 | import type { DownloadTask, TextResponse } from '../download.ts' 2 | import type { PostsList } from '../posts-list.ts' 3 | import { atom } from './atom.ts' 4 | import { jsonFeed } from './json-feed.ts' 5 | import { rss } from './rss.ts' 6 | 7 | export type Loader = { 8 | getMineLinksFromText(response: TextResponse): string[] 9 | getPosts(task: DownloadTask, url: string, text?: TextResponse): PostsList 10 | getSuggestedLinksFromText(response: TextResponse): string[] 11 | isMineText(response: TextResponse): false | string 12 | isMineUrl(url: URL): false | string | undefined 13 | } 14 | 15 | export const loaders = { 16 | atom, 17 | jsonFeed, 18 | rss 19 | } satisfies { 20 | [Name in string]: Loader 21 | } 22 | 23 | export type LoaderName = keyof typeof loaders 24 | 25 | export interface FeedLoader { 26 | loader: Loader 27 | name: LoaderName 28 | title: string 29 | url: string 30 | } 31 | 32 | export function getLoaderForText(response: TextResponse): false | FeedLoader { 33 | let names = Object.keys(loaders) as LoaderName[] 34 | let parsed = new URL(response.url) 35 | for (let name of names) { 36 | if (loaders[name].isMineUrl(parsed) !== false) { 37 | let title = loaders[name].isMineText(response) 38 | if (title !== false) { 39 | return { 40 | loader: loaders[name], 41 | name, 42 | title: title.trim(), 43 | url: response.url 44 | } 45 | } 46 | } 47 | } 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /core/messages/common/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const commonMessages = i18n('common', { 4 | brokenCategory: 'Broken category', 5 | empty: 'The value is required', 6 | generalCategory: 'General', 7 | loading: 'Loading', 8 | noUrl: 'Doesn’t look like web page address', 9 | openPost: 'Open post', 10 | showNextPage: 'Show next page' 11 | }) 12 | -------------------------------------------------------------------------------- /core/messages/export/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const exportMessages = i18n('export', { 4 | allFeeds: 'All feeds', 5 | allPosts: 'All posts', 6 | chooseTitle: 'Choose format', 7 | exportFeeds: 'Export feeds', 8 | exportPosts: 'Export posts', 9 | exportTitle: 'Export', 10 | noPosts: 'No posts', 11 | selectFeeds: 'Select feeds', 12 | submitInternal: 'Export to internal', 13 | submitOPML: 'Export to OPML', 14 | type: 'Feed type' 15 | }) 16 | -------------------------------------------------------------------------------- /core/messages/fast/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const fastMessages = i18n('fast', { 4 | noPosts: 'No posts', 5 | pageTitle: 'Fast reading', 6 | readLast: 'Mark read', 7 | readNext: 'Mark read and load next', 8 | showNext: 'Show next' 9 | }) 10 | -------------------------------------------------------------------------------- /core/messages/import/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const importMessages = i18n('import', { 4 | allFeeds: 'All feeds', 5 | failedParseJSONError: 'Failed to parse JSON or invalid structure', 6 | formatError: 'Unsupported file format', 7 | importTitle: 'Import', 8 | invalidJSONError: 'Invalid JSON structure', 9 | loadError: 'Failed to load the following feeds', 10 | loadProccess: 'Importing', 11 | OPMLError: 'File is not in OPML format', 12 | selectFeeds: 'Select feeds', 13 | type: 'import type' 14 | }) 15 | -------------------------------------------------------------------------------- /core/messages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common/en.ts' 2 | export * from './export/en.ts' 3 | export * from './fast/en.ts' 4 | export * from './import/en.ts' 5 | export * from './navbar/en.ts' 6 | export * from './organize/en.ts' 7 | export * from './preview/en.ts' 8 | export * from './refresh/en.ts' 9 | export * from './settings/en.ts' 10 | export * from './slow/en.ts' 11 | export * from './start/en.ts' 12 | -------------------------------------------------------------------------------- /core/messages/navbar/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const navbarMessages = i18n('navbar', { 4 | about: 'About the App', 5 | add: 'Add feed', 6 | back: 'Back', 7 | download: 'Feed Loading', 8 | export: 'Export', 9 | fast: 'Fun', 10 | feeds: 'Manage Feeds', 11 | feedsByCategory: 'By Category', 12 | import: 'Import', 13 | interface: 'User Interface', 14 | menu: 'Feeds & Settings', 15 | postRefreshing: 'Checking feeds for new posts', 16 | profile: 'Profile', 17 | refresh: 'Check for new posts', 18 | refreshing: 'Post updating', 19 | settings: 'Settings', 20 | slow: 'Useful' 21 | }) 22 | -------------------------------------------------------------------------------- /core/messages/organize/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const organizeMessages = i18n('organize', { 4 | addCategory: 'Add category…', 5 | addFilter: 'Add filter', 6 | byCategoryTitle: 'Feeds by Category', 7 | category: 'Category', 8 | categoryName: 'Category name', 9 | deleteCategory: 'Delete', 10 | deleteCategoryConform: 11 | 'All feeds in this category will be moved to General. Are you sure?', 12 | deleteConform: 13 | 'We will delete feed statistics and posts. ' + 14 | 'It cannot be undone. Are you sure?', 15 | deleteFeed: 'Delete', 16 | deleteFilter: 'Delete filter', 17 | fast: 'Fast-food reading (fun, but not useful)', 18 | filterAction: 'Filter action', 19 | filterActionDelete: 'Delete post', 20 | filterActionFast: 'Move to fast', 21 | filterActionSlow: 'Move to slow', 22 | filterQuery: 'Filter query', 23 | invalidFilter: 'Filter query is invalid', 24 | moveFilterDown: 'Move down', 25 | moveFilterUp: 'Move up', 26 | name: 'Feed Name', 27 | renameCategory: 'Rename', 28 | slow: 'Slow reading (deep and useful)', 29 | type: 'Feed type', 30 | url: 'Feed URL' 31 | }) 32 | -------------------------------------------------------------------------------- /core/messages/preview/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const previewMessages = i18n('preview', { 4 | add: 'Subscribe to feed', 5 | edit: 'Edit', 6 | invalidUrl: 'URL has an mistake, please check it', 7 | noResults: 8 | 'Feeds were not found on this website.\n\n' + 9 | 'Please check URL and [open an issue] if it’s correct.', 10 | searchGuide: 11 | 'For now we support RSS, Atom and JSON Feed feeds.\n\n' + 12 | 'Social networks are coming soon, but you can use RSS wrappers for them.', 13 | title: 'Add', 14 | unloadable: 'Can’t open this website', 15 | urlLabel: 'Web page URL or social account handle' 16 | }) 17 | -------------------------------------------------------------------------------- /core/messages/refresh/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const refreshMessages = i18n('refresh', { 4 | stop: 'Stop' 5 | }) 6 | -------------------------------------------------------------------------------- /core/messages/settings/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const settingsMessages = i18n('settings', { 4 | about: 'About the App', 5 | deleteProfile: 'Delete all data', 6 | deleteProfileConfirm: 'Data deletion cannot be undone. Are you sure?', 7 | interface: 'User Interface', 8 | preload: 'Feed Loading', 9 | preloadAlways: 'Always pre-load media', 10 | preloadFree: 'Pre-load by Wi-Fi only', 11 | preloadImages: 'Preload posts images', 12 | preloadNever: 'Never pre-load media', 13 | profile: 'Profile', 14 | source: 'Source code', 15 | theme: 'Application theme', 16 | themeDark: 'Dark theme', 17 | themeLight: 'Light theme', 18 | themeSystem: 'System theme' 19 | }) 20 | -------------------------------------------------------------------------------- /core/messages/slow/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const slowMessages = i18n('slow', { 4 | noPosts: 'No posts', 5 | pageTitle: 'Slow reading', 6 | posts: 'posts' 7 | }) 8 | -------------------------------------------------------------------------------- /core/messages/start/en.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../i18n.ts' 2 | 3 | export const startMessages = i18n('start', { 4 | localButton: 'Start local demo', 5 | localDescription1: 'Slow Reader works locally.', 6 | localDescription2: 'You don’t need to create account to try it out.', 7 | pageTitle: 'Start', 8 | title: 'No account in this browser yet' 9 | }) 10 | -------------------------------------------------------------------------------- /core/not-found.ts: -------------------------------------------------------------------------------- 1 | import type { LoguxUndoError } from '@logux/client' 2 | import { atom } from 'nanostores' 3 | 4 | import { onEnvironment } from './environment.ts' 5 | import { router } from './router.ts' 6 | 7 | export const notFound = atom(false) 8 | 9 | export class NotFoundError extends Error { 10 | constructor() { 11 | super('Not found') 12 | this.name = 'NotFoundError' 13 | Error.captureStackTrace(this, NotFoundError) 14 | } 15 | } 16 | 17 | export function isNotFoundError( 18 | error: unknown 19 | ): error is LoguxUndoError | NotFoundError { 20 | if (error instanceof Error) { 21 | return ( 22 | error.name === 'NotFoundError' || 23 | (error.name === 'LoguxUndoError' && error.message.includes('notFound')) 24 | ) 25 | } 26 | return false 27 | } 28 | 29 | onEnvironment(({ errorEvents }) => { 30 | errorEvents.addEventListener('unhandledrejection', event => { 31 | if (isNotFoundError(event.reason)) { 32 | notFound.set(true) 33 | } 34 | 35 | let unbindRouter = router.listen(() => { 36 | notFound.set(false) 37 | unbindRouter() 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /core/opened-popups.ts: -------------------------------------------------------------------------------- 1 | import { computed, type ReadableAtom } from 'nanostores' 2 | 3 | import { type Popup, popups } from './popups/index.ts' 4 | import { router } from './router.ts' 5 | 6 | let prevPopups: Popup[] = [] 7 | 8 | export const openedPopups: ReadableAtom = computed(router, route => { 9 | let lastIndex = 0 10 | let nextPopups = route.popups.map((popup, index) => { 11 | lastIndex = index 12 | let prevPopup = prevPopups[index] 13 | if ( 14 | prevPopup && 15 | prevPopup.name === popup.popup && 16 | prevPopup.param === popup.param 17 | ) { 18 | return prevPopup 19 | } else { 20 | if (prevPopup) prevPopup.destroy() 21 | return popups[popup.popup](popup.param) 22 | } 23 | }) 24 | for (let closedPopup of prevPopups.slice(lastIndex + 1)) { 25 | closedPopup.destroy() 26 | } 27 | prevPopups = nextPopups 28 | return nextPopups 29 | }) 30 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slowreader/core", 3 | "private": true, 4 | "type": "module", 5 | "engines": { 6 | "node": "^22.16.0", 7 | "pnpm": "^10.0.0" 8 | }, 9 | "scripts": { 10 | "test": "FORCE_COLOR=1 pnpm run /^test:/", 11 | "test:coverage": "c8 bnt", 12 | "clean:coverage": "rm -rf coverage" 13 | }, 14 | "exports": { 15 | ".": "./index.ts", 16 | "./package.json": "./package.json" 17 | }, 18 | "dependencies": { 19 | "@logux/actions": "github:logux/actions#next", 20 | "@logux/client": "github:logux/client#next", 21 | "@logux/core": "0.9.0", 22 | "@nanostores/i18n": "1.0.0", 23 | "@nanostores/persistent": "1.0.0", 24 | "@slowreader/api": "workspace:*", 25 | "dompurify": "3.2.6", 26 | "just-debounce-it": "3.2.0", 27 | "nanodelay": "2.0.2", 28 | "nanoid": "5.1.5", 29 | "nanostores": "1.0.1" 30 | }, 31 | "devDependencies": { 32 | "@types/jsdom": "21.1.7", 33 | "better-node-test": "0.7.1", 34 | "c8": "10.1.3", 35 | "jsdom": "26.1.0", 36 | "nanospy": "1.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/pages/common.ts: -------------------------------------------------------------------------------- 1 | import { atom, type ReadableAtom, type WritableStore } from 'nanostores' 2 | 3 | import { getEnvironment } from '../environment.ts' 4 | import type { ParamlessRouteName, RouteName, Routes } from '../router.ts' 5 | 6 | type Extra = { 7 | exit?: () => void 8 | } 9 | 10 | export type ParamStores = { 11 | [Param in keyof Routes[Name]]-?: WritableStore 12 | } 13 | 14 | export type BasePage = { 15 | destroy(): void 16 | readonly loading: ReadableAtom 17 | params: ParamStores 18 | readonly route: Name 19 | } 20 | 21 | export interface PageCreator< 22 | Name extends RouteName, 23 | Rest extends Extra = Record 24 | > { 25 | (): BasePage & Rest 26 | cache?: BasePage & Rest 27 | } 28 | 29 | export function createPage( 30 | route: Name, 31 | builder: () => { params: ParamStores } & Rest 32 | ): PageCreator { 33 | let creator: PageCreator = () => { 34 | if (!creator.cache) { 35 | let rest = builder() 36 | creator.cache = { 37 | destroy() { 38 | creator.cache?.exit?.() 39 | creator.cache = undefined 40 | }, 41 | loading: atom(false), 42 | route, 43 | ...rest 44 | } 45 | } 46 | return creator.cache 47 | } 48 | return creator 49 | } 50 | 51 | export function createSimplePage( 52 | route: Name 53 | ): PageCreator { 54 | return createPage(route, () => ({ params: {} as ParamStores })) 55 | } 56 | 57 | export function createRedirectPage( 58 | route: Name, 59 | redirectTo: ParamlessRouteName 60 | ): PageCreator { 61 | return createPage(route, () => { 62 | getEnvironment().openRoute( 63 | { params: {}, popups: [], route: redirectTo }, 64 | true 65 | ) 66 | return { loading: atom(true), params: {} as ParamStores } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /core/pages/feeds-by-categories.ts: -------------------------------------------------------------------------------- 1 | import { atom, effect } from 'nanostores' 2 | 3 | import { 4 | feedsByCategory, 5 | type FeedsByCategory, 6 | getCategories 7 | } from '../category.ts' 8 | import { getFeeds } from '../feed.ts' 9 | import { createPage } from './common.ts' 10 | 11 | export const feedsByCategoriesPage = createPage('feedsByCategories', () => { 12 | let $categories = getCategories() 13 | let $feeds = getFeeds() 14 | 15 | let $groups = atom([]) 16 | let $loading = atom(true) 17 | let unbind = effect([$categories, $feeds], (categories, feeds) => { 18 | if (!categories.isLoading && !feeds.isLoading) { 19 | $groups.set(feedsByCategory(categories.list, feeds.list)) 20 | $loading.set(false) 21 | } else { 22 | $loading.set(true) 23 | } 24 | }) 25 | 26 | return { 27 | exit() { 28 | unbind() 29 | }, 30 | groups: $groups, 31 | loading: $loading, 32 | params: {} 33 | } 34 | }) 35 | 36 | export type FeedsByCategoriesPage = ReturnType 37 | -------------------------------------------------------------------------------- /core/pages/home.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'nanostores' 2 | 3 | import { getEnvironment } from '../environment.ts' 4 | import { getFeeds } from '../feed.ts' 5 | import { createPage } from './common.ts' 6 | 7 | export const homePage = createPage('home', () => { 8 | let unbindFeeds = getFeeds().subscribe(feeds => { 9 | if (!feeds.isLoading) { 10 | getEnvironment().openRoute({ 11 | params: {}, 12 | popups: [], 13 | route: feeds.isEmpty ? 'welcome' : 'slow' 14 | }) 15 | } 16 | }) 17 | 18 | return { 19 | exit() { 20 | unbindFeeds() 21 | }, 22 | loading: atom(true), 23 | params: {} 24 | } 25 | }) 26 | 27 | export type HomePage = ReturnType 28 | -------------------------------------------------------------------------------- /core/pages/index.ts: -------------------------------------------------------------------------------- 1 | import type { RouteName } from '../router.ts' 2 | import { addPage } from './add.ts' 3 | import { 4 | createRedirectPage, 5 | createSimplePage, 6 | type PageCreator 7 | } from './common.ts' 8 | import { exportPage } from './export.ts' 9 | import { feedsByCategoriesPage } from './feeds-by-categories.ts' 10 | import { fastPage, slowPage } from './feeds.ts' 11 | import { homePage } from './home.ts' 12 | import { importPage } from './import.ts' 13 | 14 | export type { AddPage } from './add.ts' 15 | export * from './common.ts' 16 | export type { ExportPage } from './export.ts' 17 | export type { FeedsByCategoriesPage } from './feeds-by-categories.ts' 18 | export type { FastPage, SlowPage } from './feeds.ts' 19 | export type { HomePage } from './home.ts' 20 | export type { ImportPage } from './import.ts' 21 | 22 | export const pages = { 23 | about: createSimplePage('about'), 24 | add: addPage, 25 | download: createSimplePage('download'), 26 | export: exportPage, 27 | fast: fastPage, 28 | feeds: createRedirectPage('feeds', 'add'), 29 | feedsByCategories: feedsByCategoriesPage, 30 | home: homePage, 31 | import: importPage, 32 | interface: createSimplePage('interface'), 33 | notFound: createSimplePage('notFound'), 34 | profile: createSimplePage('profile'), 35 | refresh: createSimplePage('refresh'), 36 | settings: createRedirectPage('settings', 'interface'), 37 | signin: createSimplePage('signin'), 38 | slow: slowPage, 39 | start: createSimplePage('start'), 40 | welcome: createSimplePage('welcome') 41 | } satisfies { 42 | [Name in RouteName]: Name extends 'fast' | 'slow' 43 | ? PageCreator<'fast' | 'slow'> 44 | : PageCreator 45 | } 46 | 47 | export type Pages = typeof pages 48 | 49 | export type Page = ReturnType 50 | -------------------------------------------------------------------------------- /core/pagination.ts: -------------------------------------------------------------------------------- 1 | import { atom, type WritableAtom } from 'nanostores' 2 | 3 | export type PaginationValue = { 4 | count: number 5 | hasNext: boolean 6 | page: number 7 | show: boolean 8 | } 9 | 10 | export function createPagination( 11 | all: number, 12 | perPage: number 13 | ): WritableAtom { 14 | return setPagination( 15 | atom({ count: 0, hasNext: false, page: 0, show: false }), 16 | all, 17 | perPage 18 | ) 19 | } 20 | 21 | export function setPagination( 22 | pagination: WritableAtom, 23 | all: number, 24 | perPage: number 25 | ): WritableAtom { 26 | let count = Math.ceil(all / perPage) 27 | pagination.set({ 28 | count, 29 | hasNext: all > perPage, 30 | page: 0, 31 | show: count > 1 32 | }) 33 | return pagination 34 | } 35 | 36 | export function moveToPage( 37 | pagination: WritableAtom, 38 | page: number 39 | ): void { 40 | let prev = pagination.get() 41 | pagination.set({ 42 | ...prev, 43 | hasNext: prev.count > page + 1, 44 | page 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /core/popups/common.ts: -------------------------------------------------------------------------------- 1 | import { atom, type ReadableAtom } from 'nanostores' 2 | 3 | import { isNotFoundError } from '../not-found.ts' 4 | import type { PopupName } from '../router.ts' 5 | 6 | type Extra = { 7 | destroy: () => void 8 | } 9 | 10 | export type BasePopup< 11 | Name extends PopupName = PopupName, 12 | Loading extends boolean = boolean, 13 | NotFound extends boolean = boolean 14 | > = { 15 | destroy(): void 16 | readonly loading: ReadableAtom 17 | readonly name: Name 18 | readonly notFound: NotFound 19 | readonly param: string 20 | } 21 | 22 | export interface PopupCreator< 23 | Name extends PopupName, 24 | Rest extends Extra = Extra 25 | > { 26 | ( 27 | param: string 28 | ): 29 | | (BasePopup & Rest) 30 | | BasePopup 31 | | BasePopup 32 | } 33 | 34 | export type LoadedPopup> = Extract< 35 | ReturnType, 36 | { loading: ReadableAtom; notFound: false } 37 | > 38 | 39 | export function definePopup( 40 | name: Name, 41 | builder: (param: string) => Promise 42 | ): PopupCreator { 43 | let creator: PopupCreator = param => { 44 | let destroyed = false 45 | let rest: Rest | undefined 46 | let loading = atom(true) 47 | let popup = { 48 | destroy() { 49 | destroyed = true 50 | rest?.destroy() 51 | }, 52 | loading, 53 | name, 54 | notFound: false, 55 | param 56 | } 57 | 58 | loading.set(true) 59 | builder(param) 60 | .then(extra => { 61 | rest = extra 62 | if (destroyed) extra.destroy() 63 | for (let i in rest) { 64 | // @ts-expect-error Too complex case for TypeScript 65 | popup[i] = extra[i] 66 | } 67 | loading.set(false) 68 | }) 69 | .catch((e: unknown) => { 70 | if (isNotFoundError(e)) { 71 | popup.notFound = true 72 | popup.destroy() 73 | loading.set(false) 74 | } else { 75 | /* c8 ignore next 2 */ 76 | throw e 77 | } 78 | }) 79 | return popup as ReturnType> 80 | } 81 | return creator 82 | } 83 | -------------------------------------------------------------------------------- /core/popups/feed-url.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'nanostores' 2 | 3 | import { createDownloadTask, type TextResponse } from '../download.ts' 4 | import { addCandidate, type FeedValue, getFeeds } from '../feed.ts' 5 | import { getLoaderForText } from '../loader/index.ts' 6 | import { NotFoundError } from '../not-found.ts' 7 | import { definePopup, type LoadedPopup } from './common.ts' 8 | 9 | export const feedUrl = definePopup('feedUrl', async url => { 10 | let task = createDownloadTask({ cache: 'read' }) 11 | let response: TextResponse 12 | try { 13 | response = await task.text(url) 14 | } catch { 15 | throw new NotFoundError() 16 | } 17 | 18 | let search = getLoaderForText(response) 19 | if (!search) { 20 | throw new NotFoundError() 21 | } 22 | let candidate = search 23 | 24 | let posts = candidate.loader.getPosts(task, url, response) 25 | let $feed = atom() 26 | 27 | let feedsFilter = getFeeds({ url }) 28 | let unbindFeed = (): void => {} 29 | let unbindFeeds = feedsFilter.subscribe(feeds => { 30 | if (!feeds.isLoading) { 31 | let needed = feeds.list.find(feed => feed.url === url) 32 | if (needed) { 33 | let $needed = feeds.stores.get(needed.id)! 34 | unbindFeed = $needed.subscribe(feed => { 35 | if (!feed.isLoading) { 36 | $feed.set(feed) 37 | } 38 | }) 39 | } else { 40 | $feed.set(undefined) 41 | } 42 | } 43 | }) 44 | 45 | async function add(): Promise { 46 | return await addCandidate(candidate, {}, task, response) 47 | } 48 | 49 | return { 50 | add, 51 | destroy() { 52 | task.destroy() 53 | unbindFeeds() 54 | unbindFeed() 55 | }, 56 | feed: $feed, 57 | posts 58 | } 59 | }) 60 | 61 | export type FeedUrlPopup = LoadedPopup 62 | -------------------------------------------------------------------------------- /core/popups/feed.ts: -------------------------------------------------------------------------------- 1 | import { getFeed, getFeedLatestPosts } from '../feed.ts' 2 | import { waitSyncLoading } from '../lib/stores.ts' 3 | import { definePopup, type LoadedPopup } from './common.ts' 4 | 5 | export const feed = definePopup('feed', async id => { 6 | let $feed = await waitSyncLoading(getFeed(id)) 7 | return { 8 | destroy() {}, 9 | feed: $feed, 10 | posts: getFeedLatestPosts($feed.get()) 11 | } 12 | }) 13 | 14 | export type FeedPopup = LoadedPopup 15 | -------------------------------------------------------------------------------- /core/popups/index.ts: -------------------------------------------------------------------------------- 1 | import type { PopupName } from '../router.ts' 2 | import type { PopupCreator } from './common.ts' 3 | import { feedUrl } from './feed-url.ts' 4 | import { feed } from './feed.ts' 5 | import { post } from './post.ts' 6 | 7 | export type { BasePopup } from './common.ts' 8 | export type { FeedUrlPopup } from './feed-url.ts' 9 | export type { FeedPopup } from './feed.ts' 10 | export type { PostPopup } from './post.ts' 11 | 12 | export const popups = { 13 | feed, 14 | feedUrl, 15 | post 16 | } satisfies { 17 | [Name in PopupName]: PopupCreator 18 | } 19 | 20 | export type PopupCreators = typeof popups 21 | 22 | export type Popup = ReturnType< 23 | PopupCreators[Name] 24 | > 25 | -------------------------------------------------------------------------------- /core/popups/post.ts: -------------------------------------------------------------------------------- 1 | import { waitSyncLoading } from '../lib/stores.ts' 2 | import { getPost } from '../post.ts' 3 | import { definePopup, type LoadedPopup } from './common.ts' 4 | 5 | export const post = definePopup('post', async id => { 6 | let $post = getPost(id) 7 | return { 8 | destroy() {}, 9 | post: await waitSyncLoading($post) 10 | } 11 | }) 12 | 13 | export type PostPopup = LoadedPopup 14 | -------------------------------------------------------------------------------- /core/posts-list.ts: -------------------------------------------------------------------------------- 1 | import { map, type ReadableAtom, type StoreValue } from 'nanostores' 2 | 3 | import type { OriginPost } from './post.ts' 4 | 5 | export interface PostsListValue { 6 | hasNext: boolean 7 | isLoading: boolean 8 | list: OriginPost[] 9 | } 10 | 11 | export type PostsList = { 12 | loading: Promise 13 | next(): Promise 14 | } & ReadableAtom 15 | 16 | export type PostsListResult = [OriginPost[], PostsListLoader | undefined] 17 | 18 | export interface PostsListLoader { 19 | (): Promise 20 | } 21 | 22 | interface CreatePostsList { 23 | (posts: OriginPost[], loadNext: PostsListLoader | undefined): PostsList 24 | (posts: undefined, loadNext: PostsListLoader): PostsList 25 | } 26 | 27 | export const createPostsList: CreatePostsList = (posts, loadNext) => { 28 | let $map = map>({ 29 | hasNext: true, 30 | isLoading: true, 31 | list: [] 32 | }) 33 | let $store = { 34 | ...$map, 35 | loading: Promise.resolve([]) as PostsList['loading'], 36 | next 37 | } 38 | 39 | let isLoading = false 40 | async function next(): ReturnType { 41 | if (!loadNext) return Promise.resolve([]) 42 | if (isLoading) return $store.loading 43 | isLoading = true 44 | $store.setKey('isLoading', true) 45 | $store.loading = loadNext().then(([nextPosts, nextLoader]) => { 46 | loadNext = nextLoader 47 | isLoading = false 48 | $store.set({ 49 | hasNext: !!nextLoader, 50 | isLoading: false, 51 | list: $store.get().list.concat(nextPosts) 52 | }) 53 | return nextPosts 54 | }) 55 | return $store.loading 56 | } 57 | 58 | if (posts) { 59 | $store.set({ 60 | hasNext: !!loadNext, 61 | isLoading: false, 62 | list: posts 63 | }) 64 | } else { 65 | next().catch(() => { 66 | isLoading = false 67 | }) 68 | } 69 | 70 | return $store 71 | } 72 | -------------------------------------------------------------------------------- /core/readers/common.ts: -------------------------------------------------------------------------------- 1 | import { loadValue } from '@logux/client' 2 | import type { ReadableAtom, WritableAtom } from 'nanostores' 3 | 4 | import { getFeeds } from '../feed.ts' 5 | import { getPosts, type PostValue } from '../post.ts' 6 | import type { Routes } from '../router.ts' 7 | 8 | export interface BaseReader { 9 | exit(): void 10 | list: ReadableAtom 11 | loading: ReadableAtom 12 | name: Name 13 | } 14 | 15 | interface Extra { 16 | exit: () => void 17 | list: ReadableAtom 18 | loading: ReadableAtom 19 | } 20 | 21 | export type PostFilter = { reading: 'fast' | 'slow' } & ( 22 | | { categoryId: string } 23 | | { feedId: string } 24 | ) 25 | 26 | type FeedParams = Routes['fast'] | Routes['slow'] 27 | type FeedStores = { 28 | [K in keyof FeedParams]-?: WritableAtom 29 | } 30 | 31 | export type ReaderName = 'feed' | 'list' 32 | 33 | export interface ReaderCreator< 34 | Name extends ReaderName = ReaderName, 35 | Rest extends Extra = Extra 36 | > { 37 | (filter: PostFilter, params: FeedStores): BaseReader & Rest 38 | } 39 | 40 | export function createReader( 41 | name: Name, 42 | builder: (filter: PostFilter, params: FeedStores) => Rest 43 | ): ReaderCreator { 44 | return (filter, params) => { 45 | let reader = builder(filter, params) 46 | return { 47 | ...reader, 48 | name 49 | } 50 | } 51 | } 52 | 53 | export async function loadPosts(filter: PostFilter): Promise { 54 | let posts: PostValue[] 55 | if ('categoryId' in filter) { 56 | let [allPosts, feeds] = await Promise.all([ 57 | loadValue(getPosts({ reading: filter.reading })), 58 | loadValue(getFeeds({ categoryId: filter.categoryId })) 59 | ]) 60 | posts = allPosts.list.filter(i => feeds.stores.has(i.feedId)) 61 | } else { 62 | posts = ( 63 | await loadValue( 64 | getPosts({ feedId: filter.feedId, reading: filter.reading }) 65 | ) 66 | ).list 67 | } 68 | return posts.sort((a, b) => b.publishedAt - a.publishedAt) 69 | } 70 | -------------------------------------------------------------------------------- /core/readers/feed.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'nanostores' 2 | 3 | import { deletePost, type PostValue } from '../post.ts' 4 | import { createReader, loadPosts } from './common.ts' 5 | 6 | const POSTS_PER_PAGE = 50 7 | 8 | export const feedReader = createReader('feed', (filter, params) => { 9 | let exited = false 10 | let $loading = atom(true) 11 | let $list = atom([]) 12 | let $hasNext = atom(false) 13 | 14 | let openAt = Date.now() 15 | let nextSince: number | undefined 16 | let unbindSince = (): void => {} 17 | async function start(): Promise { 18 | let posts = await loadPosts(filter) 19 | if (exited) return 20 | 21 | unbindSince = params.since.subscribe(value => { 22 | if (!value) value = openAt 23 | let fromIndex = posts.findIndex(i => i.publishedAt < value) 24 | if (fromIndex === -1) fromIndex = posts.length 25 | let list = posts.slice(fromIndex, fromIndex + POSTS_PER_PAGE) 26 | $hasNext.set(posts.length > fromIndex + POSTS_PER_PAGE) 27 | while ( 28 | list.length > 1 && 29 | posts[fromIndex + list.length]?.publishedAt === 30 | posts[fromIndex + list.length - 1]?.publishedAt 31 | ) { 32 | list = list.slice(0, -1) 33 | } 34 | nextSince = list[list.length - 1]?.publishedAt 35 | $list.set(list) 36 | }) 37 | } 38 | start().then(() => { 39 | $loading.set(false) 40 | }) 41 | 42 | async function deleteAndNext(): Promise { 43 | let promise = Promise.all($list.get().map(i => deletePost(i.id))) 44 | params.since.set(nextSince) 45 | return promise.then() 46 | } 47 | 48 | return { 49 | deleteAndNext, 50 | exit() { 51 | exited = true 52 | unbindSince() 53 | }, 54 | hasNext: $hasNext, 55 | list: $list, 56 | loading: $loading 57 | } 58 | }) 59 | 60 | export type FeedReader = ReturnType 61 | -------------------------------------------------------------------------------- /core/readers/index.ts: -------------------------------------------------------------------------------- 1 | export type { BaseReader, ReaderCreator, ReaderName } from './common.ts' 2 | export * from './feed.ts' 3 | export * from './list.ts' 4 | -------------------------------------------------------------------------------- /core/settings.ts: -------------------------------------------------------------------------------- 1 | import { persistentAtom } from '@nanostores/persistent' 2 | import { nanoid } from 'nanoid' 3 | import type { StoreValue } from 'nanostores' 4 | 5 | import { getClient } from './client.ts' 6 | import { getEnvironment } from './environment.ts' 7 | 8 | export const userId = persistentAtom('slowreader:userId') 9 | 10 | export async function signOut(): Promise { 11 | await getClient().clean() 12 | userId.set(undefined) 13 | getEnvironment().restartApp() 14 | } 15 | 16 | export function generateCredentials(): void { 17 | userId.set(nanoid(10)) 18 | } 19 | 20 | export const theme = persistentAtom<'dark' | 'light' | 'system'>( 21 | 'slowreader:theme', 22 | 'system' 23 | ) 24 | 25 | export const preloadImages = persistentAtom<'always' | 'free' | 'never'>( 26 | 'slowreader:preloadImages', 27 | 'always' 28 | ) 29 | 30 | export interface Settings { 31 | preloadImages: StoreValue 32 | theme: StoreValue 33 | } 34 | -------------------------------------------------------------------------------- /core/test/busy.test.ts: -------------------------------------------------------------------------------- 1 | import { equal } from 'node:assert' 2 | import { afterEach, test } from 'node:test' 3 | import { setTimeout } from 'node:timers/promises' 4 | 5 | import { busy, busyDuring } from '../index.ts' 6 | import { cleanClientTest } from './utils.ts' 7 | 8 | afterEach(async () => { 9 | await cleanClientTest() 10 | }) 11 | 12 | test('allows to manually set busy state', async () => { 13 | equal(busy.get(), false) 14 | await busyDuring(async () => { 15 | equal(busy.get(), true) 16 | await busyDuring(async () => { 17 | equal(busy.get(), true) 18 | await setTimeout(10) 19 | }) 20 | equal(busy.get(), true) 21 | }) 22 | equal(busy.get(), false) 23 | }) 24 | -------------------------------------------------------------------------------- /core/test/comfort.test.ts: -------------------------------------------------------------------------------- 1 | import { equal } from 'node:assert' 2 | import { afterEach, beforeEach, test } from 'node:test' 3 | 4 | import { comfortMode, setBaseTestRoute, userId } from '../index.ts' 5 | import { cleanClientTest, enableClientTest } from './utils.ts' 6 | 7 | beforeEach(() => { 8 | enableClientTest() 9 | }) 10 | 11 | afterEach(async () => { 12 | await cleanClientTest() 13 | }) 14 | 15 | test('has routes groups', () => { 16 | userId.set(undefined) 17 | setBaseTestRoute({ params: {}, route: 'home' }) 18 | equal(comfortMode.get(), true) 19 | 20 | userId.set('10') 21 | setBaseTestRoute({ params: {}, route: 'refresh' }) 22 | equal(comfortMode.get(), true) 23 | 24 | setBaseTestRoute({ params: {}, route: 'slow' }) 25 | equal(comfortMode.get(), true) 26 | 27 | setBaseTestRoute({ params: { category: 'general' }, route: 'fast' }) 28 | equal(comfortMode.get(), false) 29 | 30 | setBaseTestRoute({ params: {}, route: 'profile' }) 31 | equal(comfortMode.get(), true) 32 | }) 33 | -------------------------------------------------------------------------------- /core/test/dom-parser.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom' 2 | 3 | let window = new JSDOM().window 4 | // @ts-expect-error JSDOM types are incomplete 5 | global.window = window 6 | global.DOMParser = window.DOMParser 7 | global.File = window.File 8 | global.FileReader = window.FileReader 9 | global.ErrorEvent = window.ErrorEvent 10 | -------------------------------------------------------------------------------- /core/test/environment.ts: -------------------------------------------------------------------------------- 1 | import { getTestEnvironment, setupEnvironment } from '../index.ts' 2 | 3 | setupEnvironment(getTestEnvironment()) 4 | -------------------------------------------------------------------------------- /core/test/html.test.ts: -------------------------------------------------------------------------------- 1 | import './dom-parser.ts' 2 | 3 | import { equal } from 'node:assert' 4 | import { test } from 'node:test' 5 | 6 | import { parseLink, parseRichTranslation, sanitizeDOM } from '../index.ts' 7 | 8 | test('sanitizes HTML', () => { 9 | equal( 10 | ( 11 | sanitizeDOM( 12 | '' + 13 | 'Safe' + 14 | '
    ' + 15 | '