├── .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, '')
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 | '
AB
'
26 | )
27 | })
28 |
29 | test('converts links in translation', () => {
30 | equal(
31 | parseLink('[Link] to example', 'https://example.com'),
32 | 'Link to example'
33 | )
34 | })
35 |
--------------------------------------------------------------------------------
/core/test/loader/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { deepStrictEqual } from 'node:assert'
2 | import { test } from 'node:test'
3 |
4 | import { createTextResponse } from '../../download.js'
5 | import { findHeaderLinks } from '../../loader/utils.js'
6 |
7 | test('returns urls from link http header', () => {
8 | deepStrictEqual(
9 | findHeaderLinks(
10 | createTextResponse(``, {
11 | headers: new Headers({
12 | Link:
13 | '; rel="alternate"; type="application/rss+xml"' +
14 | ', ; rel="alternate"; type="application/rss+xml"'
15 | }),
16 | url: 'https://example.com'
17 | }),
18 | 'application/rss+xml'
19 | ),
20 | ['https://one.example.com', 'https://two.example.com']
21 | )
22 | })
23 |
24 | test('handles root-relative urls in http header', () => {
25 | deepStrictEqual(
26 | findHeaderLinks(
27 | createTextResponse(``, {
28 | headers: new Headers({
29 | Link: '; rel="alternate"; type="application/atom+xml"'
30 | }),
31 | url: 'https://example.com/blog'
32 | }),
33 | 'application/atom+xml'
34 | ),
35 | ['https://example.com/rss']
36 | )
37 | })
38 |
39 | test('handles relative urls in http header', () => {
40 | deepStrictEqual(
41 | findHeaderLinks(
42 | createTextResponse(``, {
43 | headers: new Headers({
44 | Link: '<./rss>; rel="alternate"; type="application/atom+xml"'
45 | }),
46 | url: 'https://example.com/blog/'
47 | }),
48 | 'application/atom+xml'
49 | ),
50 | ['https://example.com/blog/rss']
51 | )
52 | })
53 |
--------------------------------------------------------------------------------
/core/test/not-found.test.ts:
--------------------------------------------------------------------------------
1 | import { LoguxUndoError } from '@logux/client'
2 | import { equal } from 'node:assert'
3 | import { afterEach, beforeEach, test } from 'node:test'
4 |
5 | import { notFound, NotFoundError, setBaseTestRoute } from '../index.ts'
6 | import { cleanClientTest, enableClientTest } from './utils.ts'
7 |
8 | let listener: (e: { reason: Error }) => void
9 |
10 | beforeEach(() => {
11 | enableClientTest({
12 | errorEvents: {
13 | addEventListener(event, cb) {
14 | listener = cb
15 | }
16 | }
17 | })
18 | })
19 |
20 | afterEach(async () => {
21 | await cleanClientTest()
22 | })
23 |
24 | test('listens for not found error', () => {
25 | setBaseTestRoute({ params: { feed: 'unknown' }, route: 'feedsByCategories' })
26 | equal(notFound.get(), false)
27 |
28 | listener({
29 | reason: new LoguxUndoError({
30 | action: { channel: 'feeds/unknown', type: 'logux/subscribe' },
31 | id: '1 1:0:0 0',
32 | reason: 'notFound',
33 | type: 'logux/undo'
34 | })
35 | })
36 | equal(notFound.get(), true)
37 |
38 | setBaseTestRoute({ params: { feed: 'another' }, route: 'feedsByCategories' })
39 | equal(notFound.get(), false)
40 |
41 | listener({
42 | reason: new NotFoundError()
43 | })
44 | equal(notFound.get(), true)
45 | })
46 |
--------------------------------------------------------------------------------
/core/test/pages/feeds-by-categories.test.ts:
--------------------------------------------------------------------------------
1 | import { loadValue } from '@logux/client'
2 | import { deepStrictEqual, equal } from 'node:assert'
3 | import { afterEach, beforeEach, test } from 'node:test'
4 |
5 | import {
6 | addCategory,
7 | addFeed,
8 | getFeed,
9 | testFeed,
10 | waitLoading
11 | } from '../../index.ts'
12 | import { cleanClientTest, enableClientTest, openPage } from '../utils.ts'
13 |
14 | beforeEach(() => {
15 | enableClientTest()
16 | })
17 |
18 | afterEach(async () => {
19 | await cleanClientTest()
20 | })
21 |
22 | test('groups feeds by categories', async () => {
23 | let page = openPage({
24 | params: {},
25 | route: 'feedsByCategories'
26 | })
27 |
28 | equal(page.loading.get(), true)
29 | await waitLoading(page.loading)
30 | deepStrictEqual(page.groups.get(), [])
31 |
32 | let idA = await addCategory({ title: 'A' })
33 | let feed1 = await addFeed(testFeed({ categoryId: idA, title: '1' }))
34 | let feed2 = await addFeed(testFeed({ categoryId: idA, title: '2' }))
35 | deepStrictEqual(page.groups.get(), [
36 | [
37 | { id: idA, isLoading: false, title: 'A' },
38 | [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))]
39 | ]
40 | ])
41 |
42 | let idC = await addCategory({ title: 'C' })
43 | let idB = await addCategory({ title: 'B' })
44 | let feed3 = await addFeed(testFeed({ categoryId: idB, title: '1' }))
45 | let feed4 = await addFeed(testFeed({ categoryId: 'general', title: '1' }))
46 | let feed5 = await addFeed(testFeed({ categoryId: 'unknown', title: '1' }))
47 |
48 | deepStrictEqual(page.groups.get(), [
49 | [{ id: 'general', title: 'General' }, [await loadValue(getFeed(feed4))]],
50 | [
51 | { id: idA, isLoading: false, title: 'A' },
52 | [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))]
53 | ],
54 | [
55 | { id: idB, isLoading: false, title: 'B' },
56 | [await loadValue(getFeed(feed3))]
57 | ],
58 | [{ id: idC, isLoading: false, title: 'C' }, []],
59 | [
60 | { id: 'broken', title: 'Broken category' },
61 | [await loadValue(getFeed(feed5))]
62 | ]
63 | ])
64 |
65 | openPage({
66 | params: {},
67 | route: 'welcome'
68 | })
69 | })
70 |
--------------------------------------------------------------------------------
/core/test/pages/redirects.test.ts:
--------------------------------------------------------------------------------
1 | import { keepMount } from 'nanostores'
2 | import { equal } from 'node:assert'
3 | import { afterEach, beforeEach, test } from 'node:test'
4 | import { setTimeout } from 'node:timers/promises'
5 |
6 | import {
7 | addFeed,
8 | busy,
9 | busyUntilMenuLoader,
10 | currentPage,
11 | setBaseTestRoute,
12 | testFeed,
13 | waitLoading
14 | } from '../../index.ts'
15 | import { cleanClientTest, enableClientTest } from '../utils.ts'
16 |
17 | beforeEach(() => {
18 | enableClientTest()
19 | setBaseTestRoute({
20 | params: {},
21 | route: 'notFound'
22 | })
23 | })
24 |
25 | afterEach(async () => {
26 | await cleanClientTest()
27 | })
28 |
29 | test('redirects from settings root to interface page', () => {
30 | keepMount(currentPage)
31 | setBaseTestRoute({
32 | params: {},
33 | route: 'settings'
34 | })
35 | equal(currentPage.get().route, 'interface')
36 | })
37 |
38 | test('redirects from feeds root to add feed page', () => {
39 | keepMount(currentPage)
40 | setBaseTestRoute({
41 | params: {},
42 | route: 'feeds'
43 | })
44 | equal(currentPage.get().route, 'add')
45 | })
46 |
47 | test('redirects from home depending on feeds', async () => {
48 | busyUntilMenuLoader()
49 | await waitLoading(busy)
50 |
51 | keepMount(currentPage)
52 | setBaseTestRoute({
53 | params: {},
54 | route: 'home'
55 | })
56 | equal(currentPage.get().route, 'welcome')
57 |
58 | await addFeed(testFeed({ reading: 'slow' }))
59 | setBaseTestRoute({
60 | params: {},
61 | route: 'home'
62 | })
63 | await setTimeout(10)
64 | equal(currentPage.get().route, 'slow')
65 | })
66 |
--------------------------------------------------------------------------------
/core/test/popups/feed.test.ts:
--------------------------------------------------------------------------------
1 | import { cleanStores, keepMount } from 'nanostores'
2 | import { equal } from 'node:assert'
3 | import { afterEach, beforeEach, test } from 'node:test'
4 |
5 | import {
6 | addFeed,
7 | addPost,
8 | closeLastPopup,
9 | type FeedPopup,
10 | openedPopups,
11 | testFeed,
12 | testPost,
13 | waitLoading
14 | } from '../../index.ts'
15 | import {
16 | checkLoadedPopup,
17 | cleanClientTest,
18 | enableClientTest,
19 | openTestPopup
20 | } from '../utils.ts'
21 |
22 | beforeEach(() => {
23 | enableClientTest()
24 | })
25 |
26 | afterEach(async () => {
27 | await cleanClientTest()
28 | cleanStores(openedPopups)
29 | })
30 |
31 | test('opens feed', async () => {
32 | keepMount(openedPopups)
33 | equal(openedPopups.get().length, 0)
34 | let feed = await addFeed(testFeed({ categoryId: 'general' }))
35 | let post = await addPost(testPost({ feedId: feed }))
36 |
37 | let popup = openTestPopup('feed', feed)
38 | equal(openedPopups.get().length, 1)
39 | equal(popup.name, 'feed')
40 | equal(popup.param, feed)
41 | equal(popup.loading.get(), true)
42 |
43 | await waitLoading(popup.loading)
44 | equal(popup.loading.get(), false)
45 | equal(popup.notFound, false)
46 | equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed)
47 |
48 | closeLastPopup()
49 | equal(openedPopups.get().length, 0)
50 |
51 | let unknown = openTestPopup('feed', 'unknown')
52 | equal(unknown.loading.get(), true)
53 |
54 | await waitLoading(unknown.loading)
55 | equal(unknown.notFound, true)
56 |
57 | closeLastPopup()
58 | equal(openedPopups.get().length, 0)
59 |
60 | let feedPopup = openTestPopup('feed', feed)
61 | await waitLoading(feedPopup.loading)
62 |
63 | let postPopup = openTestPopup('post', post)
64 | equal(openedPopups.get().length, 2)
65 | equal(feedPopup.loading.get(), false)
66 | equal(postPopup.loading.get(), true)
67 |
68 | await waitLoading(postPopup.loading)
69 | equal(checkLoadedPopup(feedPopup).feed.get().id, feed)
70 | equal(checkLoadedPopup(postPopup).post.get().id, post)
71 | })
72 |
--------------------------------------------------------------------------------
/core/test/post.test.ts:
--------------------------------------------------------------------------------
1 | import { equal } from 'node:assert'
2 | import { afterEach, beforeEach, test } from 'node:test'
3 |
4 | import { getPostContent, getPostIntro, type OriginPost } 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 | function longText(): string {
16 | return 'a'.repeat(5000)
17 | }
18 |
19 | test('loads post content', () => {
20 | let origin = { media: [], originId: '' } satisfies OriginPost
21 |
22 | equal(getPostContent(origin), '')
23 | equal(getPostContent({ ...origin, intro: 'a' }), 'a')
24 | equal(getPostContent({ ...origin, full: 'b', intro: 'a' }), 'b')
25 |
26 | equal(getPostIntro(origin), '')
27 | equal(getPostIntro({ ...origin, full: 'short' }), 'short')
28 | equal(getPostIntro({ ...origin, full: 'short', intro: 'intro' }), 'intro')
29 | equal(getPostIntro({ ...origin, full: longText() }), '')
30 | })
31 |
--------------------------------------------------------------------------------
/core/test/production.test.ts:
--------------------------------------------------------------------------------
1 | import './environment.ts'
2 |
3 | import { cleanStores } from 'nanostores'
4 | import { match } from 'node:assert'
5 | import { afterEach, test } from 'node:test'
6 |
7 | import { client, userId } from '../index.ts'
8 |
9 | global.WebSocket = function () {} as any
10 |
11 | afterEach(() => {
12 | cleanStores(client)
13 | userId.set(undefined)
14 | })
15 |
16 | test('uses real server in production', () => {
17 | userId.set('10')
18 | match(client.get()!.options.server as string, /^ws:\/\//)
19 | })
20 |
--------------------------------------------------------------------------------
/core/test/settings.test.ts:
--------------------------------------------------------------------------------
1 | import { keepMount } from 'nanostores'
2 | import { equal } from 'node:assert'
3 | import { afterEach, beforeEach, test } from 'node:test'
4 |
5 | import {
6 | generateCredentials,
7 | preloadImages,
8 | signOut,
9 | theme,
10 | userId
11 | } from '../index.ts'
12 | import { cleanClientTest, enableClientTest } from '../test/utils.ts'
13 |
14 | let restarts = 0
15 |
16 | beforeEach(() => {
17 | enableClientTest({
18 | restartApp() {
19 | restarts += 1
20 | }
21 | })
22 | })
23 |
24 | afterEach(async () => {
25 | await cleanClientTest()
26 | restarts = 0
27 | })
28 |
29 | test('generates user data', () => {
30 | userId.set(undefined)
31 | keepMount(userId)
32 |
33 | generateCredentials()
34 | equal(typeof userId.get(), 'string')
35 | })
36 |
37 | test('signs out', async () => {
38 | userId.set('10')
39 | keepMount(userId)
40 | equal(restarts, 0)
41 |
42 | await signOut()
43 | equal(userId.get(), undefined)
44 | equal(restarts, 1)
45 | })
46 |
47 | test('has store for theme', () => {
48 | equal(theme.get(), 'system')
49 | })
50 |
51 | test('has store for images preload settings', () => {
52 | equal(preloadImages.get(), 'always')
53 | })
54 |
--------------------------------------------------------------------------------
/docs/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Standards
4 |
5 | - **When we disagree, try to understand why.** Our goal should not be to “win” every disagreement or argument. A more productive goal is to be open to ideas that make our own ideas better.
6 | - **Be careful in the words that you choose.** Harassment and other exclusionary behavior aren’t acceptable:
7 | - Violent threats or language directed against another person.
8 | - Discriminatory jokes and language.
9 | - Posting (or threatening to post) other people’s personally identifying information (“doxing”).
10 | - Personal insults, especially those using racist or sexist terms.
11 | - Unwelcome sexual attention.
12 | - Repeated harassment of others. In general, if someone asks you to stop, then stop.
13 | - **Appreciate and accommodate our differences.** Be respectful of people with different cultural practices, personal habits to clothing, attitudes, and beliefs. Respect the way how people ask you to communicate with them.
14 | - **Judge ideas, not people.** Every idea counts for its merit, not who brings it up.
15 | - **Do not blame for mistakes.** If we have a problem, we should discuss how to prevent it in the future.
16 |
17 | ## Where Does It of Conduct Take Effect?
18 |
19 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community.
20 |
21 | - All communications in GitHub.
22 | - Conversations in our Discord.
23 | - Git commits and code comments.
24 | - Project’s social accounts.
25 |
26 | ## How Can You Report Violations?
27 |
28 | Unacceptable behavior may be reported to the community leaders responsible for enforcement at [andrey@sitnik.ru](mailto:andrey@sitnik.ru).
29 |
30 |
31 |
32 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
33 |
--------------------------------------------------------------------------------
/docs/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | To report a security vulnerability, please write to [andrey@sitnik.ru](mailto:andrey@sitnik.ru).
6 |
--------------------------------------------------------------------------------
/docs/new_page.md:
--------------------------------------------------------------------------------
1 | # How to Add a New Page to a Web Client?
2 |
3 | If we are adding `foo` page.
4 |
5 | 1. Add route:
6 | 1. Think about a good semantic URL route.
7 | 2. Add the `foo` route to `Routes` in `core/router.ts`.
8 | 3. Add `foo` route with URL pattern to `createRouter()` call in `web/stores/router.ts`.
9 | 2. If you need logic on the page, create a core module.
10 | 1. Create `core/pages/foo.ts` with `export foo = createPage('foo', builder)` and `FooPage` types. See other pages for example.
11 | 2. Add `foo` to `pages` in `core/pages/index.ts` and re-export `FooPage` type.
12 | 3. Add tests in `core/test/pages/foo.test.ts`. See tests for other pages for example.
13 | 3. Add translations for page:
14 | 1. Create `core/messages/foo/en.ts`.
15 | 2. Export these files from `core/messages/index.ts`.
16 | 4. Add page:
17 | 1. Create Svelte component `web/pages/foo.svelte` using `foo` messages and `foo` page.
18 | 2. Wrap Svelte styles into `:global {}`. Use BEM system like `.foo_element.is-modifier` for CSS selectors.
19 | 3. Use this component with the route in `web/main/main.svelte`.
20 | 4. Add visual tests in `web/stories/pages/foo.stories.svelte`.
21 | 5. Add a link to the page to the menu (`web/ui/navbar/`) if necessary.
22 |
--------------------------------------------------------------------------------
/docs/onboarding.md:
--------------------------------------------------------------------------------
1 | # Core Team Onboarding
2 |
3 | - [Enable 2FA on GitHub](#enable-2fa-on-github)
4 | - [Encrypt Your Laptop](#encrypt-your-laptop)
5 | - [Enable Signing Git Commits](#enable-signing-git-commits)
6 |
7 | ## Enable 2FA on GitHub
8 |
9 | All core team members _must_ [enable two-factor authentication](https://github.com/settings/security) on GitHub (and any related platform).
10 |
11 | You can start by using any TOTP application (that app with 6-digit codes). We just don’t recommend keeping TOTP codes in the same cloud where you have your GitHub password.
12 |
13 | The best way to have 2FA is with a hardware key. Remember to store Recovery codes in a secure place in case you lose the key.
14 |
15 | ## Encrypt Your Laptop
16 |
17 | Your SSH key to access the repository is stored on your laptop.
18 |
19 | All core team members _must_ enable file system encryption for the machine.
20 |
21 | Check the documentation for your operating system.
22 |
23 | ## Enable Signing Git Commits
24 |
25 | By default, anybody can make commits with your name.
26 |
27 | We recommend enabling signing git commits to verify that these are commits really made by you:
28 |
29 | ```sh
30 | git config --global commit.gpgsign true
31 | ```
32 |
33 | If you don’t have a GPG key, you can use an SSH key (the same key you sign to GitHub):
34 |
35 | ```sh
36 | git config --global gpg.format ssh
37 | # Replace the path to your key
38 | git config --global user.signingKey ~/.ssh/id_rsa.pub
39 | ```
40 |
41 | Then add your SSH key _also_ as the commit signing key:
42 |
43 | 1. Open [New SSH Key](https://github.com/settings/ssh/new) page.
44 | 2. Select `Key type`: `Signing key`.
45 | 3. Copy the content of `~/.ssh/id_rsa.pub` file (or other file you used in `user.signingKey` above).
46 |
47 | If you’re on **Windows Subsystem for Linux 2**, this may help:
48 |
49 | 1. Add those lines to `~/.gnupg/gpg.conf:
50 |
51 | ```sh
52 | use-agent
53 | pinentry-mode loopback
54 | ```
55 |
56 | 2. Add this line to `~/.gnupg/gpg-agent.conf`:
57 |
58 | ```sh
59 | allow-loopback-pinentry
60 | ```
61 |
--------------------------------------------------------------------------------
/docs/pull_request_template.md:
--------------------------------------------------------------------------------
1 | Fixes #
2 |
3 |
4 |
5 | # Motivation
6 |
7 |
8 |
9 | # Screenshot or Video
10 |
11 |
12 |
--------------------------------------------------------------------------------
/extension/.env:
--------------------------------------------------------------------------------
1 | VITE_HOST=http://localhost:5173
2 |
--------------------------------------------------------------------------------
/extension/.env.dev.example:
--------------------------------------------------------------------------------
1 | VITE_HOST=http://localhost:5173
2 |
--------------------------------------------------------------------------------
/extension/.env.example:
--------------------------------------------------------------------------------
1 | VITE_HOST=https://dev.slowreader.app
2 |
--------------------------------------------------------------------------------
/extension/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/extension/api.ts:
--------------------------------------------------------------------------------
1 | export type AppMessage = {
2 | options: RequestInit
3 | url: string
4 | }
5 |
6 | export type ExtensionMessage =
7 | | { data: string; type: 'fetched' }
8 | | { error: string; type: 'error' }
9 | | { type: 'connected' }
10 |
--------------------------------------------------------------------------------
/extension/background.ts:
--------------------------------------------------------------------------------
1 | import type { AppMessage, ExtensionMessage } from './api.js'
2 | import { config } from './config.js'
3 |
4 | const FETCH_TIMEOUT_MS = 30000
5 |
6 | function sendMessage(
7 | port: chrome.runtime.Port,
8 | message: ExtensionMessage
9 | ): void {
10 | port.postMessage(message)
11 | }
12 |
13 | chrome.runtime.onConnectExternal.addListener(port => {
14 | if (port.sender?.origin === config.HOST) {
15 | sendMessage(port, { type: 'connected' })
16 | port.onMessage.addListener(async (message: AppMessage) => {
17 | try {
18 | let response = await fetch(message.url, {
19 | ...message.options,
20 | signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
21 | })
22 | let data = await response.text()
23 | sendMessage(port, { data, type: 'fetched' })
24 | } catch (error) {
25 | if (error instanceof Error) {
26 | sendMessage(port, {
27 | error: error.toString(),
28 | type: 'error'
29 | })
30 | }
31 | }
32 | })
33 | }
34 | })
35 |
--------------------------------------------------------------------------------
/extension/config.ts:
--------------------------------------------------------------------------------
1 | export const config = {
2 | HOST: import.meta.env.VITE_HOST
3 | }
4 |
--------------------------------------------------------------------------------
/extension/manifest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineManifest } from '@crxjs/vite-plugin'
2 |
3 | const URL =
4 | process.env.NODE_ENV === 'production'
5 | ? 'https://*.slowreader.app/*'
6 | : 'http://localhost:5173/*'
7 |
8 | export default defineManifest(() => ({
9 | background: {
10 | service_worker: 'background.ts',
11 | type: 'module'
12 | },
13 | description: 'Fetch data from websites for dev.slowreader.app',
14 | externally_connectable: {
15 | matches: [URL]
16 | },
17 | host_permissions: [URL],
18 | manifest_version: 3,
19 | name: 'Slowreader Extension',
20 | version: '0.0.1'
21 | }))
22 |
--------------------------------------------------------------------------------
/extension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@slowreader/extension",
3 | "private": true,
4 | "type": "module",
5 | "engines": {
6 | "node": "^22.16.0",
7 | "pnpm": "^10.0.0"
8 | },
9 | "scripts": {
10 | "start": "vite",
11 | "build": "vite build",
12 | "test": "pnpm build"
13 | },
14 | "dependencies": {
15 | "@crxjs/vite-plugin": "2.0.0-beta.32",
16 | "vite": "6.3.5"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/extension/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/extension/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { crx } from '@crxjs/vite-plugin'
2 | import { defineConfig } from 'vite'
3 |
4 | import defineManifest from './manifest.config.ts'
5 |
6 | export default defineConfig({
7 | plugins: [crx({ manifest: defineManifest })]
8 | })
9 |
--------------------------------------------------------------------------------
/loader-tests/README.md:
--------------------------------------------------------------------------------
1 | # Slow Reader Loader Tests
2 |
3 | Integration tests for each social network or news format.
4 |
5 | This project allows testing of different types of web feed aggregators on the Internet and provides assurance that our reader can load tests from real feeds.
6 |
7 | ## Check Loaders by User’s OPML
8 |
9 | Test that Slow Reader can work with all feeds from your RSS reader by using OPML feeds export.
10 |
11 | 1. Check out [`example.opml`](./example.opml) for the structure of `.opml` file.
12 | 2. Once in the root you can run:
13 |
14 | ```sh
15 | pnpm check-opml PATH_TO_YOUR_FILE.opml
16 | ```
17 |
18 | ## Check Loaders by Different Blog Platforms
19 |
20 | Test that Slow Reader can work with different feeds from popular blogging platforms.
21 |
22 | ```sh
23 | cd loader-tests/
24 | pnpm test
25 | ```
26 |
27 | ## Debug Posts Loading
28 |
29 | A small helper to run posts loading for specific feed.
30 |
31 | ```sh
32 | cd loader-tests/
33 | pnpm run url URL
34 | ```
35 |
36 | ## Debug Feed Search
37 |
38 | A small helper to run feed searching for specific feed.
39 |
40 | ```sh
41 | cd loader-tests/
42 | pnpm run home URL HOME_URL
43 | ```
44 |
--------------------------------------------------------------------------------
/loader-tests/check-opml.ts:
--------------------------------------------------------------------------------
1 | import { createTextResponse } from '@slowreader/core'
2 |
3 | import {
4 | completeTasks,
5 | createCLI,
6 | enableTestClient,
7 | error,
8 | fetchAndParsePosts,
9 | findRSSfromHome,
10 | finish,
11 | initializeProgressBar,
12 | isString,
13 | type LoaderTestFeed as OpmlFeed,
14 | readText
15 | } from './utils.ts'
16 |
17 | async function parseFeedsFromFile(path: string): Promise {
18 | if (!path.endsWith('.opml') && !path.endsWith('.xml')) {
19 | error(`Unsupported file extension found on ${path}`)
20 | process.exit(1)
21 | }
22 | let text = createTextResponse(await readText(path))
23 | return [...text.parseXml()!.querySelectorAll('[type="rss"]')]
24 | .filter(feed => isString(feed.getAttribute('xmlUrl')))
25 | .map(
26 | f =>
27 | ({
28 | homeUrl: f.getAttribute('htmlUrl')!,
29 | title: f.getAttribute('title') || '',
30 | url: f.getAttribute('xmlUrl')!
31 | }) as OpmlFeed
32 | )
33 | }
34 |
35 | let cli = createCLI(
36 | 'Test all feeds from user OPML',
37 | '$ pnpm check-opml PATH_TO_YOUR_FILE.opml\n' +
38 | '$ pnpm check-opml PATH_TO_YOUR_FILE.opml --home'
39 | )
40 |
41 | cli.run(async args => {
42 | enableTestClient()
43 |
44 | let opmlFile: string | undefined
45 | let home = false
46 | for (let arg of args) {
47 | if (arg === '--home') {
48 | home = true
49 | } else if (!opmlFile) {
50 | opmlFile = arg
51 | } else {
52 | cli.wrongArg('Unknown argument: ' + arg)
53 | return
54 | }
55 | }
56 |
57 | if (!opmlFile) {
58 | cli.wrongArg('Please provide a path to the OPML file')
59 | return
60 | }
61 |
62 | let feeds = await parseFeedsFromFile(opmlFile)
63 | initializeProgressBar(home ? feeds.length * 2 : feeds.length)
64 |
65 | await completeTasks(
66 | feeds.map(feed => () => fetchAndParsePosts(feed.url, true))
67 | )
68 | if (home) {
69 | for (let feed of feeds) {
70 | await findRSSfromHome(feed)
71 | }
72 | }
73 |
74 | finish(`${feeds.length} ${feeds.length === 1 ? 'feed' : 'feeds'} checked`)
75 | })
76 |
--------------------------------------------------------------------------------
/loader-tests/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 |
--------------------------------------------------------------------------------
/loader-tests/feeds.yml:
--------------------------------------------------------------------------------
1 | feeds:
2 | # Popular feeds
3 |
4 | - title: DEV Community
5 | url: https://dev.to/feed
6 |
7 | - title: WordPress Official Blog
8 | url: https://wordpress.org/news/feed/
9 | homeUrl: https://wordpress.org/news/
10 |
11 | - title: GitHub Blog
12 | url: https://github.blog/feed/
13 |
14 | - title: Stack Overflow
15 | url: https://stackoverflow.blog/feed/
16 |
17 | - title: The Verge
18 | url: https://www.theverge.com/rss/index.xml
19 |
20 | # Popular blog platforms and engines
21 |
22 | - title: Tumblr
23 | url: https://thecollectibles.tumblr.com/rss
24 |
25 | - title: LiveJournal
26 | url: https://vintage-ads.livejournal.com/data/atom
27 |
28 | - title: Aegea
29 | url: https://ilyabirman.ru/meanwhile/json/
30 | homeUrl: https://ilyabirman.ru/meanwhile/
31 |
32 | - title: Pika
33 | url: https://wavelengths.online/posts_feed
34 |
35 | - title: Wiki News
36 | url: https://en.wikinews.org/w/index.php?title=Special:NewsFeed&feed=atom
37 | findFromHome: false
38 |
39 | # Edge Cases
40 |
41 | - title: Wrong Content Type
42 | url: https://exler.ru/rss.xml
43 |
--------------------------------------------------------------------------------
/loader-tests/home.ts:
--------------------------------------------------------------------------------
1 | import { createCLI, enableTestClient, findRSSfromHome } from './utils.ts'
2 |
3 | let cli = createCLI(
4 | 'Debug feed search with specific feed',
5 | '$ pnpm run home FEED_URL\n$ pnpm url FEED_URL HOME_URL'
6 | )
7 |
8 | cli.run(async args => {
9 | enableTestClient()
10 |
11 | let url = args[0]
12 | let homeUrl = args[1]
13 | if (!url) {
14 | cli.wrongArg('Please provide a feed URL')
15 | return
16 | }
17 |
18 | process.exit((await findRSSfromHome({ homeUrl, title: url, url })) ? 0 : 1)
19 | })
20 |
--------------------------------------------------------------------------------
/loader-tests/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@slowreader/loader-tests",
3 | "private": true,
4 | "type": "module",
5 | "engines": {
6 | "node": "^22.16.0",
7 | "pnpm": "^10.0.0"
8 | },
9 | "scripts": {
10 | "check-opml": "../scripts/tsnode check-opml.ts",
11 | "test": "node --run test:loaders",
12 | "url": "../scripts/tsnode url.ts",
13 | "home": "../scripts/tsnode home.ts",
14 | "test:loaders": "../scripts/tsnode test-loaders.ts"
15 | },
16 | "devDependencies": {
17 | "@slowreader/core": "workspace:*",
18 | "jsdom": "26.1.0",
19 | "nanostores": "1.0.1",
20 | "yaml": "2.8.0"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/loader-tests/test-loaders.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'node:path'
2 | import yaml from 'yaml'
3 |
4 | import {
5 | completeTasks,
6 | createCLI,
7 | enableTestClient,
8 | fetchAndParsePosts,
9 | findRSSfromHome,
10 | finish,
11 | initializeProgressBar,
12 | type LoaderTestFeed,
13 | readText
14 | } from './utils.ts'
15 |
16 | const FEEDS = join(import.meta.dirname, 'feeds.yml')
17 |
18 | interface YamlFeed extends LoaderTestFeed {
19 | findFromHome?: boolean
20 | }
21 |
22 | async function parseFeedsFromFile(path: string): Promise {
23 | let data = yaml.parse(await readText(path)) as { feeds: YamlFeed[] }
24 | return data.feeds
25 | }
26 |
27 | let cli = createCLI('Run all tests on feeds.yml')
28 |
29 | cli.run(async () => {
30 | enableTestClient()
31 |
32 | let feeds = await parseFeedsFromFile(FEEDS)
33 | initializeProgressBar(
34 | feeds.length + feeds.filter(feed => feed.findFromHome !== false).length
35 | )
36 |
37 | await completeTasks(feeds.map(feed => () => fetchAndParsePosts(feed.url)))
38 | for (let feed of feeds) {
39 | if (feed.findFromHome !== false) {
40 | await findRSSfromHome(feed, 3)
41 | }
42 | }
43 | finish(`${feeds.length} ${feeds.length === 1 ? 'feed' : 'feeds'} checked`)
44 | })
45 |
--------------------------------------------------------------------------------
/loader-tests/url.ts:
--------------------------------------------------------------------------------
1 | import { createCLI, enableTestClient, fetchAndParsePosts } from './utils.ts'
2 |
3 | let cli = createCLI(
4 | 'Debug post loading with specific feed',
5 | '$ pnpm run url URL'
6 | )
7 |
8 | cli
9 | .run(async args => {
10 | enableTestClient()
11 |
12 | let url = args[0]
13 | if (!url) {
14 | cli.wrongArg('Please provide a feed URL')
15 | return
16 | }
17 |
18 | await fetchAndParsePosts(url)
19 | })
20 | .catch((e: unknown) => {
21 | throw e
22 | })
23 |
--------------------------------------------------------------------------------
/nano-staged.json:
--------------------------------------------------------------------------------
1 | {
2 | "*.{html,md,json}": "prettier --write",
3 | "*.{js,ts,cjs}": ["prettier --write", "eslint --fix"],
4 | "*.css": "stylelint --fix",
5 | "*.svelte": ["prettier --write", "stylelint --fix"],
6 | "*.svg": "svgo",
7 | ".devcontainer/Dockerfile": "bash ./scripts/tsnode ./scripts/check-versions.ts",
8 | ".node-version": "bash ./scripts/tsnode ./scripts/check-versions.ts",
9 | "package.json": "bash ./scripts/tsnode ./scripts/check-versions.ts",
10 | "*/package.json": "bash ./scripts/tsnode ./scripts/check-versions.ts",
11 | "*/Dockerfile": "bash ./scripts/tsnode ./scripts/check-versions.ts",
12 | "*.test.ts": "bash ./scripts/tsnode ./scripts/check-focused-tests.ts",
13 | "core/messages/*/en.ts": "bash ./scripts/tsnode ./scripts/check-messages.ts",
14 | "web/index.html": "pnpm -F web build:web"
15 | }
16 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - api
3 | - core
4 | - server
5 | - web
6 | - proxy
7 | - loader-tests
8 | - extension
9 | onlyBuiltDependencies:
10 | - husky
11 |
--------------------------------------------------------------------------------
/proxy/.c8rc.json:
--------------------------------------------------------------------------------
1 | {
2 | "all": true,
3 | "check-coverage": true,
4 | "clean": true,
5 | "exclude": [
6 | "dist/",
7 | "**/*.test.*",
8 | "test/*",
9 | "coverage/",
10 | "*.d.ts",
11 | "server.ts"
12 | ],
13 | "lines": 100,
14 | "reporter": ["text-summary", "text", "lcov"],
15 | "skip-full": true
16 | }
17 |
--------------------------------------------------------------------------------
/proxy/.dockerignore:
--------------------------------------------------------------------------------
1 | # Put only dist/ files to Docker image to make deploy faster
2 |
3 | *
4 | !dist/
5 |
--------------------------------------------------------------------------------
/proxy/.gitignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | dist/
3 |
--------------------------------------------------------------------------------
/proxy/.npmignore:
--------------------------------------------------------------------------------
1 | # What files need to be copied to dist/ folder to reduce security risks
2 |
3 | *
4 | !*.ts
5 | *.d.ts
6 |
--------------------------------------------------------------------------------
/proxy/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM cgr.dev/chainguard/wolfi-base:latest@sha256:aeb9e51fa607521afc213f69c8391ea22b4d5e1f42566a30610abbc1f018e3c2 as base
2 |
3 | ENV NODE_VERSION 22.16.0
4 | ENV NODE_CHECKSUM sha256:f4cb75bb036f0d0eddf6b79d9596df1aaab9ddccd6a20bf489be5abe9467e84e
5 |
6 | ADD --checksum=$NODE_CHECKSUM https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /node.tar.xz
7 | RUN tar -xf "node.tar.xz" --strip-components=1 -C /usr/local/ \
8 | "node-v${NODE_VERSION}-linux-x64/bin/node"
9 | RUN apk add --no-cache binutils
10 | RUN strip /usr/local/bin/node
11 |
12 | FROM cgr.dev/chainguard/glibc-dynamic:latest@sha256:2e513385804864a3ea3b6d4138b44cde11ad2513033c63600cb88353b8cac794
13 | WORKDIR /var/app
14 | ENV NODE_ENV production
15 |
16 | COPY --from=base /usr/local/bin/node /usr/local/bin/node
17 | COPY ./dist/ /var/app/
18 |
19 | USER nonroot
20 |
21 | ENTRYPOINT ["/usr/local/bin/node"]
22 | CMD ["--experimental-strip-types", "--disable-warning=ExperimentalWarning", "server.ts"]
23 |
--------------------------------------------------------------------------------
/proxy/README.md:
--------------------------------------------------------------------------------
1 | # Slow Reader Proxy
2 |
3 | HTTP-server to proxy all RSS fetching request from web client.
4 |
5 | User could use it to bypass censorship or to try web client before they install upcoming extension (to bypass CORS limit of web app).
6 |
7 | [Server](../server/) could use this proxy at `/proxy/*` endpoint.
8 |
9 | _See the [full architecture guide](../README.md) first._
10 |
11 | ## Scripts
12 |
13 | - `cd proxy && pnpm test`: run all proxy tests.
14 | - `cd proxy && pnpm start`: run proxy server.
15 | - `cd proxy && pnpm build`: prepare deploy files with production dependencies only.
16 | - `cd proxy && pnpm production`: start production build of the proxy server.
17 |
18 | ## Abuse Protection
19 |
20 | - Allows only GET requests and HTTP/HTTPS protocols.
21 | - Does not allow requests to in-cloud IP addresses like `127.0.0.1`.
22 | - Removes cookie headers.
23 | - Sets user’s IP in `X-Forwarded-For` header.
24 | - Has timeout and response size limit.
25 |
26 | ## Environment Variables
27 |
28 | To run proxy server you must define environment variables:
29 |
30 | - `PORT` with HTTP post to listen. It is Google Cloud Run convention.
31 | - `PROXY_ORIGIN` with RegExp for `Origin` header.
32 |
33 | Example:
34 |
35 | ```sh
36 | PORT=8080 PROXY_ORIGIN=^http:\\/\\/localhost:5173$ pnpm start
37 | ```
38 |
39 | ## Deploy
40 |
41 | For deploy we:
42 |
43 | 1. Use `pnpm deploy` to create `dist/` only with production dependencies.
44 | 2. Build Docker image with Node.js.
45 | 3. Run this image on Google Cloud Run.
46 |
47 | We have 2 proxy servers:
48 |
49 | - `proxy.slowreader.app` works only for production clients.
50 | - `dev-proxy.slowreader.app` works with staging.
51 |
52 | All Google Cloud settings are documented in [script](../scripts/prepare-google-cloud.sh).
53 |
--------------------------------------------------------------------------------
/proxy/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@slowreader/proxy",
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 | "scripts": {
14 | "start": "../scripts/tsnode --watch server.ts",
15 | "test": "FORCE_COLOR=1 pnpm run /^test:/",
16 | "build": "node --run clean:build && pnpm -F proxy --prod deploy dist",
17 | "production": "node --run build && ./scripts/run-image.sh",
18 | "test:proxy-coverage": "c8 bnt",
19 | "clean:coverage": "rm -rf coverage/",
20 | "clean:build": "rm -rf dist/"
21 | },
22 | "devDependencies": {
23 | "better-node-test": "0.7.1",
24 | "c8": "10.1.3"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/proxy/scripts/run-image.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Test real production environment with Podman or Docker
3 |
4 | source "$(dirname "$0")/../../scripts/utils.sh"
5 | build_and_run 5284 "-e PROXY_ORIGIN=^http:\\/\\/localhost:5173$"
6 |
--------------------------------------------------------------------------------
/proxy/server.ts:
--------------------------------------------------------------------------------
1 | import { createServer } from 'node:http'
2 | import { styleText } from 'node:util'
3 |
4 | import { createProxy, DEFAULT_PROXY_CONFIG } from './index.ts'
5 |
6 | if (!process.env.PROXY_ORIGIN || !process.env.PORT) {
7 | process.stderr.write(
8 | styleText('red', 'Set PROXY_ORIGIN and PORT environment variables\n')
9 | )
10 | process.exit(1)
11 | }
12 |
13 | let proxy = createServer(
14 | createProxy({ ...DEFAULT_PROXY_CONFIG, allowsFrom: process.env.PROXY_ORIGIN })
15 | )
16 | proxy.listen(process.env.PORT)
17 |
18 | process.on('SIGINT', () => {
19 | proxy.close()
20 | process.exit(0)
21 | })
22 |
--------------------------------------------------------------------------------
/scripts/check-focused-tests.ts:
--------------------------------------------------------------------------------
1 | // Script to avoid focused tests in the codebase.
2 | // The developer could focus test by using test.only() and forget to unfocus
3 | // it before committing the code.
4 |
5 | import { globSync } from 'node:fs'
6 | import { readFile } from 'node:fs/promises'
7 | import { join, relative } from 'node:path'
8 | import { styleText } from 'node:util'
9 |
10 | const ROOT = join(import.meta.dirname, '..')
11 |
12 | function check(
13 | all: Buffer,
14 | part: string,
15 | filename: string,
16 | message: string
17 | ): void {
18 | if (all.includes(part)) {
19 | let lines = all.toString().split('\n')
20 | let line = lines.findIndex(i => i.includes(part)) + 1
21 | let path = relative(ROOT, filename)
22 | process.stderr.write(styleText('red', `${path}:${line} ${message}\n`))
23 | process.exit(1)
24 | }
25 | }
26 |
27 | async function checkFile(filename: string): Promise {
28 | let code = await readFile(filename)
29 | check(code, 'test.only(', filename, 'has focused test')
30 | check(code, 'test.skip(', filename, 'has skipped test')
31 | }
32 |
33 | let files =
34 | process.argv.length > 2 ? process.argv.slice(2) : globSync('**/*.test.ts')
35 |
36 | for (let filename of files) {
37 | checkFile(filename)
38 | }
39 |
--------------------------------------------------------------------------------
/scripts/check-messages.ts:
--------------------------------------------------------------------------------
1 | // Script to check that all core/messages/*.en.ts files have the right name.
2 | // core/messages/foo.en.ts should exports fooMessages with 'foo' name.
3 |
4 | import { globSync } from 'node:fs'
5 | import { readFile } from 'node:fs/promises'
6 | import { dirname, join, relative } from 'node:path'
7 | import { styleText } from 'node:util'
8 |
9 | const ROOT = join(import.meta.dirname, '..')
10 | const MESSAGES = join(ROOT, 'core', 'messages')
11 |
12 | function check(all: Buffer, part: string, filename: string): void {
13 | if (!all.includes(part)) {
14 | let path = relative(ROOT, filename)
15 | process.stderr.write(styleText('red', `${path} has no "${part}"\n`))
16 | process.exit(1)
17 | }
18 | }
19 |
20 | async function checkFile(filename: string): Promise {
21 | let name = dirname(relative(MESSAGES, filename))
22 | let code = await readFile(filename)
23 | check(code, `export const ${name}Messages`, filename)
24 | check(code, `i18n('${name}', {`, filename)
25 | }
26 |
27 | let files =
28 | process.argv.length > 2
29 | ? process.argv.slice(2)
30 | : globSync(join(MESSAGES, '/**/en.ts'))
31 |
32 | for (let filename of files) {
33 | checkFile(filename)
34 | }
35 |
--------------------------------------------------------------------------------
/scripts/tsnode:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Shortcut to run Node.js with TS support and without experimental warnings
3 |
4 | node --experimental-strip-types --disable-warning=ExperimentalWarning $@
5 |
--------------------------------------------------------------------------------
/scripts/utils.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Common functions for testing production environments with Podman or Docker
3 |
4 | GOOD='\033[0;32m\033[1m'
5 | BAD='\033[0;31m\033[1m'
6 | ERROR='\033[0;31m'
7 | WARN='\033[0;33m'
8 | NOTE='\033[0;90m'
9 | NC='\033[0m'
10 |
11 | command_exists() {
12 | command -v "$1" >/dev/null 2>&1
13 | }
14 |
15 | get_container_tool() {
16 | if command_exists podman; then
17 | echo "podman"
18 | elif command_exists docker; then
19 | echo "docker"
20 | else
21 | echo -e "${ERROR}No Podman or Docker found${NC}" >&2
22 | exit 1
23 | fi
24 | }
25 |
26 | run_container() {
27 | local container_tool=$1
28 | local port=$2
29 | local envs=$3
30 | local image_id=$4
31 |
32 | $container_tool run --rm -p "$port:$port" -e PORT=$port $envs -it "$image_id"
33 | }
34 |
35 | build_and_run() {
36 | local port=$1
37 | local envs=$2
38 |
39 | local tool=$(get_container_tool)
40 |
41 | echo "Building image with $tool"
42 | BUILD_OUTPUT=$($tool build . 2>&1) || {
43 | echo -e "${ERROR}Build failed:${NC}\n$BUILD_OUTPUT"
44 | exit 1
45 | }
46 | IMAGE_ID=$(echo "$BUILD_OUTPUT" | tail -1)
47 |
48 | SIZE=$($tool image inspect "$IMAGE_ID" --format='{{.Size}}' | \
49 | awk '{printf "%d MB", $1/1024/1024}')
50 | echo -e "${WARN}Image size: ${SIZE}${NC}"
51 |
52 | run_container "$tool" "$port" "$envs" "$IMAGE_ID"
53 | }
54 |
55 |
--------------------------------------------------------------------------------
/server/.c8rc.json:
--------------------------------------------------------------------------------
1 | {
2 | "all": true,
3 | "check-coverage": true,
4 | "clean": true,
5 | "exclude": [
6 | "db/index.ts",
7 | "test/*",
8 | "coverage/",
9 | "dist/",
10 | "web/",
11 | "drizzle.config.ts",
12 | "index.ts"
13 | ],
14 | "lines": 100,
15 | "reporter": ["text-summary", "text", "lcov"],
16 | "skip-full": true
17 | }
18 |
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | # Put only dist/ files to Docker image to make deploy faster
2 |
3 | *
4 | !dist/
5 | !web/
6 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | db/pgdata/
3 | dist/
4 | web/
5 |
--------------------------------------------------------------------------------
/server/.npmignore:
--------------------------------------------------------------------------------
1 | # What files need to be copied to dist/ folder to reduce security risks
2 |
3 | *
4 | !*/*.ts
5 | !*.ts
6 | !db/**
7 | db/pgdata
8 | test/*
9 | drizzle.config.ts
10 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM cgr.dev/chainguard/wolfi-base:latest@sha256:aeb9e51fa607521afc213f69c8391ea22b4d5e1f42566a30610abbc1f018e3c2 as base
2 |
3 | ENV NODE_VERSION 22.16.0
4 | ENV NODE_CHECKSUM sha256:f4cb75bb036f0d0eddf6b79d9596df1aaab9ddccd6a20bf489be5abe9467e84e
5 |
6 | ADD --checksum=$NODE_CHECKSUM https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /node.tar.xz
7 | RUN tar -xf "node.tar.xz" --strip-components=1 -C /usr/local/ \
8 | "node-v${NODE_VERSION}-linux-x64/bin/node"
9 | RUN apk add --no-cache binutils
10 | RUN strip /usr/local/bin/node
11 |
12 | FROM cgr.dev/chainguard/glibc-dynamic:latest@sha256:2e513385804864a3ea3b6d4138b44cde11ad2513033c63600cb88353b8cac794
13 | WORKDIR /var/app
14 | ENV NODE_ENV production
15 | ENV LOGUX_HOST 0.0.0.0
16 | ENV LOGUX_LOGGER json
17 |
18 | COPY --from=base /usr/local/bin/node /usr/local/bin/node
19 | COPY ./dist/ /var/app/
20 | COPY ./web/ /var/web/
21 |
22 | USER nonroot
23 |
24 | ENTRYPOINT ["/usr/local/bin/node"]
25 | CMD ["--import", "tsx", "index.ts"]
26 |
--------------------------------------------------------------------------------
/server/db/index.ts:
--------------------------------------------------------------------------------
1 | import { PGlite } from '@electric-sql/pglite'
2 | import type { MigrationConfig } from 'drizzle-orm/migrator'
3 | import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
4 | import { drizzle as devDrizzle } from 'drizzle-orm/pglite'
5 | import { migrate as devMigrate } from 'drizzle-orm/pglite/migrator'
6 | import { drizzle as prodDrizzle } from 'drizzle-orm/postgres-js'
7 | import { migrate as prodMigrate } from 'drizzle-orm/postgres-js/migrator'
8 | import { Buffer } from 'node:buffer'
9 | import { existsSync } from 'node:fs'
10 | import { readFile, writeFile } from 'node:fs/promises'
11 | import { join } from 'node:path'
12 | import postgres from 'postgres'
13 |
14 | import { config } from '../lib/config.ts'
15 | import * as schema from './schema.ts'
16 | export * from './schema.ts'
17 |
18 | const MIGRATE_CONFIG: MigrationConfig = {
19 | migrationsFolder: join(import.meta.dirname, 'migrations')
20 | }
21 |
22 | let drizzle: PgDatabase
23 | if (
24 | config.db.startsWith('memory:') ||
25 | config.db.startsWith('file:') ||
26 | config.db.startsWith('dump:')
27 | ) {
28 | let pglite: PGlite
29 | if (config.db.startsWith('dump:')) {
30 | let path = config.db.slice(5)
31 | if (existsSync(path)) {
32 | let dump = await readFile(path)
33 | pglite = new PGlite({
34 | loadDataDir: new Blob([dump], { type: 'application/x-tar' })
35 | })
36 | } else {
37 | pglite = new PGlite()
38 | }
39 | async function dumpDb(): Promise {
40 | let blob = await pglite.dumpDataDir('none')
41 | await writeFile(path, Buffer.from(await blob.arrayBuffer()), {
42 | encoding: 'binary'
43 | })
44 | }
45 | setInterval(dumpDb, 60 * 60 * 1000)
46 | } else {
47 | pglite = new PGlite(config.db)
48 | }
49 | let drizzlePglite = devDrizzle(pglite, { schema })
50 | await devMigrate(drizzlePglite, MIGRATE_CONFIG)
51 | drizzle = drizzlePglite
52 | } else {
53 | drizzle = prodDrizzle(postgres(config.db), { schema })
54 | let migrateConnection = postgres(config.db, { max: 1 })
55 | await prodMigrate(prodDrizzle(migrateConnection), MIGRATE_CONFIG)
56 | await migrateConnection.end()
57 | }
58 |
59 | export const db = drizzle
60 |
--------------------------------------------------------------------------------
/server/db/migrations/0000_plpgsql.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS plpgsql;
2 |
--------------------------------------------------------------------------------
/server/db/migrations/0001_low_eternity.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "sessions" (
2 | "createdAt" timestamp DEFAULT now() NOT NULL,
3 | "id" serial PRIMARY KEY NOT NULL,
4 | "token" text NOT NULL,
5 | "usedAt" timestamp DEFAULT now() NOT NULL,
6 | "userId" text NOT NULL
7 | );
8 | --> statement-breakpoint
9 | CREATE TABLE IF NOT EXISTS "users" (
10 | "createdAt" timestamp DEFAULT now() NOT NULL,
11 | "id" text PRIMARY KEY NOT NULL
12 | );
13 | --> statement-breakpoint
14 | DO $$ BEGIN
15 | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
16 | EXCEPTION
17 | WHEN duplicate_object THEN null;
18 | END $$;
19 | --> statement-breakpoint
20 | CREATE INDEX IF NOT EXISTS "sessionsUserIdx" ON "sessions" USING btree ("userId");
--------------------------------------------------------------------------------
/server/db/migrations/0002_huge_zodiak.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "users" ADD COLUMN "passwordHash" text;
--------------------------------------------------------------------------------
/server/db/migrations/0003_lying_imperial_guard.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "sessions" ADD COLUMN "clientId" text;
--------------------------------------------------------------------------------
/server/db/migrations/0004_slippery_revanche.sql:
--------------------------------------------------------------------------------
1 | CREATE SEQUENCE "public"."actionsAdded" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1;--> statement-breakpoint
2 | CREATE TABLE "actions" (
3 | "added" integer NOT NULL,
4 | "encrypted" "bytea" NOT NULL,
5 | "id" text PRIMARY KEY NOT NULL,
6 | "iv" "bytea" NOT NULL,
7 | "subprotocol" text NOT NULL,
8 | "time" integer NOT NULL,
9 | "userId" text NOT NULL
10 | );
11 | --> statement-breakpoint
12 | ALTER TABLE "actions" ADD CONSTRAINT "actions_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/server/db/migrations/0005_faithful_punisher.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "actions" ADD COLUMN "compressed" boolean NOT NULL;
--------------------------------------------------------------------------------
/server/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1734006544415,
9 | "tag": "0000_plpgsql",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1724539278584,
16 | "tag": "0001_low_eternity",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1724722229148,
23 | "tag": "0002_huge_zodiak",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "7",
29 | "when": 1724891569801,
30 | "tag": "0003_lying_imperial_guard",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "7",
36 | "when": 1746203521591,
37 | "tag": "0004_slippery_revanche",
38 | "breakpoints": true
39 | },
40 | {
41 | "idx": 5,
42 | "version": "7",
43 | "when": 1746301512826,
44 | "tag": "0005_faithful_punisher",
45 | "breakpoints": true
46 | }
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/server/db/schema.ts:
--------------------------------------------------------------------------------
1 | import {
2 | boolean,
3 | customType,
4 | index,
5 | integer,
6 | pgSequence,
7 | pgTable,
8 | serial,
9 | text,
10 | timestamp
11 | } from 'drizzle-orm/pg-core'
12 |
13 | export const users = pgTable('users', {
14 | createdAt: timestamp('createdAt').notNull().defaultNow(),
15 | id: text('id').primaryKey(),
16 | passwordHash: text('passwordHash')
17 | })
18 |
19 | export const sessions = pgTable(
20 | 'sessions',
21 | {
22 | clientId: text('clientId'),
23 | createdAt: timestamp('createdAt').notNull().defaultNow(),
24 | id: serial('id').primaryKey(),
25 | token: text('token').notNull(),
26 | usedAt: timestamp('usedAt').notNull().defaultNow(),
27 | userId: text('userId')
28 | .references(() => users.id)
29 | .notNull()
30 | },
31 | table => [index('sessionsUserIdx').on(table.userId)]
32 | )
33 |
34 | const bytea = customType<{ data: Buffer; default: false; notNull: false }>({
35 | dataType() {
36 | return 'bytea'
37 | }
38 | })
39 |
40 | export const actionsAdded = pgSequence('actionsAdded')
41 |
42 | export const actions = pgTable('actions', {
43 | added: integer('added').notNull(),
44 | compressed: boolean('compressed').notNull(),
45 | encrypted: bytea('encrypted').notNull(),
46 | id: text('id').primaryKey(),
47 | iv: bytea('iv').notNull(),
48 | subprotocol: text('subprotocol').notNull(),
49 | time: integer('time').notNull(),
50 | userId: text('userId')
51 | .references(() => users.id)
52 | .notNull()
53 | })
54 |
--------------------------------------------------------------------------------
/server/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | // Config to drizzle-kit CLI
2 |
3 | import { defineConfig } from 'drizzle-kit'
4 |
5 | export default defineConfig({
6 | dbCredentials: {
7 | url: './db/pgdata'
8 | },
9 | dialect: 'postgresql',
10 | driver: 'pglite',
11 | out: './db/migrations',
12 | schema: './db/schema.ts'
13 | })
14 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import { Server } from '@logux/server'
2 | import { SUBPROTOCOL } from '@slowreader/api'
3 |
4 | const server = new Server(
5 | Server.loadOptions(process, {
6 | fileUrl: import.meta.url,
7 | host: '0.0.0.0',
8 | port: process.env.PORT,
9 | subprotocol: SUBPROTOCOL,
10 | supports: '0.x'
11 | })
12 | )
13 |
14 | await server.autoloadModules('modules/*.ts')
15 |
16 | server.listen().catch((error: unknown) => {
17 | throw error
18 | })
19 |
--------------------------------------------------------------------------------
/server/lib/config.ts:
--------------------------------------------------------------------------------
1 | export type Config = {
2 | assets: boolean
3 | db: string
4 | env: 'development' | 'production' | 'test'
5 | proxyOrigin: string | undefined
6 | staging: boolean
7 | }
8 |
9 | function getDefaultDatabase(env: Config['env']): string {
10 | if (env === 'production') {
11 | throw new Error('Set DATABASE_URL with PostgreSQL credentials')
12 | } else if (env === 'test') {
13 | return 'memory://'
14 | } else {
15 | return 'file://./db/pgdata'
16 | }
17 | }
18 |
19 | export function getConfig(from: Record): Config {
20 | let env = from.NODE_ENV ?? 'development'
21 | if (env !== 'test' && env !== 'production' && env !== 'development') {
22 | throw new Error('Unknown NODE_ENV')
23 | }
24 | let proxyOrigin = from.PROXY_ORIGIN
25 | if (!proxyOrigin && env === 'development') {
26 | proxyOrigin = '^http:\\/\\/localhost:\\d+$'
27 | }
28 | return {
29 | assets: !!from.ASSETS,
30 | db: from.DATABASE_URL ?? getDefaultDatabase(env),
31 | env,
32 | proxyOrigin,
33 | staging: !!from.STAGING
34 | }
35 | }
36 |
37 | export const config = getConfig(process.env)
38 |
--------------------------------------------------------------------------------
/server/lib/http.ts:
--------------------------------------------------------------------------------
1 | import type { BaseServer } from '@logux/server'
2 | import type { Endpoint } from '@slowreader/api'
3 | import type { IncomingMessage, ServerResponse } from 'node:http'
4 |
5 | function badRequest(res: ServerResponse, msg: string): true {
6 | res.writeHead(400, { 'Content-Type': 'text/plain' })
7 | res.end(msg)
8 | return true
9 | }
10 |
11 | function collectBody(req: IncomingMessage): Promise {
12 | return new Promise(resolve => {
13 | let data = ''
14 | req.on('data', chunk => {
15 | data += String(chunk)
16 | })
17 | req.on('end', () => {
18 | resolve(data)
19 | })
20 | })
21 | }
22 |
23 | export function jsonApi(
24 | server: BaseServer,
25 | endpoint: Endpoint,
26 | listener: (
27 | params: Request,
28 | res: ServerResponse,
29 | req: IncomingMessage
30 | ) => false | Promise | Promise | Response
31 | ): void {
32 | server.http(async (req, res) => {
33 | if (req.method === endpoint.method) {
34 | let url = new URL(req.url!, 'http://localhost')
35 | let urlParams = endpoint.parseUrl(url.pathname)
36 | if (urlParams) {
37 | if (req.headers['content-type'] !== 'application/json') {
38 | return badRequest(res, 'Wrong content type')
39 | }
40 | let data = await collectBody(req)
41 | let body: unknown
42 | try {
43 | body = JSON.parse(data)
44 | } catch {
45 | return badRequest(res, 'Invalid JSON')
46 | }
47 | let validated = endpoint.checkBody(body, urlParams)
48 | if (!validated) {
49 | return badRequest(res, 'Invalid body')
50 | }
51 | let answer = await listener(validated, res, req)
52 | if (answer === false) {
53 | return badRequest(res, 'Invalid request')
54 | }
55 | res.writeHead(200, { 'Content-Type': 'application/json' })
56 | res.end(JSON.stringify(answer))
57 |
58 | return true
59 | }
60 | }
61 | return false
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/server/modules/added.ts:
--------------------------------------------------------------------------------
1 | import type { BaseServer } from '@logux/server'
2 | import { sql } from 'drizzle-orm'
3 |
4 | import { actionsAdded, db } from '../db/index.ts'
5 |
6 | const NEXT_QUERY = sql.raw(`SELECT nextval('"${actionsAdded.seqName}"')`)
7 |
8 | export default (server: BaseServer): void => {
9 | server.log.store.getLastAdded = async () => {
10 | let result = (await db.execute(NEXT_QUERY)) as {
11 | rows: [{ nextval: number }]
12 | }
13 | return result.rows[0].nextval
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/server/modules/passwords.ts:
--------------------------------------------------------------------------------
1 | import type { BaseServer } from '@logux/server'
2 | import { deletePassword, setPassword } from '@slowreader/api'
3 | import { hash } from 'argon2'
4 | import { eq } from 'drizzle-orm'
5 |
6 | import { db, users } from '../db/index.ts'
7 |
8 | export default (server: BaseServer): void => {
9 | server.type(setPassword, {
10 | access(ctx, action) {
11 | return action.userId ? ctx.isServer : true
12 | },
13 | async process(ctx, action) {
14 | await db
15 | .update(users)
16 | .set({ passwordHash: await hash(action.password) })
17 | .where(eq(users.id, action.userId ?? ctx.userId))
18 | }
19 | })
20 | server.type(deletePassword, {
21 | access() {
22 | return true
23 | },
24 | async process(ctx) {
25 | await db
26 | .update(users)
27 | .set({ passwordHash: null })
28 | .where(eq(users.id, ctx.userId))
29 | }
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/server/modules/proxy.ts:
--------------------------------------------------------------------------------
1 | import type { BaseServer } from '@logux/server'
2 | import {
3 | createProxy,
4 | DEFAULT_PROXY_CONFIG,
5 | type ProxyConfig
6 | } from '@slowreader/proxy'
7 |
8 | import { config } from '../lib/config.ts'
9 |
10 | export default (server: BaseServer, opts: Partial = {}): void => {
11 | let allowsFrom = config.proxyOrigin ?? opts.allowsFrom
12 | if (!allowsFrom) return
13 | server.logger.info('CORS proxy is enabled')
14 |
15 | let proxy = createProxy({
16 | ...DEFAULT_PROXY_CONFIG,
17 | ...opts,
18 | allowsFrom
19 | })
20 |
21 | server.http((req, res) => {
22 | if (req.url!.startsWith('/proxy/')) {
23 | proxy(req, res)
24 | return true
25 | } else {
26 | return false
27 | }
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/server/modules/sync.ts:
--------------------------------------------------------------------------------
1 | import { zero, zeroClean } from '@logux/actions'
2 | import type { BaseServer } from '@logux/server'
3 | import { SUBPROTOCOL } from '@slowreader/api'
4 | import { and, eq, gt } from 'drizzle-orm'
5 |
6 | import { actions, db } from '../db/index.ts'
7 |
8 | export default (server: BaseServer): void => {
9 | server.type(zero, {
10 | access() {
11 | return true
12 | },
13 | async process(ctx, action, meta) {
14 | await db.insert(actions).values({
15 | added: await server.log.store.getLastAdded(),
16 | compressed: action.z,
17 | encrypted: Buffer.from(action.d, 'base64'),
18 | id: meta.id,
19 | iv: Buffer.from(action.iv, 'base64'),
20 | subprotocol: meta.subprotocol ?? SUBPROTOCOL,
21 | time: meta.time,
22 | userId: ctx.userId
23 | })
24 | },
25 | resend(ctx) {
26 | return { user: ctx.userId }
27 | }
28 | })
29 |
30 | server.type(zeroClean, {
31 | async access(ctx, action) {
32 | let deleting = await db.query.actions.findFirst({
33 | where: eq(actions.id, action.id)
34 | })
35 | return deleting?.userId === ctx.userId
36 | },
37 | async process(ctx, action) {
38 | await db.delete(actions).where(eq(actions.id, action.id))
39 | },
40 | resend(ctx) {
41 | return { user: ctx.userId }
42 | }
43 | })
44 |
45 | server.sendOnConnect(async (ctx, lastSynced) => {
46 | let list = await db.query.actions.findMany({
47 | where: and(eq(actions.userId, ctx.userId), gt(actions.added, lastSynced))
48 | })
49 | return list.map(column => {
50 | return [
51 | zero({
52 | d: Buffer.from(column.encrypted).toString('base64'),
53 | iv: Buffer.from(column.iv).toString('base64'),
54 | z: column.compressed
55 | }),
56 | {
57 | added: column.added,
58 | id: column.id,
59 | subprotocol: column.subprotocol,
60 | time: column.time
61 | }
62 | ]
63 | })
64 | })
65 | }
66 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@slowreader/server",
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 | "start": "../scripts/tsnode --watch index.ts",
12 | "migration": "drizzle-kit generate && prettier --write ./db/**/*.json",
13 | "database": "drizzle-kit studio",
14 | "build": "node --run clean:build && pnpm run /^build:/",
15 | "production": "node --run build && ./scripts/run-image.sh",
16 | "build:server": "pnpm -F server --prod deploy dist",
17 | "build:assets": "mkdir -p web/ && cp -fr ../web/dist web/ && cp ../web/routes.regexp web/",
18 | "test:server-coverage": "c8 bnt",
19 | "clean:coverage": "rm -rf coverage",
20 | "clean:build": "rm -rf dist/ web/"
21 | },
22 | "dependencies": {
23 | "@electric-sql/pglite": "0.3.2",
24 | "@logux/actions": "github:logux/actions#next",
25 | "@logux/server": "github:logux/server#next",
26 | "@slowreader/api": "workspace:*",
27 | "@slowreader/proxy": "workspace:*",
28 | "argon2": "0.43.0",
29 | "cookie": "1.0.2",
30 | "drizzle-orm": "0.44.1",
31 | "nanoid": "5.1.5",
32 | "postgres": "3.4.7",
33 | "tsx": "4.19.4"
34 | },
35 | "devDependencies": {
36 | "@logux/client": "github:logux/client#next",
37 | "better-node-test": "0.7.1",
38 | "c8": "10.1.3",
39 | "drizzle-kit": "0.31.1",
40 | "prettier": "3.5.3"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/server/scripts/run-image.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Test real production environment with Podman or Docker
3 |
4 | source "$(dirname "$0")/../../scripts/utils.sh"
5 | build_and_run 31337 "-e DATABASE_URL=memory:// -e ASSETS=1"
6 |
--------------------------------------------------------------------------------
/server/test/config.test.ts:
--------------------------------------------------------------------------------
1 | import { deepStrictEqual, equal, match, throws } from 'node:assert'
2 | import { test } from 'node:test'
3 |
4 | import { config, getConfig } from '../lib/config.ts'
5 |
6 | const DATABASE_URL = 'postgresql://user:pass@localhost:5432/db'
7 |
8 | test('throws on missed DATABASE_URL in production', () => {
9 | throws(() => {
10 | getConfig({ NODE_ENV: 'production' })
11 | }, /Set DATABASE_URL with PostgreSQL credentials/)
12 | equal(
13 | getConfig({
14 | DATABASE_URL,
15 | NODE_ENV: 'production'
16 | }).db,
17 | DATABASE_URL
18 | )
19 | equal(getConfig({ NODE_ENV: 'test' }).db, 'memory://')
20 | equal(getConfig({ DATABASE_URL, NODE_ENV: 'test' }).db, DATABASE_URL)
21 | equal(getConfig({ NODE_ENV: 'development' }).db, 'file://./db/pgdata')
22 | equal(getConfig({ DATABASE_URL, NODE_ENV: 'development' }).db, DATABASE_URL)
23 | })
24 |
25 | test('checks environment', () => {
26 | equal(getConfig({}).env, 'development')
27 | equal(getConfig({ NODE_ENV: 'test' }).env, 'test')
28 | throws(() => {
29 | getConfig({ NODE_ENV: 'staging' })
30 | }, /NODE_ENV/)
31 | equal(getConfig({}).staging, false)
32 | equal(getConfig({ STAGING: '1' }).staging, true)
33 | })
34 |
35 | test('sets proxy origin', () => {
36 | match(getConfig({ NODE_ENV: 'development' }).proxyOrigin!, /localhost/)
37 | equal(
38 | getConfig({ DATABASE_URL, NODE_ENV: 'production' }).proxyOrigin,
39 | undefined
40 | )
41 | equal(
42 | getConfig({
43 | DATABASE_URL,
44 | NODE_ENV: 'production',
45 | PROXY_ORIGIN: '^http:\\/\\/slowreader.app$'
46 | }).proxyOrigin,
47 | '^http:\\/\\/slowreader.app$'
48 | )
49 | })
50 |
51 | test('passes keys', () => {
52 | deepStrictEqual(
53 | getConfig({
54 | ASSETS: '1',
55 | DATABASE_URL,
56 | NODE_ENV: 'production',
57 | PROXY_ORIGIN: '^http:\\/\\/slowreader.app$'
58 | }),
59 | {
60 | assets: true,
61 | db: DATABASE_URL,
62 | env: 'production',
63 | proxyOrigin: '^http:\\/\\/slowreader.app$',
64 | staging: false
65 | }
66 | )
67 | })
68 |
69 | test('has predefined config', () => {
70 | equal(config.env, 'test')
71 | })
72 |
--------------------------------------------------------------------------------
/server/test/utils.ts:
--------------------------------------------------------------------------------
1 | import { PgTable } from 'drizzle-orm/pg-core'
2 | import { equal } from 'node:assert'
3 |
4 | import { db } from '../db/index.ts'
5 | import * as tables from '../db/schema.ts'
6 |
7 | export async function cleanAllTables(): Promise {
8 | await Promise.all(
9 | Object.values(tables).map(async table => {
10 | if (table instanceof PgTable) {
11 | await db.delete(table)
12 | }
13 | })
14 | )
15 | }
16 |
17 | export async function throws(
18 | cb: () => Promise,
19 | msg: string
20 | ): Promise {
21 | let error: Error | undefined
22 | try {
23 | await cb()
24 | } catch (e) {
25 | error = e as Error
26 | }
27 | equal(error?.message, msg)
28 | return error
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowImportingTsExtensions": true,
4 | "noUncheckedIndexedAccess": true,
5 | "experimentalDecorators": true,
6 | "noImplicitOverride": true,
7 | "verbatimModuleSyntax": true,
8 | "moduleResolution": "NodeNext",
9 | "erasableSyntaxOnly": true,
10 | "esModuleInterop": true,
11 | "skipLibCheck": true,
12 | "allowJs": true,
13 | "target": "ES2024",
14 | "module": "NodeNext",
15 | "strict": true,
16 | "noEmit": true,
17 | "types": ["chrome", "node"],
18 | "lib": ["es2024", "dom"]
19 | },
20 | "include": ["**/*.ts", "**/*.cts", "web/**/*.svelte", "web/.storybook/*.ts"],
21 | "exclude": ["*/dist", "web-archive/**/*"]
22 | }
23 |
--------------------------------------------------------------------------------
/web-archive/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "ignoreFiles": ["**/*"],
3 | "rules": {}
4 | }
5 |
--------------------------------------------------------------------------------
/web-archive/pages/busy.svelte:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
--------------------------------------------------------------------------------
/web-archive/pages/feeds/export.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 | {#snippet one()}
27 | {$t.exportTitle}
28 |
29 |
30 | {#each formats as format (format.id)}
31 |
39 | {/each}
40 |
41 | {/snippet}
42 | {#snippet two()}
43 |
44 | {#if formatId === 'opml'}
45 |
46 | {/if}
47 | {#if formatId === 'internal'}
48 |
49 | {/if}
50 |
51 | {/snippet}
52 |
53 |
--------------------------------------------------------------------------------
/web-archive/pages/feeds/opml.svelte:
--------------------------------------------------------------------------------
1 |
41 |
42 | {$t.chooseTitle}
43 |
74 |
75 |
82 |
--------------------------------------------------------------------------------
/web-archive/pages/feeds/posts.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 | {#if $posts.isLoading}
25 |
26 | {:else}
27 |
28 | {#each $posts.list as post (post.originId)}
29 | -
30 |
31 |
32 | {/each}
33 |
34 | {/if}
35 |
36 |
43 |
--------------------------------------------------------------------------------
/web-archive/pages/not-found.svelte:
--------------------------------------------------------------------------------
1 | 404
2 |
--------------------------------------------------------------------------------
/web-archive/pages/refresh.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 | {#if $isRefreshing}
10 |
11 | {/if}
12 |
--------------------------------------------------------------------------------
/web-archive/pages/settings/about.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | {$t.source}
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/web-archive/pages/settings/download.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 | {
32 | preloadImages.set(value)
33 | }}
34 | values={preloadOptions}
35 | />
36 |
37 |
38 |
--------------------------------------------------------------------------------
/web-archive/pages/settings/interface.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | {
15 | theme.set(value)
16 | }}
17 | values={[
18 | ['system', $t.themeSystem],
19 | ['light', $t.themeLight],
20 | ['dark', $t.themeDark]
21 | ]}
22 | />
23 |
24 |
25 |
--------------------------------------------------------------------------------
/web-archive/pages/settings/profile.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/web-archive/pages/slow.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 | {#snippet one()}
20 | {#if $slowPosts.isLoading}
21 |
22 | {:else if $slowPosts.list.length === 0}
23 | {$t.noPosts}
24 | {:else}
25 |
26 | {#each $slowPosts.list as post (post.id)}
27 | -
28 |
39 |
40 | {/each}
41 |
42 | {#if $slowPosts.isLoading}
43 |
44 | {/if}
45 | {#if $totalSlowPages > 1}
46 |
51 | {/if}
52 | {/if}
53 | {/snippet}
54 | {#snippet two()}
55 | {#if $openedSlowPost}
56 | {#if $openedSlowPost.isLoading}
57 |
58 | {:else}
59 |
60 | {/if}
61 | {/if}
62 | {/snippet}
63 |
64 |
65 |
72 |
--------------------------------------------------------------------------------
/web-archive/pages/start.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 | {$t.title}
14 |
15 |
16 | {$t.localDescription1}
17 | {$t.localDescription2}
18 |
19 |
22 |
23 |
24 |
25 |
26 |
27 |
34 |
--------------------------------------------------------------------------------
/web-archive/pages/welcome.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/web-archive/stories/assets/long_width_example.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hplush/slowreader/521b91795a6fa775a0510f1e06c69996f56b91e9/web-archive/stories/assets/long_width_example.avif
--------------------------------------------------------------------------------
/web-archive/stories/assets/short_width_example.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hplush/slowreader/521b91795a6fa775a0510f1e06c69996f56b91e9/web-archive/stories/assets/short_width_example.avif
--------------------------------------------------------------------------------
/web-archive/stories/pages/busy.stories.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/fast.stories.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/feeds/categories.stories.svelte:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/feeds/export.stories.svelte:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/refresh.stories.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/settings/download.stories.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/settings/interface.stories.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/slow.stories.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
31 |
33 |
34 |
35 |
36 |
37 |
39 |
40 |
--------------------------------------------------------------------------------
/web-archive/stories/pages/start.stories.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web-archive/stories/ui/pagination-bar.stories.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
18 |
19 |
20 |
21 | No pagination
22 | {}}
26 | totalPages={1}
27 | />
28 |
29 |
30 | {}}
34 | totalPages={2}
35 | />
36 |
37 |
38 | {}}
42 | totalPages={3}
43 | />
44 |
45 |
46 | {}}
50 | totalPages={3}
51 | />
52 |
53 |
54 | {}}
58 | totalPages={3}
59 | />
60 |
61 |
62 | {}}
66 | totalPages={3}
67 | />
68 |
69 |
70 | {
74 | page.set(value)
75 | }}
76 | totalPages={50}
77 | />
78 |
79 |
80 |
81 |
82 |
83 | {}}
87 | totalPages={2}
88 | />
89 |
90 |
91 |
--------------------------------------------------------------------------------
/web-archive/stories/ui/rich-translation.stories.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
17 |
18 | <i>XSS</i>'} />
19 |
20 |
23 |
26 |
29 |
32 |
33 |
--------------------------------------------------------------------------------
/web-archive/stories/ui/select-field.stories.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
16 |
17 |
18 |
19 | {
23 | selectValue = value
24 | }}
25 | values={[
26 | ['1', 'First'],
27 | ['2', 'Second'],
28 | ['3', 'Third']
29 | ]}
30 | />
31 |
32 |
43 |
54 |
65 |
76 |
88 |
89 |
--------------------------------------------------------------------------------
/web-archive/ui/card-actions.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
10 |
11 |
23 |
--------------------------------------------------------------------------------
/web-archive/ui/card-links.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 | {@render children()}
11 |
12 |
13 |
29 |
--------------------------------------------------------------------------------
/web-archive/ui/card.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | {@render children()}
9 |
10 |
11 |
27 |
--------------------------------------------------------------------------------
/web-archive/ui/hotkey.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | {getHotKeyHint(window, hotkey)}
9 |
10 |
11 |
28 |
--------------------------------------------------------------------------------
/web-archive/ui/icon.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
13 |
14 |
35 |
--------------------------------------------------------------------------------
/web-archive/ui/navbar/category.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 | {name}
6 |
7 |
20 |
--------------------------------------------------------------------------------
/web-archive/ui/navbar/fast.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 | {#if $fastCategories.isLoading}
25 |
26 | {:else if $fastCategories.categories.length > 0}
27 | {#each $fastCategories.categories as category (category.id)}
28 |
35 | {/each}
36 | {/if}
37 |
--------------------------------------------------------------------------------
/web-archive/ui/navbar/fireplace.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
81 |
--------------------------------------------------------------------------------
/web-archive/ui/navbar/other.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
31 |
32 |
39 |
40 |
47 |
48 |
55 |
56 |
57 |
58 |
65 |
66 |
73 |
74 |
81 |
82 |
89 |
--------------------------------------------------------------------------------
/web-archive/ui/navbar/progress.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
36 |
37 |
54 |
--------------------------------------------------------------------------------
/web-archive/ui/navbar/slow.svelte:
--------------------------------------------------------------------------------
1 |
29 |
30 | {#if $slowCategories.isLoading}
31 |
32 | {:else}
33 | {#each $slowCategories.tree as [category, feeds] (category.id)}
34 |
35 | {#each feeds as [feed, unread] (feed.id)}
36 |
42 | {/each}
43 | {/each}
44 | {/if}
45 |
--------------------------------------------------------------------------------
/web-archive/ui/page-title.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | {@render children()}
9 |
10 |
11 |
19 |
--------------------------------------------------------------------------------
/web-archive/ui/page.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
30 | {@render children()}
31 |
32 |
33 |
63 |
--------------------------------------------------------------------------------
/web-archive/ui/paragraph.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 | {@render children()}
9 |
10 |
11 |
26 |
--------------------------------------------------------------------------------
/web-archive/ui/post-card.svelte:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
36 | {#if author}
37 | {author.title}:
38 | {/if}
39 | {#if post.title}
40 |
41 | {#if post.url}
42 |
43 | {post.title}
44 |
45 | {:else}
46 | {post.title}
47 | {/if}
48 |
49 | {/if}
50 | {#if full}
51 |
52 | {:else}
53 |
54 | {/if}
55 | {#if open}
56 |
57 | {/if}
58 |
59 |
60 |
61 |
87 |
--------------------------------------------------------------------------------
/web-archive/ui/rich-translation.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 | {#if url}
12 | {@html parseLink(parseRichTranslation(text), url)}
13 | {:else}
14 | {@html parseRichTranslation(text)}
15 | {/if}
16 |
17 |
18 |
29 |
--------------------------------------------------------------------------------
/web-archive/ui/row.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | {@render children()}
10 |
11 |
12 |
25 |
--------------------------------------------------------------------------------
/web-archive/ui/under-construction.svelte:
--------------------------------------------------------------------------------
1 | Under Construction
2 |
3 |
22 |
--------------------------------------------------------------------------------
/web/.browserslistrc:
--------------------------------------------------------------------------------
1 | # Browsers that we support
2 | # See actual browsers: https://browsersl.ist/#q=defaults+and+supports+es6-module
3 | #
4 | # Don’t forget to update links on changes.
5 |
6 | defaults and supports es6-module
7 |
--------------------------------------------------------------------------------
/web/.dockerignore:
--------------------------------------------------------------------------------
1 | # Put only dist/ and nginx.conf files to Docker image to make deploy faster
2 |
3 | *
4 | !dist/
5 | !nginx.conf.compiled
6 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | dist/
3 | storybook-static/
4 | vite.config.ts.timestamp*
5 | routes.regexp
6 | nginx.conf.compiled
7 |
--------------------------------------------------------------------------------
/web/.size-limit.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Files to download",
4 | "path": [
5 | "dist/**/*",
6 | "!dist/.well-known",
7 | "!dist/ui/**",
8 | "!dist/404.html",
9 | "!dist/500.html",
10 | "!dist/error.avif"
11 | ],
12 | "limit": "50 KB"
13 | },
14 | {
15 | "name": "Core scripts to execute",
16 | "path": "dist/assets/index-*.js",
17 | "brotli": false,
18 | "limit": "125 KB"
19 | }
20 | ]
21 |
--------------------------------------------------------------------------------
/web/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/svelte-vite'
2 | import type { InlineConfig } from 'vite'
3 |
4 | export default {
5 | addons: ['@storybook/addon-svelte-csf', '@storybook/addon-themes'],
6 | core: {
7 | disableTelemetry: true
8 | },
9 | framework: '@storybook/svelte-vite',
10 | stories: ['../stories/**/*.stories.svelte'],
11 | viteFinal(config: InlineConfig) {
12 | config.publicDir = false
13 | return Promise.resolve(config)
14 | }
15 | } satisfies StorybookConfig
16 |
--------------------------------------------------------------------------------
/web/.storybook/manager-head.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/web/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import '../main/browser.ts'
2 | import '../stories/environment.ts'
3 |
4 | import '../main/index.css'
5 | import '../main/loader.css'
6 |
7 | import { withThemeByClassName } from '@storybook/addon-themes'
8 | import type { Preview } from '@storybook/svelte'
9 | import { INITIAL_VIEWPORTS, MINIMAL_VIEWPORTS } from 'storybook/viewport'
10 |
11 | export default {
12 | decorators: [
13 | withThemeByClassName({
14 | defaultTheme: 'light',
15 | themes: {
16 | dark: 'is-dark-theme',
17 | light: 'is-light-theme'
18 | }
19 | })
20 | ],
21 | parameters: {
22 | viewport: {
23 | defaultViewport: 'responsive',
24 | viewports: {
25 | ...INITIAL_VIEWPORTS,
26 | ...MINIMAL_VIEWPORTS,
27 | ipad: {
28 | name: 'iPad',
29 | styles: {
30 | height: '768px',
31 | width: '1024px'
32 | },
33 | type: 'tablet'
34 | }
35 | }
36 | }
37 | }
38 | } satisfies Preview
39 |
--------------------------------------------------------------------------------
/web/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@logux/stylelint-config", "@stylistic/stylelint-config"],
3 | "ignoreFiles": ["*/coverage/lcov-report/*", "dist/**/*"],
4 | "plugins": ["stylelint-use-logical", "@stylistic/stylelint-plugin"],
5 | "rules": {
6 | "function-disallowed-list": ["rgb", "rgba", "hsl", "hsla"],
7 | "alpha-value-notation": "percentage",
8 | "@stylistic/declaration-colon-newline-after": null,
9 | "@stylistic/string-quotes": "single",
10 | "csstools/use-logical": [
11 | "always",
12 | {
13 | "except": [
14 | "height",
15 | "width",
16 | "min-height",
17 | "min-width",
18 | "max-height",
19 | "max-width",
20 | "top",
21 | "bottom",
22 | "margin-top",
23 | "margin-bottom",
24 | "padding-top",
25 | "padding-bottom",
26 | "border-top",
27 | "border-bottom"
28 | ]
29 | }
30 | ],
31 | "no-descending-specificity": null,
32 | "color-named": "never"
33 | },
34 | "overrides": [
35 | {
36 | "files": ["**/*.svelte"],
37 | "customSyntax": "postcss-html"
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/web/Dockerfile:
--------------------------------------------------------------------------------
1 | # Web server to serve web client for staging and pull request previews
2 |
3 | FROM cgr.dev/chainguard/nginx:latest@sha256:ef859f025ed894cd9d0a4065e3b969ba0065c440bd1a833ea5e35cf8751fc2a5
4 |
5 | COPY ./nginx.conf.compiled /etc/nginx/nginx.conf
6 | COPY ./dist/ /var/www/
7 |
--------------------------------------------------------------------------------
/web/main/browser.ts:
--------------------------------------------------------------------------------
1 | import { comfortMode, theme } from '@slowreader/core'
2 |
3 | import { locale } from '../stores/locale.ts'
4 |
5 | let root = document.documentElement
6 | let themeTag = document.querySelector('meta[name="theme-color"]')
7 |
8 | function updateTheme(): void {
9 | let background = window
10 | .getComputedStyle(document.body)
11 | .getPropertyValue('background-color')
12 |
13 | if (themeTag && background) {
14 | themeTag.setAttribute('content', background)
15 | }
16 | }
17 |
18 | comfortMode.subscribe(mode => {
19 | root.classList.toggle('is-comfort-mode', mode)
20 | updateTheme()
21 | })
22 |
23 | theme.subscribe(themeValue => {
24 | root.classList.toggle('is-dark-theme', themeValue === 'dark')
25 | root.classList.toggle('is-light-theme', themeValue === 'light')
26 | updateTheme()
27 | })
28 |
29 | locale.subscribe(lang => {
30 | root.lang = lang
31 | })
32 |
33 | window.addEventListener('load', () => {
34 | updateTheme()
35 | })
36 |
37 | window.addEventListener('load', () => {
38 | import('./devtools.ts').then(() => {})
39 | })
40 |
--------------------------------------------------------------------------------
/web/main/colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --focus-color: var(--accent-color);
3 |
4 | @media (prefers-color-scheme: light) {
5 | --text-color: oklch(0.15 0 0);
6 | --land-color: oklch(0.95 0 70);
7 |
8 | &.is-comfort-mode {
9 | --land-color: oklch(0.95 0.02 70);
10 | }
11 | }
12 |
13 | @media (prefers-color-scheme: dark) {
14 | --text-color: oklch(0.98 0 0);
15 | --land-color: oklch(0.25 0 70);
16 |
17 | &.is-comfort-mode {
18 | --land-color: oklch(0.25 0.02 70);
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/web/main/common.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --base-font: 1rem/1.6 system-ui;
3 | }
4 |
--------------------------------------------------------------------------------
/web/main/devtools.ts:
--------------------------------------------------------------------------------
1 | import { fillFeedsWithPosts } from '@slowreader/core'
2 |
3 | declare global {
4 | interface Window {
5 | fillFeedsWithPosts?: typeof fillFeedsWithPosts
6 | }
7 | }
8 |
9 | window.fillFeedsWithPosts = fillFeedsWithPosts
10 |
--------------------------------------------------------------------------------
/web/main/global.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | interface Navigator {
5 | connection:
6 | | {
7 | saveData?: boolean
8 | type?:
9 | | 'bluetooth'
10 | | 'cellular'
11 | | 'ethernet'
12 | | 'none'
13 | | 'other'
14 | | 'unknown'
15 | | 'wifi'
16 | | 'wimax'
17 | }
18 | | undefined
19 | }
20 |
21 | declare module '*.avif'
22 | declare module '*.png'
23 |
--------------------------------------------------------------------------------
/web/main/index.css:
--------------------------------------------------------------------------------
1 | @import url('./reset.css');
2 | @import url('./colors.css');
3 | @import url('./common.css');
4 |
5 | :root {
6 | color-scheme: light dark;
7 |
8 | &.is-light-theme {
9 | color-scheme: light;
10 | }
11 |
12 | &.is-dark-theme {
13 | color-scheme: dark;
14 | }
15 | }
16 |
17 | * {
18 | flex-shrink: 0;
19 | }
20 |
21 | body {
22 | color: var(--text-color);
23 | background: var(--land-color);
24 | -webkit-tap-highlight-color: oklch(0 0 0 / 0%);
25 | }
26 |
27 | body,
28 | input {
29 | font: var(--base-font);
30 | }
31 |
32 | :focus-visible {
33 | z-index: 10;
34 | outline: 3px solid var(--focus-color);
35 | outline-offset: 3px;
36 | transition:
37 | outline-width 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
38 | outline-offset 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
39 | }
40 |
41 | :focus-visible:active {
42 | outline-offset: 0;
43 | transition: none;
44 | }
45 |
46 | ul[role='list'],
47 | ol[role='list'] {
48 | list-style: none;
49 | }
50 |
51 | #main {
52 | box-sizing: border-box;
53 | width: 100%;
54 | }
55 |
--------------------------------------------------------------------------------
/web/main/index.ts:
--------------------------------------------------------------------------------
1 | import './environment.ts'
2 | import './browser.ts'
3 |
4 | import './index.css'
5 |
6 | import { busyUntilMenuLoader } from '@slowreader/core'
7 | import { mount } from 'svelte'
8 |
9 | import Main from './main.svelte'
10 |
11 | busyUntilMenuLoader()
12 |
13 | let target = document.getElementById('main')
14 | if (target) mount(Main, { target })
15 |
16 | document.querySelector('title + style')!.remove()
17 |
--------------------------------------------------------------------------------
/web/main/main.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 | {#if $busy}
8 |
9 | {:else}
10 | {$router.route}
11 | {/if}
12 |
--------------------------------------------------------------------------------
/web/main/reset.css:
--------------------------------------------------------------------------------
1 | /* Remove default padding */
2 |
3 | body,
4 | h1,
5 | h2,
6 | h3,
7 | p,
8 | ul,
9 | ol,
10 | li,
11 | figure,
12 | figcaption,
13 | blockquote,
14 | dl,
15 | dd,
16 | pre,
17 | input,
18 | button,
19 | fieldset {
20 | padding: 0;
21 | margin: 0;
22 | }
23 |
24 | /* Use screen height */
25 |
26 | html {
27 | height: 100%;
28 | }
29 |
30 | /* Prevent font size change after orientation changes in iOS */
31 |
32 | body {
33 | min-height: 100%;
34 | -webkit-text-size-adjust: 100%;
35 | -webkit-font-smoothing: antialiased;
36 | -moz-osx-font-smoothing: grayscale;
37 | }
38 |
39 | /* Fix default display */
40 |
41 | img,
42 | label,
43 | legend {
44 | display: block;
45 | }
46 |
47 | /* Global font in controls */
48 |
49 | input,
50 | button,
51 | textarea,
52 | select {
53 | font: inherit;
54 | }
55 |
56 | /* Fix clickable styling in iOS and Safari */
57 |
58 | button {
59 | -webkit-appearance: button;
60 | }
61 |
62 | /* Pointer cursor for all interactive elements */
63 |
64 | a,
65 | select:not(:disabled),
66 | button:not(:disabled),
67 | label[tabindex] {
68 | cursor: pointer;
69 | }
70 |
71 | /* Remove border in Firefox */
72 |
73 | fieldset {
74 | border: none;
75 | }
76 |
--------------------------------------------------------------------------------
/web/nginx.conf:
--------------------------------------------------------------------------------
1 | # Web server to serve web client for staging and pull request previews.
2 | # Headers/redirect logic is duplicated between this file
3 | # and server/modules/assets.ts.
4 | # If you change anything here, change the second place too.
5 |
6 | worker_processes 1;
7 | pid /var/run/nginx.pid;
8 |
9 | events {
10 | worker_connections 1024;
11 | }
12 |
13 | http {
14 | access_log off;
15 | error_log stderr error;
16 | server_tokens off;
17 |
18 | include mime.types;
19 | types {
20 | application/manifest+json webmanifest;
21 | }
22 | default_type application/octet-stream;
23 | charset_types application/javascript text/css application/manifest+json image/svg+xml;
24 | sendfile on;
25 |
26 | server {
27 | listen 8080;
28 |
29 | root /var/www;
30 | charset UTF-8;
31 | gzip_static on;
32 |
33 | gzip on;
34 | gzip_types text/css application/javascript application/json application/manifest+json image/svg+xml;
35 |
36 | error_page 404 /404.html;
37 | error_page 500 /500.html;
38 |
39 | absolute_redirect off;
40 |
41 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
42 | add_header X-Content-Type-Options "nosniff";
43 | add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-xSVdXLow/pL0wb4kHDfDS6NDjZW+sg6IxJyrm4yNlng=' 'sha256-eJHzD9wIr2E84rPeZQeXO3bt9ihyOFLyeZZXh4m3BpE=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'";
44 |
45 | location ~* __ROUTES__ {
46 | try_files /index.html =404;
47 | }
48 |
49 | location ~* ^(/ui)?/assets {
50 | expires 1y;
51 | add_header Cache-Control 'public';
52 | }
53 |
54 | rewrite ^/ui$ /ui/ permanent;
55 |
56 | location /ui/ {
57 | add_header Content-Security-Policy "";
58 | try_files $uri $uri/ =404;
59 | }
60 |
61 | location / {
62 | rewrite ^(.+)/$ $1 permanent;
63 | try_files $uri $uri/ =404;
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/web/pages/busy.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 | {#if !pageLoader}
25 |
26 | {/if}
27 |
28 |
29 |
40 |
--------------------------------------------------------------------------------
/web/postcss.config.cts:
--------------------------------------------------------------------------------
1 | let autoprefixer = require('autoprefixer')
2 |
3 | let htmlKeeper = require('./postcss/html-keeper.cts')
4 | let pseudoClasses = require('./postcss/pseudo-classes.cts')
5 | let themeClasses = require('./postcss/theme-classes.cts')
6 |
7 | module.exports = {
8 | plugins: [htmlKeeper, themeClasses, pseudoClasses, autoprefixer]
9 | }
10 |
--------------------------------------------------------------------------------
/web/postcss/html-keeper.cts:
--------------------------------------------------------------------------------
1 | // PostCSS plugin to avoid any CSS changes by Vite in index.html inline styles.
2 | // We use this inline styles to show some loading state.
3 | //
4 | // These changes are not useful in our case. So we save CSS document before
5 | // any other PostCSS plugin and then restore it after all other plugins.
6 | //
7 | // Also we remove all whitespaces.
8 |
9 | import type { Plugin, Root } from 'postcss'
10 |
11 | module.exports = {
12 | postcssPlugin: 'html-keeper',
13 | prepare(result) {
14 | if (result.opts.from?.includes('.html')) {
15 | let before: Root
16 | return {
17 | Once(root) {
18 | before = root.clone()
19 | before.walkDecls(decl => {
20 | decl.raws = { before: '', between: ':' }
21 | })
22 | before.walkRules(rule => {
23 | rule.raws = { after: '', before: '', between: '' }
24 | })
25 | before.walkAtRules(atrule => {
26 | atrule.params = atrule.params.replace(/:\s+/g, ':')
27 | atrule.raws = { after: '', afterName: ' ', before: '' }
28 | })
29 | },
30 | OnceExit(root) {
31 | root.raws = {}
32 | root.nodes = []
33 | root.append(before.nodes)
34 | }
35 | }
36 | } else {
37 | return {}
38 | }
39 | }
40 | } satisfies Plugin
41 |
--------------------------------------------------------------------------------
/web/postcss/props-checker.ts:
--------------------------------------------------------------------------------
1 | // PostCSS plugin for ../script/check-css.ts to check that we use
2 | // all defined CSS Custom Properties.
3 |
4 | import type { Node, Plugin } from 'postcss'
5 |
6 | let globalUsed = new Set()
7 | let globalProps = new Set()
8 |
9 | export const propsChecker: Plugin = {
10 | postcssPlugin: 'props-checker',
11 | prepare() {
12 | let used = new Set()
13 | let vars = new Map()
14 | return {
15 | Declaration(decl) {
16 | if (decl.prop.startsWith('--')) {
17 | let nodes = vars.has(decl.prop) ? vars.get(decl.prop)! : []
18 | vars.set(decl.prop, nodes.concat(decl))
19 | globalProps.add(decl.prop)
20 | decl.raws.between = ':'
21 | }
22 | if (decl.value.includes('var(--')) {
23 | let found = decl.value.match(/var\((--[^)]+)\)/g)
24 | if (found) {
25 | for (let variable of found) {
26 | let name = variable.slice(4, -1)
27 | used.add(name)
28 | globalUsed.add(name)
29 | }
30 | }
31 | }
32 | }
33 | }
34 | }
35 | }
36 |
37 | export function getPropsError(): string | undefined {
38 | let unused = []
39 | for (let name of globalProps) {
40 | if (!globalUsed.has(name)) {
41 | unused.push(name)
42 | }
43 | }
44 |
45 | if (unused.length === 0) {
46 | return
47 | }
48 |
49 | return `Unused CSS variables: ${unused.join(', ')}`
50 | }
51 |
52 | export function resetCleanerGlobals(): void {
53 | globalUsed = new Set()
54 | globalProps = new Set()
55 | }
56 |
--------------------------------------------------------------------------------
/web/postcss/pseudo-classes.cts:
--------------------------------------------------------------------------------
1 | // PostCSS plugin to add .is-pseudo-active for each :active and so on
2 | // for other pseudo-classes (:hover, :active, :focus-visible).
3 | // We are then using these classes in Storybook and KeyUX.
4 |
5 | import type { Plugin } from 'postcss'
6 |
7 | const PSEUDO = [':focus', ':hover', ':active', ':focus-visible']
8 |
9 | module.exports = {
10 | postcssPlugin: 'pseudo-classes',
11 | Rule(rule) {
12 | if (!rule.selector.includes(':')) return
13 |
14 | let extra: string[] = []
15 | for (let selector of rule.selectors) {
16 | for (let pseudo of PSEUDO) {
17 | if (selector.includes(pseudo)) {
18 | extra.push(
19 | selector.replaceAll(pseudo, `.is-pseudo-${pseudo.slice(1)}`)
20 | )
21 | }
22 | }
23 | }
24 |
25 | extra = extra.filter(selector => !rule.selectors.includes(selector))
26 | if (extra.length > 0) {
27 | rule.selectors = rule.selectors.concat(...extra)
28 | }
29 | }
30 | } satisfies Plugin
31 |
--------------------------------------------------------------------------------
/web/postcss/svelte-nesting-css-fixer.cts:
--------------------------------------------------------------------------------
1 | // PostCSS plugin to fix Svelte issue with nesting CSS
2 | // https://github.com/sveltejs/svelte/issues/9320
3 |
4 | import type { Plugin } from 'postcss'
5 |
6 | module.exports = {
7 | postcssPlugin: 'svelte-nesting-css-fixer',
8 | Rule(rule) {
9 | delete rule.raws.ownSemicolon
10 | }
11 | } satisfies Plugin
12 |
--------------------------------------------------------------------------------
/web/public/.well-known/security.txt:
--------------------------------------------------------------------------------
1 | Contact: mailto:andrey@sitnik.ru
2 | Expires: 2029-12-31T23:00:00.000Z
3 | Encryption: https://keybase.io/iskin/key.asc
4 | Preferred-Languages: en, ru
5 |
--------------------------------------------------------------------------------
/web/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hplush/slowreader/521b91795a6fa775a0510f1e06c69996f56b91e9/web/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hplush/slowreader/521b91795a6fa775a0510f1e06c69996f56b91e9/web/public/favicon.ico
--------------------------------------------------------------------------------
/web/public/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hplush/slowreader/521b91795a6fa775a0510f1e06c69996f56b91e9/web/public/icon-192.png
--------------------------------------------------------------------------------
/web/public/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hplush/slowreader/521b91795a6fa775a0510f1e06c69996f56b91e9/web/public/icon-512.png
--------------------------------------------------------------------------------
/web/public/icon-dev.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/public/icon-staging.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/public/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/public/manifest.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Slow Reader",
3 | "start_url": "/",
4 | "icons": [
5 | { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
6 | { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /ui/
3 |
--------------------------------------------------------------------------------
/web/scripts/build-nginx-config.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Add all apps routes to __ROUTES__ template in config
3 |
4 | awk 'NR==FNR{a=$0;next} {gsub("__ROUTES__", a)}1' ./routes.regexp ./nginx.conf > ./nginx.conf.compiled
5 |
--------------------------------------------------------------------------------
/web/scripts/check-css-props.ts:
--------------------------------------------------------------------------------
1 | // Check that all design tokens (CSS Custom Properties) are used
2 |
3 | import { globSync } from 'node:fs'
4 | import { readFile } from 'node:fs/promises'
5 | import { join } from 'node:path'
6 | import { styleText } from 'node:util'
7 | import postcss from 'postcss'
8 |
9 | import { getPropsError, propsChecker } from '../postcss/props-checker.ts'
10 |
11 | function printError(message: string): void {
12 | process.stderr.write(styleText('red', message) + '\n')
13 | }
14 |
15 | const cssChecker = postcss([propsChecker])
16 |
17 | let files = globSync(
18 | join(import.meta.dirname, '..', 'dist', 'assets', '**', '*.css')
19 | )
20 | await Promise.all(
21 | files.map(async file => {
22 | let css = await readFile(file)
23 | try {
24 | await cssChecker.process(css, { from: file })
25 | } catch (e) {
26 | if (!(e instanceof Error)) {
27 | printError(String(e))
28 | } else if (e instanceof Error && e.name === 'CssSyntaxError') {
29 | printError(e.message)
30 | } else {
31 | printError(e.stack ?? e.message)
32 | }
33 | process.exit(1)
34 | }
35 | })
36 | )
37 |
38 | let error = getPropsError()
39 | if (error) {
40 | printError(error)
41 | process.exit(1)
42 | }
43 |
--------------------------------------------------------------------------------
/web/scripts/export-routes.ts:
--------------------------------------------------------------------------------
1 | // Save web client pages for server to return HTML on GET request to this routes
2 |
3 | // eslint-disable
4 |
5 | import { writeFileSync } from 'node:fs'
6 | import { join } from 'node:path'
7 |
8 | import { pathRouter } from '../stores/router.ts'
9 |
10 | const ROUTES = join(import.meta.dirname, '../routes.regexp')
11 |
12 | const FIXES: Record = {
13 | '^\\/feeds\\/add(?:\\/([^/]+))?$': '^/feeds/add(/|$)'
14 | }
15 |
16 | function removeNamingGroup(regexp: string): string {
17 | return regexp.replace(/\(\?<(\w+?)>\(\?<=\\\/\)/g, '(')
18 | }
19 |
20 | function fixRegexp(regexp: string): string {
21 | return FIXES[regexp] || regexp
22 | }
23 |
24 | writeFileSync(
25 | ROUTES,
26 | pathRouter.routes
27 | .map(([, regexp]) => fixRegexp(removeNamingGroup(regexp.source)))
28 | .join('|')
29 | )
30 |
--------------------------------------------------------------------------------
/web/scripts/generate-csp.ts:
--------------------------------------------------------------------------------
1 | // Content-Security-Policy header blocks all JS/CSS not from allow-list.
2 | // This script is adding allow hashes for inline
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web/stories/ui/loader.stories.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
30 |
31 |
32 |
35 |
38 |
41 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/web/svelte.config.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss'
2 | import loadPostcssConfig from 'postcss-load-config'
3 |
4 | let { plugins } = await loadPostcssConfig({}, import.meta.filename)
5 | let processor = postcss(plugins)
6 |
7 | /**
8 | * @typedef {Object} SvelteConfig
9 | * @property {import('svelte/compiler').PreprocessorGroup} preprocess
10 | */
11 |
12 | /**
13 | * @type {SvelteConfig}
14 | */
15 | export default {
16 | preprocess: {
17 | async style({ content, filename }) {
18 | let { css, map, messages } = await processor.process(content, {
19 | from: filename,
20 | map: { annotation: false, inline: false }
21 | })
22 | let dependencies = messages.reduce((list, msg) => {
23 | if (msg.type === 'dependency') list.push(msg.file)
24 | return list
25 | }, [])
26 | return {
27 | code: css,
28 | dependencies,
29 | map
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/web/ui/icon.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
13 |
14 |
35 |
--------------------------------------------------------------------------------
/web/ui/loader.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
34 |
35 |
38 |
--------------------------------------------------------------------------------
/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { nodeResolve } from '@rollup/plugin-node-resolve'
2 | import { svelte } from '@sveltejs/vite-plugin-svelte'
3 | import { defineConfig } from 'vite'
4 |
5 | function replaceIcon(html: string, icon: string): string {
6 | return html
7 | .replace('', '')
8 | .replace(
9 | //,
10 | ``
11 | )
12 | }
13 |
14 | export default defineConfig(() => ({
15 | build: {
16 | assetsInlineLimit: 0,
17 | target: 'es2024'
18 | },
19 | plugins: [
20 | svelte(),
21 | nodeResolve({
22 | extensions: ['.js', '.ts']
23 | }),
24 | {
25 | enforce: 'pre',
26 | name: 'html-transform',
27 | transformIndexHtml(html) {
28 | if (process.env.NODE_ENV === 'development') {
29 | return replaceIcon(html, 'icon-dev')
30 | } else if (process.env.STAGING) {
31 | return replaceIcon(html, 'icon-staging')
32 | } else {
33 | return html
34 | }
35 | }
36 | }
37 | ]
38 | }))
39 |
--------------------------------------------------------------------------------