diff --git a/.claude/settings.json b/.claude/settings.json index 07ee9748..bcefeaee 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "pnpm quick || exit 2" + "command": "out=$(pnpm -w quick 2>&1) || { printf '%s\\n' \"$out\" >&2; exit 2; }" } ] } diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3ac0be25..8f02d477 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,19 +2,22 @@ # so every developer will have the same environment # registry.fedoraproject.org/fedora:44 -FROM registry.fedoraproject.org/fedora@sha256:590825dbaee41a97a162ecdffc3305264bd11cb3ff1e9cfd710d41ca5f936134 +FROM registry.fedoraproject.org/fedora@sha256:b4087c9dd39f6bc163b4f5da1534328f093892ed586b70d0b1ab9c68e354865d ARG TARGETARCH -ENV NODE_VERSION=26.5.1 \ - NODE_CHECKSUM_X64=cc7b3484ade63bd203a9d304f21ec37a3b622b988d7bdecf1dc4d68fc44a91b7 \ - NODE_CHECKSUM_ARM64=0b6b0cc2a1eecbe736f9918de8b5a6c9a48d286b88bec1298a3c1e3376182ea8 +ENV NODE_VERSION=26.7.0 \ + NODE_CHECKSUM_X64=982aa24dd8be4c889c6a8ab337ddff3b0896645b20f4239356e80552c16277ee \ + NODE_CHECKSUM_ARM64=afc7a004018485092ac8985b817b0d5684472bd9472e0b57d2ab88737e50090d -ENV PNPM_VERSION=11.18.0 \ - PNPM_CHECKSUM_X64=f4e62794c56f7a1ad01097f94b90ca95680080aa2e83ebc3d0a751fb8aa8e4ad \ - PNPM_CHECKSUM_ARM64=f9c09bb564421d9d39dfff60af41df88a4864af5637afdabe5f54d12853f8426 +ENV PNPM_VERSION=12.0.0-rc.6 \ + PNPM_CHECKSUM_X64=91a587656717af75003896010fd1e9a78cd050b489028fcab541be59f9d0d06c \ + PNPM_CHECKSUM_ARM64=b00ba83f1bb5a3b01614e6be55e5842a0dd7f32889cf81ed5a4acf72a0c6d3fb RUN dnf install -yq zsh micro git tig psmisc procps-ng awk libatomic wget \ + gtk3 nss nspr alsa-lib cups-libs mesa-libgbm dbus-libs libX11-xcb \ + libXcomposite libXdamage libXrandr libXcursor libXi libxkbcommon \ + google-noto-sans-fonts google-noto-emoji-fonts \ && dnf clean all \ && rm -rf /var/cache/dnf diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 570cb675..10b8790f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,6 +4,9 @@ }, "runArgs": [ "--net=bridge", + "--shm-size=1g", + "--security-opt", + "seccomp=unconfined", "-p", "2553:2553", "-p", diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml index 04c0b413..75bdf091 100644 --- a/.github/actions/deploy/action.yml +++ b/.github/actions/deploy/action.yml @@ -35,7 +35,7 @@ runs: INPUTS_TAG: ${{ inputs.tag }} INPUTS_CONTEXT: ${{ inputs.context }} - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/actions/init-node/action.yml b/.github/actions/init-node/action.yml index d9e61b99..25bd9396 100644 --- a/.github/actions/init-node/action.yml +++ b/.github/actions/init-node/action.yml @@ -13,7 +13,7 @@ runs: using: composite steps: - name: Install pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Install Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 9b7981d9..ca041116 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -14,15 +14,15 @@ on: - pnpm-lock.yaml schedule: - cron: '00 23 * * *' # Runs at midnight UTC every day -permissions: - contents: read jobs: audit: name: Audit runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > @@ -48,9 +48,12 @@ jobs: socket: name: Check via Socket runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > @@ -80,4 +83,5 @@ jobs: socketcli \ --target-path $GITHUB_WORKSPACE \ --scm github \ - --pr-number $PR_NUMBER + --pr-number $PR_NUMBER \ + --disable-overview diff --git a/.github/workflows/clean-images.yml b/.github/workflows/clean-images.yml index eaaa0675..186cf00e 100644 --- a/.github/workflows/clean-images.yml +++ b/.github/workflows/clean-images.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b8f4ea83..3d975b62 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit # Allow all to validate links - name: Checkout the repository diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 05035007..de19a1e8 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > @@ -33,7 +33,8 @@ jobs: - name: Checkout the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: false + persist-credentials: true + ref: ${{ github.head_ref || github.ref_name }} - name: Initialize Node.js uses: ./.github/actions/init-node - name: Check for today commits diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index abc2d4d2..ab79850b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/messages.yml b/.github/workflows/messages.yml index 2f30c9a2..3536d397 100644 --- a/.github/workflows/messages.yml +++ b/.github/workflows/messages.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/preview-clean.yml b/.github/workflows/preview-clean.yml index eea74429..cfe54b29 100644 --- a/.github/workflows/preview-clean.yml +++ b/.github/workflows/preview-clean.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/preview-close.yml b/.github/workflows/preview-close.yml index ded22beb..f7605d91 100644 --- a/.github/workflows/preview-close.yml +++ b/.github/workflows/preview-close.yml @@ -25,7 +25,7 @@ jobs: if: contains(github.event.pull_request.labels.*.name, 'preview') steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 01aacbfb..e4d81e70 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -18,7 +18,7 @@ jobs: group: staging-web steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > @@ -70,7 +70,7 @@ jobs: - name: Load Docker image run: docker load < image.tar - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/preview-prepare.yml b/.github/workflows/preview-prepare.yml index 1f52acaa..b2a14d1a 100644 --- a/.github/workflows/preview-prepare.yml +++ b/.github/workflows/preview-prepare.yml @@ -27,7 +27,7 @@ jobs: if: contains(github.event.pull_request.labels.*.name, 'preview') steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml index 7d2d4fc3..b660679a 100644 --- a/.github/workflows/workflows.yml +++ b/.github/workflows/workflows.yml @@ -24,6 +24,6 @@ jobs: with: persist-credentials: false - name: Lint CI workflows - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 with: advanced-security: false diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..a918b86d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,44 @@ +{ + "mcpServers": { + "chromium": { + "args": [ + "-c", + "./node_modules/.bin/playwright-mcp install-browser chromium 1>&2 && exec ./node_modules/.bin/playwright-mcp \"$@\"", + "playwright-mcp", + "--headless", + "--browser", + "chromium", + "--caps", + "devtools", + "--viewport-size", + "1280,800", + "--user-data-dir", + "/home/developer/.local/share/playwright-chromium", + "--output-dir", + "/home/developer/.local/share/playwright-output/chromium", + "--save-session" + ], + "command": "sh" + }, + "firefox": { + "args": [ + "-c", + "./node_modules/.bin/playwright-mcp install-browser firefox 1>&2 && exec ./node_modules/.bin/playwright-mcp \"$@\"", + "playwright-mcp", + "--headless", + "--browser", + "firefox", + "--caps", + "devtools", + "--viewport-size", + "1280,800", + "--user-data-dir", + "/home/developer/.local/share/playwright-firefox", + "--output-dir", + "/home/developer/.local/share/playwright-output/firefox", + "--save-session" + ], + "command": "sh" + } + } +} diff --git a/.node-version b/.node-version index 0a2925f0..91d2624e 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -26.5.1 +26.7.0 diff --git a/AGENTS.md b/AGENTS.md index 67831f7b..77379148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ In all interactions, plans, and commit messages, be extremely concise and sacrif ## Architecture - Avoid adding dependencies. +- Keep non-browser logic in `core/`. Client folders should have only environment-specific code. +- Update `README.md` of the package on every new script, folder, or tool. ## LLMS @@ -27,3 +29,7 @@ In all interactions, plans, and commit messages, be extremely concise and sacrif ## Testing - Run specific test by `pnpm bnt path/to/test.test.ts -t 'test name'` + +## Browser + +- Use Playwright MCP for visual issues and client database bugs. See [docs/browser.md](./docs/browser.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62c4eea6..0cd02627 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,11 +81,10 @@ To fill your web clients with real posts: 4. Open browser DevTools. 5. Run `fillFeedsWithPosts()`. -To run the visual testing tool (Storybook), run it from the `web/` folder: +To run the visual testing tool (Storybook): ```sh -cd web/ -pnpm visual +pnpm -F web visual ``` It will show all UI components and all possible states of pages. It is the best tool to test browser compatibility or change CSS. diff --git a/README.md b/README.md index 54ca22b7..23a7af77 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The source of truth in the client is a list of changes (action log). An action i } ``` -To render the UI, the client reduces actions from the log into the state (objects of feeds, posts, etc.). We store the state cache in [Nano Stores](https://github.com/nanostores/nanostores). +To render the UI, the client reduces actions from the log into SQL tables (feeds, posts, etc.) defined in [`core/schema.ts`](./core/schema.ts). The UI subscribes to reactive SQL queries from [Nano Stores SQL](https://github.com/nanostores/sql). Web keeps the database in SQLite compiled to WebAssembly, Node.js tests use in-memory SQLite. The log simplifies synchronization. We just need to track the last synchronized action and send all actions after that one. @@ -137,11 +137,11 @@ Each project has its own tools, too. - `pnpm test`: run all tests. - `pnpm quick`: run quick checks for changed files. - `pnpm offline`: run all tests expect which need Internet. It is useful for developing in airplane/train. -- `pnpm start`: run proxy and web client development server. +- `pnpm start`: run server and web client development server. - `pnpm format`: fix code style in all files. - `pnpm clean`: remove all temporary files. -- `pnpm check-opml`: test loaders with user’s OPML RSS export. -- `pnpm test-loaders`: test loaders with different blogging platforms. +- `pnpm -F loader-tests check-opml`: test loaders with user’s OPML RSS export. +- `pnpm -F loader-tests test`: test loaders with different blogging platforms. - `pnpm unused-messages`: check that all messages are used. - `pnpm update-env`: check for Node.js and pnpm updates. - `pnpm update-review`: run Multiocular to review updates. @@ -177,13 +177,10 @@ The server doesn’t see those actions because clients encrypt them before sendi ## Client Storage -The clients store a list of changes (action log). Durin -console.log(client1.log.entries())g the start, the client reduces all necessary actions from the log to the [Logux SyncMap stores](https://logux.org/web-api/#globals-syncmaptemplate). +The clients store a list of changes (action log). During the start, the client reduces all necessary actions from the log to the SQLite table on OPFS. For simple things like client local settings, we use [Nano Store Persistent](https://github.com/nanostores/persistent). -The web client uses IndexedDB to store log and `localStorage` for the client’s settings. - ## Test Strategy If any mistake happens a few times, we should add an automatic tool to **prevent mistakes** in the future. Possible strategies: @@ -239,7 +236,7 @@ How we choose dependencies: You can use [bundlejs.com](https://bundlejs.com/) and [npmgraph.js.org](https://npmgraph.js.org) to get the size in bundle, `node_modules`, and number of sub-dependencies. -After adding a web client dependency, do not forget to call `cd web && pnpm size` to check the real size of dependency in our JS bundle. +After adding a web client dependency, do not forget to call `pnpm -F web size` to check the real size of dependency in our JS bundle. We put to `dependencies` only dependencies we need for production deploy. All other dependencies you should put to `devDependencies`. During production deploy we will use `pnpm install --prod` to reduce security risks of having malicious code in some dependency. @@ -270,11 +267,11 @@ To update all dependencies: pnpm update-env # Update Docker base images -./scripts/update-docker.sh +pnpm update-docker # Update Node.js dependencies -pnpm update --interactive --latest -r --include-workspace-root -pnpm update -r --include-workspace-root +pnpm update --interactive --latest -r +pnpm update -r # Update GitHub actions pnpm update-ci diff --git a/api/http/utils.ts b/api/http/utils.ts index b96c51f3..6c4b55b7 100644 --- a/api/http/utils.ts +++ b/api/http/utils.ts @@ -76,9 +76,7 @@ export function createRequester( } export interface Endpoint< - // Need to put it inside type to pass all types to server in a single variable - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Response, + _Response, Request, UrlParams extends Record = Record > { diff --git a/api/package.json b/api/package.json index a7d24c84..109ac335 100644 --- a/api/package.json +++ b/api/package.json @@ -7,13 +7,13 @@ "./package.json": "./package.json" }, "dependencies": { - "@logux/actions": "0.5.0" + "@logux/actions": "github:logux/actions#next" }, "devDependencies": { - "@logux/core": "0.10.0" + "@logux/core": "github:logux/core#next" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/core/README.md b/core/README.md index 48da7088..1265981d 100644 --- a/core/README.md +++ b/core/README.md @@ -32,7 +32,7 @@ In the best scenario, the client should just subscribe to stores, render UI acco ## Scripts -- `cd core && pnpm test`: run core unit tests and check coverage. +- `pnpm -F core test`: run core unit tests and check coverage. ## Client Environments @@ -71,10 +71,10 @@ We run unit tests by `node --test` with [`better-node-test`](https://github.com/ ```sh # Run all tests with coverage -cd core && pnpm test +pnpm -F core test # Run all tests without coverage (a little faster) -cd core && n bnt +pnpm -F core exec bnt # Run specific test file n bnt core/test/html.test.ts diff --git a/core/auth.ts b/core/auth.ts index 1107ae9d..2d175de1 100644 --- a/core/auth.ts +++ b/core/auth.ts @@ -4,6 +4,7 @@ import { customAlphabet } from 'nanoid' import { getClient } from './client.ts' import { getEnvironment } from './environment.ts' import { checkErrors } from './lib/http.ts' +import { markDatabaseDownloading } from './schema.ts' import { encryptionKey, hasPassword, syncServer, userId } from './settings.ts' let generateUserId = customAlphabet('0123456789', 16) @@ -53,6 +54,7 @@ export async function signIn( userId.set(undefined) getEnvironment().saveSession(response.session) hasPassword.set(true) + markDatabaseDownloading() useCredentials(credentials) } diff --git a/core/benchmark.ts b/core/benchmark.ts new file mode 100644 index 00000000..d6270d5f --- /dev/null +++ b/core/benchmark.ts @@ -0,0 +1,380 @@ +import { persistentAtom } from '@nanostores/persistent' + +import { busyDuring } from './busy.ts' +import { addCategory } from './category.ts' +import { client } from './client.ts' +import { addFeed } from './feed.ts' +import { setLoaderReporter } from './loader.ts' +import { benchmarkMessages } from './messages/index.ts' +import { + addPost, + type NewPost, + type PostValue, + stringifyMedia +} from './post.ts' +import { setRequestMethod } from './request.ts' +import { encryptionKey, userId } from './settings.ts' + +export interface LoaderSpan { + end: number | undefined + name: string + start: number +} + +let rendered = new Set() +let spans: LoaderSpan[] | undefined + +setLoaderReporter(name => { + let span: LoaderSpan = { end: undefined, name, start: performance.now() } + rendered.add(span) + spans?.push(span) + return () => { + span.end = performance.now() + rendered.delete(span) + } +}) + +/** + * Start to collect spans of all rendered loaders. Returns function to stop + * and to get all spans. + */ +export function recordLoaders(): () => LoaderSpan[] { + let started: LoaderSpan[] = [] + spans = started + return () => { + if (spans === started) spans = undefined + return started + } +} + +/** + * Loaders, which are rendered right now. + */ +export function getRenderedLoaders(): LoaderSpan[] { + return [...rendered] +} + +export interface FillStatistics { + biggestCategory: string + debug: boolean + duration: number + feeds: number + posts: number + readerFeed: string + slowFeeds: string[] +} + +let debug = false + +/** + * Use small database and single run of every scenario to develop benchmark + * faster. Client should set it from `DEBUG=1` or `?benchmark=debug`. + */ +export function setBenchmarkDebug(value: boolean): void { + debug = value +} + +/** + * How many times every scenario should be repeated. + */ +export function getBenchmarkRuns(): number { + return debug ? 1 : 5 +} + +/** + * How many failed runs stop the scenario. + */ +export const BENCHMARK_GIVE_UP = 2 + +const SEED = 1 + +const MINUTE = 60 + +const HOUR = 60 * 60 + +const YEAR = 365 * 24 * 60 * 60 + +let newest = Math.round(Date.now() / 1000) + +const DISTRIBUTION: [number, number, number][] = [ + [0.4, 0, 10], + [0.35, 10, 50], + [0.15, 50, 200], + [0.08, 200, 800], + [0.02, 800, 2000] +] + +const WORDS = `about after again below could every first found great house +large learn light might never other place plant point right small sound spell +still study their there these thing think three under water where which world +would write years animal answer became better between change during enough +follow ground itself listen mother number people person picture problem school +second sentence several southern together toward without`.split(/\s+/) + +const IMAGE = + 'data:image/svg+xml,' + + encodeURIComponent( + '' + + '' + ) + +function createRandom(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let value = Math.imul(state ^ (state >>> 15), 1 | state) + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value + return ((value ^ (value >>> 14)) >>> 0) / 4294967296 + } +} + +function between(random: () => number, from: number, to: number): number { + return from + Math.floor(random() * (to - from)) +} + +function words(random: () => number, from: number, to: number): string { + let list: string[] = [] + for (let i = between(random, from, to); i > 0; i--) { + list.push(WORDS[between(random, 0, WORDS.length)]!) + } + return list.join(' ') +} + +function sentence(random: () => number, from: number, to: number): string { + let text = words(random, from, to) + return text[0]!.toUpperCase() + text.slice(1) +} + +function paragraphs(random: () => number): string { + let list: string[] = [] + for (let i = between(random, 2, 6); i > 0; i--) { + list.push(`

${sentence(random, 20, 60)}.

`) + } + return list.join('') +} + +function item(id: string, postId: string, published: number): string { + return ( + `${postId}` + + `${postId}` + + `https://example.com/${id}/${postId}` + + `${new Date(published * 1000).toUTCString()}` + + `Benchmark post ${postId}` + ) +} + +function rss(url: string): string { + let id = new URL(url).pathname.replace(/^\//, '').replace(/\.xml$/, '') + let index = Number(id.match(/\d+$/)?.[0] ?? 0) + let old = Math.round(Date.now() / 1000) - HOUR + + let items = '' + if (index % 5 === 0) { + for (let i = 0; i < 3; i++) { + newest += MINUTE + items += item(id, `${id}-new-${newest}`, newest) + } + } + for (let i = 0; i < 10; i++) { + items += item(id, `${id}-post-${i}`, old) + } + + return ( + `` + + `${id}https://example.com/${id}` + + `${items}` + ) +} + +// Random distribution can’t promise them on small database of debug mode. +const ANCHORS: [PostValue['reading'], number][] = [ + ['slow', 300], + ['slow', 150], + ['fast', 300] +] + +// Feed reader is set here, so benchmark will not switch reader before scenario +const READER_FEED = 'feed-1' + +const POSTS_BATCH = 100 + +function postsInFeed(random: () => number): number { + let point = random() + let sum = 0 + for (let [share, from, to] of DISTRIBUTION) { + sum += share + if (point <= sum) return between(random, from, to) + } + return 0 +} + +function createPost( + random: () => number, + feedId: string, + index: number, + reading: PostValue['reading'], + now: number +): NewPost { + return { + feedId, + full: paragraphs(random), + id: `${feedId}-post-${index}`, + intro: `

${sentence(random, 15, 40)}.

`, + media: + random() < 0.2 + ? stringifyMedia([{ type: 'image/svg+xml', url: IMAGE }]) + : undefined, + originId: `${feedId}-post-${index}`, + publishedAt: now - Math.floor(random() ** 2 * YEAR), + read: 0, + reading, + title: sentence(random, 3, 9), + url: `https://example.com/${feedId}/post-${index}` + } +} + +async function fillClient( + progress: (done: number, total: number) => void +): Promise { + let size = debug + ? { categories: 2, feeds: 10 } + : // Remove after fixing app’s memory usage. Right now the full benchmark + // is killing the browser. + // : { categories: 20, feeds: 1000 } + { categories: 10, feeds: 100 } + let random = createRandom(SEED) + let started = performance.now() + let now = Math.round(Date.now() / 1000) + + let categories = ['general'] + for (let i = 1; i < size.categories; i++) { + categories.push(await addCategory({ title: sentence(random, 1, 3) })) + } + + let biggestCategory = 'general' + let biggestFast = 0 + let posts = 0 + let slow: [string, number][] = [] + for (let i = 0; i < size.feeds; i++) { + let anchor = ANCHORS[i] + let reading: PostValue['reading'] = + anchor?.[0] ?? (random() < 0.7 ? 'fast' : 'slow') + let categoryId = categories[between(random, 0, categories.length)]! + let feedId = `feed-${i}` + await addFeed({ + categoryId, + id: feedId, + lastOriginId: `${feedId}-post-0`, + lastPublishedAt: now, + loader: 'rss', + reading, + refreshedAt: now, + slowReader: feedId === READER_FEED ? 'feed' : undefined, + title: sentence(random, 1, 4), + url: `https://example.com/${feedId}.xml` + }) + + let count = anchor?.[1] ?? postsInFeed(random) + let batch: NewPost[] = [] + for (let j = 0; j < count; j++) { + batch.push(createPost(random, feedId, j, reading, now)) + if (batch.length === POSTS_BATCH) { + await addPost(batch) + batch = [] + } + } + if (batch.length > 0) await addPost(batch) + + if (reading === 'slow') { + slow.push([feedId, count]) + } else if (count > biggestFast) { + biggestCategory = categoryId + biggestFast = count + } + posts += count + progress(i + 1, size.feeds) + } + + return { + biggestCategory, + debug, + duration: performance.now() - started, + feeds: size.feeds, + posts, + readerFeed: READER_FEED, + slowFeeds: slow + .toSorted((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(i => i[0]) + } +} + +/** + * Statistics of the last created benchmark data. + */ +export const benchmarkStatistics = persistentAtom( + 'slowreader:benchmark', + undefined, + { + decode(value) { + return JSON.parse(value) as FillStatistics + }, + encode(value) { + return JSON.stringify(value) + } + } +) + +/** + * Answer feed requests with generated RSS instead of the network. + */ +export function mockBenchmarkRequests(): void { + setRequestMethod(url => { + let known = /^\/(new-)?feed-\d+\.xml$/.test(new URL(url).pathname) + let response = new Response(known ? rss(url) : '', { + headers: { + 'Content-Type': known ? 'application/rss+xml' : 'text/plain' + } + }) + Object.defineProperty(response, 'url', { value: url }) + return Promise.resolve(response) + }) +} + +/** + * Create local benchmark user to get the client without the server. + */ +export async function signInBenchmark(): Promise { + if (userId.get() !== '9999999999999999') { + encryptionKey.set('benchmarkKey') + userId.set('9999999999999999') + } + if (!client.get()) { + await new Promise(resolve => { + let unbind = client.subscribe(logux => { + if (logux) { + setTimeout(() => { + unbind() + resolve() + }) + } + }) + }) + } +} + +/** + * Create categories, feeds and posts and save statistics about them. + */ +export async function fillBenchmarkData(): Promise { + let statistics = await busyDuring( + benchmarkMessages.get().creatingData, + setProgress => { + return fillClient((done, total) => { + setProgress(done / total) + }) + } + ) + benchmarkStatistics.set(statistics) + return statistics +} diff --git a/core/busy.ts b/core/busy.ts index 2fae237e..573c66b8 100644 --- a/core/busy.ts +++ b/core/busy.ts @@ -1,20 +1,45 @@ -import { atom, computed } from 'nanostores' +import { atom } from 'nanostores' -let $tasks = atom(0) +export interface BusyValue { + label: string + progress?: number +} + +let tasks: BusyValue[] = [] /** * Show loader over whole app until task is running. */ -export async function busyDuring(cb: () => Promise): Promise { - $tasks.set($tasks.get() + 1) - try { - await cb() - } finally { - $tasks.set($tasks.get() - 1) +export const busy = atom(false) + +function update(): void { + if (tasks.length === 0) { + busy.set(false) + } else { + let last = tasks.findLast(i => i.label || i.progress !== undefined) ?? { + label: '' + } + busy.set({ label: last?.label, progress: last?.progress }) } } /** - * Do we need to show loader over whole app. + * Show loader over whole app until task is running. */ -export const busy = computed($tasks, tasks => tasks > 0) +export async function busyDuring( + label: string, + cb: (setProgress: (progress: number) => void) => Promise +): Promise { + let task: BusyValue = { label } + tasks.push(task) + update() + try { + return await cb(progress => { + task.progress = progress + update() + }) + } finally { + tasks = tasks.filter(i => i !== task) + update() + } +} diff --git a/core/category.ts b/core/category.ts index 952c34bd..10de7c1c 100644 --- a/core/category.ts +++ b/core/category.ts @@ -1,78 +1,70 @@ -import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type FilterStore, - loadValue, - type SyncMapStore, - syncMapTemplate -} from '@logux/client' -import { nanoid } from 'nanoid' -import { atom } from 'nanostores' - -import { getClient } from './client.ts' -import { changeFeed, type FeedValue, getFeeds } from './feed.ts' +import { withMeta } from '@logux/client/db' +import type { SqlStore } from '@nanostores/sql' +import { atom, type ReadableAtom } from 'nanostores' + +import { changeFeed, loadFeedsByCategory } from './feed.ts' +import { firstRow } from './lib/stores.ts' import { commonMessages as t } from './messages/index.ts' -import type { UsefulReaderName } from './readers/common.ts' +import { + type CategoryChanges, + type CategoryValue, + type FeedValue, + getTables, + type NewCategory, + select +} from './schema.ts' -export type CategoryValue = { - fastReader?: UsefulReaderName - id: string - slowReader?: UsefulReaderName - title: string -} +export type { CategoryValue, NewCategory } export type FeedsByCategory = [CategoryValue, FeedValue[]][] -export const Category = syncMapTemplate('categories', { - offline: true, - remote: false -}) - -export function getCategory(categoryId: string): SyncMapStore { +export function getCategory( + categoryId: string +): ReadableAtom { if (categoryId === 'general') { /* node:coverage ignore next 2 */ return atom(getGeneralCategory()) } else { - return Category(categoryId, getClient()) + return firstRow(getTables().categories.select`WHERE "id" = ${categoryId}`) } } -export function getCategories(): FilterStore { - return createFilter(getClient(), Category) +export function getCategories(): SqlStore { + return getTables().categories.select`ORDER BY "title"` +} + +export async function loadCategory( + categoryId: string +): Promise { + if (categoryId === 'general') return getGeneralCategory() + let rows = await select` + SELECT * FROM "categories" WHERE "id" = ${categoryId} + ` + return rows[0] +} + +export function loadCategories(): Promise { + return select`SELECT * FROM "categories" ORDER BY "title"` } -export async function addCategory( - fields: Omit -): Promise { - let id = nanoid() - await createSyncMap(getClient(), Category, { id, ...fields }) - return id +export function addCategory(fields: NewCategory): Promise { + return getTables().categories.create(fields) } -export async function changeCategory( +export function changeCategory( categoryId: string, - changes: Partial + changes: CategoryChanges ): Promise { - return changeSyncMapById(getClient(), Category, categoryId, changes) + return getTables().categories.update(categoryId, changes) } export async function deleteCategory(categoryId: string): Promise { - let feeds = await loadValue(getFeeds({ categoryId })) - await Promise.all( - feeds.list.map(async feed => { - if (!feeds.stores.get(feed.id)?.deleted) { - await changeFeed(feed.id, { categoryId: 'general' }) - } - }) + let feeds = await loadFeedsByCategory(categoryId) + await changeFeed( + feeds.map(feed => feed.id), + { categoryId: 'general' } ) - return deleteSyncMapById(getClient(), Category, categoryId) -} - -export const GENERAL_CATEGORY: CategoryValue = { - id: 'general', - title: '' + return getTables().categories.delete(categoryId) } export function feedsByCategory( @@ -115,5 +107,10 @@ export function feedsByCategory( } export function getGeneralCategory(): CategoryValue { - return { id: 'general', title: t.value?.generalCategory || 'General' } + return withMeta({ + fastReader: null, + id: 'general', + slowReader: null, + title: t.value?.generalCategory || 'General' + }) } diff --git a/core/client.ts b/core/client.ts index acef69a5..616350f1 100644 --- a/core/client.ts +++ b/core/client.ts @@ -1,7 +1,7 @@ import type { - SyncMapChangedAction, - SyncMapCreatedAction, - SyncMapDeletedAction + CrdtTableChangedAction, + CrdtTableCreatedAction, + CrdtTableDeletedAction } from '@logux/actions' import { type ClientOptions, @@ -10,6 +10,7 @@ import { status, type StatusValue } from '@logux/client' +import { SqlLogStore } from '@logux/client/db' import { type Action, type Meta, @@ -18,6 +19,7 @@ import { TestPair, TestTime } from '@logux/core' +import type { Database } from '@nanostores/sql' import { deleteUser, SUBPROTOCOL } from '@slowreader/api' import { atom, computed, effect, onMount } from 'nanostores' @@ -63,29 +65,39 @@ function getServer(): ClientOptions['server'] { let prevClient: CrossTabClient | undefined export const client = atom() + +/** + * SQLite database for log and data tables. + */ +export const database = atom() + export const isOutdatedClient = atom(false) -function isSyncMapFieldsAction( - action: Action -): action is SyncMapChangedAction | SyncMapCreatedAction { - return action.type.endsWith('/changed') || action.type.endsWith('/created') -} +type CrdtAction = + | CrdtTableChangedAction + | CrdtTableCreatedAction + | CrdtTableDeletedAction -function isSyncMapDeleyeAction(action: Action): action is SyncMapDeletedAction { - return action.type.endsWith('/deleted') +function isCrdtAction(action: Action): action is CrdtAction { + return ( + action.type.endsWith('/created') || + action.type.endsWith('/changed') || + action.type.endsWith('/deleted') + ) } -onEnvironment(({ logStoreCreator }) => { +onEnvironment(({ databaseCreator }) => { let unbindUser = effect( [userId, hasPassword, encryptionKey], (user, connect, key) => { prevClient?.destroy() if (user && key) { + let db = databaseCreator() let logux = new CrossTabClient({ prefix: 'slowreader', server: getServer(), - store: logStoreCreator(), + store: new SqlLogStore(db), subprotocol: SUBPROTOCOL, time: testTime, token: getEnvironment().getSession(), @@ -100,21 +112,36 @@ onEnvironment(({ logStoreCreator }) => { void logux.log.changeMeta(meta.id, { reasons: [] }) } + function changedRows(action: CrdtAction): [string, string[]][] { + if ('records' in action) { + return action.records.map(record => [ + record.id, + Object.keys(record).filter(field => field !== 'id') + ]) + } + let fields = 'fields' in action ? Object.keys(action.fields) : [] + let ids = 'ids' in action ? action.ids : [action.id] + return ids.map(id => [id, fields]) + } + logux.on('preadd', (action, meta) => { if (parseId(meta.id).clientId === logux.clientId) { meta.sync = true - } else if (isSyncMapFieldsAction(action)) { + } else if (isCrdtAction(action)) { let plural = action.type.split('/')[0]! - for (let i in action.fields) { - meta.reasons.push(`${plural}/${action.id}/${i}`) + let rows = changedRows(action) + if (action.type.endsWith('/deleted')) { + for (let [id] of rows) { + void logux.log.each({ index: `${plural}/${id}` }, removeAction) + } + } else { + for (let [id, fields] of rows) { + for (let field of fields) { + meta.reasons.push(`${plural}/${id}/${field}`) + } + } + meta.indexes = [plural, ...rows.map(row => `${plural}/${row[0]}`)] } - meta.indexes = [plural, `${plural}/${action.id}`] - } else if (isSyncMapDeleyeAction(action)) { - let plural = action.type.split('/')[0]! - void logux.log.each( - { index: `${plural}/${action.id}` }, - removeAction - ) } }) @@ -131,9 +158,11 @@ onEnvironment(({ logStoreCreator }) => { logux.start(connect) } prevClient = logux + database.set(db) client.set(logux) } else { client.set(undefined) + database.set(undefined) } } ) diff --git a/core/current-page.ts b/core/current-page.ts index 6b846f9e..c068039b 100644 --- a/core/current-page.ts +++ b/core/current-page.ts @@ -40,7 +40,7 @@ function getPageParams( for (let i in page.params) { let name = i as keyof SomeRoute['params'] // @ts-expect-error Too complex types for TS - // eslint-disable-next-line + // oxlint-disable-next-line params[name] = page.params[name].get() } return params diff --git a/core/devtools.ts b/core/devtools.ts index 16b1d95e..59e9b82d 100644 --- a/core/devtools.ts +++ b/core/devtools.ts @@ -1,16 +1,14 @@ -import { loadValue } from '@logux/client' - import { busyDuring } from './busy.ts' import { HTTPStatusError } from './errors.ts' -import { getFeed, getFeedLatestPosts, getFeeds } from './feed.ts' -import { loadFilters } from './filter.ts' +import { getFeedLatestPosts, loadFeed, loadFeeds } from './feed.ts' +import { loadFilterChecker } from './filter.ts' import { createDownloadTask } from './lib/download.ts' import { loaders } from './loader/index.ts' import { addPost, deletePost, - getPost, - getPosts, + loadPost, + loadPostIdsByFeed, processOriginPost } from './post.ts' import { proxyDebug } from './request.ts' @@ -20,17 +18,14 @@ import { router } from './router.ts' * Create test feeds and posts for new client. */ export async function fillFeedsWithPosts(): Promise { - await busyDuring(async () => { + await busyDuring('', async () => { let task = createDownloadTask() - let feeds = await loadValue(getFeeds()) + let feeds = await loadFeeds() await Promise.all( - feeds.list.map(async feed => { - let old = await loadValue(getPosts({ feedId: feed.id })) - for (let post of old.list) { - await deletePost(post.id) - } + feeds.map(async feed => { + await deletePost(await loadPostIdsByFeed(feed.id)) let posts = await getFeedLatestPosts(feed, task).next() - let filters = await loadFilters({ feedId: feed.id }) + let filters = await loadFilterChecker(feed.id) for (let origin of posts) { let reading = filters(origin) ?? feed.reading if (reading !== 'delete') { @@ -56,9 +51,9 @@ export function enablePostDebug(): void { id = popup.param.slice(5) } if (id) { - let post = await loadValue(getPost(id)) + let post = await loadPost(id) if (!post) return - let feed = await loadValue(getFeed(post.feedId)) + let feed = await loadFeed(post.feedId) let source = await loaders[feed!.loader].getPostSource( feed!, post.originId diff --git a/core/environment.ts b/core/environment.ts index 0cfe63bc..6754b964 100644 --- a/core/environment.ts +++ b/core/environment.ts @@ -1,7 +1,6 @@ // Dependency Injection to change behavior in different environment // (web, mobile native, tests, etc). -import type { ClientOptions } from '@logux/client' import type { TestServer } from '@logux/server' import type { TranslationLoader } from '@nanostores/i18n' import { @@ -9,12 +8,13 @@ import { type PersistentStore, setPersistentEngine } from '@nanostores/persistent' +import type { Database } from '@nanostores/sql' import { atom, type ReadableAtom, type StoreValue } from 'nanostores' import type { BaseRouter, Route, Routes } from './router.ts' -interface LogStoreCreator { - (): ClientOptions['store'] +interface DatabaseCreator { + (): Database } export type NetworkType = 'free' | 'paid' | 'unknown' | undefined @@ -71,10 +71,13 @@ export interface Environment { cleanStorage(): void /** - * Object like `window` in web or `process` in Node.js to track unhandled - * errors. - * - * For instance, we are using it to catch not-found errors. + * SQL database engine. Like SQLocal in Web, in-memory SQLite in Node.js, + * Expo for mobile, etc. It keeps both Logux log and app’s tables. + */ + databaseCreator: DatabaseCreator + + /** + * `window` in web or `process` in Node.js to track unhandled errors. */ errorEvents: ErrorEvents @@ -88,11 +91,6 @@ export interface Environment { */ locale: ReadableAtom - /** - * Persistent storage for Logux log. - */ - logStoreCreator: LogStoreCreator - /** * Detect network type to not download images over expensive tariff. */ @@ -191,10 +189,10 @@ export function setupEnvironment( currentEnvironment = { baseRouter: env.baseRouter, cleanStorage: env.cleanStorage, + databaseCreator: env.databaseCreator, errorEvents: env.errorEvents, getSession: env.getSession, locale: env.locale, - logStoreCreator: env.logStoreCreator, networkType: env.networkType, openRoute: env.openRoute, restartApp: env.restartApp, diff --git a/core/feed.ts b/core/feed.ts index a898249d..2d673b16 100644 --- a/core/feed.ts +++ b/core/feed.ts @@ -1,74 +1,74 @@ -import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type Filter, - type FilterStore, - loadValue, - type SyncMapStore, - syncMapTemplate -} from '@logux/client' -import { nanoid } from 'nanoid' -import { atom, onMount } from 'nanostores' - -import { getClient } from './client.ts' +import { withMeta, type WithoutMeta } from '@logux/client/db' +import type { SqlStore } from '@nanostores/sql' +import { atom, onMount, type ReadableAtom } from 'nanostores' + import { createDownloadTask, type TextResponse } from './lib/download.ts' -import type { OptionalId } from './lib/stores.ts' -import { type FeedLoader, type LoaderName, loaders } from './loader/index.ts' -import { deletePost, getPosts, recalcPostsReading } from './post.ts' +import { firstRow } from './lib/stores.ts' +import { type FeedLoader, loaders } from './loader/index.ts' +import { deletePost, loadPostIdsByFeed, recalcPostsReading } from './post.ts' import type { PostsList } from './posts-list.ts' -import type { UsefulReaderName } from './readers/common.ts' - -export type FeedValue = { - categoryId: string - fastReader?: UsefulReaderName - id: string - lastOriginId?: string - lastPublishedAt?: number - loader: LoaderName - reading: 'fast' | 'slow' - refreshedAt?: number - slowReader?: UsefulReaderName - title: string - url: string +import { + type FeedChanges, + type FeedValue, + getDatabase, + getTables, + type NewFeed, + select +} from './schema.ts' + +export type { FeedValue, NewFeed } + +export function getFeed(feedId: string): ReadableAtom { + return firstRow(getTables().feeds.select`WHERE "id" = ${feedId}`) } -export const Feed = syncMapTemplate('feeds', { - offline: true, - remote: false -}) +export function getFeeds(): SqlStore { + return getTables().feeds.select`ORDER BY "title"` +} + +export function getFeedsByUrl(url: string): SqlStore { + return getTables().feeds.select`WHERE "url" = ${url}` +} -export function getFeed(feedId: string): SyncMapStore { - return Feed(feedId, getClient()) +export function loadFeed(feedId: string): Promise { + return select`SELECT * FROM "feeds" WHERE "id" = ${feedId}`.then( + rows => rows[0] + ) } -export function getFeeds( - filter: Filter = {} -): FilterStore { - return createFilter(getClient(), Feed, filter) +export function loadFeeds(): Promise { + return select`SELECT * FROM "feeds" ORDER BY "title"` } -export async function addFeed(fields: OptionalId): Promise { - let id = fields.id ?? nanoid() - await createSyncMap(getClient(), Feed, { id, ...fields }) - return id +export function loadFeedsByCategory(categoryId: string): Promise { + return select` + SELECT * FROM "feeds" WHERE "categoryId" = ${categoryId} ORDER BY "title" + ` +} + +export function loadFeedByUrl(url: string): Promise { + return select`SELECT * FROM "feeds" WHERE "url" = ${url}`.then( + rows => rows[0] + ) +} + +export function addFeed(fields: NewFeed): Promise { + return getTables().feeds.create(fields) } export async function deleteFeed(feedId: string): Promise { - let feed = Feed.cache[feedId] - if (feed) feed.deleted = true - let posts = await loadValue(getPosts({ feedId })) - await Promise.all(posts.list.map(post => deletePost(post.id))) - return deleteSyncMapById(getClient(), Feed, feedId) + await deletePost(await loadPostIdsByFeed(feedId)) + return getTables().feeds.delete(feedId) } export async function changeFeed( - feedId: string, - changes: Partial + feedId: string[] | string, + changes: FeedChanges ): Promise { - await changeSyncMapById(getClient(), Feed, feedId, changes) - await recalcPostsReading(feedId) + await getTables().feeds.update(feedId, changes) + if (changes.reading) { + await Promise.all([feedId].flat().map(id => recalcPostsReading(id))) + } } /** @@ -76,7 +76,7 @@ export async function changeFeed( */ export async function addCandidate( candidate: FeedLoader, - fields: Partial = {}, + fields: Partial = {}, task = createDownloadTask(), response?: TextResponse ): Promise { @@ -104,30 +104,38 @@ export function getFeedLatestPosts( task, feed.url, undefined, - feed.refreshedAt + feed.refreshedAt ?? undefined ) } let testFeedId = 0 -export function testFeed(feed: Partial = {}): FeedValue { +export function testFeed( + feed: Partial> = {} +): FeedValue { testFeedId += 1 - return { + return withMeta({ categoryId: 'general', + fastReader: null, id: `feed-${testFeedId}`, - lastOriginId: undefined, - lastPublishedAt: undefined, + lastOriginId: null, + lastPublishedAt: null, loader: 'rss', reading: 'fast', + refreshedAt: null, + slowReader: null, title: `Test ${testFeedId}`, url: `http://example.com/${testFeedId}`, ...feed - } + }) } export const needWelcome = atom() onMount(needWelcome, () => { - return getFeeds().subscribe(feeds => { - needWelcome.set(feeds.isLoading ? undefined : feeds.isEmpty) + let $total = getDatabase().store<{ + total: number + }>`SELECT COUNT("url") AS "total" FROM "feeds"` + return $total.subscribe(value => { + needWelcome.set(value.isLoading ? undefined : value.value[0]!.total === 0) }) }) diff --git a/core/filter.ts b/core/filter.ts index d415f61f..df38f711 100644 --- a/core/filter.ts +++ b/core/filter.ts @@ -1,18 +1,14 @@ +import { type PostContent, recalcPostsReading } from './post.ts' import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type FilterStore, - loadValue, - type Filter as LoguxFilter, - syncMapTemplate -} from '@logux/client' -import { nanoid } from 'nanoid' - -import { getClient } from './client.ts' -import type { FeedValue } from './feed.ts' -import { type OriginPost, recalcPostsReading } from './post.ts' + type FeedValue, + type FilterChanges, + type FilterValue, + getTables, + type NewFilter, + select +} from './schema.ts' + +export type { FilterValue, NewFilter } const QUERY_REGEXP = /^(not\s+)?([\w]+)(?:\(([^]+)\))?$/ @@ -30,11 +26,11 @@ const QUERIES = { } } satisfies { [Key in string]?: keyof typeof QUERIES_WITH_PARAM extends Key - ? (post: OriginPost, param: string) => boolean - : (post: OriginPost) => boolean + ? (post: PostContent, param: string) => boolean + : (post: PostContent) => boolean } -export type FilterAction = 'delete' | 'fast' | 'slow' +export type FilterAction = FilterValue['action'] function maxPriority(filters: FilterValue[]): number { let max = 0 @@ -44,34 +40,29 @@ function maxPriority(filters: FilterValue[]): number { return max } -export type FilterValue = { - action: FilterAction - feedId: string - id: string - priority: number - query: string +export function loadFilters(): Promise { + return select`SELECT * FROM "filters" ORDER BY "priority", "id"` } -export const Filter = syncMapTemplate('filters', { - offline: true, - remote: false -}) +export function loadFiltersByFeed(feedId: string): Promise { + return select` + SELECT * FROM "filters" WHERE "feedId" = ${feedId} + ORDER BY "priority", "id" + ` +} -export function getFilters( - filter: LoguxFilter = {} -): FilterStore { - return createFilter(getClient(), Filter, filter) +export function loadFilter(filterId: string): Promise { + return select` + SELECT * FROM "filters" WHERE "id" = ${filterId} + `.then(rows => rows[0]) } export async function addFilter( - fields: Omit + fields: { priority?: number } & Omit ): Promise { - let id = nanoid() - let other = await loadValue(getFilters({ feedId: fields.feedId })) - let priority = maxPriority(other.list) + 100 - await createSyncMap(getClient(), Filter, { id, priority, ...fields }) + let priority = maxPriority(await loadFiltersByFeed(fields.feedId)) + 100 + let id = await getTables().filters.create({ priority, ...fields }) await recalcPostsReading(fields.feedId) - return id } @@ -84,9 +75,9 @@ export async function addFilterForFeed(feed: FeedValue): Promise { } export async function deleteFilter(filterId: string): Promise { - let filter = await loadValue(Filter(filterId, getClient())) + let filter = await loadFilter(filterId) - await deleteSyncMapById(getClient(), Filter, filterId) + await getTables().filters.delete(filterId) if (filter) { await recalcPostsReading(filter.feedId) @@ -95,10 +86,10 @@ export async function deleteFilter(filterId: string): Promise { export async function changeFilter( filterId: string, - changes: Partial + changes: FilterChanges ): Promise { - await changeSyncMapById(getClient(), Filter, filterId, changes) - let filter = await loadValue(Filter(filterId, getClient())) + await getTables().filters.update(filterId, changes) + let filter = await loadFilter(filterId) if (filter) { await recalcPostsReading(filter.feedId) } @@ -121,11 +112,9 @@ export function sortFilters(filters: FilterValue[]): FilterValue[] { * relative to neighboring filters to maintain sort order. */ async function move(filterId: string, diff: -1 | 1): Promise { - let store = Filter(filterId, getClient()) - let filter = await loadValue(store) + let filter = await loadFilter(filterId) if (!filter) return - let feedId = filter.feedId - let sorted = sortFilters((await loadValue(getFilters({ feedId }))).list) + let sorted = sortFilters(await loadFiltersByFeed(filter.feedId)) let last = sorted.length - 1 let index = sorted.findIndex(i => i.id === filterId) let next = index + diff @@ -192,7 +181,7 @@ export function isValidFilterQuery(query: string): boolean { } export interface FilterChecker { - (post: OriginPost): FilterAction | undefined + (post: PostContent): FilterAction | undefined } export function prepareFilters(filters: FilterValue[]): FilterChecker { @@ -224,9 +213,8 @@ export function prepareFilters(filters: FilterValue[]): FilterChecker { } } -export async function loadFilters( - filter: LoguxFilter = {} +export async function loadFilterChecker( + feedId: string ): Promise { - let filters = await loadValue(getFilters(filter)) - return prepareFilters(filters.list) + return prepareFilters(await loadFiltersByFeed(feedId)) } diff --git a/core/index.ts b/core/index.ts index f491e597..55b1ce84 100644 --- a/core/index.ts +++ b/core/index.ts @@ -16,6 +16,7 @@ export * from './lib/html.ts' export * from './lib/pagination.ts' export { subscribeUntil, waitLoading } from './lib/stores.ts' export * from './lib/validators.ts' +export * from './loader.ts' export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' @@ -29,4 +30,5 @@ export * from './readers/index.ts' export * from './refresh.ts' export * from './request.ts' export * from './router.ts' +export * from './schema.ts' export * from './settings.ts' diff --git a/core/lib/stores.ts b/core/lib/stores.ts index 84a5cdac..3f0992c0 100644 --- a/core/lib/stores.ts +++ b/core/lib/stores.ts @@ -1,17 +1,20 @@ // Helpers to work with Nano Stores and Logux to simplify code // by moving complexity to helper. -import type { Action } from '@logux/core' +import type { SqlStore } from '@nanostores/sql' import { + computed, type MapStore, onMount, type ReadableAtom, type StoreValue } from 'nanostores' -import { client } from '../client.ts' - -export type OptionalId = { id?: string } & Omit +export function firstRow( + store: SqlStore +): ReadableAtom { + return computed(store, rows => (rows.isLoading ? undefined : rows.value[0])) +} type NumberKeys = { [K in keyof T]: T[K] extends number ? K : never @@ -21,7 +24,7 @@ export function increaseKey( store: Store, key: NumberKeys> ): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // oxlint-disable-next-line typescript/no-unsafe-member-access let value = store.get()[key] as number store.setKey(key, value + 1) } @@ -48,30 +51,15 @@ export function waitLoading(store: ReadableAtom): Promise { }) } -export type LoadedValue = { - isLoading: false -} & Type - -export type LoadableStore = { - readonly loading: Promise -} & ReadableAtom<{ isLoading: boolean }> - -/** - * Return promise which wait until `store.isLoading` is stop to have `true`. - */ -export async function waitSyncLoading( - store: Store -): Promise>>> { - let value = store.get() - if (value.isLoading) { - let unbind = store.listen(() => {}) - try { - await store.loading - } finally { - unbind() - } +export async function waitSql(store: SqlStore): Promise { + let unbind = store.listen(() => {}) + try { + await store.loading + let value = store.get() + return value.isLoading ? [] : value.value + } finally { + unbind() } - return store as unknown as ReadableAtom>> } /** @@ -100,27 +88,6 @@ export function onMountAny(stores: ReadableAtom[], cb: () => () => void): void { } } -/** - * Run callback on every Logux action. - * - * It hides complexity of client re-creating on sign-in to re-subscribing. - */ -export function onLogAction(cb: (action: Action) => void): () => void { - let unbindLog: (() => void) | undefined - let unbindClient = client.subscribe(loguxClient => { - unbindLog?.() - unbindLog = undefined - if (loguxClient) { - unbindLog = loguxClient.on('add', cb) - } - }) - - return () => { - unbindLog?.() - unbindClient() - } -} - /** * Subscribe to store and run callback on every store’s change until callback * return `true`. diff --git a/core/loader.ts b/core/loader.ts new file mode 100644 index 00000000..d5da50c0 --- /dev/null +++ b/core/loader.ts @@ -0,0 +1,23 @@ +// Loaders report themselves to the benchmark. Reporter is empty by default +// and `benchmark.ts` sets the real one, so clients will not download +// benchmark tools. + +export interface LoaderReporter { + (name: string): () => void +} + +let reporter: LoaderReporter = () => { + return () => {} +} + +export function setLoaderReporter(cb: LoaderReporter): void { + reporter = cb +} + +/** + * Tell benchmark that the loader is rendered. Returns function to call + * when the loader was removed from the screen. + */ +export function reportLoader(name: string): () => void { + return reporter(name) +} diff --git a/core/menu.ts b/core/menu.ts index 0b66bdcf..7a6d2841 100644 --- a/core/menu.ts +++ b/core/menu.ts @@ -1,20 +1,15 @@ -import { ensureLoaded, loadValue } from '@logux/client' import { persistentAtom } from '@nanostores/persistent' -import { atom, computed, keepMount } from 'nanostores' +import { atom, computed, effect, keepMount } from 'nanostores' import { busyDuring } from './busy.ts' -import { - type CategoryValue, - getCategories, - getGeneralCategory -} from './category.ts' +import { type CategoryValue, getGeneralCategory } from './category.ts' import { client } from './client.ts' import { layoutType } from './environment.ts' -import { type FeedValue, getFeeds } from './feed.ts' -import { getFilters } from './filter.ts' -import { onLogAction, onMountAny, waitLoading } from './lib/stores.ts' -import { getPosts } from './post.ts' +import type { FeedValue } from './feed.ts' +import { onMountAny, waitLoading } from './lib/stores.ts' +import { commonMessages } from './messages/index.ts' import { isOtherRoute, router } from './router.ts' +import { getDatabase } from './schema.ts' export type MenuType = 'fast' | 'other' | 'slow' @@ -78,79 +73,58 @@ export let fastMenu = atom([]) export let slowMenu = atom([]) export let menuLoading = atom(true) +type UnreadCount = { feedId: string; unread: number } + /** * Performance optimization is postponed after the prototype. * * So we rebuild the fast/slow feeds menu on every feed/category/filter changes. */ -async function rebuild(): Promise { - let [posts, feeds, categories, fastFilters] = await Promise.all([ - loadValue(getPosts()), - loadValue(getFeeds()), - loadValue(getCategories()), - loadValue(getFilters({ action: 'fast' })) - ]) - - let slowPosts = posts.list.filter(i => i.reading === 'slow' && !i.read) - let fastFeeds = feeds.list.filter(i => i.reading === 'fast') - - let feedsWithFastFilters = fastFilters.list.map(i => { - return feeds.stores.get(i.feedId)!.get() - }) - - let uniqueFastCategories: Record = {} - for (let feed of [...fastFeeds, ...feedsWithFastFilters]) { - let id = ensureLoaded(feed).categoryId - if (!uniqueFastCategories[id]) { - if (id === 'general') { - uniqueFastCategories[id] = getGeneralCategory() - } else { - let found = categories.list.find(i => i.id === id) - if (found) uniqueFastCategories[id] = found - } +function rebuild( + categories: CategoryValue[], + fastCategories: Pick[], + slowFeeds: FeedValue[], + unread: UnreadCount[] +): void { + let byId = new Map(categories.map(category => [category.id, category])) + + let fast: CategoryValue[] = [] + for (let { categoryId } of fastCategories) { + if (categoryId === 'general') { + fast.push(getGeneralCategory()) + } else { + let found = byId.get(categoryId) + if (found) fast.push(found) } } + fast.sort((a, b) => a.title.localeCompare(b.title)) + fastMenu.set(fast.length > 0 ? fast : [getGeneralCategory()]) - let fast = Object.values(uniqueFastCategories).toSorted((a, b) => { - return a.title.localeCompare(b.title) - }) - - if (fast.length > 0) { - fastMenu.set(fast) - } else { - fastMenu.set([getGeneralCategory()]) - } - - let byCategory: Record = {} + let unreadByFeed = new Map(unread.map(i => [i.feedId, i.unread])) + let byCategory = new Map() let general = false - - let unreadByFeed: Record = {} - for (let post of slowPosts) { - unreadByFeed[post.feedId] = (unreadByFeed[post.feedId] ?? 0) + 1 - } - for (let [feedId, unread] of Object.entries(unreadByFeed)) { - let feedStore = feeds.stores.get(feedId) - if (!feedStore) continue - let feed = ensureLoaded(feedStore.get()) - let category = feed.categoryId - if (category === 'general') { + for (let feed of slowFeeds) { + if (feed.categoryId === 'general') { general = true - } else if (!categories.stores.has(category)) { + } else if (!byId.has(feed.categoryId)) { continue } - if (!byCategory[category]) byCategory[category] = [] - byCategory[category].push([feed, unread]) + let list = byCategory.get(feed.categoryId) + if (!list) { + list = [] + byCategory.set(feed.categoryId, list) + } + list.push([feed, unreadByFeed.get(feed.id)!]) } - let allCategories = [...categories.list] as CategoryValue[] + let allCategories = [...categories] if (general) allCategories.push(getGeneralCategory()) - let categoriesByName = allCategories.toSorted((a, b) => { - return a.title.localeCompare(b.title) - }) let result: SlowMenu = [] - for (let category of categoriesByName) { - let list = byCategory[category.id] + for (let category of allCategories.toSorted((a, b) => { + return a.title.localeCompare(b.title) + })) { + let list = byCategory.get(category.id) if (list) { list.sort((a, b) => a[0].title.localeCompare(b[0].title)) result.push([category, list]) @@ -163,22 +137,49 @@ async function rebuild(): Promise { onMountAny([fastMenu, slowMenu], () => { menuLoading.set(true) - void rebuild().then(() => { - menuLoading.set(false) - }) - let unbindAction = onLogAction(action => { - if ( - action.type.startsWith('categories/') || - action.type.startsWith('feeds/') || - action.type.startsWith('posts/') || - action.type.startsWith('filters/') - ) { - void rebuild() + let database = getDatabase() + let unbind = effect( + [ + database.store`SELECT * FROM "categories"`, + database.store>` + SELECT DISTINCT "categoryId" FROM "feeds" + WHERE "reading" = 'fast' + OR "id" IN (SELECT "feedId" FROM "filters" WHERE "action" = 'fast') + `, + database.store` + SELECT * FROM "feeds" WHERE "id" IN ( + SELECT "feedId" FROM "posts" + WHERE "reading" = 'slow' AND "read" = 0 + ) + `, + database.store` + SELECT "feedId", COUNT("originId") AS "unread" FROM "posts" + WHERE "reading" = 'slow' AND "read" = 0 + GROUP BY "feedId" + ` + ], + (categories, fastCategories, slowFeeds, unread) => { + if ( + categories.isLoading || + fastCategories.isLoading || + slowFeeds.isLoading || + unread.isLoading + ) { + return + } + // TODO Logux DB: replace with reducer + rebuild( + categories.value, + fastCategories.value, + slowFeeds.value, + unread.value + ) + menuLoading.set(false) } - }) + ) return () => { - unbindAction() + unbind() fastMenu.set([]) slowMenu.set([]) menuLoading.set(true) @@ -214,7 +215,9 @@ export function busyUntilMenuLoader(): Promise { if (client.get()) { keepMount(fastMenu) keepMount(slowMenu) - return busyDuring(() => waitLoading(menuLoading)) + return busyDuring(commonMessages.get().loadingData, () => + waitLoading(menuLoading) + ) } else { return Promise.resolve() } diff --git a/core/messages/benchmark/en.ts b/core/messages/benchmark/en.ts new file mode 100644 index 00000000..618f44aa --- /dev/null +++ b/core/messages/benchmark/en.ts @@ -0,0 +1,5 @@ +import { i18n } from '../../i18n.ts' + +export const benchmarkMessages = i18n('benchmark', { + creatingData: 'Creating benchmark data' +}) diff --git a/core/messages/common/en.ts b/core/messages/common/en.ts index 49f31781..7e6747e6 100644 --- a/core/messages/common/en.ts +++ b/core/messages/common/en.ts @@ -5,6 +5,7 @@ import { i18n } from '../../i18n.ts' export const commonMessages = i18n('common', { addCategory: 'Add category…', closePopup: 'Close popup', + downloadingData: 'Downloading your data from the cloud', empty: 'The value is required', error400: 'Bad request', error401: 'Unauthorized', @@ -21,6 +22,8 @@ export const commonMessages = i18n('common', { invalidUrl: 'This doesn’t look like a valid web address', invalidUserId: 'User ID must contain 16 digits', loading: 'Loading…', + loadingData: 'Loading local data', + migratingDatabase: 'Migrating local database', networkError: 'Can’t reach the server. Please check your internet connection.', parseError: 'Syntax error in the feed file', diff --git a/core/messages/index.ts b/core/messages/index.ts index d4d5f23a..d5df6a23 100644 --- a/core/messages/index.ts +++ b/core/messages/index.ts @@ -1,6 +1,7 @@ export * from './about/en.ts' export * from './add/en.ts' export * from './auth/en.ts' +export * from './benchmark/en.ts' export * from './cloud/en.ts' export * from './common/en.ts' export * from './export/en.ts' diff --git a/core/package.json b/core/package.json index ca21a44b..b0cfc528 100644 --- a/core/package.json +++ b/core/package.json @@ -4,39 +4,41 @@ "type": "module", "exports": { ".": "./index.ts", + "./benchmark": "./benchmark.ts", "./package.json": "./package.json", "./test": "./test.ts" }, "scripts": { "test": "FORCE_COLOR=1 pnpm run /^test:/", - "test:coverage": "bnt --coverage 100 --coverage-exclude '{devtools.ts,errors.ts,test.ts,../api/**,../server/**,../proxy/**,**/test/**}'" + "test:coverage": "bnt --coverage 100 --coverage-exclude '{benchmark*,devtools.ts,errors.ts,test.ts,../api/**,../server/**,../proxy/**,**/test/**}'" }, "dependencies": { - "@logux/actions": "0.5.0", - "@logux/client": "0.23.2", - "@logux/core": "0.10.0", + "@logux/actions": "github:logux/actions#next", + "@logux/client": "github:logux/client#next", + "@logux/core": "github:logux/core#next", "@nanostores/i18n": "1.3.3", "@nanostores/persistent": "1.3.5", + "@nanostores/sql": "0.4.1", "@slowreader/api": "workspace:*", "@slowreader/server": "workspace:*", - "dompurify": "3.4.12", + "dompurify": "3.4.13", "just-debounce-it": "3.2.0", "lz-string": "1.5.0", "nanodelay": "2.0.2", - "nanoid": "6.0.0", - "nanostores": "1.4.1" + "nanoid": "6.0.1", + "nanostores": "1.4.2" }, "devDependencies": { - "@logux/server": "0.14.0", + "@logux/server": "github:logux/server", "@slowreader/proxy": "workspace:*", - "@types/jsdom": "28.0.3", + "@types/jsdom": "30.0.0", "@types/trusted-types": "2.0.7", "better-node-test": "1.0.0", - "jsdom": "30.0.0", + "jsdom": "30.0.1", "nanospy": "2.0.2" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/core/pages/export.ts b/core/pages/export.ts index d365aa13..8243a0ba 100644 --- a/core/pages/export.ts +++ b/core/pages/export.ts @@ -1,16 +1,15 @@ -import type { SyncMapValues } from '@logux/actions' -import { type FilterStore, loadValue } from '@logux/client' +import { type WithoutMeta, withoutMeta } from '@logux/client/db' import { atom } from 'nanostores' import { type CategoryValue, feedsByCategory, - getCategories + loadCategories } from '../category.ts' import { getEnvironment } from '../environment.ts' -import { type FeedValue, getFeeds } from '../feed.ts' -import { type FilterValue, getFilters } from '../filter.ts' -import { getPosts, type PostValue } from '../post.ts' +import { type FeedValue, loadFeeds } from '../feed.ts' +import { type FilterValue, loadFilters } from '../filter.ts' +import { loadPosts, type PostValue } from '../post.ts' import { preloadImages, type Settings, @@ -21,10 +20,10 @@ import { import { createPage } from './common.ts' export interface StateExport { - categories: Omit[] - feeds: Omit[] - filters: Omit[] - posts: Omit[] + categories: WithoutMeta[] + feeds: WithoutMeta[] + filters: WithoutMeta[] + posts: WithoutMeta[] settings: Settings } @@ -45,16 +44,6 @@ export function isStateExportFile(state: unknown): state is StateExport { ) } -async function loadList( - filter: FilterStore -): Promise[]> { - return (await loadValue(filter)).list.map(i => { - let copy = { ...i } as Omit - delete copy.isLoading - return copy - }) -} - const NO_OPML_CATEGORY: Record = { general: true } @@ -88,14 +77,14 @@ export const exportPage = createPage('export', () => { ' \n' let [categories, allFeeds] = await Promise.all([ - loadValue(getCategories()), - loadValue(getFeeds()) + loadCategories(), + loadFeeds() ]) if (stopped) { $exportingOpml.set(false) return } - let tree = feedsByCategory(categories.list, allFeeds.list) + let tree = feedsByCategory(categories, allFeeds) for (let [category, feeds] of tree) { if (!NO_OPML_CATEGORY[category.id]) { @@ -125,10 +114,10 @@ export const exportPage = createPage('export', () => { async function exportBackup(): Promise { $exportingBackup.set(true) let state = { - categories: await loadList(getCategories()), - feeds: await loadList(getFeeds()), - filters: await loadList(getFilters()), - posts: await loadList(getPosts()), + categories: withoutMeta(await loadCategories()), + feeds: withoutMeta(await loadFeeds()), + filters: withoutMeta(await loadFilters()), + posts: withoutMeta(await loadPosts()), settings: { preloadImages: preloadImages.get(), theme: theme.get(), diff --git a/core/pages/feeds-by-categories.ts b/core/pages/feeds-by-categories.ts index 96ccd78a..b840c8cc 100644 --- a/core/pages/feeds-by-categories.ts +++ b/core/pages/feeds-by-categories.ts @@ -19,7 +19,7 @@ export const feedsByCategoriesPage = createPage('feedsByCategories', () => { let $loading = atom(true) let unbind = effect([$categories, $feeds], (categories, feeds) => { if (!categories.isLoading && !feeds.isLoading) { - $groups.set(feedsByCategory(categories.list, feeds.list)) + $groups.set(feedsByCategory(categories.value, feeds.value)) $loading.set(false) } else { $loading.set(true) diff --git a/core/pages/feeds.ts b/core/pages/feeds.ts index 940db694..fd080283 100644 --- a/core/pages/feeds.ts +++ b/core/pages/feeds.ts @@ -5,7 +5,7 @@ import { changeFeed, type FeedValue, getFeed, needWelcome } from '../feed.ts' import { fastMenu, menuLoading, slowMenu } from '../menu.ts' import { deletePost, fastPostsCount, slowPostsCount } from '../post.ts' import { - loadPosts, + loadReadPostIds, type PostFilter, type ReaderHelpers, type UsefulReaderName @@ -20,6 +20,7 @@ import { welcomeReader } from '../readers/index.ts' import { nextRouteIsRedirect } from '../router.ts' +import { hasDatabase } from '../schema.ts' import { createPage } from './common.ts' let pages = (['slow', 'fast'] as const).map(reading => { @@ -36,14 +37,9 @@ let pages = (['slow', 'fast'] as const).map(reading => { let lastFilter: PostFilter | undefined async function deleteRead(): Promise { - if (lastFilter) { - let posts = await loadPosts(lastFilter) - await Promise.all( - posts.map(async post => { - if (post.get().read) await deletePost(post.get().id) - }) - ) - } + if (!lastFilter || !hasDatabase()) return + let posts = await loadReadPostIds(lastFilter) + if (hasDatabase()) await deletePost(posts) } let prevLoadingUnbind = (): void => {} @@ -86,11 +82,11 @@ let pages = (['slow', 'fast'] as const).map(reading => { $feed.set(undefined) if (feedId) { unbindTarget = getFeed(feedId).subscribe(value => { - if (!value.isLoading) $feed.set(value) + if (value) $feed.set(value) }) } else if (categoryId) { unbindTarget = getCategory(categoryId).subscribe(value => { - if (!value.isLoading) $category.set(value) + if (value) $category.set(value) }) } else if (!menuLoading.get()) { void nextRouteIsRedirect(() => { @@ -156,15 +152,13 @@ let pages = (['slow', 'fast'] as const).map(reading => { ) async function changeReader(reader: UsefulReaderName): Promise { - $from.set(undefined) let feedId = $feedId.get() + let categoryId = $categoryId.get() + $from.set(undefined) if (feedId) { await changeFeed(feedId, { [readerProp]: reader }) - } else { - let categoryId = $categoryId.get() - if (categoryId) { - await changeCategory(categoryId, { [readerProp]: reader }) - } + } else if (categoryId) { + await changeCategory(categoryId, { [readerProp]: reader }) } } diff --git a/core/pages/import.ts b/core/pages/import.ts index 93fd7392..64659cea 100644 --- a/core/pages/import.ts +++ b/core/pages/import.ts @@ -1,8 +1,7 @@ -import { loadValue } from '@logux/client' import { atom } from 'nanostores' import { addCategory } from '../category.ts' -import { addCandidate, addFeed, getFeeds } from '../feed.ts' +import { addCandidate, addFeed, loadFeedByUrl } from '../feed.ts' import { addFilter } from '../filter.ts' import { createDownloadTask } from '../lib/download.ts' import { parseDocument } from '../lib/html.ts' @@ -13,17 +12,12 @@ import { createPage } from './common.ts' import { isStateExportFile, type StateExport } from './export.ts' async function readFile(file: File): Promise { - return new Promise(resolve => { - let reader = new FileReader() - reader.addEventListener('load', () => { - resolve(reader.result as string) - }) + try { + return await file.text() /* node:coverage ignore next 3 */ - reader.addEventListener('error', () => { - resolve(false) - }) - reader.readAsText(file) - }) + } catch { + return false + } } type FeedError = 'exists' | 'noFeeds' | 'unknown' | 'unloadable' @@ -54,8 +48,7 @@ export const importPage = createPage('import', () => { } async function feedExists(url: string): Promise { - let feeds = await loadValue(getFeeds({ url })) - return feeds.list.some(i => !feeds.stores.get(i.id)!.deleted) + return !!(await loadFeedByUrl(url)) } async function importOpml(doc: Document): Promise { diff --git a/core/popups/feed.ts b/core/popups/feed.ts index 7415c497..6f7e8252 100644 --- a/core/popups/feed.ts +++ b/core/popups/feed.ts @@ -1,16 +1,21 @@ -import type { FilterStore } from '@logux/client' import { atom } from 'nanostores' import { getCategories } from '../category.ts' import { getEnvironment } from '../environment.ts' import { errorToMessage, NotFoundError } from '../errors.ts' -import { addCandidate, deleteFeed, type FeedValue, getFeeds } from '../feed.ts' +import { + addCandidate, + deleteFeed, + type FeedValue, + getFeedsByUrl, + loadFeedByUrl +} from '../feed.ts' import { createDownloadTask, type DownloadTask, type TextResponse } from '../lib/download.ts' -import { waitSyncLoading } from '../lib/stores.ts' +import { waitSql } from '../lib/stores.ts' import { type FeedLoader, getLoaderForText } from '../loader/index.ts' import { commonMessages } from '../messages/index.ts' import { createPostsList } from '../posts-list.ts' @@ -38,32 +43,28 @@ function swapHttpProtocol(url: string): string { return u.toString() } -async function getFeedsFilter(url: string): Promise> { - let exactFilter = getFeeds({ url }) - let feeds = await waitSyncLoading(exactFilter) - - if (feeds.get().list.length === 0) { - let fallbackFilter = getFeeds({ url: swapHttpProtocol(url) }) - feeds = await waitSyncLoading(fallbackFilter) - if (feeds.get().list.length > 0) { - return fallbackFilter - } - } - - return exactFilter +/** + * Feed could be saved with another protocol than in the current URL. + */ +async function findFeedUrl(url: string): Promise { + if (await loadFeedByUrl(url)) return url + let swapped = swapHttpProtocol(url) + if (await loadFeedByUrl(swapped)) return swapped + return url } export const feed = definePopup('feed', async url => { let task = createDownloadTask({ cache: 'read' }) - let feedsFilter = await getFeedsFilter(url) + let feedsStore = getFeedsByUrl(await findFeedUrl(url)) + let categoriesStore = getCategories() let error: string | undefined - let [responseOrError, categoriesFilter, feeds] = await Promise.all([ + let [responseOrError, feeds] = await Promise.all([ loadFeedFromURL(task, url), - waitSyncLoading(getCategories()), - waitSyncLoading(feedsFilter) + waitSql(feedsStore), + waitSql(categoriesStore) ]) - let existing = feeds.get().list[0] + let existing = feeds[0] let response: TextResponse | undefined let candidate: false | FeedLoader | undefined let posts = createPostsList(undefined) @@ -81,26 +82,16 @@ export const feed = definePopup('feed', async url => { let $feed = atom() - let unbindFeed = (): void => {} - let unbindFeeds = feedsFilter.subscribe(value => { + let unbindFeeds = feedsStore.subscribe(value => { if (!value.isLoading) { - let find = value.list[0] - if (find) { - let $find = value.stores.get(find.id)! - unbindFeed = $find.subscribe(i => { - if (!i.isLoading) { - $feed.set(i) - } - }) - } else { - $feed.set(undefined) - } + $feed.set(value.value[0]) } }) let $categories = atom<[string, string][]>([]) - let unbindCategories = categoriesFilter.subscribe(value => { - let list = value.list.map( + let unbindCategories = categoriesStore.subscribe(value => { + if (value.isLoading) return + let list = value.value.map( category => [category.id, category.title] as [string, string] ) $categories.set([ @@ -129,7 +120,6 @@ export const feed = definePopup('feed', async url => { destroy() { task.destroy() unbindFeeds() - unbindFeed() unbindCategories() }, error, diff --git a/core/popups/post.ts b/core/popups/post.ts index 6b671e41..eefc26d4 100644 --- a/core/popups/post.ts +++ b/core/popups/post.ts @@ -1,4 +1,3 @@ -import { ensureLoadedStore, loadValue } from '@logux/client' import lz from 'lz-string' import { atom, @@ -8,11 +7,12 @@ import { } from 'nanostores' import { NotFoundError } from '../errors.ts' -import { type FeedValue, getFeeds } from '../feed.ts' +import { type FeedValue, loadFeed } from '../feed.ts' import { formatPublishedAt, i18nFormat } from '../format.ts' import { changePost, - getPosts, + getPost, + loadPost, type OriginPost, type PostValue } from '../post.ts' @@ -45,10 +45,15 @@ export const post = definePopup('post', async loader => { id = loader.slice(5) } + let unbindStored = (): void => {} if (id) { - let filter = await loadValue(getPosts()) - if (!filter.stores.has(id)) throw new NotFoundError() - $post = ensureLoadedStore(filter.stores.get(id)!) + let stored = await loadPost(id) + if (!stored) throw new NotFoundError() + let $stored = atom(stored) + unbindStored = getPost(id).subscribe(value => { + if (value) $stored.set(value) + }) + $post = $stored } else if (loader.startsWith('data:')) { let data = loader.slice(5) try { @@ -67,22 +72,20 @@ export const post = definePopup('post', async loader => { if (loader.startsWith('read:')) { read = atom(false) unbindPost = $post.subscribe(value => { - read!.set(!!value.read) + read!.set('read' in value && value.read === 1) }) unbindRead = read.listen(async value => { - if (value !== $post.get().read) { - await changePost(id!, { read: value }) + let current = $post.get() + if ('read' in current && current.read !== (value ? 1 : 0)) { + await changePost(id!, { read: value ? 1 : 0 }) } }) } let postValue = $post.get() if ('feedId' in postValue) { - let feedId = postValue.feedId - let feedFilter = await loadValue(getFeeds()) - if (feedFilter.stores.has(feedId)) { - $feed = ensureLoadedStore(feedFilter.stores.get(feedId)!) - } + let feedValue = await loadFeed(postValue.feedId) + if (feedValue) $feed = atom(feedValue) } let $publishedAt = computed([$post, i18nFormat], (value, format) => { @@ -93,6 +96,7 @@ export const post = definePopup('post', async loader => { return { destroy() { + unbindStored() unbindPost() unbindRead() }, diff --git a/core/post.ts b/core/post.ts index a0abf4c6..b8f82b19 100644 --- a/core/post.ts +++ b/core/post.ts @@ -1,27 +1,24 @@ -import { defineSyncMapActions } from '@logux/actions' -import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type Filter, - type FilterStore, - loadValue, - type SyncMapStore, - syncMapTemplate -} from '@logux/client' +import { withMeta, type WithoutMeta } from '@logux/client/db' import { formatter } from '@nanostores/i18n' -import { nanoid } from 'nanoid' -import { atom, onMount } from 'nanostores' +import { atom, onMount, type ReadableAtom, type WritableAtom } from 'nanostores' -import { getClient } from './client.ts' import { getEnvironment } from './environment.ts' -import { getFeed } from './feed.ts' -import { loadFilters } from './filter.ts' +import { loadFeed } from './feed.ts' +import { loadFilterChecker } from './filter.ts' import { $locale } from './i18n.ts' import { sanitizeHtml, stripHTML, truncateHTML } from './lib/html.ts' -import type { OptionalId } from './lib/stores.ts' +import { firstRow } from './lib/stores.ts' import { truncateText } from './lib/text.ts' +import { + getDatabase, + getTables, + type NewPost, + type PostChanges, + type PostValue, + select +} from './schema.ts' + +export type { NewPost, PostValue } export type OriginPost = { full?: string @@ -29,76 +26,96 @@ export type OriginPost = { media?: string originId: string publishedAt?: number - read?: boolean title?: string url?: string } +/** + * Post’s text from a feed or from the database. Database returns `null` + * for missing values, while loaders return `undefined`. + */ +export type PostContent = { + full?: null | string + intro?: null | string + originId: string + publishedAt?: null | number + title?: null | string + url?: null | string +} + export type PostMedia = { fromText?: boolean type: string url: string } -export type PostValue = { - feedId: string - id: string - publishedAt: number - reading: 'fast' | 'slow' -} & Omit +export function addPost(posts: NewPost[]): Promise +export function addPost(fields: NewPost): Promise +export function addPost( + fields: NewPost | NewPost[] +): Promise { + return getTables().posts.create(fields) +} -export const Post = syncMapTemplate('posts', { - offline: true, - remote: false -}) +export function getPost(postId: string): ReadableAtom { + return firstRow(getTables().posts.select`WHERE "id" = ${postId}`) +} + +export function loadPost(postId: string): Promise { + return select`SELECT * FROM "posts" WHERE "id" = ${postId}`.then( + rows => rows[0] + ) +} -export async function addPost(fields: OptionalId): Promise { - let id = fields.id ?? nanoid() - await createSyncMap(getClient(), Post, { id, ...fields }) - return id +export function loadPosts(): Promise { + return select`SELECT * FROM "posts" ORDER BY "publishedAt" DESC` } -export function getPost(id: string): SyncMapStore { - return Post(id, getClient()) +export function loadPostsByFeed(feedId: string): Promise { + return select` + SELECT * FROM "posts" WHERE "feedId" = ${feedId} + ORDER BY "publishedAt" DESC + ` } -export function getPosts( - filter: Filter = {} -): FilterStore { - return createFilter(getClient(), Post, filter) +export function loadPostIdsByFeed(feedId: string): Promise { + return select<{ + id: string + }>`SELECT "id" FROM "posts" WHERE "feedId" = ${feedId}`.then(rows => + rows.map(row => row.id) + ) } -export function deletePost(postId: string): Promise { - return deleteSyncMapById(getClient(), Post, postId) +export function deletePost(postId: string[] | string): Promise { + return getTables().posts.delete(postId) } -export async function changePost( - postId: string, - changes: Partial +export function changePost( + postId: string[] | string, + changes: PostChanges ): Promise { - return changeSyncMapById(getClient(), Post, postId, changes) + return getTables().posts.update(postId, changes) } export function processOriginPost( origin: OriginPost, feedId: string, reading: PostValue['reading'] -): PostValue { +): NewPost { return { ...origin, feedId, - id: nanoid(), publishedAt: origin.publishedAt ?? Date.now(), reading } } -export function getPostIntro(post: OriginPost): [string, boolean] { +export function getPostIntro(post: PostContent): [string, boolean] { if (post.intro) { let more = !!post.full && post.full !== post.intro return [post.intro, more] } else if (post.full) { - let sanitized = sanitizeHtml(post.full, post.url) + let sanitized = sanitizeHtml(post.full, post.url ?? undefined) let truncated = truncateHTML(sanitized, 300, 500) return [truncated, truncated !== sanitized] } else { @@ -106,7 +123,7 @@ export function getPostIntro(post: OriginPost): [string, boolean] { } } -export function getPostTitle(post: OriginPost): string { +export function getPostTitle(post: PostContent): string { if (post.title) { return stripHTML(post.title) } else if (post.intro) { @@ -123,54 +140,76 @@ export function getPostTitle(post: OriginPost): string { } export async function recalcPostsReading(feedId: string): Promise { - let feed = await loadValue(getFeed(feedId)) + let feed = await loadFeed(feedId) if (!feed) return let [filters, posts] = await Promise.all([ - loadFilters({ feedId }), - loadValue(getPosts({ feedId })) + loadFilterChecker(feedId), + loadPostsByFeed(feedId) ]) - for (let post of posts.list) { + let fast: string[] = [] + let slow: string[] = [] + for (let post of posts) { let reading = filters(post) ?? feed.reading - if (reading !== 'delete') { - await changePost(post.id, { reading }) + if (reading === 'delete' || reading === post.reading) continue + if (reading === 'fast') { + fast.push(post.id) + } else { + slow.push(post.id) } } + + await Promise.all([ + changePost(fast, { reading: 'fast' }), + changePost(slow, { reading: 'slow' }) + ]) } let testPostId = 0 -export function testPost(feed: Partial = {}): PostValue { +export function testPost( + post: Partial> = {} +): PostValue { testPostId += 1 - return { + return withMeta({ feedId: 'feed-1', + full: null, id: `post-${testPostId}`, intro: `Post ${testPostId}`, + media: null, originId: `test-${testPostId}`, publishedAt: 1000, + read: 0, reading: 'fast', + title: null, url: `http://example.com/${testPostId}`, - ...feed - } + ...post + }) +} + +function countPosts( + store: WritableAtom, + reading: PostValue['reading'] +): () => void { + let $total = getDatabase().store<{ total: number }>` + SELECT COUNT("originId") AS "total" FROM "posts" WHERE "reading" = ${reading} + ` + return $total.subscribe(value => { + store.set(value.isLoading ? undefined : value.value[0]!.total) + }) } export const fastPostsCount = atom(undefined) onMount(fastPostsCount, () => { - return getPosts({ reading: 'fast' }).subscribe(posts => { - fastPostsCount.set(posts.isLoading ? undefined : posts.list.length) - }) + return countPosts(fastPostsCount, 'fast') }) export const slowPostsCount = atom(undefined) onMount(slowPostsCount, () => { - return getPosts({ reading: 'slow' }).subscribe(posts => { - slowPostsCount.set(posts.isLoading ? undefined : posts.list.length) - }) + return countPosts(slowPostsCount, 'slow') }) -export const postsChangedAction = defineSyncMapActions('posts')[4] - export function stringifyMedia(media: PostMedia[]): string | undefined { if (media.length === 0) return undefined let used = new Set() @@ -182,7 +221,7 @@ export function stringifyMedia(media: PostMedia[]): string | undefined { return JSON.stringify(unique) } -export function parseMedia(value: string | undefined): PostMedia[] { +export function parseMedia(value: null | string | undefined): PostMedia[] { if (!value) return [] try { return JSON.parse(value) as PostMedia[] diff --git a/core/readers/common.ts b/core/readers/common.ts index d9c5d262..bcb949fb 100644 --- a/core/readers/common.ts +++ b/core/readers/common.ts @@ -1,14 +1,8 @@ -import { - ensureLoadedStore, - type LoadedSyncMap, - loadValue, - type SyncMapStore -} from '@logux/client' import type { ReadableAtom, WritableAtom } from 'nanostores' -import { getFeeds } from '../feed.ts' -import { getPosts, type PostValue } from '../post.ts' +import type { PostValue } from '../post.ts' import type { Routes } from '../router.ts' +import { select } from '../schema.ts' export interface BaseReader { exit(): void @@ -70,30 +64,52 @@ export function createReader( } } -export async function loadPosts( - filter: PostFilter -): Promise>[]> { - let posts: PostValue[] - let stores: Map> - if ('categoryId' in filter) { - let [allPosts, feeds] = await Promise.all([ - loadValue(getPosts({ reading: filter.reading })), - loadValue(getFeeds({ categoryId: filter.categoryId })) - ]) - stores = allPosts.stores - posts = allPosts.list.filter(i => { - return feeds.stores.has(i.feedId) && !allPosts.stores.get(i.id)?.deleted - }) +/** + * Posts to be shown in the reader. Read posts are deleted on opening + * the page, so the reader always starts from unread ones. + */ +export function loadPosts(filter: PostFilter): Promise { + if (filter.categoryId) { + return select` + SELECT "posts".* FROM "posts" + JOIN "feeds" ON "feeds"."id" = "posts"."feedId" + WHERE "posts"."reading" = ${filter.reading} AND "posts"."read" = 0 + AND "feeds"."categoryId" = ${filter.categoryId} + ORDER BY "posts"."publishedAt" DESC + ` } else { - let value = await loadValue( - getPosts({ feedId: filter.feedId, reading: filter.reading }) - ) - stores = value.stores - posts = value.list.filter(i => !value.stores.get(i.id)?.deleted) + return select` + SELECT * FROM "posts" + WHERE "reading" = ${filter.reading} AND "read" = 0 + AND "feedId" = ${filter.feedId ?? null} + ORDER BY "publishedAt" DESC + ` } - return posts - .toSorted((a, b) => b.publishedAt - a.publishedAt) - .map(i => stores.get(i.id)) - .filter(i => !!i) - .map(i => ensureLoadedStore(i)) +} + +/** + * Only IDs, since read posts are loaded to be deleted. + */ +export function loadReadPostIds(filter: PostFilter): Promise { + let rows: Promise<{ id: string }[]> + if (filter.categoryId) { + rows = select` + SELECT "posts"."id" FROM "posts" + JOIN "feeds" ON "feeds"."id" = "posts"."feedId" + WHERE "posts"."reading" = ${filter.reading} AND "posts"."read" = 1 + AND "feeds"."categoryId" = ${filter.categoryId} + ` + } else if (filter.feedId) { + rows = select` + SELECT "id" FROM "posts" + WHERE "reading" = ${filter.reading} AND "read" = 1 + AND "feedId" = ${filter.feedId} + ` + } else { + rows = select` + SELECT "id" FROM "posts" + WHERE "reading" = ${filter.reading} AND "read" = 1 + ` + } + return rows.then(list => list.map(row => row.id)) } diff --git a/core/readers/feed.ts b/core/readers/feed.ts index 7eef3adb..017ef39f 100644 --- a/core/readers/feed.ts +++ b/core/readers/feed.ts @@ -1,12 +1,6 @@ -import { - ensureLoadedStore, - type LoadedSyncMap, - type SyncMapStore -} from '@logux/client' import { atom } from 'nanostores' -import { type FeedValue, getFeed } from '../feed.ts' -import { waitSyncLoading } from '../lib/stores.ts' +import { type FeedValue, loadFeedsByCategory } from '../feed.ts' import { changePost, type PostValue } from '../post.ts' import { createReader, loadPosts } from './common.ts' @@ -17,10 +11,8 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { let exited = false let $loading = atom(true) - let $list = atom>[]>([]) - let $authors = atom>>>( - new Map() - ) + let $list = atom([]) + let $authors = atom>(new Map()) let $hasNext = atom(false) let $nextFrom = atom() let $prevFrom = atom() @@ -28,25 +20,13 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { let openAt = Date.now() let unbindFrom = (): void => {} async function start(): Promise { + // TODO: more optimized feed to now keep everything in memory let posts = await loadPosts(filter) if (exited) return if (filter.categoryId) { - let feedIds = new Set() - posts.forEach(post => { - feedIds.add(post.get().feedId) - }) - $authors.set( - new Map( - await Promise.all( - [...feedIds].map(async id => { - let feed = getFeed(id) - await waitSyncLoading(feed) - return [id, ensureLoadedStore(feed)] as const - }) - ) - ) - ) + let feeds = await loadFeedsByCategory(filter.categoryId) + $authors.set(new Map(feeds.map(feed => [feed.id, feed]))) // It could be switched to false during await above // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (exited) return @@ -54,18 +34,18 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { unbindFrom = params.from.subscribe(value => { let from = value ?? openAt - let fromIndex = posts.findIndex(i => i.get().publishedAt < from) + let fromIndex = posts.findIndex(i => i.publishedAt < from) if (fromIndex === -1) fromIndex = posts.length let list = posts.slice(fromIndex, fromIndex + POSTS_PER_PAGE) $hasNext.set(posts.length > fromIndex + POSTS_PER_PAGE) if (value) { let prevIndex = fromIndex - POSTS_PER_PAGE - 1 - let prevForm = posts[prevIndex]?.get().publishedAt ?? openAt + let prevForm = posts[prevIndex]?.publishedAt ?? openAt $prevFrom.set(prevForm === value ? undefined : prevForm) } else { $prevFrom.set(undefined) } - $nextFrom.set(list[list.length - 1]?.get().publishedAt) + $nextFrom.set(list[list.length - 1]?.publishedAt) $list.set(list) }) } @@ -74,15 +54,19 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { }) async function readAndNext(): Promise { - let promise = Promise.all( - $list.get().map(i => changePost(i.get().id, { read: true })) + let unread = $list.get().filter(i => !i.read) + let promise = changePost( + unread.map(i => i.id), + { read: 1 } ) + // Loaded posts are a snapshot, so we need to mark them as read too + for (let post of unread) post.read = 1 if ($hasNext.get()) { params.from.set($nextFrom.get()) } else { helpers.renderEmpty() } - return promise.then() + return promise } return { diff --git a/core/readers/list.ts b/core/readers/list.ts index eed623e9..a6ac6a93 100644 --- a/core/readers/list.ts +++ b/core/readers/list.ts @@ -1,4 +1,3 @@ -import type { LoadedSyncMap, SyncMapStore } from '@logux/client' import { atom } from 'nanostores' import { @@ -16,11 +15,12 @@ export const listReader = createReader('list', (filter, params) => { let exited = false let $loading = atom(true) - let $list = atom>[]>([]) + let $list = atom([]) let $pages = createPagination(1, 1) let unbindFrom = (): void => {} async function start(): Promise { + // TODO Logux DB: more optimizaed way with SQL let posts = await loadPosts(filter) if (exited) return @@ -41,10 +41,13 @@ export const listReader = createReader('list', (filter, params) => { }) async function readPage(): Promise { - let list = $list.get() - let promise = Promise.all( - list.map(i => changePost(i.get().id, { read: true })) + let unread = $list.get().filter(i => !i.read) + let promise = changePost( + unread.map(i => i.id), + { read: 1 } ) + // Loaded posts are a snapshot, so we need to mark them as read too + for (let post of unread) post.read = 1 if ($pages.get().hasNext) { params.from.set($pages.get().page + 1) } diff --git a/core/refresh.ts b/core/refresh.ts index f5a40ffe..def38dfc 100644 --- a/core/refresh.ts +++ b/core/refresh.ts @@ -1,15 +1,14 @@ -import { loadValue } from '@logux/client' import { atom, computed, map } from 'nanostores' import { getEnvironment } from './environment.ts' import { changeFeed, type FeedValue, - getFeed, getFeedLatestPosts, - getFeeds + loadFeed, + loadFeeds } from './feed.ts' -import { loadFilters } from './filter.ts' +import { loadFilterChecker } from './filter.ts' import { createQueue } from './lib/queue.ts' import { increaseKey } from './lib/stores.ts' import { addPost, type OriginPost, processOriginPost } from './post.ts' @@ -78,7 +77,7 @@ async function addPosts(feed: FeedValue, posts: OriginPost[]): Promise { } if (wasAlreadyAdded(feed, first)) return - let filters = await loadFilters({ feedId: feed.id }) + let filters = await loadFilterChecker(feed.id) for (let origin of posts) { if (wasAlreadyAdded(feed, origin)) { break @@ -113,7 +112,7 @@ async function checkForNextPage( await checkForNextPage(feed, pages) }) } else { - if (!getFeed(feed.id).deleted) { + if (await loadFeed(feed.id)) { await addPosts(feed, pages.get().list) } await changeFeed(feed.id, { @@ -133,14 +132,14 @@ export async function refreshPosts(): Promise { refreshErrors.set([]) refreshStatistics.set({ ...DEFAULT_REFRESH_STATISTICS, initializing: true }) - let feeds = await loadValue(getFeeds()) + let feeds = await loadFeeds() refreshStatistics.set({ ...refreshStatistics.get(), initializing: false, - totalFeeds: feeds.list.length + totalFeeds: feeds.length }) - queue = createQueue(feeds.list) + queue = createQueue(feeds) await queue.start( 6, feed => { diff --git a/core/schema.ts b/core/schema.ts new file mode 100644 index 00000000..5f31c160 --- /dev/null +++ b/core/schema.ts @@ -0,0 +1,226 @@ +// Single place with all database tables. Every table is a CRDT LWW map +// on top of SQL database, which is filled by actions from Logux log. + +import type { CrossTabClient } from '@logux/client' +import { + bigint, + type CrdtDatabase, + type NewCrdtRow, + type CrdtRowFields, + type CrdtTable, + type CrdtTableRow, + createCrdtDatabase, + number, + oneOf, + optional, + string +} from '@logux/client/db' +import type { Database, SqlParam } from '@nanostores/sql' + +import { busyDuring } from './busy.ts' +import { client, database, isOutdatedClient } from './client.ts' +import { onEnvironment } from './environment.ts' +import type { LoaderName } from './loader/index.ts' +import { commonMessages } from './messages/index.ts' +import type { UsefulReaderName } from './readers/common.ts' + +const READERS = ['feed', 'list'] as const satisfies readonly UsefulReaderName[] + +const READINGS = ['fast', 'slow'] as const + +let categoriesSchema = { + fastReader: optional(oneOf(READERS)), + slowReader: optional(oneOf(READERS)), + title: string() +} + +let feedsSchema = { + categoryId: string({ default: 'general' }), + fastReader: optional(oneOf(READERS)), + lastOriginId: optional(string()), + lastPublishedAt: optional(bigint()), + loader: string(), + reading: oneOf(READINGS), + refreshedAt: optional(bigint()), + slowReader: optional(oneOf(READERS)), + title: string(), + url: string() +} + +let filtersSchema = { + action: oneOf(['delete', 'fast', 'slow']), + feedId: string(), + priority: number(), + query: string() +} + +let postsSchema = { + feedId: string(), + full: optional(string()), + intro: optional(string()), + media: optional(string()), + originId: string(), + publishedAt: bigint(), + // SQLite has no boolean, so we are using 0 and 1 + read: number({ default: 0 }), + reading: oneOf(READINGS), + title: optional(string()), + url: optional(string()) +} + +export type CategoryValue = CrdtTableRow +export type FeedValue = CrdtTableRow +export type FilterValue = CrdtTableRow +export type PostValue = CrdtTableRow + +export type NewCategory = NewCrdtRow +export type NewFeed = NewCrdtRow +export type NewFilter = NewCrdtRow +export type NewPost = NewCrdtRow + +export type CategoryChanges = Partial> +export type FeedChanges = Partial> +export type FilterChanges = Partial> +export type PostChanges = Partial> + +export interface Tables { + categories: CrdtTable + feeds: CrdtTable + filters: CrdtTable + posts: CrdtTable +} + +let currentCrdt: CrdtDatabase | undefined +let currentDatabase: Database | undefined +let currentTables: Tables | undefined +let ready: Promise = Promise.resolve() +let downloading = false + +/** + * Tell that the next database will be filled from the server, not from + * the local log. Call it on sign in to the existing account. + */ +export function markDatabaseDownloading(): void { + downloading = true +} + +/** + * Tables of the database of the current user. + */ +export function getTables(): Tables { + /* node:coverage ignore next 3 */ + if (!currentTables) { + throw new Error('No Slow Reader database') + } + return currentTables +} + +/** + * Is the database still open. Sign-out closes it, so async tasks started + * before it should stop instead of throwing. + */ +export function hasDatabase(): boolean { + return !!currentTables +} + +/** + * Database of the current user for queries across few tables. + */ +export function getDatabase(): Database { + /* node:coverage ignore next 3 */ + if (!currentDatabase) { + throw new Error('No Slow Reader database') + } + return currentDatabase +} + +/** + * One-time SQL query, which waits for tables to be filled from Logux log. + */ +export function select( + sql: TemplateStringsArray, + ...params: SqlParam[] +): Promise { + return ready.then(() => getDatabase().select(sql, ...params)) +} + +/** + * Remove all rows from all tables, but keep the database working. + * + * Sign-out cleans the database by `Client#clean()`, which also stops it. + * This function is for tests and visual stories, which reset the data + * between cases and keep using the same client: the client can’t be + * replaced in the same page, since mounted SQL stores will keep + * the subscription to the previous database. + */ +export async function cleanDatabase(): Promise { + if (!currentDatabase) return + await ready + await Promise.all([ + getDatabase().exec`DELETE FROM "categories"`, + getDatabase().exec`DELETE FROM "feeds"`, + getDatabase().exec`DELETE FROM "filters"`, + getDatabase().exec`DELETE FROM "posts"` + ]) +} + +function openDatabase(logux: CrossTabClient, db: Database): void { + currentDatabase = db + let crdt = createCrdtDatabase(logux, db, { + key: 'slowreader:db', + /* node:coverage ignore next 3 */ + migrating(done) { + void busyDuring(commonMessages.get().migratingDatabase, () => done) + }, + /* node:coverage ignore next 3 */ + stop() { + isOutdatedClient.set(true) + } + }) + currentCrdt = crdt + currentTables = { + categories: crdt.table('categories', categoriesSchema), + feeds: crdt.table('feeds', feedsSchema), + filters: crdt.table('filters', filtersSchema), + posts: crdt.table('posts', postsSchema) + } + ready = crdt.ready + + void busyDuring( + downloading + ? commonMessages.get().downloadingData + : commonMessages.get().loadingData, + () => ready + ) + downloading = false +} + +function closeDatabase(): void { + if (!currentDatabase) return + let closingDb = currentDatabase + currentCrdt!.destroy() + + currentCrdt = undefined + currentDatabase = undefined + currentTables = undefined + + let filling = ready + ready = Promise.resolve() + void filling.then(() => { + setTimeout(() => { + void closingDb.close() + }) + }) +} + +onEnvironment(() => { + let unbind = client.subscribe(logux => { + closeDatabase() + let db = database.get() + if (logux && db) openDatabase(logux, db) + }) + return () => { + unbind() + closeDatabase() + } +}) diff --git a/core/test.ts b/core/test.ts index f9f741a1..0feb6b08 100644 --- a/core/test.ts +++ b/core/test.ts @@ -1,4 +1,5 @@ -import { MemoryStore } from '@logux/core' +import { openDb } from '@nanostores/sql' +import { nodeDriver } from '@nanostores/sql/node' import { delay } from 'nanodelay' import { atom } from 'nanostores' @@ -46,14 +47,14 @@ export function getTestEnvironment(): EnvironmentAndStore { delete persistentStore[key] } }, + databaseCreator() { + return openDb(nodeDriver(':memory:')) + }, errorEvents: { addEventListener() {} }, getSession() { return testSession }, locale: atom('en'), - logStoreCreator() { - return new MemoryStore() - }, networkType() { return { saveData: undefined, type: undefined } }, diff --git a/core/test/busy.test.ts b/core/test/busy.test.ts index 768879b7..1ec61160 100644 --- a/core/test/busy.test.ts +++ b/core/test/busy.test.ts @@ -1,4 +1,4 @@ -import { equal } from 'node:assert/strict' +import { deepStrictEqual, equal } from 'node:assert/strict' import { afterEach, describe, test } from 'node:test' import { setTimeout } from 'node:timers/promises' @@ -12,14 +12,36 @@ describe('busy', () => { test('allows to manually set busy state', async () => { equal(busy.get(), false) - await busyDuring(async () => { - equal(busy.get(), true) - await busyDuring(async () => { - equal(busy.get(), true) + await busyDuring('', async () => { + deepStrictEqual(busy.get(), { label: '', progress: undefined }) + await busyDuring('', async () => { await setTimeout(10) }) - equal(busy.get(), true) + deepStrictEqual(busy.get(), { label: '', progress: undefined }) }) equal(busy.get(), false) }) + + test('shows label and progress of the latest task', async () => { + let result = await busyDuring('Loading', async setProgress => { + deepStrictEqual(busy.get(), { label: 'Loading', progress: undefined }) + setProgress(0.5) + deepStrictEqual(busy.get(), { label: 'Loading', progress: 0.5 }) + + await busyDuring('', async setNested => { + deepStrictEqual(busy.get(), { label: 'Loading', progress: 0.5 }) + setNested(0.1) + deepStrictEqual(busy.get(), { label: '', progress: 0.1 }) + await busyDuring('Saving', async () => { + deepStrictEqual(busy.get(), { label: 'Saving', progress: undefined }) + await setTimeout(10) + }) + }) + + deepStrictEqual(busy.get(), { label: 'Loading', progress: 0.5 }) + return 'done' + }) + equal(result, 'done') + equal(busy.get(), false) + }) }) diff --git a/core/test/dom-parser.ts b/core/test/dom-parser.ts index bd2ef3d7..248cdd50 100644 --- a/core/test/dom-parser.ts +++ b/core/test/dom-parser.ts @@ -5,5 +5,4 @@ let window = new JSDOM().window global.window = window global.DOMParser = window.DOMParser global.File = window.File -global.FileReader = window.FileReader global.ErrorEvent = window.ErrorEvent diff --git a/core/test/filter.test.ts b/core/test/filter.test.ts index 9033316d..6bb40960 100644 --- a/core/test/filter.test.ts +++ b/core/test/filter.test.ts @@ -1,5 +1,4 @@ -import { ensureLoaded, loadValue } from '@logux/client' -import { keepMount } from 'nanostores' +import { withMeta, withoutMeta } from '@logux/client/db' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -8,9 +7,9 @@ import { addFilter, addFilterForFeed, type FilterValue, - getFeed, - getFilters, isValidFilterQuery, + loadFeed, + loadFiltersByFeed, moveFilterDown, moveFilterUp, prepareFilters, @@ -32,25 +31,23 @@ describe('filter', () => { let feedId1 = await addFeed(testFeed({ reading: 'fast' })) let feedId2 = await addFeed(testFeed({ reading: 'slow' })) - let filterId1 = await addFilterForFeed((await loadValue(getFeed(feedId1)))!) - let filterId2 = await addFilterForFeed((await loadValue(getFeed(feedId2)))!) + let filterId1 = await addFilterForFeed((await loadFeed(feedId1))!) + let filterId2 = await addFilterForFeed((await loadFeed(feedId2))!) - deepEqual((await loadValue(getFilters({ feedId: feedId1 }))).list, [ + deepEqual(withoutMeta(await loadFiltersByFeed(feedId1)), [ { action: 'slow', feedId: feedId1, id: filterId1, - isLoading: false, priority: 100, query: '' } ]) - deepEqual((await loadValue(getFilters({ feedId: feedId2 }))).list, [ + deepEqual(withoutMeta(await loadFiltersByFeed(feedId2)), [ { action: 'fast', feedId: feedId2, id: filterId2, - isLoading: false, priority: 100, query: '' } @@ -61,29 +58,28 @@ describe('filter', () => { let common = { action: 'fast', feedId: '10', - isLoading: false, query: 'include(some text)' } as const - let filter100: FilterValue = { + let filter100 = withMeta({ ...common, id: '100', priority: 100 - } - let filter200a: FilterValue = { + }) + let filter200a = withMeta({ ...common, id: '200', priority: 200 - } - let filter200b: FilterValue = { + }) + let filter200b = withMeta({ ...common, id: '400', priority: 200 - } - let filter300: FilterValue = { + }) + let filter300 = withMeta({ ...common, id: '300', priority: 300 - } + }) deepEqual(sortFilters([filter200a, filter300, filter100, filter200b]), [ filter100, filter200a, @@ -93,14 +89,12 @@ describe('filter', () => { }) test('moves filters in sorting order', async () => { - let filters10 = getFilters({ feedId: '10' }) - keepMount(filters10) - function getSorted(): string[] { - return sortFilters(ensureLoaded(filters10.get()).list).map(i => i.id) + async function getSorted(): Promise { + return sortFilters(await loadFiltersByFeed('10')).map(i => i.id) } - function getPriorities(): Record { + async function getPriorities(): Promise> { return Object.fromEntries( - ensureLoaded(filters10.get()).list.map(i => [i.id, i.priority]) + (await loadFiltersByFeed('10')).map(i => [i.id, i.priority]) ) } @@ -113,28 +107,28 @@ describe('filter', () => { let id1 = await addFilter({ ...common }) let id2 = await addFilter({ ...common }) let id3 = await addFilter({ ...common }) - deepEqual(getSorted(), [id1, id2, id3]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 300 }) + deepEqual(await getSorted(), [id1, id2, id3]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 300 }) await moveFilterUp(id3) - deepEqual(getSorted(), [id1, id3, id2]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 150 }) + deepEqual(await getSorted(), [id1, id3, id2]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 150 }) await moveFilterUp(id3) - deepEqual(getSorted(), [id3, id1, id2]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id1, id2]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) await moveFilterUp(id3) - deepEqual(getSorted(), [id3, id1, id2]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id1, id2]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) await moveFilterDown(id1) - deepEqual(getSorted(), [id3, id2, id1]) - deepEqual(getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id2, id1]) + deepEqual(await getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) await moveFilterDown(id1) - deepEqual(getSorted(), [id3, id2, id1]) - deepEqual(getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id2, id1]) + deepEqual(await getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) }) test('validates filter query', () => { @@ -151,20 +145,20 @@ describe('filter', () => { }) test('applies filters to posts', () => { - let filterSpecial: FilterValue = { + let filterSpecial = withMeta({ action: 'slow', feedId: '10', id: '2', priority: 200, query: 'include(Special Text)' - } - let filterOther: FilterValue = { + }) + let filterOther = withMeta({ action: 'delete', feedId: '10', id: '3', priority: 300, query: 'include(other text)' - } + }) let checker = prepareFilters([filterSpecial, filterOther]) equal( @@ -237,20 +231,20 @@ describe('filter', () => { }) test('supports not in filters', () => { - let filterA: FilterValue = { + let filterA = withMeta({ action: 'fast', feedId: '10', id: '1', priority: 100, query: 'include(a)' - } - let filterNotB: FilterValue = { + }) + let filterNotB = withMeta({ action: 'slow', feedId: '10', id: '2', priority: 200, query: 'not include(b)' - } + }) let checker = prepareFilters([filterA, filterNotB]) equal( @@ -290,20 +284,20 @@ describe('filter', () => { }) test('is ready for broken filters', () => { - let filterA: FilterValue = { + let filterA = withMeta({ action: 'fast', feedId: '10', id: '1', priority: 100, query: 'broken' - } - let filterNotB: FilterValue = { + }) + let filterNotB = withMeta({ action: 'slow', feedId: '10', id: '2', priority: 200, query: 'include(a)' - } + }) let checker = prepareFilters([filterA, filterNotB]) equal( diff --git a/core/test/loader.test.ts b/core/test/loader.test.ts new file mode 100644 index 00000000..7dc00cf4 --- /dev/null +++ b/core/test/loader.test.ts @@ -0,0 +1,29 @@ +import { deepStrictEqual } from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { reportLoader, setLoaderReporter } from '../index.ts' + +describe('loader', () => { + test('does nothing without reporter', () => { + reportLoader('test')() + }) + + test('reports loaders to the reporter', () => { + let calls: string[] = [] + setLoaderReporter(name => { + calls.push(`show ${name}`) + return () => { + calls.push(`hide ${name}`) + } + }) + + let hide = reportLoader('posts') + deepStrictEqual(calls, ['show posts']) + hide() + deepStrictEqual(calls, ['show posts', 'hide posts']) + + setLoaderReporter(() => { + return () => {} + }) + }) +}) diff --git a/core/test/menu.test.ts b/core/test/menu.test.ts index 4651feb9..97029e04 100644 --- a/core/test/menu.test.ts +++ b/core/test/menu.test.ts @@ -1,4 +1,3 @@ -import { loadValue } from '@logux/client' import { LoguxError } from '@logux/core' import { cleanStores, keepMount } from 'nanostores' import { deepEqual, equal } from 'node:assert/strict' @@ -15,9 +14,12 @@ import { changeCategory, closedCategories, closeMenu, + commonMessages, fastMenu, getClient, - getFeed, + getGeneralCategory, + loadCategory, + loadFeed, menuLoading, menuSlider, openCategory, @@ -63,7 +65,7 @@ describe('menu', () => { testFeed({ categoryId: category1, reading: 'slow' }) ) await addFeed(testFeed({ categoryId: category1, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) setBaseTestRoute({ params: {}, @@ -107,7 +109,7 @@ describe('menu', () => { equal(openedMenu.get(), undefined) await addPost(testPost({ feedId: slow, reading: 'slow' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('slow'), false) equal(menuSlider.get(), 'slow') equal(openedMenu.get(), 'slow') @@ -142,7 +144,7 @@ describe('menu', () => { }) await addFeed(testFeed({ categoryId: category2, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('fast'), false) equal(menuSlider.get(), 'fast') @@ -164,7 +166,7 @@ describe('menu', () => { testFeed({ categoryId: category1, reading: 'slow' }) ) await addFeed(testFeed({ categoryId: category1, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) setBaseTestRoute({ params: {}, @@ -207,7 +209,7 @@ describe('menu', () => { equal(openedMenu.get(), 'fast') await addPost(testPost({ feedId: slow, reading: 'slow' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('slow'), true) equal(menuSlider.get(), 'fast') equal(openedMenu.get(), 'fast') @@ -240,7 +242,7 @@ describe('menu', () => { }) await addFeed(testFeed({ categoryId: category2, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('fast'), true) equal(menuSlider.get(), 'other') @@ -262,7 +264,7 @@ describe('menu', () => { equal(menuLoading.get(), false) deepEqual(slowMenu.get(), []) - deepEqual(fastMenu.get(), [{ id: 'general', title: 'General' }]) + deepEqual(fastMenu.get(), [getGeneralCategory()]) let idB = await addCategory({ title: 'B' }) let feed1 = await addFeed(testFeed({ categoryId: idB, reading: 'slow' })) @@ -288,9 +290,9 @@ describe('menu', () => { deepEqual(slowMenu.get(), []) deepEqual(fastMenu.get(), [ - { id: idA, isLoading: false, title: 'A' }, - { id: idB, isLoading: false, title: 'B' }, - { id: 'general', title: 'General' } + await loadCategory(idA), + await loadCategory(idB), + getGeneralCategory() ]) await addPost(testPost({ feedId: feed2, reading: 'slow' })) @@ -298,29 +300,26 @@ describe('menu', () => { await addPost(testPost({ feedId: feed5, reading: 'slow' })) await addPost(testPost({ feedId: feed5, reading: 'slow' })) deepEqual(slowMenu.get(), [ + [await loadCategory(idC), [[await loadFeed(feed2), 1]]], [ - { id: idC, isLoading: false, title: 'C' }, - [[await loadValue(getFeed(feed2)), 1]] - ], - [ - { id: 'general', title: 'General' }, + getGeneralCategory(), [ - [await loadValue(getFeed(feed4)), 1], - [await loadValue(getFeed(feed5)), 2] + [await loadFeed(feed4), 1], + [await loadFeed(feed5), 2] ] ] ]) deepEqual(fastMenu.get(), [ - { id: idA, isLoading: false, title: 'A' }, - { id: idB, isLoading: false, title: 'B' }, - { id: 'general', title: 'General' } + await loadCategory(idA), + await loadCategory(idB), + getGeneralCategory() ]) await changeCategory(idA, { title: 'Z' }) deepEqual(fastMenu.get(), [ - { id: idB, isLoading: false, title: 'B' }, - { id: 'general', title: 'General' }, - { id: idA, isLoading: false, title: 'Z' } + await loadCategory(idB), + getGeneralCategory(), + await loadCategory(idA) ]) await addPost(testPost({ feedId: 'unknown', reading: 'fast' })) @@ -330,29 +329,29 @@ describe('menu', () => { ) await addPost(testPost({ feedId: orphan, reading: 'slow' })) deepEqual(slowMenu.get(), [ + [await loadCategory(idC), [[await loadFeed(feed2), 1]]], [ - { id: idC, isLoading: false, title: 'C' }, - [[await loadValue(getFeed(feed2)), 1]] - ], - [ - { id: 'general', title: 'General' }, + getGeneralCategory(), [ - [await loadValue(getFeed(feed4)), 1], - [await loadValue(getFeed(feed5)), 2] + [await loadFeed(feed4), 1], + [await loadFeed(feed5), 2] ] ] ]) deepEqual(fastMenu.get(), [ - { id: idB, isLoading: false, title: 'B' }, + await loadCategory(idB), - { id: 'general', title: 'General' }, - { id: idA, isLoading: false, title: 'Z' } + getGeneralCategory(), + await loadCategory(idA) ]) }) test('has helper to block app while menu is loading', async () => { busyUntilMenuLoader() - equal(busy.get(), true) + deepEqual(busy.get(), { + label: commonMessages.get().loadingData, + progress: undefined + }) await setTimeout(10) equal(busy.get(), false) equal(menuLoading.get(), false) diff --git a/core/test/pages/export.test.ts b/core/test/pages/export.test.ts index 4ecf5eea..2d62b9b8 100644 --- a/core/test/pages/export.test.ts +++ b/core/test/pages/export.test.ts @@ -99,16 +99,23 @@ describe('export page', () => { deepEqual(json, { categories: [ { + fastReader: null, id: category, + slowReader: null, title: 'A' } ], feeds: [ { categoryId: category, + fastReader: null, id: feed, + lastOriginId: null, + lastPublishedAt: null, loader: 'rss', reading: 'fast', + refreshedAt: null, + slowReader: null, title: '1', url: 'http://example.com/5' } @@ -125,11 +132,15 @@ describe('export page', () => { posts: [ { feedId: feed, + full: null, id: post, intro: 'Post 1', + media: null, originId: 'test-1', publishedAt: 1000, + read: 0, reading: 'fast', + title: null, url: 'http://example.com/1' } ], diff --git a/core/test/pages/feeds-by-categories.test.ts b/core/test/pages/feeds-by-categories.test.ts index 70c2900e..f3bed4c5 100644 --- a/core/test/pages/feeds-by-categories.test.ts +++ b/core/test/pages/feeds-by-categories.test.ts @@ -1,4 +1,3 @@ -import { loadValue } from '@logux/client' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -6,7 +5,9 @@ import { addCategory, addFeed, deleteCategory, - getFeed, + getGeneralCategory, + loadCategory, + loadFeed, testFeed, waitLoading } from '../../index.ts' @@ -35,10 +36,7 @@ describe('feeds by categories page', () => { let feed1 = await addFeed(testFeed({ categoryId: idA, title: '1' })) let feed2 = await addFeed(testFeed({ categoryId: idA, title: '2' })) deepEqual(page.groups.get(), [ - [ - { id: idA, isLoading: false, title: 'A' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ] + [await loadCategory(idA), [await loadFeed(feed1), await loadFeed(feed2)]] ]) let idC = await addCategory({ title: 'C' }) @@ -47,16 +45,10 @@ describe('feeds by categories page', () => { let feed4 = await addFeed(testFeed({ categoryId: 'general', title: '1' })) deepEqual(page.groups.get(), [ - [{ id: 'general', title: 'General' }, [await loadValue(getFeed(feed4))]], - [ - { id: idA, isLoading: false, title: 'A' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ], - [ - { id: idB, isLoading: false, title: 'B' }, - [await loadValue(getFeed(feed3))] - ], - [{ id: idC, isLoading: false, title: 'C' }, []] + [getGeneralCategory(), [await loadFeed(feed4)]], + [await loadCategory(idA), [await loadFeed(feed1), await loadFeed(feed2)]], + [await loadCategory(idB), [await loadFeed(feed3)]], + [await loadCategory(idC), []] ]) openPage({ @@ -78,26 +70,14 @@ describe('feeds by categories page', () => { }) await waitLoading(page.loading) deepEqual(page.groups.get(), [ - [ - { id: idA, isLoading: false, title: 'A' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ], - [ - { id: idB, isLoading: false, title: 'B' }, - [await loadValue(getFeed(feed3))] - ] + [await loadCategory(idA), [await loadFeed(feed1), await loadFeed(feed2)]], + [await loadCategory(idB), [await loadFeed(feed3)]] ]) await deleteCategory(idA) deepEqual(page.groups.get(), [ - [ - { id: 'general', title: 'General' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ], - [ - { id: idB, isLoading: false, title: 'B' }, - [await loadValue(getFeed(feed3))] - ] + [getGeneralCategory(), [await loadFeed(feed1), await loadFeed(feed2)]], + [await loadCategory(idB), [await loadFeed(feed3)]] ]) }) }) diff --git a/core/test/pages/feeds.test.ts b/core/test/pages/feeds.test.ts index 79753899..8803cb05 100644 --- a/core/test/pages/feeds.test.ts +++ b/core/test/pages/feeds.test.ts @@ -39,7 +39,7 @@ describe('feeds page', () => { }) equal(page.params.category.get(), undefined) equal(page.params.feed.get(), undefined) - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'welcome') page = openPage({ @@ -47,7 +47,7 @@ describe('feeds page', () => { route: 'fast' }) equal(page.params.category.get(), 'general') - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'welcome') let category1 = await addCategory({ title: 'A1' }) @@ -65,7 +65,7 @@ describe('feeds page', () => { }) equal(page.params.category.get(), undefined) equal(page.params.feed.get(), undefined) - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'empty') page = openPage({ @@ -74,7 +74,7 @@ describe('feeds page', () => { }) equal(page.params.category.get(), category1) equal(page.params.feed.get(), undefined) - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'empty') await addPost(testPost({ feedId: feed2, reading: 'slow' })) @@ -107,6 +107,7 @@ describe('feeds page', () => { route: 'slow' }) await waitLoading(empty.loading) + await setTimeout(10) equal(empty.posts.get()?.name, 'welcome') let category1 = await addCategory({ title: '1' }) @@ -124,6 +125,7 @@ describe('feeds page', () => { params: { feed: feed3 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'empty') await addPost(testPost({ feedId: feed1, reading: 'slow' })) @@ -132,12 +134,14 @@ describe('feeds page', () => { params: { feed: feed3 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') page = openPage({ params: { feed: feed1 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') page.changeReader('feed') @@ -148,12 +152,14 @@ describe('feeds page', () => { params: { feed: feed2 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') page = openPage({ params: { feed: feed1 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'feed') page = openPage({ @@ -164,6 +170,7 @@ describe('feeds page', () => { equal(page.posts.get()!.name, 'empty') await addPost(testPost({ feedId: feed2, reading: 'fast' })) + await setTimeout(10) equal(page.posts.get()!.name, 'feed') page.changeReader('list') @@ -182,6 +189,7 @@ describe('feeds page', () => { params: { category: category1 }, route: 'fast' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') }) }) diff --git a/core/test/pages/import.test.ts b/core/test/pages/import.test.ts index aad90043..27cb0c4e 100644 --- a/core/test/pages/import.test.ts +++ b/core/test/pages/import.test.ts @@ -1,6 +1,6 @@ import '../dom-parser.ts' -import { loadValue } from '@logux/client' +import { withoutMeta } from '@logux/client/db' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' import { setTimeout } from 'node:timers/promises' @@ -10,18 +10,18 @@ import { addFeed, addFilter, addPost, - type CategoryValue, deleteCategory, deleteFeed, deleteFilter, deletePost, - type FeedValue, - type FilterValue, - getCategories, - getFeeds, - getFilters, - getPosts, - type PostValue, + loadCategories, + loadFeeds, + loadFilters, + loadPosts, + type NewCategory, + type NewFeed, + type NewFilter, + type NewPost, preloadImages, testFeed, theme, @@ -44,7 +44,7 @@ describe('import page', () => { xml: 'application/xml' } - let CATEGORY = { id: 'c1', title: 'A' } satisfies CategoryValue + let CATEGORY = { id: 'c1', title: 'A' } satisfies NewCategory let FEED = { categoryId: CATEGORY.id, id: 'f1', @@ -52,14 +52,14 @@ describe('import page', () => { reading: 'slow', title: 'F1', url: 'https://example.com/feed' - } satisfies FeedValue + } satisfies NewFeed let FILTER = { action: 'fast', feedId: FEED.id, id: 'filter1', priority: 1, query: 'include(text)' - } satisfies FilterValue + } satisfies NewFilter let POST = { feedId: FEED.id, id: 'p1', @@ -67,7 +67,7 @@ describe('import page', () => { publishedAt: 1000, reading: 'fast', title: 'Post 1' - } satisfies PostValue + } satisfies NewPost function file(format: keyof typeof MIME_TYPES, content: string): File { return new File([content], `feeds.${format}`, { @@ -133,17 +133,22 @@ describe('import page', () => { equal(page.done.get(), 1) equal(theme.get(), 'light') equal(preloadImages.get(), 'never') - deepEqual((await loadValue(getCategories())).list, [ - { ...CATEGORY, isLoading: false } + deepEqual(withoutMeta(await loadCategories()), [ + { ...CATEGORY, fastReader: null, slowReader: null } ]) - deepEqual((await loadValue(getFeeds())).list, [ - { ...FEED, isLoading: false } - ]) - deepEqual((await loadValue(getFilters())).list, [ - { ...FILTER, isLoading: false } + deepEqual(withoutMeta(await loadFeeds()), [ + { + ...FEED, + fastReader: null, + lastOriginId: null, + lastPublishedAt: null, + refreshedAt: null, + slowReader: null + } ]) - deepEqual((await loadValue(getPosts())).list, [ - { ...POST, isLoading: false } + deepEqual(withoutMeta(await loadFilters()), [FILTER]) + deepEqual(withoutMeta(await loadPosts()), [ + { ...POST, full: null, intro: null, media: null, read: 0, url: null } ]) }) @@ -187,20 +192,20 @@ describe('import page', () => { ]) equal(page.done.get(), 2) - let categories = await loadValue(getCategories()) + let categories = await loadCategories() deepEqual( - categories.list.map(i => i.title), + categories.map(i => i.title), ['A'] ) deepEqual( - (await loadValue(getFeeds())).list.map(i => ({ + (await loadFeeds()).map(i => ({ categoryId: i.categoryId, loader: i.loader, title: i.title })), [ - { categoryId: 'general', loader: 'atom', title: FEED2.title }, - { categoryId: categories.list[0]?.id, loader: 'rss', title: FEED.title } + { categoryId: categories[0]?.id, loader: 'rss', title: FEED.title }, + { categoryId: 'general', loader: 'atom', title: FEED2.title } ] ) }) @@ -416,8 +421,7 @@ describe('import page', () => { deepEqual(page.feedErrors.get(), []) equal(page.done.get(), 0) - let feeds = await loadValue(getFeeds()) - equal(feeds.list.length, 2) + equal((await loadFeeds()).length, 2) }) test('imports OPML with .xml extension', async () => { @@ -449,7 +453,7 @@ describe('import page', () => { equal(page.done.get(), 1) deepEqual( - (await loadValue(getFeeds())).list.map(i => ({ + (await loadFeeds()).map(i => ({ loader: i.loader, title: i.title })), diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts index e6c57d8f..0796810b 100644 --- a/core/test/popups/feed.test.ts +++ b/core/test/popups/feed.test.ts @@ -1,6 +1,5 @@ import '../dom-parser.ts' -import { loadValue } from '@logux/client' import { cleanStores, keepMount } from 'nanostores' import { deepEqual, equal, match, notEqual } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -10,7 +9,7 @@ import { addFeed, changeFeed, closeLastPopup, - getFeed, + loadFeed, getPopupId, HTTPStatusError, openedPopups, @@ -123,9 +122,9 @@ describe('feed popup', () => { equal(addedId, feedId) equal(checkLoadedPopup(popup).feed.get()!.url, 'https://a.com/atom') equal(checkLoadedPopup(popup).feed.get()!.title, 'Atom') - equal((await loadValue(getFeed(feedId)))!.title, 'Atom') - equal((await loadValue(getFeed(feedId)))!.lastOriginId, '2') - equal((await loadValue(getFeed(feedId)))!.lastPublishedAt, 1688169600) + equal((await loadFeed(feedId))!.title, 'Atom') + equal((await loadFeed(feedId))!.lastOriginId, '2') + equal((await loadFeed(feedId))!.lastPublishedAt, 1688169600) await changeFeed(feedId, { title: 'Test Atom' }) equal(checkLoadedPopup(popup).feed.get()!.title, 'Test Atom') @@ -206,7 +205,7 @@ describe('feed popup', () => { let addedId = await checkLoadedPopup(popup).add() let feedId = checkLoadedPopup(popup).feed.get()!.id equal(addedId, feedId) - equal((await loadValue(getFeed(feedId)))!.url, 'http://a.com/atom') + equal((await loadFeed(feedId))!.url, 'http://a.com/atom') expectRequest('https://a.com/atom').andRespond( 200, diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index 4256c14e..2bbf2a0d 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -1,4 +1,3 @@ -import { ensureLoaded } from '@logux/client' import { cleanStores, keepMount } from 'nanostores' import { deepEqual, equal, match } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -8,7 +7,7 @@ import { addFeed, addPost, closeLastPopup, - getPosts, + loadPost, openedPopups, type OriginPost, type PostValue, @@ -16,7 +15,6 @@ import { testPost, waitLoading } from '../../index.ts' -import { waitSyncLoading } from '../../lib/stores.ts' import { getPostPopupParam } from '../../popups/post.ts' import { checkLoadedPopup, @@ -98,25 +96,20 @@ describe('post popup', () => { let id1 = await addPost(testPost({ feedId: feed })) let id2 = await addPost(testPost({ feedId: feed })) - let posts = getPosts() - await waitSyncLoading(posts) - let post1 = ensureLoaded(posts.get()).stores.get(id1)! - let post2 = ensureLoaded(posts.get()).stores.get(id2)! - let popup = openTestPopup('post', getPostPopupParam({ id: id1 }, true)) await waitLoading(popup.loading) - equal(ensureLoaded(post1.get()).read, undefined) - equal(ensureLoaded(post2.get()).read, undefined) + equal((await loadPost(id1))!.read, 0) + equal((await loadPost(id2))!.read, 0) checkLoadedPopup(popup).read!.set(true) await setTimeout(1) - equal(ensureLoaded(post1.get()).read, true) - equal(ensureLoaded(post2.get()).read, undefined) + equal((await loadPost(id1))!.read, 1) + equal((await loadPost(id2))!.read, 0) checkLoadedPopup(popup).read!.set(false) await setTimeout(1) - equal(ensureLoaded(post1.get()).read, false) - equal(ensureLoaded(post2.get()).read, undefined) + equal((await loadPost(id1))!.read, 0) + equal((await loadPost(id2))!.read, 0) }) test('opens candidate post', async () => { diff --git a/core/test/post.test.ts b/core/test/post.test.ts index 9d12e07d..7d7d58c5 100644 --- a/core/test/post.test.ts +++ b/core/test/post.test.ts @@ -1,14 +1,17 @@ import './dom-parser.ts' -import { loadValue } from '@logux/client' import { deepEqual, doesNotMatch, equal, match } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' import { + addFeed, + addFilter, addPost, - getPost, + changeFeed, + loadPost, getPostIntro, getPostTitle, + testFeed, testPost } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' @@ -42,10 +45,47 @@ describe('post', () => { test('has helper to load post', async () => { let id = await addPost(testPost({ title: 'Test Post' })) - let post = await loadValue(getPost(id)) + let post = await loadPost(id) equal(post?.title, 'Test Post') }) + test('adds posts by a batch action', async () => { + let feedId = await addFeed(testFeed()) + let ids = await addPost([ + { feedId, originId: 'a', publishedAt: 1, reading: 'fast' }, + { feedId, originId: 'b', publishedAt: 2, reading: 'slow' } + ]) + equal(ids.length, 2) + equal((await loadPost(ids[0]!))!.originId, 'a') + equal((await loadPost(ids[1]!))!.reading, 'slow') + }) + + test('recalculates reading of feed’s posts', async () => { + let feedId = await addFeed(testFeed({ reading: 'fast' })) + let same = await addPost(testPost({ feedId, reading: 'fast', title: 'A' })) + let toFast = await addPost( + testPost({ feedId, reading: 'slow', title: 'B' }) + ) + let toSlow = await addPost( + testPost({ feedId, reading: 'fast', title: 'Slow' }) + ) + let deleted = await addPost( + testPost({ feedId, reading: 'fast', title: 'Trash' }) + ) + + await addFilter({ action: 'slow', feedId, query: 'include(slow)' }) + await addFilter({ action: 'delete', feedId, query: 'include(trash)' }) + + equal((await loadPost(same))!.reading, 'fast') + equal((await loadPost(toFast))!.reading, 'fast') + equal((await loadPost(toSlow))!.reading, 'slow') + equal((await loadPost(deleted))!.reading, 'fast') + + await changeFeed(feedId, { reading: 'slow' }) + equal((await loadPost(same))!.reading, 'slow') + equal((await loadPost(deleted))!.reading, 'fast') + }) + test('loads post title', () => { equal( getPostTitle({ originId: 'id', publishedAt: 1763900785 }), diff --git a/core/test/readers/feed.test.ts b/core/test/readers/feed.test.ts index af8ad9b4..4049a767 100644 --- a/core/test/readers/feed.test.ts +++ b/core/test/readers/feed.test.ts @@ -53,7 +53,7 @@ describe('feed reader', () => { equal(reader.hasNext.get(), false) equal(reader.authors.get().size, 0) equal(reader.list.get().length, 30) - equal(reader.list.get()[0]!.get().title, '60') + equal(reader.list.get()[0]!.title, '60') page = openPage({ params: { category: categoryId }, @@ -65,24 +65,24 @@ describe('feed reader', () => { reader = ensureReader(page.posts, 'feed') equal(reader.hasNext.get(), true) equal(reader.authors.get().size, 2) - equal(reader.authors.get().get(feed1)!.get().title, 'Test 1') - equal(reader.authors.get().get(feed2)!.get().title, 'Test 2') + equal(reader.authors.get().get(feed1)!.title, 'Test 1') + equal(reader.authors.get().get(feed2)!.title, 'Test 2') equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '60') - equal(reader.list.get()[39]!.get().title, '21') + equal(reader.list.get()[0]!.title, '60') + equal(reader.list.get()[39]!.title, '21') page.params.from.set(10) equal(page.loading.get(), false) equal(reader.list.get().length, 9) - equal(reader.list.get()[0]!.get().title, '9') + equal(reader.list.get()[0]!.title, '9') equal(reader.hasNext.get(), false) page.params.from.set(undefined) - equal(reader.list.get()[0]!.get().title, '60') + equal(reader.list.get()[0]!.title, '60') equal(reader.list.get().length, 40) let promise = reader.readAndNext() - equal(reader.list.get()[0]!.get().title, '20') + equal(reader.list.get()[0]!.title, '20') equal(reader.list.get().length, 20) equal(reader.hasNext.get(), false) await promise @@ -133,37 +133,37 @@ describe('feed reader', () => { let reader = ensureReader(page.posts, 'feed') equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '110') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '110') + equal(reader.list.get()[0]!.read, 0) await reader.readAndNext() equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '70') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '70') + equal(reader.list.get()[0]!.read, 0) page.params.from.set(reader.prevFrom.get()) equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '110') - equal(reader.list.get()[0]!.get().read, true) + equal(reader.list.get()[0]!.title, '110') + equal(reader.list.get()[0]!.read, 1) page.params.from.set(reader.nextFrom.get()) equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '70') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '70') + equal(reader.list.get()[0]!.read, 0) page.params.from.set(reader.nextFrom.get()) equal(reader.hasNext.get(), false) equal(reader.list.get().length, 30) - equal(reader.list.get()[0]!.get().title, '30') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '30') + equal(reader.list.get()[0]!.read, 0) page.params.from.set(reader.prevFrom.get()) equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '70') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '70') + equal(reader.list.get()[0]!.read, 0) }) }) diff --git a/core/test/readers/list.test.ts b/core/test/readers/list.test.ts index 5b086c0d..5551c3fb 100644 --- a/core/test/readers/list.test.ts +++ b/core/test/readers/list.test.ts @@ -1,4 +1,3 @@ -import { loadValue } from '@logux/client' import { keepMount } from 'nanostores' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -9,8 +8,8 @@ import { addFeed, addPost, changePost, - getCategory, - getFeed, + loadCategory, + loadFeed, slowMenu, testFeed, testPost, @@ -50,10 +49,10 @@ describe('list reader', () => { deepEqual(slowMenu.get(), [ [ - await loadValue(getCategory(categoryId)), + await loadCategory(categoryId), [ - [await loadValue(getFeed(feed1)), 75], - [await loadValue(getFeed(feed2)), 75] + [await loadFeed(feed1), 75], + [await loadFeed(feed2), 75] ] ] ]) @@ -65,7 +64,7 @@ describe('list reader', () => { await waitLoading(page.loading) let reader = ensureReader(page.posts, 'list') equal(reader.list.get().length, 75) - equal(reader.list.get()[0]!.get().title, '150') + equal(reader.list.get()[0]!.title, '150') deepEqual(reader.pages.get(), { count: 1, hasNext: false, @@ -83,8 +82,8 @@ describe('list reader', () => { await waitLoading(page.postsLoading) reader = ensureReader(page.posts, 'list') equal(reader.list.get().length, 100) - equal(reader.list.get()[0]!.get().title, '150') - equal(reader.list.get()[99]!.get().title, '51') + equal(reader.list.get()[0]!.title, '150') + equal(reader.list.get()[99]!.title, '51') deepEqual(reader.pages.get(), { count: 2, hasNext: true, @@ -93,22 +92,22 @@ describe('list reader', () => { show: true }) - await changePost(reader.list.get()[0]!.get().id, { read: true }) - await changePost(reader.list.get()[5]!.get().id, { read: true }) + await changePost(reader.list.get()[0]!.id, { read: 1 }) + await changePost(reader.list.get()[5]!.id, { read: 1 }) equal(reader.list.get().length, 100) deepEqual(slowMenu.get(), [ [ - await loadValue(getCategory(categoryId)), + await loadCategory(categoryId), [ - [await loadValue(getFeed(feed1)), 74], - [await loadValue(getFeed(feed2)), 74] + [await loadFeed(feed1), 74], + [await loadFeed(feed2), 74] ] ] ]) page.params.from.set(1) equal(reader.list.get().length, 50) - equal(reader.list.get()[0]!.get().title, '50') + equal(reader.list.get()[0]!.title, '50') deepEqual(reader.pages.get(), { count: 2, hasNext: false, @@ -122,6 +121,7 @@ describe('list reader', () => { params: { feed: feed1 }, route: 'slow' }) + await setTimeout(10) page = openPage({ params: { category: categoryId, from: 1 }, @@ -132,7 +132,7 @@ describe('list reader', () => { equal(reader.list.get().length, 48) page.params.from.set(0) - equal(reader.list.get()[99]!.get().title, '49') + equal(reader.list.get()[99]!.title, '49') await reader.readPage() equal(page.params.from.get(), 1) @@ -144,12 +144,13 @@ describe('list reader', () => { pages: [0, 1], show: true }) + await setTimeout(10) deepEqual(slowMenu.get(), [ [ - await loadValue(getCategory(categoryId)), + await loadCategory(categoryId), [ - [await loadValue(getFeed(feed1)), 24], - [await loadValue(getFeed(feed2)), 24] + [await loadFeed(feed1), 24], + [await loadFeed(feed2), 24] ] ] ]) diff --git a/core/test/refresh.test.ts b/core/test/refresh.test.ts index 01bc55bd..e052609c 100644 --- a/core/test/refresh.test.ts +++ b/core/test/refresh.test.ts @@ -1,6 +1,5 @@ import './dom-parser.ts' -import { loadValue } from '@logux/client' import { restoreAll, spyOn } from 'nanospy' import { deepEqual, equal, fail, ok } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -11,8 +10,8 @@ import { addFilter, createPostsList, deleteFeed, - getFeed, - getPosts, + loadFeed, + loadPosts, HTTPStatusError, isRefreshing, loaders, @@ -54,8 +53,8 @@ describe('refresh', () => { async function getPostKeys( key: Key ): Promise { - let posts = await loadValue(getPosts()) - return posts.list + let posts = await loadPosts() + return posts .toSorted((a, b) => a.originId.localeCompare(b.originId)) .map(post => post[key]) } @@ -173,9 +172,9 @@ describe('refresh', () => { deepEqual(await getPostKeys('title'), ['2', '3']) deepEqual(await getPostKeys('reading'), ['slow', 'slow']) deepEqual(await getPostKeys('feedId'), [feedId1, feedId1]) - ok((await getPostKeys('publishedAt'))[0]! + 100 > Date.now()) - deepEqual((await loadValue(getFeed(feedId1)))!.lastOriginId, 'post3') - deepEqual((await loadValue(getFeed(feedId1)))!.lastPublishedAt, undefined) + ok((await getPostKeys('publishedAt'))[0]! + 1000 > Date.now()) + deepEqual((await loadFeed(feedId1))!.lastOriginId, 'post3') + deepEqual((await loadFeed(feedId1))!.lastPublishedAt, null) let atom2 = atom1.next() atom1.resolve([ @@ -200,8 +199,8 @@ describe('refresh', () => { }) deepEqual(await getPostKeys('title'), ['2', '3']) deepEqual(await getPostKeys('reading'), ['slow', 'slow']) - deepEqual((await loadValue(getFeed(feedId2)))!.lastOriginId, 'post2') - deepEqual((await loadValue(getFeed(feedId2)))!.lastPublishedAt, 5000) + deepEqual((await loadFeed(feedId2))!.lastOriginId, 'post2') + deepEqual((await loadFeed(feedId2))!.lastPublishedAt, 5000) atom2.resolve([ [ @@ -215,8 +214,8 @@ describe('refresh', () => { ]) await setTimeout(10) deepEqual(await getPostKeys('title'), ['2', '3', '6', '7', '8 slow']) - deepEqual((await loadValue(getFeed(feedId2)))!.lastOriginId, 'post9') - deepEqual((await loadValue(getFeed(feedId2)))!.lastPublishedAt, 9000) + deepEqual((await loadFeed(feedId2))!.lastOriginId, 'post9') + deepEqual((await loadFeed(feedId2))!.lastPublishedAt, 9000) equal(finished, true) deepEqual(refreshErrors.get(), []) equal(refreshStatus.get(), 'done') @@ -426,7 +425,7 @@ describe('refresh', () => { equal(refreshStatus.get(), 'done') deepEqual(await getPostKeys('title'), ['4', '5', '6']) - let feed = await loadValue(getFeed(feedId)) + let feed = await loadFeed(feedId) deepEqual(feed!.lastOriginId, 'post6') deepEqual(feed!.lastPublishedAt, 6000) }) @@ -460,7 +459,7 @@ describe('refresh', () => { equal(refreshStatus.get(), 'done') deepEqual(await getPostKeys('title'), ['2', '3', '4']) - let feed = await loadValue(getFeed(feedId)) + let feed = await loadFeed(feedId) deepEqual(feed!.lastOriginId, 'post4') deepEqual(feed!.lastPublishedAt, 4000) }) diff --git a/core/test/schema.test.ts b/core/test/schema.test.ts new file mode 100644 index 00000000..08795df7 --- /dev/null +++ b/core/test/schema.test.ts @@ -0,0 +1,73 @@ +import { deepEqual } from 'node:assert/strict' +import { afterEach, beforeEach, describe, test } from 'node:test' + +import { + addCategory, + addFeed, + addFilter, + addPost, + cleanDatabase, + database, + getClient, + loadCategories, + loadFeeds, + loadFilters, + loadPosts, + testFeed, + testPost +} from '../index.ts' +import { cleanClientTest, enableClientTest } from './utils.ts' + +describe('schema', () => { + beforeEach(() => { + enableClientTest() + }) + + afterEach(async () => { + await cleanClientTest() + }) + + test('cleans all tables without stopping the database', async () => { + let categoryId = await addCategory({ title: 'A' }) + let feedId = await addFeed(testFeed({ categoryId })) + await addFilter({ action: 'fast', feedId, query: 'include(a)' }) + await addPost(testPost({ feedId })) + + await cleanDatabase() + + deepEqual(await loadCategories(), []) + deepEqual(await loadFeeds(), []) + deepEqual(await loadFilters(), []) + deepEqual(await loadPosts(), []) + + await addCategory({ title: 'B' }) + deepEqual((await loadCategories()).length, 1) + }) + + test('does nothing without database', async () => { + await cleanClientTest() + await cleanDatabase() + }) + + test('drops all tables on sign-out', async () => { + let categoryId = await addCategory({ title: 'A' }) + let feedId = await addFeed(testFeed({ categoryId })) + await addFilter({ action: 'fast', feedId, query: 'include(a)' }) + await addPost(testPost({ feedId })) + + let db = database.get()! + await getClient().clean() + + let tables = await db.select<{ name: string }>` + SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' + ` + deepEqual( + tables.map(row => row.name).filter(name => !name.startsWith('logux_')), + [] + ) + let actions = await db.select<{ total: number }>` + SELECT COUNT("added") AS "total" FROM "logux_log" + ` + deepEqual(actions[0]!.total, 0) + }) +}) diff --git a/core/test/utils.ts b/core/test/utils.ts index 8496c1cf..817a3d9d 100644 --- a/core/test/utils.ts +++ b/core/test/utils.ts @@ -6,7 +6,6 @@ import { type BasePopup, type BaseReader, type BaseRoute, - Category, client, currentPage, enableTestTime, @@ -14,9 +13,7 @@ import { type EnvironmentAndStore, fastMenu, fastPostsCount, - Feed, type FeedReader, - Filter, hasPassword, type ListReader, type Loader, @@ -27,7 +24,6 @@ import { type Page, type Popup, type PopupName, - Post, type ReaderName, setLayoutType, setupEnvironment, @@ -77,10 +73,6 @@ export function enableClientTest(env: Partial = {}): void { export async function cleanClientTest(): Promise { cleanStores( - Feed, - Filter, - Category, - Post, fastMenu, slowMenu, menuLoading, diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 00000000..bfbe3a44 --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,142 @@ +# How to Benchmark UI? + +Benchmark fills the client with a lot of data, replays user actions in the real app, and reports loader durations and main thread freezes. + +It runs inside the real web client (lazy chunk, loaded only with `?benchmark`), not in a special page, so numbers include real bundle, real Svelte components, real SQLite in OPFS. + +## Run Script + +Script starts the server, opens Chromium from Playwright’s cache (downloads it +on the first run), fills the database on the first run, shows the progress bar, +and prints total numbers: + +```sh +pnpm -F web benchmark +``` + +Other commands: + +```sh +pnpm -F web benchmark --help # all options +DEBUG=1 pnpm -F web benchmark --clean # faster benchmark for development +``` + +Database is kept in `/tmp/slowreader-benchmark` between runs, so filling +happens only once. + +Chromium is downloaded to `~/.cache/ms-playwright/` by +`playwright-mcp install-browser chromium`, the same cache as Playwright MCP +uses. Set `CHROMIUM_PATH` to use another binary. + +Script uses Chromium, not Firefox, because only Chromium has long tasks +and memory API. + +## Run in Browser + +It is better to use Chrome because it has long tasks and memory API. + +```sh +pnpm start +``` + +Open `http://localhost:2553/?benchmark`. On the first opening benchmark +creates the database and reloads the page. + +Then in browser console: + +```js +await benchmark.run() // returns results object +benchmark.results // last results, use copy() to get JSON +benchmark.end() // return the tab to the normal app +``` + +Other commands: + +```js +await benchmark.run('read-page') // single scenario, for debugging +await benchmark.clean() // remove database and reload with new data +await benchmark.fill() // create data again +await benchmark.storage() // IndexedDB, OPFS, and localStorage sizes in bytes +``` + +For benchmark development you can use `?benchmark=debug` to make it faster. + +## Methodology + +10 categories, 100 feeds, 0–2000 posts in feed with long tail distribution (most feeds are small, few are huge), ~7000 posts total. Network is mocked. Every 5th feed returns 3 new posts on every refresh, so `refresh` scenario also measures database inserts and menu rebuilds. + +Every scenario runs 5 times. Run, which did not finish in 1 minute or did not find element to click, is counted in `failed` and does not stop other scenarios. After 2 failed runs benchmark gives up on the scenario and goes to the next one. + +```js +{ + feeds: 100, // Feeds in the database + fill: 4360, // Time of database filling in ms + posts: 7342, // Posts in the database + scenarios: { + 'read-page': { + // The biggest number of elements in the page during the scenario, + // counted on every 10th frame + domSize: 2688, + // From start to the moment when no loader is rendered and 2 frames passed + duration: 516, + // Did not finish in 1 minute or did not find element to click + failed: 0, + freezes: 7, // Frames longer than 50 ms + loaders: { posts: 120 }, // How long every loader was rendered in ms + longestFrame: 300, // The longest frame in ms + // 0 if the page did not render + // The longest task in the main thread in ms + // 0 if there were no tasks longer than 50 ms (Chromium only) + longestTask: 305, + // The biggest JS heap in MB seen on scenario’s frames. Chromium only. + memory: 112 + } + //, … other scenarios + }, + start: 12000, // Time from page opening to app without loaders in ms + storage: { // Sizes in bytes + end: { // After all scenarios + indexedDB: 13000000, // From navigator.storage.estimate() + localStorage: 2048, // Sum of all keys and values + opfs: 0, // Sum of all files in origin private file system + // All storages by navigator.storage.estimate(). Browser counts it + // lazily, so it can be smaller than the real size right after big writes + total: 13500000 + }, + start: {} // The same, but after database filling, before scenarios + }, + total: { + domSize: 2688, // Sum of scenarios + duration: 35462, // Sum + failed: 5, // Sum + freezes: 7, // Sum + loaders: 749, // Sum of all loaders of all scenarios + longestFrame: 300, // The biggest of scenarios + longestTask: 305, // The biggest + memory: 112, // The biggest + // Only the script can take 3 numbers below. They are not in + // benchmark.run() results in the browser. They are the biggest values + // of the whole session. + documents: 1004, + listeners: 40610, + // Memory of all renderer processes in MB. Renderer is killed by this + // number, not by JS heap: it also has DOM, IndexedDB, images, workers, + // and buffers outside the heap + renderer: 1209 + } +} +``` + +## Loaders + +UI loaders reports itself by `reportLoader()` while it is rendered. + +## Scenarios + +Scenarios are in `web/benchmark/scenarios.ts`. They click real elements, found by `data-anchor` markers. + +`freeze` scenario blocks the main thread for 200 ms. It is not in the report, but you can run it to check that freeze metrics are still working: + +```sh +pnpm -F web benchmark --scenario freeze +``` diff --git a/docs/browser.md b/docs/browser.md new file mode 100644 index 00000000..f3ffc4ba --- /dev/null +++ b/docs/browser.md @@ -0,0 +1,41 @@ +# How to Debug in Browser? + +Dev Container has Firefox and Chromium with [Playwright MCP](https://github.com/microsoft/playwright-mcp) server. LLM uses them to check visual issues and to debug client database. + +## Run Application + +```sh +pnpm start +``` + +Web client will be on `http://localhost:2553`. + +Storybook is a separate process: + +```sh +pnpm -F web visual +``` + +## Open Story + +Open story without Storybook UI to keep screenshot clean: + +```text +http://localhost:2555/iframe.html?id=STORY_ID&globals=theme:light +``` + +Story IDs are in `http://localhost:2555/index.json`. Themes are `theme:light` and `theme:dark`. + +## Record Steps + +`--caps devtools` adds `browser_start_tracing`, `browser_stop_tracing`, and video tools. Record long scenario to get DOM snapshots, console, and network for every step. + +## Reset State + +Browser profile keeps cookies, `localStorage`, and IndexedDB between sessions: + +```sh +rm -rf ~/.local/share/playwright-chromium ~/.local/share/playwright-firefox +``` + +Screenshots and session logs are in `~/.local/share/playwright-output`. diff --git a/extension/README.md b/extension/README.md index 48e61117..5657adbe 100644 --- a/extension/README.md +++ b/extension/README.md @@ -6,13 +6,13 @@ _See the [full architecture guide](../README.md) first._ ## Scripts -- `cd extension && pnpm install` to install dependencies. -- `cd extension && `pnpm start` to run the plugin in development mode. -- `cd extension && pnpm build` to build the plugin for production. +- `pnpm -F extension install` to install dependencies. +- `pnpm -F extension start` to run the plugin in development mode. +- `pnpm -F extension build` to build the plugin for production. ## Quick Start -1. Run `cd extension && pnpm start` to build the extension and watch the changes. +1. Run `pnpm -F extension start` to build the extension and watch the changes. 2. Open `chrome://extensions/` → `Load unpacked` and choose `dist` folder from this repo. The extension should appear in the list of your extensions. 3. In the `.env` file of the main app, place the next line (`EXTENSION_ID` can be found in the `ID` line inside the uploaded extension block): diff --git a/extension/package.json b/extension/package.json index 66ccbf35..38b80393 100644 --- a/extension/package.json +++ b/extension/package.json @@ -9,10 +9,10 @@ }, "dependencies": { "@crxjs/vite-plugin": "2.7.1", - "vite": "8.1.5" + "vite": "8.2.1" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/loader-tests/README.md b/loader-tests/README.md index 6d6812e1..c5a1fc46 100644 --- a/loader-tests/README.md +++ b/loader-tests/README.md @@ -12,7 +12,7 @@ Test that Slow Reader can work with all feeds from your RSS reader by using OPML 2. Once in the root you can run: ```sh - pnpm check-opml PATH_TO_YOUR_FILE.opml + pnpm -F loader-tests check-opml PATH_TO_YOUR_FILE.opml ``` ## Check Loaders by Different Blog Platforms @@ -20,8 +20,7 @@ Test that Slow Reader can work with all feeds from your RSS reader by using OPML Test that Slow Reader can work with different feeds from popular blogging platforms. ```sh -cd loader-tests/ -pnpm test +pnpm -F loader-tests test ``` ## Debug Feed Search @@ -29,8 +28,7 @@ pnpm test A small helper to run feed searching for specific feed. ```sh -cd loader-tests/ -pnpm run feed URL HOME_URL +pnpm -F loader-tests feed URL HOME_URL ``` ## Debug Posts Loading @@ -38,6 +36,5 @@ pnpm run feed URL HOME_URL A small helper to run posts loading for specific feed. ```sh -cd loader-tests/ -pnpm run url URL +pnpm -F loader-tests url URL ``` diff --git a/loader-tests/check-opml.ts b/loader-tests/check-opml.ts index 8bbe7c06..c3613c1c 100644 --- a/loader-tests/check-opml.ts +++ b/loader-tests/check-opml.ts @@ -7,14 +7,13 @@ import { import { createProxy, DEFAULT_PROXY_CONFIG } from '@slowreader/proxy' import { createServer } from 'node:http' +import { error, finish, startProgress } from '../scripts/progress.ts' import { createCLI, enableTestClient, - error, fetchAndParsePosts, - finish, - initializeProgressBar, - readText + readText, + USER_AGENT } from './utils.ts' let cli = createCLI( @@ -58,7 +57,8 @@ await cli.run(async args => { let headers = opts.headers as object | undefined let response = await fetch(nextUrl, { headers: { - Origin: 'http://localhost:8000', + 'Origin': 'http://localhost:8000', + 'User-Agent': USER_AGENT, ...headers }, ...opts @@ -83,7 +83,7 @@ await cli.run(async args => { } let unbindTotal = page.total.listen(all => { - initializeProgressBar(all) + startProgress(all) unbindTotal() }) let unbindLastAdded = page.lastAdded.listen(async url => { diff --git a/loader-tests/dom-api.ts b/loader-tests/dom-api.ts index 4be0bc84..9d7dfd22 100644 --- a/loader-tests/dom-api.ts +++ b/loader-tests/dom-api.ts @@ -4,24 +4,3 @@ let window = new JSDOM().window // @ts-expect-error JSDOM types are incomplete global.window = window global.DOMParser = window.DOMParser - -if (typeof globalThis.FileReader === 'undefined') { - // @ts-expect-error Simple polyfill - globalThis.FileReader = class { - onerror: (() => void) | null = null - onload: (() => void) | null = null - result: null | string = null - - readAsText(file: Blob): void { - file - .text() - .then(text => { - this.result = text - this.onload?.() - }) - .catch(() => { - this.onerror?.() - }) - } - } -} diff --git a/loader-tests/feeds.yml b/loader-tests/feeds.yml index 24f10f90..e53e723b 100644 --- a/loader-tests/feeds.yml +++ b/loader-tests/feeds.yml @@ -39,3 +39,6 @@ feeds: - title: JSON Feed url: https://vas3k.club/feed.json + + - title: autoSelectFamilyAttemptTimeout test + url: https://bolknote.ru/rss/ diff --git a/loader-tests/package.json b/loader-tests/package.json index 37680768..d2d8b9cc 100644 --- a/loader-tests/package.json +++ b/loader-tests/package.json @@ -11,14 +11,14 @@ "devDependencies": { "@slowreader/core": "workspace:*", "@slowreader/proxy": "workspace:*", - "@types/jsdom": "28.0.3", + "@types/jsdom": "30.0.0", "@types/trusted-types": "2.0.7", - "jsdom": "30.0.0", - "nanostores": "1.4.1", + "jsdom": "30.0.1", + "nanostores": "1.4.2", "yaml": "2.9.0" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/loader-tests/test-loaders.ts b/loader-tests/test-loaders.ts index 95602639..6f739eab 100644 --- a/loader-tests/test-loaders.ts +++ b/loader-tests/test-loaders.ts @@ -1,14 +1,13 @@ import { join } from 'node:path' import yaml from 'yaml' +import { finish, startProgress } from '../scripts/progress.ts' import { completeTasks, createCLI, enableTestClient, fetchAndParsePosts, findRSSFromHome, - finish, - initializeProgressBar, type LoaderTestFeed, readText } from './utils.ts' @@ -30,7 +29,7 @@ await cli.run(async () => { enableTestClient() let feeds = await parseFeedsFromFile(FEEDS) - initializeProgressBar( + startProgress( feeds.length + feeds.filter(feed => feed.findFromHome !== false).length ) diff --git a/loader-tests/tsconfig.json b/loader-tests/tsconfig.json index 6858cda8..0b5bd10a 100644 --- a/loader-tests/tsconfig.json +++ b/loader-tests/tsconfig.json @@ -4,5 +4,5 @@ "composite": true, "types": ["node", "trusted-types"] }, - "include": ["**/*.ts"] + "include": ["../scripts/progress.ts", "**/*.ts"] } diff --git a/loader-tests/utils.ts b/loader-tests/utils.ts index 2fd47dcd..76617095 100644 --- a/loader-tests/utils.ts +++ b/loader-tests/utils.ts @@ -16,10 +16,16 @@ import { } from '@slowreader/core' import { getTestEnvironment, setBaseTestRoute } from '@slowreader/core/test' import { readFile } from 'node:fs/promises' +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net' import { isAbsolute, join } from 'node:path' -import readline from 'node:readline' -import { isatty } from 'node:tty' -import { styleText } from 'node:util' + +import { + error, + printAboveProgress, + semiSuccess, + success, + warning +} from '../scripts/progress.ts' export interface LoaderTestFeed { homeUrl?: string @@ -40,6 +46,13 @@ export function isString(attr: null | string): attr is string { return typeof attr === 'string' && attr.length > 0 } +// Node.js sends `node` and websites with bot protection block it +export const USER_AGENT = 'SlowReader/1.0 (+https://slowreader.app)' + +// Node.js gives every IP address only 500 ms to connect and gives up on all +// of them. Slow websites need more time than browsers’ default. +setDefaultAutoSelectFamilyAttemptTimeout(2000) + export function enableTestClient(route: RouteName = 'home'): void { setupEnvironment({ ...getTestEnvironment(), @@ -50,7 +63,11 @@ export function enableTestClient(route: RouteName = 'home'): void { enableTestTime() useCredentials(generateCredentials()) setBaseTestRoute({ params: {}, route }) - setRequestMethod(fetch) + setRequestMethod((url, opts = {}) => { + let headers = new Headers(opts.headers) + if (!headers.has('User-Agent')) headers.set('User-Agent', USER_AGENT) + return fetch(url, { ...opts, headers }) + }) } export function timeout( @@ -67,114 +84,6 @@ export function timeout( ]) } -interface NoFileError extends Error { - code: string - path: string -} - -function isNoFileError(e: unknown): e is NoFileError { - return e instanceof Error && `code` in e && e.code === 'ENOENT' -} - -let progress = 0 -let totalJobs = 0 - -export function initializeProgressBar(totalValue: number): void { - if (!process.env.CI) { - totalJobs = totalValue - renderProgressBar() - } -} - -function renderProgressBar(): void { - let filled = Math.floor((process.stderr.columns * progress) / totalJobs) - process.stderr.write( - '█'.repeat(filled) + '░'.repeat(process.stderr.columns - filled) + '\n' - ) - readline.moveCursor(process.stderr, 0, 0) -} - -const SIMPLE_SHELL = !isatty(1) || process.env.CI - -function updateProgressBar(): void { - if (totalJobs > 0 && progress < totalJobs && !SIMPLE_SHELL) { - progress += 1 - readline.moveCursor(process.stderr, 0, -1) - readline.clearLine(process.stderr, 0) - if (progress < totalJobs) { - renderProgressBar() - } - } -} - -export function print(msg: string): void { - if (totalJobs > 0 && progress < totalJobs && !SIMPLE_SHELL) { - readline.moveCursor(process.stderr, 0, -1) - readline.clearLine(process.stderr, 0) - process.stderr.write(`${msg}\n`) - renderProgressBar() - } else { - process.stderr.write(`${msg}\n`) - } -} - -let errors = 0 - -export function warning(text: string): void { - print(styleText('yellow', text)) - updateProgressBar() -} - -export function error(err: unknown, details?: string): void { - errors += 1 - let msg: string - if (isNoFileError(err)) { - msg = `File not found: ${err.path}` - } else if (err instanceof Error) { - msg = err.stack ?? err.message - } else { - msg = String(err) - } - print('') - print( - styleText('bold', styleText('bgRed', ' ERROR ')) + - ' ' + - styleText('bold', styleText('red', msg)) - ) - if (details) print(details) - print('') - updateProgressBar() -} - -export function finish(msg: string): void { - print('') - let postfix = '' - if (errors > 0) { - postfix = - ', ' + styleText('red', styleText('bold', `${errors} errors found`)) - } - print(styleText('gray', msg + postfix)) - process.exit(errors > 0 ? 1 : 0) -} - -export function success(msg: string, details?: string): void { - if (details) { - msg += ` ${styleText('gray', details)}` - } - print(styleText('green', styleText('bold', '✓ ') + msg)) - updateProgressBar() -} - -export function semiSuccess(msg: string, note: string): void { - print( - styleText( - 'yellow', - styleText('bold', '✓ ') + msg + ' ' + styleText('bold', note) - ) - ) - updateProgressBar() -} - export async function fetchAndParsePosts( url: string, badSource = false @@ -337,8 +246,8 @@ export function createCLI(help: string, usage?: string): CLI { args.includes('-h') || args.includes('help') ) { - print(help) - if (usage) print('Usage:\n' + usage) + printAboveProgress(help) + if (usage) printAboveProgress('Usage:\n' + usage) process.exit(0) } else { try { @@ -351,7 +260,7 @@ export function createCLI(help: string, usage?: string): CLI { }, wrongArg(message) { error(message) - if (usage) print('Usage:\n' + usage) + if (usage) printAboveProgress('Usage:\n' + usage) process.exit(1) } } diff --git a/oxlint.config.ts b/oxlint.config.ts index ed5073ec..08b24808 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -15,7 +15,11 @@ export default defineConfig({ } }, { - files: ['web/stories/*.ts', 'core/devtools.ts'], + files: [ + 'web/stories/*.ts', + 'core/devtools.ts', + 'web/benchmark/benchmark.ts' + ], rules: { 'no-console': 'off' } diff --git a/package.json b/package.json index 4f7002c7..214d3179 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,17 @@ "license": "AGPL-3.0-or-later", "type": "module", "scripts": { - "test": "FORCE_COLOR=1 pnpm run -r --include-workspace-root /^test:/", + "test": "pnpm run --parallel -F '*' /^test:/", "quick": "nano-staged --unstaged --quiet --bail", - "offline": "FORCE_COLOR=1 pnpm run -r --include-workspace-root '/^test:(?!online\\b)/'", - "start": "FORCE_COLOR=1 pnpm run -F server -F web start", - "format": "pnpm run -r --include-workspace-root /^format:/", - "clean": "pnpm run -r --include-workspace-root /^clean:/", + "offline": "pnpm run --parallel -F '*' '/^test:[^:]+$/'", + "start": "pnpm run --parallel -F server -F web start", + "format": "pnpm run --parallel -F '*' /^format:/", + "clean": "pnpm run --parallel -F '*' /^clean:/", "build": "pnpm run -r build", "unused-messages": "node scripts/check-unused-messages.ts", "update-browsers": "node web/scripts/update-browsers.ts", "update-env": "node ./scripts/update-env.ts", + "update-docker": "node ./scripts/update-docker.ts", "update-ci": "node --env-file-if-exists=.env node_modules/actions-up/bin/actions-up.js --min-age 1", "update-review": "node --env-file-if-exists=.env node_modules/multiocular/server/bin.js --port 2556", "format:oxfmt": "oxfmt .", @@ -28,22 +29,23 @@ }, "devDependencies": { "@logux/eslint-config": "57.1.0", - "@logux/oxc-configs": "1.0.0", - "@types/chrome": "0.2.2", - "@types/node": "26.1.2", + "@logux/oxc-configs": "1.1.0", + "@playwright/mcp": "0.0.79", + "@types/chrome": "0.2.6", + "@types/node": "26.2.0", "@types/ws": "8.18.1", "@typescript/native": "npm:typescript@7.0.2", - "actions-up": "1.16.0", + "actions-up": "1.17.0", "better-node-test": "1.0.0", - "eslint": "10.8.0", - "eslint-plugin-svelte": "3.22.0", + "eslint": "10.8.1", + "eslint-plugin-svelte": "3.23.0", "jiti": "2.7.0", "multiocular": "0.8.4", "nano-staged": "1.0.2", - "oxfmt": "0.61.0", - "oxlint": "1.76.0", + "oxfmt": "0.63.0", + "oxlint": "1.78.0", "oxlint-tsgolint": "7.0.2001", - "postcss-html": "1.8.1", + "postcss-html": "2.0.0", "remark-cli": "12.0.1", "remark-lint-check-toc": "1.0.0", "remark-lint-code-block-split-list": "1.0.0", @@ -57,14 +59,14 @@ "remark-lint-smarty-pants-typography": "1.1.0", "remark-validate-links": "13.1.0", "stylelint": "17.14.1", - "svelte": "5.56.8", + "svelte": "5.56.9", "svelte-eslint-parser": "1.8.0", "svgo": "4.0.2", "typescript": "6.0.3" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" }, - "packageManager": "pnpm@11.18.0" + "packageManager": "pnpm@12.0.0-rc.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87ddee49..550b8387 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0-rc.6 + version: 12.0.0-rc.6 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0-rc.6': + resolution: {integrity: sha512-m57z/dG0gzeh4rCApyLaHbaLh+4SwOiIoEQAyYUvY/YVVThBY4PrgJIE/0jxfNe9WeJR2cGO6W0ehiQ9LZ4csg==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0-rc.6': + resolution: {integrity: sha512-Kqojv9k2hrAoUKEa+U2J1nMcYGZnxBnZ90Al7RMavfcKIDE13GdnWMaeiNKFBWoyPdYgtR2qCtwyjAprWSUstg==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0-rc.6': + resolution: {integrity: sha512-FGeJUQ9Pralhzibl1o5QZUPhPIIWbomoeJG5Gm2HFFNEkvDWm+MwRw5eRnPdb+zyGtrXfQAc7eZtMgLQh/tuVg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0-rc.6': + resolution: {integrity: sha512-/S+hdo/9b8jOa74bpF7p+rTKUlqvsSLGLaCtNAyH+PjEKmTsbOKDinTeFM4MJK+6qEipigmrnSYrt66AwuT+CA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0-rc.6': + resolution: {integrity: sha512-Sn88BvO6o1ChhP63qA/J96tWI+WBDtN57ZycUfl3ZV8n4ccmETYkWMRo4JaCexaMZI/b5U17neoBUo0MHZChHQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0-rc.6': + resolution: {integrity: sha512-gCeOwlSLqMo0J/X4pH9yDH0GpZ+FAxHuFHGdedN7YKhCKxacUt+b4m74xSaBgSK2r4eHYR9bGCsLqSQ9ZaERTQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0-rc.6': + resolution: {integrity: sha512-q7b2oxEcCBTBTh15qORCfCZfa3EHoNiIPdHJCS1Vs1tvv2qPys2AW9FpSwdPY6N9zb419CDQxb+Cwjfe2n/ZjQ==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0-rc.6': + resolution: {integrity: sha512-YBFLDcLgl60ssEeLJlBikCwK0LIjbmeVngA/0hu2MTvbbkV1GQcNlFKuVpG5OLW17PH5YGydIAbs0qEf0PQPiA==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0-rc.6: + resolution: {integrity: sha512-OSf85JwwVLflVB3HILzntOurB/RWL6b/zns5cgol5qdn12OEmE4iZcgAeZYT9/ecXEIun1ztybZs//0Iyjx3qQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0-rc.6': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0-rc.6': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0-rc.6': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0-rc.6': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0-rc.6': + optional: true + + '@pnpm/exe.linux-x64@12.0.0-rc.6': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0-rc.6': + optional: true + + '@pnpm/exe.win32-x64@12.0.0-rc.6': + optional: true + + pnpm@12.0.0-rc.6: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0-rc.6 + '@pnpm/exe.darwin-x64': 12.0.0-rc.6 + '@pnpm/exe.linux-arm64': 12.0.0-rc.6 + '@pnpm/exe.linux-arm64-musl': 12.0.0-rc.6 + '@pnpm/exe.linux-x64': 12.0.0-rc.6 + '@pnpm/exe.linux-x64-musl': 12.0.0-rc.6 + '@pnpm/exe.win32-arm64': 12.0.0-rc.6 + '@pnpm/exe.win32-x64': 12.0.0-rc.6 + +--- lockfileVersion: '9.0' settings: @@ -5,25 +106,25 @@ settings: dedupePeers: true excludeLinksFromLockfile: false -overrides: - rollup@<2.80.0: '>=2.80.0' - importers: .: devDependencies: '@logux/eslint-config': specifier: 57.1.0 - version: 57.1.0(@typescript-eslint/utils@8.65.0)(eslint-plugin-svelte@3.22.0)(eslint@10.8.0)(supports-color@10.2.2)(svelte-eslint-parser@1.8.0)(svelte@5.56.8)(typescript@6.0.3) + version: 57.1.0(@typescript-eslint/utils@8.67.0)(eslint-plugin-svelte@3.23.0)(eslint@10.8.1)(supports-color@10.2.2)(svelte-eslint-parser@1.8.0)(svelte@5.56.9)(typescript@6.0.3) '@logux/oxc-configs': - specifier: 1.0.0 - version: 1.0.0(oxlint-tsgolint@7.0.2001)(oxlint@1.76.0)(typescript@6.0.3) + specifier: 1.1.0 + version: 1.1.0(oxlint-tsgolint@7.0.2001)(oxlint@1.78.0)(typescript@6.0.3) + '@playwright/mcp': + specifier: 0.0.79 + version: 0.0.79 '@types/chrome': - specifier: 0.2.2 - version: 0.2.2 + specifier: 0.2.6 + version: 0.2.6 '@types/node': - specifier: 26.1.2 - version: 26.1.2 + specifier: 26.2.0 + version: 26.2.0 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -31,17 +132,17 @@ importers: specifier: npm:typescript@7.0.2 version: typescript@7.0.2 actions-up: - specifier: 1.16.0 - version: 1.16.0 + specifier: 1.17.0 + version: 1.17.0 better-node-test: specifier: 1.0.0 version: 1.0.0 eslint: - specifier: 10.8.0 - version: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + specifier: 10.8.1 + version: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) eslint-plugin-svelte: - specifier: 3.22.0 - version: 3.22.0(eslint@10.8.0)(svelte@5.56.8) + specifier: 3.23.0 + version: 3.23.0(eslint@10.8.1)(svelte@5.56.9) jiti: specifier: 2.7.0 version: 2.7.0 @@ -52,17 +153,17 @@ importers: specifier: 1.0.2 version: 1.0.2 oxfmt: - specifier: 0.61.0 - version: 0.61.0(svelte@5.56.8) + specifier: 0.63.0 + version: 0.63.0(svelte@5.56.9) oxlint: - specifier: 1.76.0 - version: 1.76.0(oxlint-tsgolint@7.0.2001) + specifier: 1.78.0 + version: 1.78.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: specifier: 7.0.2001 version: 7.0.2001 postcss-html: - specifier: 1.8.1 - version: 1.8.1 + specifier: 2.0.0 + version: 2.0.0(postcss@8.5.26) remark-cli: specifier: 12.0.1 version: 12.0.1(supports-color@10.2.2) @@ -103,11 +204,11 @@ importers: specifier: 17.14.1 version: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) svelte: - specifier: 5.56.8 - version: 5.56.8(@typescript-eslint/types@8.65.0) + specifier: 5.56.9 + version: 5.56.9(@typescript-eslint/types@8.67.0) svelte-eslint-parser: specifier: 1.8.0 - version: 1.8.0(svelte@5.56.8) + version: 1.8.0(svelte@5.56.9) svgo: specifier: 4.0.2 version: 4.0.2 @@ -118,30 +219,33 @@ importers: api: dependencies: '@logux/actions': - specifier: 0.5.0 - version: 0.5.0(@logux/core@0.10.0) + specifier: github:logux/actions#next + version: https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7(@logux/core@0.10.0) devDependencies: '@logux/core': - specifier: 0.10.0 - version: 0.10.0 + specifier: github:logux/core#next + version: https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69 core: dependencies: '@logux/actions': - specifier: 0.5.0 - version: 0.5.0(@logux/core@0.10.0) + specifier: github:logux/actions#next + version: https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7(@logux/core@0.10.0) '@logux/client': - specifier: 0.23.2 - version: 0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/2590c90f3127725463540211d39302e386519a2e(@nanostores/sql@0.4.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1) '@logux/core': - specifier: 0.10.0 - version: 0.10.0 + specifier: github:logux/core#next + version: https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69 '@nanostores/i18n': specifier: 1.3.3 - version: 1.3.3(nanostores@1.4.1) + version: 1.3.3(nanostores@1.4.2) '@nanostores/persistent': specifier: 1.3.5 - version: 1.3.5(nanostores@1.4.1) + version: 1.3.5(nanostores@1.4.2) + '@nanostores/sql': + specifier: 0.4.1 + version: 0.4.1(@electric-sql/pglite@0.5.5)(nanostores@1.4.2)(sqlocal@0.18.0) '@slowreader/api': specifier: workspace:* version: link:../api @@ -149,8 +253,8 @@ importers: specifier: workspace:* version: link:../server dompurify: - specifier: 3.4.12 - version: 3.4.12 + specifier: 3.4.13 + version: 3.4.13 just-debounce-it: specifier: 3.2.0 version: 3.2.0 @@ -161,21 +265,21 @@ importers: specifier: 2.0.2 version: 2.0.2 nanoid: - specifier: 6.0.0 - version: 6.0.0 + specifier: 6.0.1 + version: 6.0.1 nanostores: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 devDependencies: '@logux/server': - specifier: 0.14.0 - version: 0.14.0 + specifier: github:logux/server + version: https://codeload.github.com/logux/server/tar.gz/659e0c3a30132545c442e429d4f096f96988a0d0 '@slowreader/proxy': specifier: workspace:* version: link:../proxy '@types/jsdom': - specifier: 28.0.3 - version: 28.0.3 + specifier: 30.0.0 + version: 30.0.0 '@types/trusted-types': specifier: 2.0.7 version: 2.0.7 @@ -183,8 +287,8 @@ importers: specifier: 1.0.0 version: 1.0.0 jsdom: - specifier: 30.0.0 - version: 30.0.0 + specifier: 30.0.1 + version: 30.0.1 nanospy: specifier: 2.0.2 version: 2.0.2 @@ -193,10 +297,10 @@ importers: dependencies: '@crxjs/vite-plugin': specifier: 2.7.1 - version: 2.7.1(supports-color@10.2.2)(vite@8.1.5) + version: 2.7.1(supports-color@10.2.2)(vite@8.2.1) vite: - specifier: 8.1.5 - version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + specifier: 8.2.1 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) loader-tests: devDependencies: @@ -207,17 +311,17 @@ importers: specifier: workspace:* version: link:../proxy '@types/jsdom': - specifier: 28.0.3 - version: 28.0.3 + specifier: 30.0.0 + version: 30.0.0 '@types/trusted-types': specifier: 2.0.7 version: 2.0.7 jsdom: - specifier: 30.0.0 - version: 30.0.0 + specifier: 30.0.1 + version: 30.0.1 nanostores: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 yaml: specifier: 2.9.0 version: 2.9.0 @@ -231,14 +335,14 @@ importers: server: dependencies: '@electric-sql/pglite': - specifier: 0.5.4 - version: 0.5.4 + specifier: 0.5.5 + version: 0.5.5 '@logux/actions': - specifier: 0.5.0 - version: 0.5.0(@logux/core@0.10.0) + specifier: github:logux/actions#next + version: https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7(@logux/core@0.10.0) '@logux/server': - specifier: 0.14.0 - version: 0.14.0 + specifier: github:logux/server + version: https://codeload.github.com/logux/server/tar.gz/659e0c3a30132545c442e429d4f096f96988a0d0 '@slowreader/api': specifier: workspace:* version: link:../api @@ -253,20 +357,17 @@ importers: version: 2.0.1 drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9) + version: 0.45.2(@electric-sql/pglite@0.5.5)(postgres@3.4.9) nanoid: - specifier: 6.0.0 - version: 6.0.0 + specifier: 6.0.1 + version: 6.0.1 postgres: specifier: 3.4.9 version: 3.4.9 - tsx: - specifier: 4.23.1 - version: 4.23.1 devDependencies: '@logux/client': - specifier: 0.23.2 - version: 0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/2590c90f3127725463540211d39302e386519a2e(@nanostores/sql@0.4.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1) better-node-test: specifier: 1.0.0 version: 1.0.0 @@ -292,50 +393,53 @@ importers: specifier: 5.3.0 version: 5.3.0 '@logux/client': - specifier: 0.23.2 - version: 0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/2590c90f3127725463540211d39302e386519a2e(@nanostores/sql@0.4.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1) '@logux/core': - specifier: 0.10.0 - version: 0.10.0 + specifier: github:logux/core#next + version: https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69 '@mdi/js': specifier: 7.4.47 version: 7.4.47 '@nanostores/i18n': specifier: 1.3.3 - version: 1.3.3(nanostores@1.4.1) + version: 1.3.3(nanostores@1.4.2) '@nanostores/media-query': specifier: 0.1.2 - version: 0.1.2(nanostores@1.4.1) + version: 0.1.2(nanostores@1.4.2) '@nanostores/persistent': specifier: 1.3.5 - version: 1.3.5(nanostores@1.4.1) + version: 1.3.5(nanostores@1.4.2) '@nanostores/router': specifier: 1.1.0 - version: 1.1.0(nanostores@1.4.1) + version: 1.1.0(nanostores@1.4.2) + '@nanostores/sql': + specifier: 0.4.1 + version: 0.4.1(@electric-sql/pglite@0.5.5)(nanostores@1.4.2)(sqlocal@0.18.0) '@slowreader/core': specifier: workspace:* version: link:../core '@storybook/addon-a11y': - specifier: 10.5.5 - version: 10.5.5(storybook@10.5.5) + specifier: 10.5.8 + version: 10.5.8(storybook@10.5.8) '@storybook/addon-svelte-csf': specifier: 5.1.2 - version: 5.1.2(@storybook/svelte@10.5.5)(@sveltejs/vite-plugin-svelte@7.2.0)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5) + version: 5.1.2(@storybook/svelte@10.5.8)(@sveltejs/vite-plugin-svelte@7.3.0)(storybook@10.5.8)(svelte@5.56.9)(vite@8.2.1) '@storybook/addon-themes': - specifier: 10.5.5 - version: 10.5.5(storybook@10.5.5) + specifier: 10.5.8 + version: 10.5.8(storybook@10.5.8) '@storybook/svelte': - specifier: 10.5.5 - version: 10.5.5(storybook@10.5.5)(svelte@5.56.8) + specifier: 10.5.8 + version: 10.5.8(storybook@10.5.8)(svelte@5.56.9) '@storybook/svelte-vite': - specifier: 10.5.5 - version: 10.5.5(@sveltejs/vite-plugin-svelte@7.2.0)(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5) + specifier: 10.5.8 + version: 10.5.8(@sveltejs/vite-plugin-svelte@7.3.0)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8)(svelte@5.56.9)(vite@8.2.1) '@sveltejs/vite-plugin-svelte': - specifier: 7.2.0 - version: 7.2.0(svelte@5.56.8)(vite@8.1.5) + specifier: 7.3.0 + version: 7.3.0(svelte@5.56.9)(vite@8.2.1) autoprefixer: specifier: 10.5.4 - version: 10.5.4(postcss@8.5.25) + version: 10.5.4(postcss@8.5.26) keyux: specifier: 0.11.6 version: 0.11.6 @@ -343,72 +447,72 @@ importers: specifier: 1.33.0 version: 1.33.0 nanostores: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 postcss: - specifier: 8.5.25 - version: 8.5.25 + specifier: 8.5.26 + version: 8.5.26 postcss-load-config: specifier: 6.0.1 - version: 6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(yaml@2.9.0) + version: 6.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0) postcss-mixins: specifier: 12.1.2 - version: 12.1.2(postcss@8.5.25) + version: 12.1.2(postcss@8.5.26) postcss-smooth-shadow: specifier: 0.3.2 - version: 0.3.2(postcss@8.5.25) + version: 0.3.2(postcss@8.5.26) rollup: - specifier: 4.62.3 - version: 4.62.3 + specifier: 4.62.4 + version: 4.62.4 + sqlocal: + specifier: 0.18.0 + version: 0.18.0(drizzle-orm@0.45.2)(react@18.3.1)(vite@8.2.1) storybook: - specifier: 10.5.5 - version: 10.5.5(react@18.3.1) + specifier: 10.5.8 + version: 10.5.8(react@18.3.1) svelte: - specifier: 5.56.8 - version: 5.56.8(@typescript-eslint/types@8.65.0) - tsx: - specifier: 4.23.1 - version: 4.23.1 + specifier: 5.56.9 + version: 5.56.9(@typescript-eslint/types@8.67.0) vite: - specifier: 8.1.5 - version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + specifier: 8.2.1 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) devDependencies: '@logux/stylelint-config': specifier: 1.0.1 version: 1.0.1(stylelint@17.14.1) '@size-limit/file': - specifier: 13.0.2 - version: 13.0.2(size-limit@13.0.2) + specifier: 13.0.3 + version: 13.0.3(size-limit@13.0.3) '@stylistic/stylelint-config': specifier: 5.0.0 version: 5.0.0(stylelint@17.14.1) '@stylistic/stylelint-plugin': - specifier: 5.2.1 - version: 5.2.1(stylelint@17.14.1) + specifier: 5.3.0 + version: 5.3.0(stylelint@17.14.1) '@types/trusted-types': specifier: 2.0.7 version: 2.0.7 browserslist: - specifier: 4.28.7 - version: 4.28.7 + specifier: 4.28.8 + version: 4.28.8 chromatic: - specifier: 18.1.0 - version: 18.1.0 + specifier: 18.2.0 + version: 18.2.0 eslint: - specifier: 10.8.0 - version: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + specifier: 10.8.1 + version: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) postcss-html: - specifier: 1.8.1 - version: 1.8.1 + specifier: 2.0.0 + version: 2.0.0(postcss@8.5.26) postcss-nesting: specifier: 14.0.1 - version: 14.0.1(postcss@8.5.25) + version: 14.0.1(postcss@8.5.26) postcss-selector-parser: - specifier: 7.1.4 - version: 7.1.4 + specifier: 7.1.5 + version: 7.1.5 size-limit: - specifier: 13.0.2 - version: 13.0.2 + specifier: 13.0.3 + version: 13.0.3 stylelint: specifier: 17.14.1 version: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) @@ -416,20 +520,20 @@ importers: specifier: 2.1.3 version: 2.1.3(stylelint@17.14.1) svelte-check: - specifier: 4.7.4 - version: 4.7.4(picomatch@4.0.5)(svelte@5.56.8)(typescript@6.0.3) + specifier: 4.7.6 + version: 4.7.6(picomatch@4.0.5)(svelte@5.56.9)(typescript@6.0.3) packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@asamuzakjp/css-color@6.0.5': - resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} engines: {node: ^22.13.0 || >=24.0.0} - '@asamuzakjp/dom-selector@8.3.0': - resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} engines: {node: ^22.13.0 || >=24.0.0} '@babel/code-frame@7.29.7': @@ -517,15 +621,12 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@electric-sql/pglite@0.5.4': - resolution: {integrity: sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==} + '@electric-sql/pglite@0.5.5': + resolution: {integrity: sha512-QeZ+oB7QaU4EJj2awrB8hwFZ5TXxLRBQ9Gfq0dQauYDdWsRtuWRCERgtri35vRfL07KQHlVlc/4Qxvn7a5AXeA==} '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -535,9 +636,6 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -567,8 +665,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -585,8 +683,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -603,8 +701,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -621,8 +719,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -639,8 +737,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -657,8 +755,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -675,8 +773,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -693,8 +791,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -711,8 +809,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -729,8 +827,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -747,8 +845,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -765,8 +863,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -783,8 +881,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -801,8 +899,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -819,8 +917,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -837,8 +935,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -855,8 +953,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -867,8 +965,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -885,8 +983,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -897,8 +995,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -915,8 +1013,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -927,8 +1025,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -945,8 +1043,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -963,8 +1061,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -981,8 +1079,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -999,8 +1097,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1121,12 +1219,21 @@ packages: peerDependencies: '@logux/core': ^0.10.0 - '@logux/client@0.23.2': - resolution: {integrity: sha512-cqDegrUH+u7WlHUa7bnq7+0UwPLlNgy+YprruO0d3uG/oO6vfkGyWUZerQt7htaKOah2Hl0vqJgvvZljNfL98w==} - engines: {node: ^20.0.0 || >=22.0.0} + '@logux/actions@https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7': + resolution: {gitHosted: true, integrity: sha512-d2kI4gsAXBol3WzgSOg5OOj17YaOif8G8FXgK/1phGRzI6nhE3fKy6QRUHqs6S/TTqK5nw7WPB3P2P06JWGIeg==, tarball: https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7} + version: 0.5.0 + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + peerDependencies: + '@logux/core': ^0.10.0 + + '@logux/client@https://codeload.github.com/logux/client/tar.gz/2590c90f3127725463540211d39302e386519a2e': + resolution: {gitHosted: true, integrity: sha512-nfFDsH5ohSbeFM8hoHqNKVlNuiASioFK0KOFlUHjv1eai7hWsQ8NOpvcjKtD1MQTlDjd7i5EGgjMBlHp8ixMhg==, tarball: https://codeload.github.com/logux/client/tar.gz/2590c90f3127725463540211d39302e386519a2e} + version: 0.23.2 + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} peerDependencies: '@nanostores/preact': '>=0.0.0' '@nanostores/react': '>=0.0.0' + '@nanostores/sql': '>=0.4.0' '@nanostores/vue': '>=0.0.0' nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 preact: '>=10.0.0' @@ -1138,6 +1245,8 @@ packages: optional: true '@nanostores/react': optional: true + '@nanostores/sql': + optional: true '@nanostores/vue': optional: true preact: @@ -1153,6 +1262,11 @@ packages: resolution: {integrity: sha512-VbgVkEloAe2ym5w0cjyRI16Eeyk2R0BgJa9pMYT1FcJEhFnzrB23E0ExIEhrok0iD48bFh7goledwmDLmgWaiw==} engines: {node: ^20.0.0 || >=22.0.0} + '@logux/core@https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69': + resolution: {gitHosted: true, integrity: sha512-HIQ+hKgOpcp9NsLfGXbCzCVjWpFFp8aaP9rvnDTzZMAki4fXSdsPpEprBmFQ/dzOjiiaMkmx1aGbbTAc9v/JoQ==, tarball: https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69} + version: 0.10.0 + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + '@logux/eslint-config@57.1.0': resolution: {integrity: sha512-oOlYsAu5iQBzNL75QqGWVnpgle/Nz5/Gp5P6BtHRy5vBg7UXyGVqEzaG3lMHF6dgYGVb+wkLqIC0DWIH8wp/LA==} engines: {node: '>=18.0.0'} @@ -1169,8 +1283,8 @@ packages: svelte-eslint-parser: optional: true - '@logux/oxc-configs@1.0.0': - resolution: {integrity: sha512-BHn8L2r2HT/Kp508jiTX+gTC3Rij2Sua7rjLOjweNzkWkQjzAbJdXwtRYI4DeBkrvdF7PmI8A6EvcCqHtDFNEQ==} + '@logux/oxc-configs@1.1.0': + resolution: {integrity: sha512-X+yJcCwTpwnaESokhw2zjH2Wn+XAUGX3VsEToYBQzXfgdXQ4IMCyjTUURKhHO+9JoSL+vGgxtqaF7LMGtPHj5w==} engines: {node: '>=22.0.0'} peerDependencies: oxlint: ^1.57.0 @@ -1181,6 +1295,11 @@ packages: resolution: {integrity: sha512-a7KRD30U252cfIekBGktEIRuwFRT7vOPZXkEsD05DwApsPi70iRnFaHEgMde8tZu+jJUuTDN/xMgabtjzHzXaQ==} engines: {node: ^20.0.0 || >=22.0.0} + '@logux/server@https://codeload.github.com/logux/server/tar.gz/659e0c3a30132545c442e429d4f096f96988a0d0': + resolution: {gitHosted: true, integrity: sha512-UokowNKHTuZLADUDtwoxctaTo4byw/CvoVh+m7PZKz6/OetHBjw0n1nzhIIIvMwdZqAFmnkbNAhj4b7xwJSmLw==, tarball: https://codeload.github.com/logux/server/tar.gz/659e0c3a30132545c442e429d4f096f96988a0d0} + version: 0.14.0 + engines: {node: ^22.5.0 || ^24.0.0 || >=26.0.0} + '@logux/stylelint-config@1.0.1': resolution: {integrity: sha512-a8vGb8ehfLtd5+ivfSgsA2MQpoM1K1aB+gmF1n6mAykweXhweWSmQzGPXYkpSyxWuo6UCZi5T1mqpVnE0oSlkw==} peerDependencies: @@ -1213,12 +1332,35 @@ packages: peerDependencies: nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 - '@napi-rs/wasm-runtime@1.2.0': - resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + '@nanostores/sql@0.4.1': + resolution: {integrity: sha512-ctB/vGWzex1LmgK0TGnpnWaHPMZXqROGrNm/rJIzqeCki9d59IdxIByT24KVlnVHOmsXgBPbflSscla6ZEDCAw==} + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + peerDependencies: + '@electric-sql/pglite': '>=0.4.0' + expo-sqlite: '>=14.0.0' + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 + sqlocal: '>=0.12.0' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + expo-sqlite: + optional: true + sqlocal: + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^2.0.0-alpha.3 - '@emnapi/runtime': ^2.0.0-alpha.3 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1386,8 +1528,8 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} @@ -1492,124 +1634,124 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.61.0': - resolution: {integrity: sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==} + '@oxfmt/binding-android-arm-eabi@0.63.0': + resolution: {integrity: sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.61.0': - resolution: {integrity: sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==} + '@oxfmt/binding-android-arm64@0.63.0': + resolution: {integrity: sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.61.0': - resolution: {integrity: sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==} + '@oxfmt/binding-darwin-arm64@0.63.0': + resolution: {integrity: sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.61.0': - resolution: {integrity: sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==} + '@oxfmt/binding-darwin-x64@0.63.0': + resolution: {integrity: sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.61.0': - resolution: {integrity: sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==} + '@oxfmt/binding-freebsd-x64@0.63.0': + resolution: {integrity: sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': - resolution: {integrity: sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': + resolution: {integrity: sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.61.0': - resolution: {integrity: sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==} + '@oxfmt/binding-linux-arm-musleabihf@0.63.0': + resolution: {integrity: sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.61.0': - resolution: {integrity: sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==} + '@oxfmt/binding-linux-arm64-gnu@0.63.0': + resolution: {integrity: sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.61.0': - resolution: {integrity: sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==} + '@oxfmt/binding-linux-arm64-musl@0.63.0': + resolution: {integrity: sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.61.0': - resolution: {integrity: sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==} + '@oxfmt/binding-linux-ppc64-gnu@0.63.0': + resolution: {integrity: sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.61.0': - resolution: {integrity: sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.63.0': + resolution: {integrity: sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.61.0': - resolution: {integrity: sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==} + '@oxfmt/binding-linux-riscv64-musl@0.63.0': + resolution: {integrity: sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.61.0': - resolution: {integrity: sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==} + '@oxfmt/binding-linux-s390x-gnu@0.63.0': + resolution: {integrity: sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.61.0': - resolution: {integrity: sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==} + '@oxfmt/binding-linux-x64-gnu@0.63.0': + resolution: {integrity: sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.61.0': - resolution: {integrity: sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==} + '@oxfmt/binding-linux-x64-musl@0.63.0': + resolution: {integrity: sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.61.0': - resolution: {integrity: sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==} + '@oxfmt/binding-openharmony-arm64@0.63.0': + resolution: {integrity: sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.61.0': - resolution: {integrity: sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==} + '@oxfmt/binding-win32-arm64-msvc@0.63.0': + resolution: {integrity: sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.61.0': - resolution: {integrity: sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==} + '@oxfmt/binding-win32-ia32-msvc@0.63.0': + resolution: {integrity: sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.61.0': - resolution: {integrity: sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==} + '@oxfmt/binding-win32-x64-msvc@0.63.0': + resolution: {integrity: sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1644,124 +1786,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.76.0': - resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} + '@oxlint/binding-android-arm-eabi@1.78.0': + resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.76.0': - resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} + '@oxlint/binding-android-arm64@1.78.0': + resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.76.0': - resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} + '@oxlint/binding-darwin-arm64@1.78.0': + resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.76.0': - resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} + '@oxlint/binding-darwin-x64@1.78.0': + resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.76.0': - resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} + '@oxlint/binding-freebsd-x64@1.78.0': + resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.76.0': - resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.76.0': - resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.76.0': - resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} + '@oxlint/binding-linux-arm64-gnu@1.78.0': + resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.76.0': - resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} + '@oxlint/binding-linux-arm64-musl@1.78.0': + resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.76.0': - resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.76.0': - resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.76.0': - resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} + '@oxlint/binding-linux-riscv64-musl@1.78.0': + resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.76.0': - resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} + '@oxlint/binding-linux-s390x-gnu@1.78.0': + resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.76.0': - resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} + '@oxlint/binding-linux-x64-gnu@1.78.0': + resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.76.0': - resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} + '@oxlint/binding-linux-x64-musl@1.78.0': + resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.76.0': - resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} + '@oxlint/binding-openharmony-arm64@1.78.0': + resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.76.0': - resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} + '@oxlint/binding-win32-arm64-msvc@1.78.0': + resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.76.0': - resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} + '@oxlint/binding-win32-ia32-msvc@1.78.0': + resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.76.0': - resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} + '@oxlint/binding-win32-x64-msvc@1.78.0': + resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1774,101 +1916,101 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/mcp@0.0.79': + resolution: {integrity: sha512-VpqD4a3vFyGQMY9sh3UJiO6wjcurggkljKfAyCHL0QWGY5m6Ehr3MNsAAHPDHO//n13g0PCjpHatAOiulrqdZQ==} + engines: {node: '>=18'} + hasBin: true + '@profoundlogic/hogan@3.0.4': resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==} hasBin: true - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1876,141 +2018,141 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.62.3': - resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.62.3': - resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.62.3': - resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.62.3': - resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.62.3': - resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.3': - resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.62.3': - resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.62.3': - resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.62.3': - resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.62.3': - resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.62.3': - resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.62.3': - resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.62.3': - resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.62.3': - resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.62.3': - resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.62.3': - resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.62.3': - resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.62.3': - resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.62.3': - resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.62.3': - resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.62.3': - resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.62.3': - resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.3': - resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.3': - resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.3': - resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} cpu: [x64] os: [win32] @@ -2022,16 +2164,20 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@size-limit/file@13.0.2': - resolution: {integrity: sha512-2jUyuOP1qJMjcsbDuAhHV4xoRK89uFCrz0DZ+qIRFwTsTxME4Rjb6E6ZYb7VNAy5K2aQyM/BQJOS+eT01KLRjg==} + '@size-limit/file@13.0.3': + resolution: {integrity: sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - size-limit: 13.0.2 + size-limit: 13.0.3 + + '@sqlite.org/sqlite-wasm@3.51.2-build9': + resolution: {integrity: sha512-tj2Sa6X4gVZqS9SiT2xlEeChSUklSGq8Hz6zKNbHW6NRiGyrSXiFcspnrWru1wPBT76RX/VxweESKxawCxfDxg==} + engines: {node: '>=22'} - '@storybook/addon-a11y@10.5.5': - resolution: {integrity: sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==} + '@storybook/addon-a11y@10.5.8': + resolution: {integrity: sha512-yunl+sKa29NaUC3I4yjESwk6ED8waEgmmvIJzNAJvdhb7thFzZYYEld68RzKmOVcGBkD3snK5eZ/Xf0l4VWliQ==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.8 '@storybook/addon-svelte-csf@5.1.2': resolution: {integrity: sha512-NpImknEb48J7yr/ArTYpvhDSvGUrgm5Nuybu9PCicjSKTACsXX7cln2R19572ORtns399RTE+t20BBOKxSPm2g==} @@ -2042,23 +2188,23 @@ packages: svelte: ^5.0.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/addon-themes@10.5.5': - resolution: {integrity: sha512-ENZCJkvTdGYBRuaE3tEE6jRilMRdGgfYUhnFNEUXAg4II2iVYg9mnrq6tuQfwSVjGuEOTAUen/3YV+l7U4oOOA==} + '@storybook/addon-themes@10.5.8': + resolution: {integrity: sha512-MYTLkN/5t02R0DJikVJUIRWnoyDqDwJprXnagUEFRT5aiO75glG7rY3uikB8n+7vXL/N9fTkgjIxER0SShkvYg==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.8 - '@storybook/builder-vite@10.5.5': - resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} + '@storybook/builder-vite@10.5.8': + resolution: {integrity: sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.8 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.5': - resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} + '@storybook/csf-plugin@10.5.8': + resolution: {integrity: sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==} peerDependencies: esbuild: '*' - rollup: '>=2.80.0' - storybook: ^10.5.5 + rollup: '*' + storybook: ^10.5.8 vite: '*' webpack: '*' peerDependenciesMeta: @@ -2082,18 +2228,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/svelte-vite@10.5.5': - resolution: {integrity: sha512-2dhYdXh8enjHdNEg7FHiF0x3ON2tNxxS6hcHjTgU7JYxFfZwvWK02gAuCGu3eKxH6YkPNpa8EIzTkocMXMom5A==} + '@storybook/svelte-vite@10.5.8': + resolution: {integrity: sha512-fWM3GGtk5iAJqMj7CUcYvUn0NxE3aSvz2DlAz+LGVKrIolWscmt/71lPsCobIDl45+CNdc8G7qFNEODAd/IKVA==} peerDependencies: '@sveltejs/vite-plugin-svelte': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - storybook: ^10.5.5 + storybook: ^10.5.8 svelte: ^5.0.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/svelte@10.5.5': - resolution: {integrity: sha512-MQD2Lc8mWtonl7e+t6x5bGWPeTGraldr619ZqNucAKHkaatM7y10JlnBAn/mONsRZ2eLzqPZ1YLZ7akV59Ch/w==} + '@storybook/svelte@10.5.8': + resolution: {integrity: sha512-bos9R5e4eIN9IDETnkOQD5wVnqdHkzEnYY1QO3El9Qw+r0QREBu0wyq43763OEOQF0mI2rzT/kuCYleBmrYaLA==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.8 svelte: ^5.0.0 '@stylistic/stylelint-config@5.0.0': @@ -2102,22 +2248,22 @@ packages: peerDependencies: stylelint: ^17.6.0 - '@stylistic/stylelint-plugin@5.2.1': - resolution: {integrity: sha512-BMRWJVtO16uADJmKcGi6mreei/QYdOviABXuaCeHEc3T3pxo4wFYYSvnxIoyiPVfDJqX+DxQdnGkuc6KxsRaSw==} + '@stylistic/stylelint-plugin@5.3.0': + resolution: {integrity: sha512-xbyxPeaO9Vns6A8/0MQV6PzZ5nNZCoGV4q1ERaNd5EBAKlWt+YcpaPapNYNUkZaiQg98+xPbikdA58ehquJG3g==} peerDependencies: stylelint: ^17.6.0 - '@sveltejs/acorn-typescript@1.0.11': - resolution: {integrity: sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==} + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} peerDependencies: acorn: ^8.9.0 - '@sveltejs/load-config@0.2.1': - resolution: {integrity: sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==} + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} engines: {node: '>= 18.0.0'} - '@sveltejs/vite-plugin-svelte@7.2.0': - resolution: {integrity: sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==} + '@sveltejs/vite-plugin-svelte@7.3.0': + resolution: {integrity: sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==} engines: {node: ^20.19 || ^22.12 || >=24} peerDependencies: svelte: ^5.46.4 @@ -2135,8 +2281,8 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -2150,8 +2296,8 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/chrome@0.2.2': - resolution: {integrity: sha512-8rSMZ4cvo2xmaSyQg0sN5yRL7oiDkntLoiHxUhfwQnv1mvnkrdoZ25SlNrKWmYKaeP50WvrfWj1pmc02+U9KKw==} + '@types/chrome@0.2.6': + resolution: {integrity: sha512-8OgXtL+OcR2IpY2ul5itg0R3xmdceAM4ldcHxh4BFL20p5z7fjhpyP5KuXAp1LIxA7oQgyViKTS9d+W8Sn8jdQ==} '@types/concat-stream@2.0.3': resolution: {integrity: sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ==} @@ -2192,8 +2338,8 @@ packages: '@types/is-empty@1.2.3': resolution: {integrity: sha512-4J1l5d79hoIvsrKh5VUKVRA1aIdsOb10Hu5j3J2VfP/msDnfTdGPmNp2E1Wg+vs97Bktzo+MZePFFXSGoykYJw==} - '@types/jsdom@28.0.3': - resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/jsdom@30.0.0': + resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2210,8 +2356,8 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} '@types/supports-color@8.1.3': resolution: {integrity: sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg==} @@ -2234,63 +2380,63 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.67.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -2416,6 +2562,9 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@ungap/with-resolvers@0.1.0': + resolution: {integrity: sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] @@ -2575,8 +2724,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - actions-up@1.16.0: - resolution: {integrity: sha512-i/2cDk8Z5YUatx1atmxmZx5jCe0z6nwpDvBiqf4FB7cTuKXnEvpwLbwDGlvKkwA7n+SOHxlosGgOhBJXtHW3IA==} + actions-up@1.17.0: + resolution: {integrity: sha512-gdLr+nJY4v2/wwrxkUUsemfSl9VfmEraCOWSJDG24DDFJHCNWoC8GYRnL2aQEyM0G1gG5mTL71k4ZnI/8hxu1A==} engines: {node: ^18.3.0 || >=20.0.0} hasBin: true @@ -2598,8 +2747,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -2658,8 +2807,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axe-core@4.12.1: - resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -2676,8 +2825,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} engines: {node: '>=6.0.0'} hasBin: true @@ -2699,22 +2848,22 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.3: - resolution: {integrity: sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2748,8 +2897,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2786,8 +2935,8 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chromatic@18.1.0: - resolution: {integrity: sha512-I9av8lUc5CQRlYzwKpmOG9cwYvZ4AFmTvtHeNrzOMC1353F9xYw45CHpSNIsNKuOiqqmzci9esXQd5akl0fi6w==} + chromatic@18.2.0: + resolution: {integrity: sha512-xyTKDhBQPDd4qrsXhJK7GAJyIStpRoSTs4b7EtPyliSL0luej3DcqkeCZ9osHxbiLXiViNyWfMxo5Xgzejex8A==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: @@ -2821,6 +2970,9 @@ packages: co@3.1.0: resolution: {integrity: sha512-CQsjCRiNObI8AtTsNIBDRMQ4oMR83CzEswHYahClvul7gKk+lDQiOKv+5qh7LQWf5sh6jkZNispz/QlsZxyNgA==} + coincident@1.2.3: + resolution: {integrity: sha512-Uxz3BMTWIslzeWjuQnizGWVg0j6khbvHUQ8+5BdM7WuJEm4ALXwq3wluYoB+uF68uPBz/oUOeJnYURKyfjexlA==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -2844,8 +2996,8 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - comment-parser@1.4.7: - resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} engines: {node: '>= 12.0.0'} concat-map@0.0.1: @@ -2994,8 +3146,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.8.2: - resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + devalue@5.9.0: + resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3036,8 +3188,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.12: - resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3141,8 +3293,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.397: - resolution: {integrity: sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==} + electron-to-chromium@1.5.406: + resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3153,8 +3305,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.24.3: - resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -3199,8 +3351,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -3256,8 +3408,8 @@ packages: peerDependencies: eslint: '>=8.23.0' - eslint-plugin-perfectionist@5.10.0: - resolution: {integrity: sha512-HiqpDrUDbGrMC6iHQbemgDyHJ0366Vyz/qRWmxQcSAkmG25cXr8BdRgx8yAhOKhEfBXn8Rnf/mTCsV4EqUJSxg==} + eslint-plugin-perfectionist@5.10.1: + resolution: {integrity: sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==} engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 @@ -3266,8 +3418,8 @@ packages: resolution: {integrity: sha512-FkCmQwZtc4nf6EKDKEYW/lWtApDXxsJA/VXWQQemLhIWWho676CJdgt53AMubOrSOTzzep0dBMP9zVFCCCdxsw==} engines: {node: '>=0.10.0'} - eslint-plugin-svelte@3.22.0: - resolution: {integrity: sha512-O3qn0NePTWta+1o25dIThqeEP/hEQ3VxDK2LVO8SQ5wG9umLMvulK+m1yQ4JGOb2Pkl8IB0G1lpRV/HXDXSLTQ==} + eslint-plugin-svelte@3.23.0: + resolution: {integrity: sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 @@ -3296,8 +3448,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.8.0: - resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + eslint@10.8.1: + resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -3332,8 +3484,8 @@ packages: esrap@1.4.9: resolution: {integrity: sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==} - esrap@2.3.0: - resolution: {integrity: sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==} + esrap@2.3.3: + resolution: {integrity: sha512-OETBYYsX6L8btUkOyi8AcdtlfpsyNO9nCmP92U/Cxm07epHeLo5we1ck6z0HsbB/jxWlfmEBF9oobZKqSBWD4Q==} peerDependencies: '@typescript-eslint/types': ^8.2.0 peerDependenciesMeta: @@ -3371,8 +3523,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} @@ -3412,8 +3564,8 @@ packages: flat-cache@6.1.23: resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - flatted@3.4.3: - resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} @@ -3430,6 +3582,11 @@ packages: resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3439,6 +3596,9 @@ packages: resolution: {integrity: sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==} engines: {node: '>=14.16'} + gc-hook@0.3.1: + resolution: {integrity: sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -3447,8 +3607,8 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.2: + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -3486,12 +3646,12 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.8.0: - resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} - globby@16.2.2: - resolution: {integrity: sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==} + globby@16.2.3: + resolution: {integrity: sha512-VZX7TV7jmd/pn71vdnLKtgwy1IWqc3KjI9x1/UtPkwoKk5fKrNLY30ltDe3cAM5xruIN7YuuaulFt133jRrKZg==} engines: {node: '>=20'} globjoin@0.1.4: @@ -3556,6 +3716,10 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -3577,8 +3741,8 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - htmlparser2@8.0.2: - resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3734,12 +3898,12 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - jsdom@30.0.0: - resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: canvas: ^3.2.3 @@ -3947,12 +4111,15 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.0: + resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} - marked@18.0.7: - resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==} + marked@18.0.9: + resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==} engines: {node: '>= 20'} hasBin: true @@ -4118,12 +4285,16 @@ packages: resolution: {integrity: sha512-6AS5aCSXsjoxq2Jr9CdaAeT60yoYDOTp6po9ziqeOeY6vf6uTEHYSqWql6EFILrM3fEfXgkZ4KqE9L0rTm/wlA==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + nanoevents@10.0.0: + resolution: {integrity: sha512-PmIJ3BNxOzgVgnS1r/Pvyj6eZx/xUV2JCPogh0brD6smDIwjRlr1KBl9yoRX323PlfaNrBDDnfCgC9SL00OjSg==} + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + nanoevents@9.1.0: resolution: {integrity: sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A==} engines: {node: ^18.0.0 || >=20.0.0} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4132,8 +4303,8 @@ packages: engines: {node: ^18 || >=20} hasBin: true - nanoid@6.0.0: - resolution: {integrity: sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==} + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} engines: {node: ^22 || ^24 || >=26} hasBin: true @@ -4144,8 +4315,8 @@ packages: resolution: {integrity: sha512-AvkslkHQavd4abp7clE0xsv4afGpBpnWUqWY23V3o4ljdyi/YIOpLiiWtrlLh1oR7kWC4GT7iBN5M2SjL5I5yw==} engines: {node: ^8.0.0 || ^10.0.0 || ^12.0.0 || ^14.0.0 || ^16.0.0 || ^18.0.0 || ^20.0.0 || ^22.0.0 || ^24.0.0 || >=26.0.0} - nanostores@1.4.1: - resolution: {integrity: sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==} + nanostores@1.4.2: + resolution: {integrity: sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==} engines: {node: ^20.0.0 || >=22.0.0} napi-postinstall@0.3.4: @@ -4163,8 +4334,8 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - node-addon-api@8.9.0: - resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} node-gyp-build@4.8.4: @@ -4174,8 +4345,8 @@ packages: node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} nopt@1.0.10: @@ -4237,8 +4408,8 @@ packages: oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} - oxfmt@0.61.0: - resolution: {integrity: sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==} + oxfmt@0.63.0: + resolution: {integrity: sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4254,8 +4425,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.76.0: - resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} + oxlint@1.78.0: + resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4360,9 +4531,21 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss-html@1.8.1: - resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} - engines: {node: ^12 || >=14} + playwright-core@1.63.0-alpha-2026-08-05: + resolution: {integrity: sha512-YussvUybTfBtyYbGXWh43f+5kNP03wg98M6mu4DphYET7PSbNVajsdLGjWE1xrsjqOw32i2wFlRP7U5mcOpMZg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0-alpha-2026-08-05: + resolution: {integrity: sha512-zbGZUK+JYkoDV3cUgfvh2czTBJL34Gmz5gHVI25xiIpvYSR17Q1M7TS8hnwECUe+IkKaeXbKrSyJTyogm2DVWw==} + engines: {node: '>=20'} + hasBin: true + + postcss-html@2.0.0: + resolution: {integrity: sha512-f2Rvw5FCollEfVj3wfN7JdQb7n2rNIthW+epw2EByio7M6P7RH0BTj8a/ODHrUXd0cmO7ychb6YniymV93182Q==} + engines: {node: ^22.12 || >=24} + peerDependencies: + postcss: ^8.5.0 postcss-js@4.1.0: resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} @@ -4412,12 +4595,6 @@ packages: peerDependencies: postcss: ^8.4 - postcss-safe-parser@6.0.0: - resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.3.3 - postcss-safe-parser@7.0.1: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -4430,8 +4607,8 @@ packages: peerDependencies: postcss: ^8.4.29 - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} engines: {node: '>=4'} postcss-simple-vars@7.0.1: @@ -4454,8 +4631,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} postgres@3.4.9: @@ -4492,6 +4669,9 @@ packages: propose@0.0.5: resolution: {integrity: sha512-Jary1vb+ap2DIwOGfyiadcK4x1Iu3pzpkDBy8tljFPmQvnc9ES3m1PMZOMiWOG50cfoAyYNtGeBzrp+Rlh4G9A==} + proxy-target@3.0.2: + resolution: {integrity: sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==} + public-ip@7.0.1: resolution: {integrity: sha512-DdNcqcIbI0wEeCBcqX+bmZpUCvrDMJHXE553zgyG1MZ8S1a/iCCxmK9iTjjql+SpHSv4cZkmRv5/zGYW93AlCw==} engines: {node: '>=18'} @@ -4542,8 +4722,8 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - recast@0.23.12: - resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} redent@3.0.0: @@ -4642,8 +4822,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4652,8 +4832,8 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - rollup@4.62.3: - resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4705,8 +4885,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - size-limit@13.0.2: - resolution: {integrity: sha512-bB8Hv6g7nBtmWW+mB9Y1lkyJoDOxk6pO6p7xPsK8Y6t9CyKvBWhzTRXLlhhv1H2Ru9HIyLKbCaVswXGHwVXiwg==} + size-limit@13.0.3: + resolution: {integrity: sha512-KVb2aNEU49BwTR21SVjD+2QHP9gBV/nWsTHzNB/heRwXtHyA7lLQiDZDQ1TiNh/B/TZXKAZrHYyTt+cvBUrzYw==} engines: {node: ^22.18.0 || ^24.0.0 || >=26.0.0} hasBin: true @@ -4748,12 +4928,35 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + sqlocal@0.18.0: + resolution: {integrity: sha512-AJvbSe7W52YeI3KWt3TOl6Xf4oH/72JdQ0oig78G6v6Hrk906dIVP0vT9uIx7HmJaYHU/G+AgyqCW12W2IJ1OA==} + peerDependencies: + '@angular/core': '>=17.0.0' + drizzle-orm: '*' + kysely: '*' + react: '>=18.0.0' + vite: '>=4.0.0' + vue: '>=3.0.0' + peerDependenciesMeta: + '@angular/core': + optional: true + drizzle-orm: + optional: true + kysely: + optional: true + react: + optional: true + vite: + optional: true + vue: + optional: true + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} - storybook@10.5.5: - resolution: {integrity: sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==} + storybook@10.5.8: + resolution: {integrity: sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4808,8 +5011,8 @@ packages: style-search@0.1.0: resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==} - stylelint-config-recess-order@7.7.0: - resolution: {integrity: sha512-TWRkg+BrwHOki4pi9y1emWgx6pFwZXOYZhNsbEObke/mzYUJVJvDEzJJQXhH7ajslQJGcrExy8ZvJqDiUbhFpA==} + stylelint-config-recess-order@7.8.0: + resolution: {integrity: sha512-ObYusdkXavm8ulgb5xvmU1l8wgCdveNYG12hOTUEdjS8bNhSRggl4or0mE0JaOnQz+Ve2xZemVpwqERCgJbntg==} peerDependencies: stylelint: ^16.18.0 || ^17.0.0 stylelint-order: ^7.0.0 || ^8.0.0 @@ -4877,8 +5080,8 @@ packages: peerDependencies: svelte: ^5.0.0 - svelte-check@4.7.4: - resolution: {integrity: sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: @@ -4894,14 +5097,14 @@ packages: svelte: optional: true - svelte2tsx@0.7.59: - resolution: {integrity: sha512-Itj7Wz9WIiGFl/uJa58+rf43ajezaljKK/KTOHq5abUjto56xe+o5SOzLwSoeJ5hma9NZEstpZiazFW2q1nZPQ==} + svelte2tsx@0.7.61: + resolution: {integrity: sha512-EpQ/+UHITBULeUojx/LLD3uTYasZXcX1BrqlrzfLUnT9vdUhaOWK59a3ryWcFU8L0sY1SP+gRhJ2Beiis98+qw==} peerDependencies: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 || ^6.0.0 - svelte@5.56.8: - resolution: {integrity: sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==} + svelte@5.56.9: + resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==} engines: {node: '>=18'} svg-tags@1.0.0: @@ -4953,11 +5156,11 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.4.9: - resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} - tldts@7.4.9: - resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true to-regex-range@5.0.1: @@ -4996,8 +5199,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -5020,8 +5223,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -5045,14 +5248,14 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.29.0: - resolution: {integrity: sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==} + undici-types@8.10.0: + resolution: {integrity: sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==} undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@8.9.0: - resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} unicorn-magic@0.4.0: @@ -5142,8 +5345,8 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -5194,13 +5397,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5307,8 +5510,8 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5360,7 +5563,7 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@asamuzakjp/css-color@6.0.5': + '@asamuzakjp/css-color@6.0.7': dependencies: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0)(@csstools/css-tokenizer@4.0.0) '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0)(@csstools/css-tokenizer@4.0.0) @@ -5368,7 +5571,7 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.5.2 - '@asamuzakjp/dom-selector@8.3.0': + '@asamuzakjp/dom-selector@8.3.2': dependencies: bidi-js: 1.0.3 css-tree: 3.2.1 @@ -5401,7 +5604,7 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@crxjs/vite-plugin@2.7.1(supports-color@10.2.2)(vite@8.1.5)': + '@crxjs/vite-plugin@2.7.1(supports-color@10.2.2)(vite@8.2.1)': dependencies: '@webcomponents/custom-elements': 1.6.0 acorn-walk: 8.3.5 @@ -5417,7 +5620,7 @@ snapshots: rollup: 2.80.0 rxjs: 7.5.7 tinyglobby: 0.2.17 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -5450,17 +5653,17 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.4)': + '@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 - '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 '@drizzle-team/brocli@0.10.2': {} - '@electric-sql/pglite@0.5.4': {} + '@electric-sql/pglite@0.5.5': {} '@emnapi/core@1.10.0': dependencies: @@ -5468,12 +5671,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -5491,11 +5688,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -5526,12 +5718,12 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.2 '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true '@esbuild/android-arm64@0.18.20': @@ -5540,7 +5732,7 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true '@esbuild/android-arm@0.18.20': @@ -5549,7 +5741,7 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true '@esbuild/android-x64@0.18.20': @@ -5558,7 +5750,7 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true '@esbuild/darwin-arm64@0.18.20': @@ -5567,7 +5759,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true '@esbuild/darwin-x64@0.18.20': @@ -5576,7 +5768,7 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true '@esbuild/freebsd-arm64@0.18.20': @@ -5585,7 +5777,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true '@esbuild/freebsd-x64@0.18.20': @@ -5594,7 +5786,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true '@esbuild/linux-arm64@0.18.20': @@ -5603,7 +5795,7 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true '@esbuild/linux-arm@0.18.20': @@ -5612,7 +5804,7 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true '@esbuild/linux-ia32@0.18.20': @@ -5621,7 +5813,7 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true '@esbuild/linux-loong64@0.18.20': @@ -5630,7 +5822,7 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true '@esbuild/linux-mips64el@0.18.20': @@ -5639,7 +5831,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true '@esbuild/linux-ppc64@0.18.20': @@ -5648,7 +5840,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true '@esbuild/linux-riscv64@0.18.20': @@ -5657,7 +5849,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true '@esbuild/linux-s390x@0.18.20': @@ -5666,7 +5858,7 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true '@esbuild/linux-x64@0.18.20': @@ -5675,13 +5867,13 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true '@esbuild/netbsd-x64@0.18.20': @@ -5690,13 +5882,13 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true '@esbuild/openbsd-x64@0.18.20': @@ -5705,13 +5897,13 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true '@esbuild/sunos-x64@0.18.20': @@ -5720,7 +5912,7 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true '@esbuild/win32-arm64@0.18.20': @@ -5729,7 +5921,7 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true '@esbuild/win32-ia32@0.18.20': @@ -5738,7 +5930,7 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true '@esbuild/win32-x64@0.18.20': @@ -5747,12 +5939,12 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1)': dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -5781,7 +5973,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5864,16 +6056,19 @@ snapshots: dependencies: '@logux/core': 0.10.0 - '@logux/client@0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1)': + '@logux/actions@https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7(@logux/core@0.10.0)': dependencies: - '@logux/actions': 0.5.0(@logux/core@0.10.0) - '@logux/core': 0.10.0 - fast-json-stable-stringify: 2.1.0 - nanodelay: 2.0.2 - nanoevents: 9.1.0 - nanoid: 5.1.16 - nanostores: 1.4.1 + '@logux/core': https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69 + + '@logux/client@https://codeload.github.com/logux/client/tar.gz/2590c90f3127725463540211d39302e386519a2e(@nanostores/sql@0.4.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1)': + dependencies: + '@logux/actions': https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7(@logux/core@0.10.0) + '@logux/core': https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69 + nanoevents: 10.0.0 + nanoid: 6.0.1 + nanostores: 1.4.2 optionalDependencies: + '@nanostores/sql': 0.4.1(@electric-sql/pglite@0.5.5)(nanostores@1.4.2)(sqlocal@0.18.0) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -5881,30 +6076,34 @@ snapshots: dependencies: nanoevents: 9.1.0 - '@logux/eslint-config@57.1.0(@typescript-eslint/utils@8.65.0)(eslint-plugin-svelte@3.22.0)(eslint@10.8.0)(supports-color@10.2.2)(svelte-eslint-parser@1.8.0)(svelte@5.56.8)(typescript@6.0.3)': + '@logux/core@https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69': + dependencies: + nanoevents: 10.0.0 + + '@logux/eslint-config@57.1.0(@typescript-eslint/utils@8.67.0)(eslint-plugin-svelte@3.23.0)(eslint@10.8.1)(supports-color@10.2.2)(svelte-eslint-parser@1.8.0)(svelte@5.56.9)(typescript@6.0.3)': dependencies: '@eslint/eslintrc': 3.3.6(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.65.0)(eslint@10.8.0)(supports-color@10.2.2) - eslint-plugin-n: 17.24.0(eslint@10.8.0)(typescript@6.0.3) - eslint-plugin-perfectionist: 5.10.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.67.0)(eslint@10.8.1)(supports-color@10.2.2) + eslint-plugin-n: 17.24.0(eslint@10.8.1)(typescript@6.0.3) + eslint-plugin-perfectionist: 5.10.1(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) eslint-plugin-prefer-let: 4.2.2 - globals: 17.8.0 - typescript-eslint: 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) + globals: 17.11.0 + typescript-eslint: 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) optionalDependencies: - eslint-plugin-svelte: 3.22.0(eslint@10.8.0)(svelte@5.56.8) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-eslint-parser: 1.8.0(svelte@5.56.8) + eslint-plugin-svelte: 3.23.0(eslint@10.8.1)(svelte@5.56.9) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + svelte-eslint-parser: 1.8.0(svelte@5.56.9) transitivePeerDependencies: - '@typescript-eslint/utils' - eslint-import-resolver-node - supports-color - typescript - '@logux/oxc-configs@1.0.0(oxlint-tsgolint@7.0.2001)(oxlint@1.76.0)(typescript@6.0.3)': + '@logux/oxc-configs@1.1.0(oxlint-tsgolint@7.0.2001)(oxlint@1.78.0)(typescript@6.0.3)': dependencies: eslint-plugin-prefer-let: 4.2.2 - oxlint: 1.76.0(oxlint-tsgolint@7.0.2001) + oxlint: 1.78.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: 7.0.2001 typescript: 6.0.3 @@ -5918,7 +6117,20 @@ snapshots: nanoid: 5.1.16 tinyglobby: 0.2.17 url-pattern: 1.0.3 - ws: 8.21.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@logux/server@https://codeload.github.com/logux/server/tar.gz/659e0c3a30132545c442e429d4f096f96988a0d0': + dependencies: + '@logux/actions': https://codeload.github.com/logux/actions/tar.gz/1af6802df1be40acbec99e1a342d09c1254767a7(@logux/core@0.10.0) + '@logux/core': https://codeload.github.com/logux/core/tar.gz/573f28b54a99540135304140b69bdb9d6cf68a69 + cookie: 2.0.1 + fastq: 1.20.1 + nanoevents: 10.0.0 + nanoid: 6.0.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5926,51 +6138,54 @@ snapshots: '@logux/stylelint-config@1.0.1(stylelint@17.14.1)': dependencies: stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) - stylelint-config-recess-order: 7.7.0(stylelint-order@8.1.1)(stylelint@17.14.1) + stylelint-config-recess-order: 7.8.0(stylelint-order@8.1.1)(stylelint@17.14.1) stylelint-config-standard: 40.0.0(stylelint@17.14.1) stylelint-gamut: 2.0.1(stylelint@17.14.1) stylelint-order: 8.1.1(stylelint@17.14.1) '@mdi/js@7.4.47': {} - '@nanostores/i18n@1.3.3(nanostores@1.4.1)': + '@nanostores/i18n@1.3.3(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@nanostores/media-query@0.1.2(nanostores@1.4.1)': + '@nanostores/media-query@0.1.2(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@nanostores/persistent@1.3.5(nanostores@1.4.1)': + '@nanostores/persistent@1.3.5(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@nanostores/router@1.1.0(nanostores@1.4.1)': + '@nanostores/router@1.1.0(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@nanostores/sql@0.4.1(@electric-sql/pglite@0.5.5)(nanostores@1.4.2)(sqlocal@0.18.0)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 + nanostores: 1.4.2 + optionalDependencies: + '@electric-sql/pglite': 0.5.5 + sqlocal: 0.18.0(drizzle-orm@0.45.2)(react@18.3.1)(vite@8.2.1) + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 @@ -6093,7 +6308,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.127.0': @@ -6107,7 +6322,7 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.142.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -6161,7 +6376,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': @@ -6170,61 +6385,61 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true - '@oxfmt/binding-android-arm-eabi@0.61.0': + '@oxfmt/binding-android-arm-eabi@0.63.0': optional: true - '@oxfmt/binding-android-arm64@0.61.0': + '@oxfmt/binding-android-arm64@0.63.0': optional: true - '@oxfmt/binding-darwin-arm64@0.61.0': + '@oxfmt/binding-darwin-arm64@0.63.0': optional: true - '@oxfmt/binding-darwin-x64@0.61.0': + '@oxfmt/binding-darwin-x64@0.63.0': optional: true - '@oxfmt/binding-freebsd-x64@0.61.0': + '@oxfmt/binding-freebsd-x64@0.63.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + '@oxfmt/binding-linux-arm-musleabihf@0.63.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.61.0': + '@oxfmt/binding-linux-arm64-gnu@0.63.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.61.0': + '@oxfmt/binding-linux-arm64-musl@0.63.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + '@oxfmt/binding-linux-ppc64-gnu@0.63.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + '@oxfmt/binding-linux-riscv64-gnu@0.63.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.61.0': + '@oxfmt/binding-linux-riscv64-musl@0.63.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.61.0': + '@oxfmt/binding-linux-s390x-gnu@0.63.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.61.0': + '@oxfmt/binding-linux-x64-gnu@0.63.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.61.0': + '@oxfmt/binding-linux-x64-musl@0.63.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.61.0': + '@oxfmt/binding-openharmony-arm64@0.63.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.61.0': + '@oxfmt/binding-win32-arm64-msvc@0.63.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.61.0': + '@oxfmt/binding-win32-ia32-msvc@0.63.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.61.0': + '@oxfmt/binding-win32-x64-msvc@0.63.0': optional: true '@oxlint-tsgolint/darwin-arm64@7.0.2001': @@ -6245,61 +6460,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.76.0': + '@oxlint/binding-android-arm-eabi@1.78.0': optional: true - '@oxlint/binding-android-arm64@1.76.0': + '@oxlint/binding-android-arm64@1.78.0': optional: true - '@oxlint/binding-darwin-arm64@1.76.0': + '@oxlint/binding-darwin-arm64@1.78.0': optional: true - '@oxlint/binding-darwin-x64@1.76.0': + '@oxlint/binding-darwin-x64@1.78.0': optional: true - '@oxlint/binding-freebsd-x64@1.76.0': + '@oxlint/binding-freebsd-x64@1.78.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.76.0': + '@oxlint/binding-linux-arm-musleabihf@1.78.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.76.0': + '@oxlint/binding-linux-arm64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.76.0': + '@oxlint/binding-linux-arm64-musl@1.78.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.76.0': + '@oxlint/binding-linux-ppc64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.76.0': + '@oxlint/binding-linux-riscv64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.76.0': + '@oxlint/binding-linux-riscv64-musl@1.78.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.76.0': + '@oxlint/binding-linux-s390x-gnu@1.78.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.76.0': + '@oxlint/binding-linux-x64-gnu@1.78.0': optional: true - '@oxlint/binding-linux-x64-musl@1.76.0': + '@oxlint/binding-linux-x64-musl@1.78.0': optional: true - '@oxlint/binding-openharmony-arm64@1.76.0': + '@oxlint/binding-openharmony-arm64@1.78.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.76.0': + '@oxlint/binding-win32-arm64-msvc@1.78.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.76.0': + '@oxlint/binding-win32-ia32-msvc@1.78.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.76.0': + '@oxlint/binding-win32-x64-msvc@1.78.0': optional: true '@phc/format@1.0.0': {} @@ -6307,191 +6522,191 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/mcp@0.0.79': + dependencies: + playwright: 1.63.0-alpha-2026-08-05 + playwright-core: 1.63.0-alpha-2026-08-05 + '@profoundlogic/hogan@3.0.4': dependencies: nopt: 1.0.10 - '@rolldown/binding-android-arm64@1.1.5': - optional: true - - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.2': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.2': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.2': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.2': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.2': optional: true '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.62.3': + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true - '@rollup/rollup-android-arm64@4.62.3': + '@rollup/rollup-android-arm64@4.62.4': optional: true - '@rollup/rollup-darwin-arm64@4.62.3': + '@rollup/rollup-darwin-arm64@4.62.4': optional: true - '@rollup/rollup-darwin-x64@4.62.3': + '@rollup/rollup-darwin-x64@4.62.4': optional: true - '@rollup/rollup-freebsd-arm64@4.62.3': + '@rollup/rollup-freebsd-arm64@4.62.4': optional: true - '@rollup/rollup-freebsd-x64@4.62.3': + '@rollup/rollup-freebsd-x64@4.62.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.3': + '@rollup/rollup-linux-arm-musleabihf@4.62.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.3': + '@rollup/rollup-linux-arm64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.62.3': + '@rollup/rollup-linux-arm64-musl@4.62.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.3': + '@rollup/rollup-linux-loong64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.62.3': + '@rollup/rollup-linux-loong64-musl@4.62.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.3': + '@rollup/rollup-linux-ppc64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.3': + '@rollup/rollup-linux-ppc64-musl@4.62.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.3': + '@rollup/rollup-linux-riscv64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.3': + '@rollup/rollup-linux-riscv64-musl@4.62.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.3': + '@rollup/rollup-linux-s390x-gnu@4.62.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.3': + '@rollup/rollup-linux-x64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-x64-musl@4.62.3': + '@rollup/rollup-linux-x64-musl@4.62.4': optional: true - '@rollup/rollup-openbsd-x64@4.62.3': + '@rollup/rollup-openbsd-x64@4.62.4': optional: true - '@rollup/rollup-openharmony-arm64@4.62.3': + '@rollup/rollup-openharmony-arm64@4.62.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.3': + '@rollup/rollup-win32-arm64-msvc@4.62.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.3': + '@rollup/rollup-win32-ia32-msvc@4.62.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.62.3': + '@rollup/rollup-win32-x64-gnu@4.62.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.62.3': + '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true '@sindresorhus/is@5.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} - '@size-limit/file@13.0.2(size-limit@13.0.2)': + '@size-limit/file@13.0.3(size-limit@13.0.3)': dependencies: - size-limit: 13.0.2 + size-limit: 13.0.3 - '@storybook/addon-a11y@10.5.5(storybook@10.5.5)': + '@sqlite.org/sqlite-wasm@3.51.2-build9': {} + + '@storybook/addon-a11y@10.5.8(storybook@10.5.8)': dependencies: '@storybook/global': 5.0.0 - axe-core: 4.12.1 - storybook: 10.5.5(react@18.3.1) + axe-core: 4.13.0 + storybook: 10.5.8(react@18.3.1) - '@storybook/addon-svelte-csf@5.1.2(@storybook/svelte@10.5.5)(@sveltejs/vite-plugin-svelte@7.2.0)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5)': + '@storybook/addon-svelte-csf@5.1.2(@storybook/svelte@10.5.8)(@sveltejs/vite-plugin-svelte@7.3.0)(storybook@10.5.8)(svelte@5.56.9)(vite@8.2.1)': dependencies: '@storybook/csf': 0.1.13 - '@storybook/svelte': 10.5.5(storybook@10.5.5)(svelte@5.56.8) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8)(vite@8.1.5) + '@storybook/svelte': 10.5.8(storybook@10.5.8)(svelte@5.56.9) + '@sveltejs/vite-plugin-svelte': 7.3.0(svelte@5.56.9)(vite@8.2.1) dedent: 1.7.2 es-toolkit: 1.50.0 esrap: 1.4.9 magic-string: 0.30.21 - storybook: 10.5.5(react@18.3.1) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte-ast-print: 0.4.2(svelte@5.56.8) - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + storybook: 10.5.8(react@18.3.1) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + svelte-ast-print: 0.4.2(svelte@5.56.9) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) zimmerframe: 1.1.4 transitivePeerDependencies: - babel-plugin-macros - '@storybook/addon-themes@10.5.5(storybook@10.5.5)': + '@storybook/addon-themes@10.5.8(storybook@10.5.8)': dependencies: - storybook: 10.5.5(react@18.3.1) + storybook: 10.5.8(react@18.3.1) ts-dedent: 2.3.0 - '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5)': + '@storybook/builder-vite@10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8)(vite@8.2.1)': dependencies: - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5) - storybook: 10.5.5(react@18.3.1) + '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8)(vite@8.2.1) + storybook: 10.5.8(react@18.3.1) ts-dedent: 2.3.0 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5)': + '@storybook/csf-plugin@10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8)(vite@8.2.1)': dependencies: - storybook: 10.5.5(react@18.3.1) + storybook: 10.5.8(react@18.3.1) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.28.1 - rollup: 4.62.3 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + esbuild: 0.28.2 + rollup: 4.62.4 + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) '@storybook/csf@0.1.13': dependencies: @@ -6503,59 +6718,59 @@ snapshots: dependencies: react: 18.3.1 - '@storybook/svelte-vite@10.5.5(@sveltejs/vite-plugin-svelte@7.2.0)(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5)': + '@storybook/svelte-vite@10.5.8(@sveltejs/vite-plugin-svelte@7.3.0)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8)(svelte@5.56.9)(vite@8.2.1)': dependencies: - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5) - '@storybook/svelte': 10.5.5(storybook@10.5.5)(svelte@5.56.8) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8)(vite@8.1.5) + '@storybook/builder-vite': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.8)(vite@8.2.1) + '@storybook/svelte': 10.5.8(storybook@10.5.8)(svelte@5.56.9) + '@sveltejs/vite-plugin-svelte': 7.3.0(svelte@5.56.9)(vite@8.2.1) magic-string: 0.30.21 - storybook: 10.5.5(react@18.3.1) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - svelte2tsx: 0.7.59(svelte@5.56.8)(typescript@5.9.3) + storybook: 10.5.8(react@18.3.1) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + svelte2tsx: 0.7.61(svelte@5.56.9)(typescript@5.9.3) typescript: 5.9.3 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/svelte@10.5.5(storybook@10.5.5)(svelte@5.56.8)': + '@storybook/svelte@10.5.8(storybook@10.5.8)(svelte@5.56.9)': dependencies: - storybook: 10.5.5(react@18.3.1) - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + storybook: 10.5.8(react@18.3.1) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) ts-dedent: 2.3.0 type-fest: 5.8.0 '@stylistic/stylelint-config@5.0.0(stylelint@17.14.1)': dependencies: - '@stylistic/stylelint-plugin': 5.2.1(stylelint@17.14.1) + '@stylistic/stylelint-plugin': 5.3.0(stylelint@17.14.1) stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) - '@stylistic/stylelint-plugin@5.2.1(stylelint@17.14.1)': + '@stylistic/stylelint-plugin@5.3.0(stylelint@17.14.1)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0)(@csstools/css-tokenizer@4.0.0) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 style-search: 0.1.0 stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) - '@sveltejs/acorn-typescript@1.0.11(acorn@8.18.0)': + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': dependencies: acorn: 8.18.0 - '@sveltejs/load-config@0.2.1': {} + '@sveltejs/load-config@0.2.3': {} - '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8)(vite@8.1.5)': + '@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1)': dependencies: deepmerge: 4.3.1 - magic-string: 0.30.21 + magic-string: 1.2.0 obug: 2.1.4 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.1.5) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.1) '@szmarczak/http-timer@5.0.1': dependencies: @@ -6581,7 +6796,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -6597,14 +6812,14 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/chrome@0.2.2': + '@types/chrome@0.2.6': dependencies: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 '@types/concat-stream@2.0.3': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@types/debug@4.1.13': dependencies: @@ -6638,12 +6853,12 @@ snapshots: '@types/is-empty@1.2.3': {} - '@types/jsdom@28.0.3': + '@types/jsdom@30.0.0': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@types/tough-cookie': 4.0.5 parse5: 8.0.1 - undici-types: 7.29.0 + undici-types: 8.10.0 '@types/json-schema@7.0.15': {} @@ -6661,7 +6876,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@26.1.2': + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -6679,17 +6894,17 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0)(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0)(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6697,56 +6912,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/parser@8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.67.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.67.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 @@ -6756,20 +6971,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.67.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.67.0 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -6834,6 +7049,8 @@ snapshots: '@ungap/structured-clone@1.3.3': {} + '@ungap/with-resolvers@0.1.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true @@ -6892,7 +7109,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -6944,7 +7161,7 @@ snapshots: acorn@8.18.0: {} - actions-up@1.16.0: + actions-up@1.17.0: dependencies: enquirer: 2.4.1 nanospinner: 1.2.2 @@ -6967,7 +7184,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6975,7 +7192,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@4.3.0: dependencies: @@ -6994,7 +7211,7 @@ snapshots: dependencies: '@phc/format': 1.0.0 cross-env: 10.1.0 - node-addon-api: 8.9.0 + node-addon-api: 8.9.2 node-gyp-build: 4.8.4 argparse@2.0.1: {} @@ -7017,16 +7234,16 @@ snapshots: astral-regex@2.0.0: {} - autoprefixer@10.5.4(postcss@8.5.25): + autoprefixer@10.5.4(postcss@8.5.26): dependencies: - browserslist: 4.28.7 - caniuse-lite: 1.0.30001806 + browserslist: 4.28.8 + caniuse-lite: 1.0.30001809 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - axe-core@4.12.1: {} + axe-core@4.13.0: {} axobject-query@4.1.0: {} @@ -7036,7 +7253,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.14: {} bcp-47-match@2.0.3: {} @@ -7050,16 +7267,16 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.16: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.3: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7067,13 +7284,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.7: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.11.7 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.397 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.7) + baseline-browser-mapping: 2.11.14 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.406 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) buffer-from@1.1.2: {} @@ -7107,7 +7324,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001809: {} ccount@2.0.1: {} @@ -7147,7 +7364,7 @@ snapshots: dependencies: readdirp: 4.1.2 - chromatic@18.1.0: + chromatic@18.2.0: dependencies: semver: 7.8.5 @@ -7165,6 +7382,18 @@ snapshots: co@3.1.0: {} + coincident@1.2.3: + dependencies: + '@ungap/structured-clone': 1.3.3 + '@ungap/with-resolvers': 0.1.0 + gc-hook: 0.3.1 + proxy-target: 3.0.2 + optionalDependencies: + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -7181,7 +7410,7 @@ snapshots: commander@11.1.0: {} - comment-parser@1.4.7: {} + comment-parser@1.4.8: {} concat-map@0.0.1: {} @@ -7204,7 +7433,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -7268,7 +7497,7 @@ snapshots: hast-util-select: 6.0.4 html-url-attributes: 3.0.1 levenshtein-edit-distance: 3.0.1 - undici: 8.9.0 + undici: 8.10.0 unist-util-visit: 5.1.0 vfile-message: 4.0.3 @@ -7313,7 +7542,7 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.8.2: {} + devalue@5.9.0: {} devlop@1.1.0: dependencies: @@ -7354,7 +7583,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.12: + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -7369,16 +7598,16 @@ snapshots: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.1 + tsx: 4.23.12 - drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9): + drizzle-orm@0.45.2(@electric-sql/pglite@0.5.5)(postgres@3.4.9): optionalDependencies: - '@electric-sql/pglite': 0.5.4 + '@electric-sql/pglite': 0.5.5 postgres: 3.4.9 eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.397: {} + electron-to-chromium@1.5.406: {} emoji-regex@10.6.0: {} @@ -7386,7 +7615,7 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.24.3: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -7468,34 +7697,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -7503,31 +7732,31 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.5.1(eslint@10.8.0): + eslint-compat-utils@0.5.1(eslint@10.8.1): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) semver: 7.8.5 eslint-import-context@0.1.9(unrs-resolver@1.12.2): dependencies: - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.2 stable-hash-x: 0.2.0 optionalDependencies: unrs-resolver: 1.12.2 - eslint-plugin-es-x@7.8.0(eslint@10.8.0): + eslint-plugin-es-x@7.8.0(eslint@10.8.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) '@eslint-community/regexpp': 4.12.2 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-compat-utils: 0.5.1(eslint@10.8.0) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) + eslint-compat-utils: 0.5.1(eslint@10.8.1) - eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.65.0)(eslint@10.8.0)(supports-color@10.2.2): + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.67.0)(eslint@10.8.1)(supports-color@10.2.2): dependencies: - '@typescript-eslint/types': 8.65.0 - comment-parser: 1.4.7 + '@typescript-eslint/types': 8.67.0 + comment-parser: 1.4.8 debug: 4.4.3(supports-color@10.2.2) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) eslint-import-context: 0.1.9(unrs-resolver@1.12.2) is-glob: 4.0.3 minimatch: 10.2.6 @@ -7535,17 +7764,17 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.12.2 optionalDependencies: - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - supports-color - eslint-plugin-n@17.24.0(eslint@10.8.0)(typescript@6.0.3): + eslint-plugin-n@17.24.0(eslint@10.8.1)(typescript@6.0.3): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) - enhanced-resolve: 5.24.3 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-plugin-es-x: 7.8.0(eslint@10.8.0) - get-tsconfig: 4.14.0 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) + enhanced-resolve: 5.24.5 + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) + eslint-plugin-es-x: 7.8.0(eslint@10.8.1) + get-tsconfig: 4.14.2 globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 @@ -7554,10 +7783,10 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-perfectionist@5.10.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3): + eslint-plugin-perfectionist@5.10.1(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) natural-orderby: 5.0.0 transitivePeerDependencies: - supports-color @@ -7567,21 +7796,21 @@ snapshots: dependencies: requireindex: 1.2.0 - eslint-plugin-svelte@3.22.0(eslint@10.8.0)(svelte@5.56.8): + eslint-plugin-svelte@3.23.0(eslint@10.8.1)(svelte@5.56.9): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 - postcss: 8.5.25 - postcss-load-config: 3.1.4(postcss@8.5.25) - postcss-safe-parser: 7.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-load-config: 3.1.4(postcss@8.5.26) + postcss-safe-parser: 7.0.1(postcss@8.5.26) semver: 7.8.5 - svelte-eslint-parser: 1.8.0(svelte@5.56.8) + svelte-eslint-parser: 1.8.0(svelte@5.56.9) optionalDependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) transitivePeerDependencies: - ts-node @@ -7603,9 +7832,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2): + eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.7.0 @@ -7669,11 +7898,11 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - esrap@2.3.0(@typescript-eslint/types@8.65.0): + esrap@2.3.3(@typescript-eslint/types@8.67.0): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 optionalDependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.67.0 esrecurse@4.3.0: dependencies: @@ -7701,7 +7930,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastest-levenshtein@1.0.16: {} @@ -7732,16 +7961,16 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.3 + flatted: 3.4.4 keyv: 4.5.4 flat-cache@6.1.23: dependencies: cacheable: 2.5.0 - flatted: 3.4.3 + flatted: 3.4.4 hookified: 1.15.1 - flatted@3.4.3: {} + flatted@3.4.4: {} foreground-child@3.3.1: dependencies: @@ -7758,16 +7987,21 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true function-timeout@0.1.1: {} + gc-hook@0.3.1: {} + get-east-asian-width@1.6.0: {} get-stream@6.0.1: {} - get-tsconfig@4.14.0: + get-tsconfig@4.14.2: dependencies: resolve-pkg-maps: 1.0.0 @@ -7806,9 +8040,9 @@ snapshots: globals@16.5.0: {} - globals@17.8.0: {} + globals@17.11.0: {} - globby@16.2.2: + globby@16.2.3: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 @@ -7924,7 +8158,10 @@ snapshots: he@1.2.0: {} - highlight.js@11.11.1: {} + highlight.js@11.11.1: + optional: true + + highlight.js@11.12.0: {} hookified@1.15.1: {} @@ -7944,7 +8181,7 @@ snapshots: html-url-attributes@3.0.1: {} - htmlparser2@8.0.2: + htmlparser2@9.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 @@ -8066,14 +8303,14 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 - jsdom@30.0.0: + jsdom@30.0.1: dependencies: - '@asamuzakjp/css-color': 6.0.5 - '@asamuzakjp/dom-selector': 8.3.0 + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) '@exodus/bytes': 1.15.1 @@ -8087,7 +8324,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.2 - undici: 8.9.0 + undici: 8.10.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -8239,9 +8476,13 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.2.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-extensions@2.0.0: {} - marked@18.0.7: {} + marked@18.0.9: {} mathml-tag-names@4.0.0: {} @@ -8498,15 +8739,15 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 1.1.18 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.3 + brace-expansion: 2.1.4 minimist@1.2.8: {} @@ -8521,10 +8762,10 @@ snapshots: '@logux/actions': 0.5.0(@logux/core@0.10.0) '@logux/server': 0.14.0 diff2html: 3.4.56 - dompurify: 3.4.12 - highlight.js: 11.11.1 - marked: 18.0.7 - nanostores: 1.4.1 + dompurify: 3.4.13 + highlight.js: 11.12.0 + marked: 18.0.9 + nanostores: 1.4.2 yaml: 2.9.0 transitivePeerDependencies: - '@logux/core' @@ -8535,13 +8776,15 @@ snapshots: nanodelay@2.0.2: {} + nanoevents@10.0.0: {} + nanoevents@9.1.0: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} nanoid@5.1.16: {} - nanoid@6.0.0: {} + nanoid@6.0.1: {} nanospinner@1.2.2: dependencies: @@ -8549,7 +8792,7 @@ snapshots: nanospy@2.0.2: {} - nanostores@1.4.1: {} + nanostores@1.4.2: {} napi-postinstall@0.3.4: {} @@ -8561,7 +8804,7 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 - node-addon-api@8.9.0: {} + node-addon-api@8.9.2: {} node-gyp-build@4.8.4: {} @@ -8570,7 +8813,7 @@ snapshots: css-select: 5.2.2 he: 1.2.0 - node-releases@2.0.51: {} + node-releases@2.0.53: {} nopt@1.0.10: dependencies: @@ -8679,30 +8922,30 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.61.0(svelte@5.56.8): + oxfmt@0.63.0(svelte@5.56.9): dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.61.0 - '@oxfmt/binding-android-arm64': 0.61.0 - '@oxfmt/binding-darwin-arm64': 0.61.0 - '@oxfmt/binding-darwin-x64': 0.61.0 - '@oxfmt/binding-freebsd-x64': 0.61.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.61.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.61.0 - '@oxfmt/binding-linux-arm64-gnu': 0.61.0 - '@oxfmt/binding-linux-arm64-musl': 0.61.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.61.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.61.0 - '@oxfmt/binding-linux-riscv64-musl': 0.61.0 - '@oxfmt/binding-linux-s390x-gnu': 0.61.0 - '@oxfmt/binding-linux-x64-gnu': 0.61.0 - '@oxfmt/binding-linux-x64-musl': 0.61.0 - '@oxfmt/binding-openharmony-arm64': 0.61.0 - '@oxfmt/binding-win32-arm64-msvc': 0.61.0 - '@oxfmt/binding-win32-ia32-msvc': 0.61.0 - '@oxfmt/binding-win32-x64-msvc': 0.61.0 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + '@oxfmt/binding-android-arm-eabi': 0.63.0 + '@oxfmt/binding-android-arm64': 0.63.0 + '@oxfmt/binding-darwin-arm64': 0.63.0 + '@oxfmt/binding-darwin-x64': 0.63.0 + '@oxfmt/binding-freebsd-x64': 0.63.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.63.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.63.0 + '@oxfmt/binding-linux-arm64-gnu': 0.63.0 + '@oxfmt/binding-linux-arm64-musl': 0.63.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.63.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.63.0 + '@oxfmt/binding-linux-riscv64-musl': 0.63.0 + '@oxfmt/binding-linux-s390x-gnu': 0.63.0 + '@oxfmt/binding-linux-x64-gnu': 0.63.0 + '@oxfmt/binding-linux-x64-musl': 0.63.0 + '@oxfmt/binding-openharmony-arm64': 0.63.0 + '@oxfmt/binding-win32-arm64-msvc': 0.63.0 + '@oxfmt/binding-win32-ia32-msvc': 0.63.0 + '@oxfmt/binding-win32-x64-msvc': 0.63.0 + svelte: 5.56.9(@typescript-eslint/types@8.67.0) oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -8713,27 +8956,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.76.0(oxlint-tsgolint@7.0.2001): + oxlint@1.78.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.76.0 - '@oxlint/binding-android-arm64': 1.76.0 - '@oxlint/binding-darwin-arm64': 1.76.0 - '@oxlint/binding-darwin-x64': 1.76.0 - '@oxlint/binding-freebsd-x64': 1.76.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 - '@oxlint/binding-linux-arm-musleabihf': 1.76.0 - '@oxlint/binding-linux-arm64-gnu': 1.76.0 - '@oxlint/binding-linux-arm64-musl': 1.76.0 - '@oxlint/binding-linux-ppc64-gnu': 1.76.0 - '@oxlint/binding-linux-riscv64-gnu': 1.76.0 - '@oxlint/binding-linux-riscv64-musl': 1.76.0 - '@oxlint/binding-linux-s390x-gnu': 1.76.0 - '@oxlint/binding-linux-x64-gnu': 1.76.0 - '@oxlint/binding-linux-x64-musl': 1.76.0 - '@oxlint/binding-openharmony-arm64': 1.76.0 - '@oxlint/binding-win32-arm64-msvc': 1.76.0 - '@oxlint/binding-win32-ia32-msvc': 1.76.0 - '@oxlint/binding-win32-x64-msvc': 1.76.0 + '@oxlint/binding-android-arm-eabi': 1.78.0 + '@oxlint/binding-android-arm64': 1.78.0 + '@oxlint/binding-darwin-arm64': 1.78.0 + '@oxlint/binding-darwin-x64': 1.78.0 + '@oxlint/binding-freebsd-x64': 1.78.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.78.0 + '@oxlint/binding-linux-arm-musleabihf': 1.78.0 + '@oxlint/binding-linux-arm64-gnu': 1.78.0 + '@oxlint/binding-linux-arm64-musl': 1.78.0 + '@oxlint/binding-linux-ppc64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-musl': 1.78.0 + '@oxlint/binding-linux-s390x-gnu': 1.78.0 + '@oxlint/binding-linux-x64-gnu': 1.78.0 + '@oxlint/binding-linux-x64-musl': 1.78.0 + '@oxlint/binding-openharmony-arm64': 1.78.0 + '@oxlint/binding-win32-arm64-msvc': 1.78.0 + '@oxlint/binding-win32-ia32-msvc': 1.78.0 + '@oxlint/binding-win32-x64-msvc': 1.78.0 oxlint-tsgolint: 7.0.2001 p-all@5.0.1: @@ -8835,83 +9078,87 @@ snapshots: picomatch@4.0.5: {} - postcss-html@1.8.1: + playwright-core@1.63.0-alpha-2026-08-05: {} + + playwright@1.63.0-alpha-2026-08-05: dependencies: - htmlparser2: 8.0.2 + playwright-core: 1.63.0-alpha-2026-08-05 + optionalDependencies: + fsevents: 2.3.2 + + postcss-html@2.0.0(postcss@8.5.26): + dependencies: + htmlparser2: 9.1.0 js-tokens: 9.0.1 - postcss: 8.5.25 - postcss-safe-parser: 6.0.0(postcss@8.5.25) + postcss: 8.5.26 + postcss-safe-parser: 7.0.1(postcss@8.5.26) - postcss-js@4.1.0(postcss@8.5.25): + postcss-js@4.1.0(postcss@8.5.26): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.25 + postcss: 8.5.26 - postcss-load-config@3.1.4(postcss@8.5.25): + postcss-load-config@3.1.4(postcss@8.5.26): dependencies: lilconfig: 2.1.0 yaml: 1.10.3 optionalDependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.25 - tsx: 4.23.1 + postcss: 8.5.26 + tsx: 4.23.12 yaml: 2.9.0 - postcss-mixins@12.1.2(postcss@8.5.25): + postcss-mixins@12.1.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 - postcss-js: 4.1.0(postcss@8.5.25) - postcss-simple-vars: 7.0.1(postcss@8.5.25) - sugarss: 5.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-js: 4.1.0(postcss@8.5.26) + postcss-simple-vars: 7.0.1(postcss@8.5.26) + sugarss: 5.0.1(postcss@8.5.26) tinyglobby: 0.2.17 - postcss-nesting@14.0.1(postcss@8.5.25): - dependencies: - '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.4) - '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.4) - postcss: 8.5.25 - postcss-selector-parser: 7.1.4 - - postcss-safe-parser@6.0.0(postcss@8.5.25): + postcss-nesting@14.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.5) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.5) + postcss: 8.5.26 + postcss-selector-parser: 7.1.5 - postcss-safe-parser@7.0.1(postcss@8.5.25): + postcss-safe-parser@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-scss@4.0.9(postcss@8.5.25): + postcss-scss@4.0.9(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-selector-parser@7.1.4: + postcss-selector-parser@7.1.5: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-simple-vars@7.0.1(postcss@8.5.25): + postcss-simple-vars@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-smooth-shadow@0.3.2(postcss@8.5.25): + postcss-smooth-shadow@0.3.2(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-sorting@10.0.0(postcss@8.5.25): + postcss-sorting@10.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser@4.2.0: {} - postcss@8.5.25: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8940,6 +9187,8 @@ snapshots: dependencies: levenshtein-edit-distance: 1.0.0 + proxy-target@3.0.2: {} + public-ip@7.0.1: dependencies: dns-socket: 4.2.2 @@ -8988,7 +9237,7 @@ snapshots: readdirp@4.1.2: {} - recast@0.23.12: + recast@0.23.21: dependencies: ast-types: 0.16.1 esprima: 4.0.1 @@ -9176,60 +9425,60 @@ snapshots: reusify@1.1.0: {} - rolldown@1.1.5: + rolldown@1.2.2: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 rollup@2.80.0: optionalDependencies: fsevents: 2.3.3 - rollup@4.62.3: + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.3 - '@rollup/rollup-android-arm64': 4.62.3 - '@rollup/rollup-darwin-arm64': 4.62.3 - '@rollup/rollup-darwin-x64': 4.62.3 - '@rollup/rollup-freebsd-arm64': 4.62.3 - '@rollup/rollup-freebsd-x64': 4.62.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 - '@rollup/rollup-linux-arm-musleabihf': 4.62.3 - '@rollup/rollup-linux-arm64-gnu': 4.62.3 - '@rollup/rollup-linux-arm64-musl': 4.62.3 - '@rollup/rollup-linux-loong64-gnu': 4.62.3 - '@rollup/rollup-linux-loong64-musl': 4.62.3 - '@rollup/rollup-linux-ppc64-gnu': 4.62.3 - '@rollup/rollup-linux-ppc64-musl': 4.62.3 - '@rollup/rollup-linux-riscv64-gnu': 4.62.3 - '@rollup/rollup-linux-riscv64-musl': 4.62.3 - '@rollup/rollup-linux-s390x-gnu': 4.62.3 - '@rollup/rollup-linux-x64-gnu': 4.62.3 - '@rollup/rollup-linux-x64-musl': 4.62.3 - '@rollup/rollup-openbsd-x64': 4.62.3 - '@rollup/rollup-openharmony-arm64': 4.62.3 - '@rollup/rollup-win32-arm64-msvc': 4.62.3 - '@rollup/rollup-win32-ia32-msvc': 4.62.3 - '@rollup/rollup-win32-x64-gnu': 4.62.3 - '@rollup/rollup-win32-x64-msvc': 4.62.3 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 run-applescript@7.1.0: {} @@ -9271,12 +9520,11 @@ snapshots: signal-exit@4.1.0: {} - size-limit@13.0.2: + size-limit@13.0.3: dependencies: bytes-iec: 3.1.1 lilconfig: 3.1.3 nanospinner: 1.2.2 - picocolors: 1.1.1 slash@5.1.0: {} @@ -9313,27 +9561,39 @@ snapshots: spdx-license-ids@3.0.23: {} + sqlocal@0.18.0(drizzle-orm@0.45.2)(react@18.3.1)(vite@8.2.1): + dependencies: + '@sqlite.org/sqlite-wasm': 3.51.2-build9 + coincident: 1.2.3 + optionalDependencies: + drizzle-orm: 0.45.2(@electric-sql/pglite@0.5.5)(postgres@3.4.9) + react: 18.3.1 + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + stable-hash-x@0.2.0: {} - storybook@10.5.5(react@18.3.1): + storybook@10.5.8(react@18.3.1): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@18.3.1) '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 - esbuild: 0.28.1 + esbuild: 0.28.2 jsonc-parser: 3.3.1 open: 10.2.0 oxc-parser: 0.127.0 oxc-resolver: 11.24.2 - recast: 0.23.12 + recast: 0.23.21 semver: 7.8.5 use-sync-external-store: 1.6.0(react@18.3.1) - ws: 8.21.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - react @@ -9377,7 +9637,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-indent@3.0.0: dependencies: @@ -9387,7 +9647,7 @@ snapshots: style-search@0.1.0: {} - stylelint-config-recess-order@7.7.0(stylelint-order@8.1.1)(stylelint@17.14.1): + stylelint-config-recess-order@7.8.0(stylelint-order@8.1.1)(stylelint@17.14.1): dependencies: stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) stylelint-order: 8.1.1(stylelint@17.14.1) @@ -9408,8 +9668,8 @@ snapshots: stylelint-order@8.1.1(stylelint@17.14.1): dependencies: - postcss: 8.5.25 - postcss-sorting: 10.0.0(postcss@8.5.25) + postcss: 8.5.26 + postcss-sorting: 10.0.0(postcss@8.5.26) stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) stylelint-use-logical@2.1.3(stylelint@17.14.1): @@ -9423,8 +9683,8 @@ snapshots: '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0)(@csstools/css-tokenizer@4.0.0) - '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.4) - '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.5) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.5) colord: 2.9.3 cosmiconfig: 9.0.2(typescript@6.0.3) css-functions-list: 3.3.3 @@ -9434,7 +9694,7 @@ snapshots: fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.5 global-modules: 2.0.0 - globby: 16.2.2 + globby: 16.2.3 globjoin: 0.1.4 html-tags: 5.1.0 ignore: 7.0.6 @@ -9444,9 +9704,9 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.25 - postcss-safe-parser: 7.0.1(postcss@8.5.25) - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-safe-parser: 7.0.1(postcss@8.5.26) + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 string-width: 8.2.2 supports-hyperlinks: 4.5.0 @@ -9457,9 +9717,9 @@ snapshots: - supports-color - typescript - sugarss@5.0.1(postcss@8.5.25): + sugarss@5.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 super-regex@0.2.0: dependencies: @@ -9476,58 +9736,58 @@ snapshots: has-flag: 5.0.1 supports-color: 10.2.2 - svelte-ast-print@0.4.2(svelte@5.56.8): + svelte-ast-print@0.4.2(svelte@5.56.9): dependencies: esrap: 1.2.2 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) zimmerframe: 1.1.2 - svelte-check@4.7.4(picomatch@4.0.5)(svelte@5.56.8)(typescript@6.0.3): + svelte-check@4.7.6(picomatch@4.0.5)(svelte@5.56.9)(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@sveltejs/load-config': 0.2.1 + '@sveltejs/load-config': 0.2.3 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) typescript: 6.0.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.8.0(svelte@5.56.8): + svelte-eslint-parser@1.8.0(svelte@5.56.9): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - postcss: 8.5.25 - postcss-scss: 4.0.9(postcss@8.5.25) - postcss-selector-parser: 7.1.4 + postcss: 8.5.26 + postcss-scss: 4.0.9(postcss@8.5.26) + postcss-selector-parser: 7.1.5 semver: 7.8.5 optionalDependencies: - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) - svelte2tsx@0.7.59(svelte@5.56.8)(typescript@5.9.3): + svelte2tsx@0.7.61(svelte@5.56.9)(typescript@5.9.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.56.8(@typescript-eslint/types@8.65.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) typescript: 5.9.3 - svelte@5.56.8(@typescript-eslint/types@8.65.0): + svelte@5.56.9(@typescript-eslint/types@8.67.0): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.11(acorn@8.18.0) + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) '@types/estree': 1.0.9 '@types/trusted-types': 2.0.7 acorn: 8.18.0 aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.8.2 + devalue: 5.9.0 esm-env: 1.2.2 - esrap: 2.3.0(@typescript-eslint/types@8.65.0) + esrap: 2.3.3(@typescript-eslint/types@8.67.0) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -9580,11 +9840,11 @@ snapshots: tinyspy@4.0.4: {} - tldts-core@7.4.9: {} + tldts-core@7.4.10: {} - tldts@7.4.9: + tldts@7.4.10: dependencies: - tldts-core: 7.4.9 + tldts-core: 7.4.10 to-regex-range@5.0.1: dependencies: @@ -9592,7 +9852,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.9 + tldts: 7.4.10 tr46@6.0.0: dependencies: @@ -9615,9 +9875,9 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -9635,13 +9895,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3): + typescript-eslint@8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0)(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0)(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -9675,11 +9935,11 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.29.0: {} + undici-types@8.10.0: {} undici-types@8.3.0: {} - undici@8.9.0: {} + undici@8.10.0: {} unicorn-magic@0.4.0: {} @@ -9871,9 +10131,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - update-browserslist-db@1.2.3(browserslist@4.28.7): + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: - browserslist: 4.28.7 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -9944,25 +10204,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0): + vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.1.5 + postcss: 8.5.26 + rolldown: 1.2.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 - esbuild: 0.28.1 + '@types/node': 26.2.0 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - sugarss: 5.0.1(postcss@8.5.25) - tsx: 4.23.1 + sugarss: 5.0.1(postcss@8.5.26) + tsx: 4.23.12 yaml: 2.9.0 - vitefu@1.1.3(vite@8.1.5): + vitefu@1.1.3(vite@8.2.1): optionalDependencies: - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.12)(yaml@2.9.0) w3c-xmlserializer@5.0.0: dependencies: @@ -10029,7 +10289,7 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.21.1: {} + ws@8.21.3: {} wsl-utils@0.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa24a98c..a6218f04 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,7 @@ allowBuilds: esbuild: true unrs-resolver: false -blockExoticSubdeps: true +blockExoticSubdeps: false forceLegacyDeploy: true @@ -35,9 +35,8 @@ minimumReleaseAgeExclude: - multiocular - '@logux/oxc-configs' - keyux - -overrides: - rollup@<2.80.0: '>=2.80.0' + - esbuild + - nanoid trustPolicy: no-downgrade diff --git a/proxy/Dockerfile b/proxy/Dockerfile index 639b11f3..f49ca333 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,31 +1,21 @@ -FROM cgr.dev/chainguard/wolfi-base@sha256:003627df3c1e1bba0c4116afcddb314aca9594ee2328c7e876a8081a6c988b2e AS base +# registry.access.redhat.com/hi/nodejs:26.7.0 +FROM registry.access.redhat.com/hi/nodejs@sha256:410701fb1ee01c897134734259770f94529921c9ef4a26af43d9f37bf52b0c76 LABEL org.opencontainers.image.source=https://github.com/hplush/slowreader LABEL org.opencontainers.image.description="Proxy server of Slow Reader" LABEL org.opencontainers.image.licenses=AGPL-3.0-or-later -ENV NODE_VERSION=26.5.1 \ - NODE_CHECKSUM=sha256:cc7b3484ade63bd203a9d304f21ec37a3b622b988d7bdecf1dc4d68fc44a91b7 - -ADD --checksum=$NODE_CHECKSUM https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /node.tar.xz -RUN tar -xf "node.tar.xz" --strip-components=1 -C /usr/local/ \ - "node-v${NODE_VERSION}-linux-x64/bin/node" -RUN apk add --no-cache binutils -RUN strip /usr/local/bin/node - -FROM cgr.dev/chainguard/glibc-dynamic@sha256:57e5704e70a85b90191182eb6110d1c817df0d8e96035cb041195c5a351f0861 WORKDIR /var/app ENV NODE_ENV=production \ PORT=5284 COPY --from=ghcr.io/tarampampam/microcheck@sha256:c9f79cd408626de7c10f2d487d67339f49adf0ba61dde96ede65343269db1f85 /bin/httpcheck /usr/bin/httpcheck -COPY --from=base /usr/local/bin/node /usr/local/bin/node COPY ./dist/ /var/app/ -USER nonroot +USER 65532 -ENTRYPOINT ["/usr/local/bin/node"] +ENTRYPOINT ["/usr/bin/node"] CMD ["server.ts"] HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ diff --git a/proxy/README.md b/proxy/README.md index e04e4df1..9666d188 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -10,10 +10,10 @@ _See the [full architecture guide](../README.md) first._ ## Scripts -- `cd proxy && pnpm test`: run all proxy tests. -- `cd proxy && pnpm start`: run proxy server. -- `cd proxy && pnpm build`: prepare deploy files with production dependencies only. -- `cd proxy && pnpm production`: start production build of the proxy server. +- `pnpm -F proxy test`: run all proxy tests. +- `pnpm -F proxy start`: run proxy server. +- `pnpm -F proxy build`: prepare deploy files with production dependencies only. +- `pnpm -F proxy production`: start production build of the proxy server. ## Abuse Protection diff --git a/proxy/index.ts b/proxy/index.ts index a66da0e0..38c72a50 100644 --- a/proxy/index.ts +++ b/proxy/index.ts @@ -40,6 +40,16 @@ export const DEFAULT_PROXY_CONFIG: Omit = { requestTimeout: 10000 } +const REDIRECTS = new Set([301, 302, 303, 307, 308]) + +const MAX_REDIRECTS = 10 + +function resolveLocation(location: string, from: string): string { + let host = new URL(from).host + return new URL(location.replace(/^(https?:)\/\/(?=\/)/i, `$1//${host}`), from) + .href +} + function allowCors(req: IncomingMessage, res: ServerResponse): void { if (req.headers.origin) { res.setHeader('Access-Control-Allow-Headers', '*') @@ -57,10 +67,59 @@ function allowCors(req: IncomingMessage, res: ServerResponse): void { } } +function checkDestination( + target: string, + allowUnsafeDestinations?: boolean +): URL { + let parsed = new URL(target) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new BadRequestError('Only HTTP or HTTPS are supported') + } + if (!allowUnsafeDestinations) { + if ( + !parsed.hostname.includes('.') || + parsed.hostname.includes('.localhost') || + /\.(local|internal)$/.test(parsed.hostname) || + parsed.hostname === 'localhost.' || + isIP(parsed.hostname.replace(/^\[|\]$/g, '')) !== 0 + ) { + throw new BadRequestError('IP addresses or local domains are not allowed') + } + } + return parsed +} + +async function loadTarget( + method: string, + url: string, + headers: Record, + requestTimeout: number +): Promise { + try { + return await fetch(url, { + headers: { ...headers, host: new URL(url).host }, + method: method, + redirect: 'manual', + signal: AbortSignal.timeout(requestTimeout) + }) + } catch (e) { + /* node:coverage disable */ + if (e instanceof TypeError) { + throw new BadRequestError(e.message, 400, { cause: e }) + } else if (e instanceof Error && e.name === 'TimeoutError') { + throw new BadRequestError('Timeout', 400, { cause: e }) + } else { + throw e + } + /* node:coverage enable */ + } +} + export function createProxy( config: ProxyConfig ): (req: IncomingMessage, res: ServerResponse) => void { let allowsFrom = new RegExp(config.allowsFrom) + return async (req, res) => { let sent = false @@ -90,12 +149,7 @@ export function createProxy( try { let url = decodeURIComponent(req.url!.slice(1).replace(/^proxy\//, '')) - let parsedUrl = new URL(url) - - // Only HTTP or HTTPS protocols are allowed - if (!url.startsWith('http://') && !url.startsWith('https://')) { - throw new BadRequestError('Only HTTP or HTTPS are supported') - } + let parsedUrl = checkDestination(url, config.allowUnsafeDestinations) // We do not typically need non-GET to load RSS if (req.method !== 'GET') { @@ -113,20 +167,6 @@ export function createProxy( ) } - if (!config.allowUnsafeDestinations) { - if ( - !parsedUrl.hostname.includes('.') || - parsedUrl.hostname.includes('.localhost') || - /\.(local|internal)$/.test(parsedUrl.hostname) || - parsedUrl.hostname === 'localhost.' || - isIP(parsedUrl.hostname.replace(/^\[|\]$/g, '')) !== 0 - ) { - throw new BadRequestError( - 'IP addresses or local domains are not allowed' - ) - } - } - let debug = req.headers['x-slowreader-debug'] let clientIp = firstValue(req.headers['x-real-ip']) ?? @@ -160,23 +200,34 @@ export function createProxy( 'X-Forwarded-For': clientIp } - let targetResponse: Response - try { - targetResponse = await fetch(url, { - headers: requestHeaders, - method: req.method, - signal: AbortSignal.timeout(config.requestTimeout) - }) - } catch (e) { - /* node:coverage disable */ - if (e instanceof TypeError) { - throw new BadRequestError(e.message, 400, { cause: e }) - } else if (e instanceof Error && e.name === 'TimeoutError') { - throw new BadRequestError('Timeout', 400, { cause: e }) - } else { - throw e + let targetUrl = url + let targetResponse = await loadTarget( + req.method, + targetUrl, + requestHeaders, + config.requestTimeout + ) + let redirects = 0 + while ( + REDIRECTS.has(targetResponse.status) && + targetResponse.headers.has('location') + ) { + if (redirects === MAX_REDIRECTS) { + throw new BadRequestError('Too many redirects') } - /* node:coverage enable */ + redirects += 1 + void targetResponse.body?.cancel() + targetUrl = resolveLocation( + targetResponse.headers.get('location')!, + targetUrl + ) + checkDestination(targetUrl, config.allowUnsafeDestinations) + targetResponse = await loadTarget( + req.method, + targetUrl, + requestHeaders, + config.requestTimeout + ) } if ( @@ -219,7 +270,7 @@ export function createProxy( } if (debug) { responseHeaders['x-slowreader-request'] = - `${req.method} ${url}\\n` + formatHeaders(requestHeaders) + `${req.method} ${targetUrl}\\n` + formatHeaders(requestHeaders) responseHeaders['x-slowreader-response'] = `${targetResponse.status}\\n` + formatHeaders(Object.fromEntries(targetResponse.headers.entries())) @@ -229,7 +280,7 @@ export function createProxy( if (targetResponse.body) { let nodeStream = Readable.fromWeb( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion targetResponse.body as WebReadableStream ) await pipeline(nodeStream, res, { diff --git a/proxy/package.json b/proxy/package.json index fe666ace..00b33a6f 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -18,7 +18,7 @@ "better-node-test": "1.0.0" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/proxy/server.ts b/proxy/server.ts index 2aeb0a18..0c8c4ee7 100755 --- a/proxy/server.ts +++ b/proxy/server.ts @@ -1,8 +1,13 @@ import { createServer } from 'node:http' +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net' import { styleText } from 'node:util' import { createProxy, DEFAULT_PROXY_CONFIG } from './index.ts' +// Node.js gives every IP address only 500 ms to connect and gives up on all +// of them. Slow websites need more time than browsers’ default. +setDefaultAutoSelectFamilyAttemptTimeout(2000) + if (!process.env.PROXY_ORIGIN) { process.stderr.write( styleText('red', 'Set PROXY_ORIGIN environment variable\n') @@ -13,7 +18,15 @@ if (!process.env.PROXY_ORIGIN) { let proxy = createServer( createProxy({ ...DEFAULT_PROXY_CONFIG, allowsFrom: process.env.PROXY_ORIGIN }) ) -proxy.listen(process.env.PORT ?? '5284') +let port = process.env.PORT ?? '5284' +proxy.listen(port, () => { + process.stdout.write( + styleText( + 'green', + `Proxy server is listening at http://localhost:${port}\n` + ) + ) +}) process.on('SIGINT', () => { proxy.close() diff --git a/proxy/test/index.test.ts b/proxy/test/index.test.ts index d6d742a5..1427e78e 100644 --- a/proxy/test/index.test.ts +++ b/proxy/test/index.test.ts @@ -31,7 +31,16 @@ describe('proxy', () => { await setTimeout(parseInt(queryParams.sleep)) } - if (queryParams.big === 'file') { + if (queryParams.redirectTo) { + res.writeHead(302, { Location: queryParams.redirectTo }) + res.end() + } else if (queryParams.emptyHost) { + res.writeHead(302, { Location: 'http:///?redirected=1' }) + res.end() + } else if (queryParams.loop) { + res.writeHead(302, { Location: '/?loop=1' }) + res.end() + } else if (queryParams.big === 'file') { res.writeHead(200, { 'Content-Length': '2000', 'Content-Type': 'text/text' @@ -147,6 +156,35 @@ describe('proxy', () => { await expectBadRequest(response2, 'Invalid URL') }) + test('follows redirects', async () => { + let response = await request( + `${targetUrl}?redirectTo=${encodeURIComponent(`${targetUrl}/final?redirected=1`)}` + ) + let parsedResponse = (await response.json()) as EchoResponse + equal(response.status, 200) + equal(parsedResponse.request.requestPath, '/final') + equal(parsedResponse.request.queryParams.redirected, '1') + }) + + test('fixes redirects with empty host', async () => { + let response = await request(`${targetUrl}?emptyHost=1`) + let parsedResponse = (await response.json()) as EchoResponse + equal(response.status, 200) + equal(parsedResponse.request.queryParams.redirected, '1') + }) + + test('stops redirect loops', async () => { + let response = await request(`${targetUrl}?loop=1`) + await expectBadRequest(response, 'Too many redirects') + }) + + test('checks redirect destination', async () => { + let response = await request( + `${targetUrl}?redirectTo=${encodeURIComponent('ftp://example.com/feed')}` + ) + await expectBadRequest(response, 'Only HTTP or HTTPS are supported') + }) + test('can use only HTTP or HTTPS protocols', async () => { let response = await fetch( `${proxyUrl}/${targetUrl.replace('http', 'ftp')}`, diff --git a/scripts/check-messages.ts b/scripts/check-messages.ts old mode 100644 new mode 100755 index 68214ddb..5cf5ccaf --- a/scripts/check-messages.ts +++ b/scripts/check-messages.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node // Script to check that all core/messages/*.en.ts files have the right name. // core/messages/foo.en.ts should exports fooMessages with 'foo' name. diff --git a/scripts/check-tests.ts b/scripts/check-tests.ts old mode 100644 new mode 100755 index 4c8f528b..2355e1ac --- a/scripts/check-tests.ts +++ b/scripts/check-tests.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node // Script to check test files in the codebase. // - Avoid focused tests (test.only()) that developer could forget to unfocus // - Avoid skipped tests (test.skip()) diff --git a/scripts/check-unused-messages.ts b/scripts/check-unused-messages.ts old mode 100644 new mode 100755 index 3292d841..d852c45c --- a/scripts/check-unused-messages.ts +++ b/scripts/check-unused-messages.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node // Script to find all unused messages import { execSync } from 'node:child_process' diff --git a/scripts/check-versions.ts b/scripts/check-versions.ts old mode 100644 new mode 100755 index 9df71f59..266e9b8b --- a/scripts/check-versions.ts +++ b/scripts/check-versions.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node // Script to check that: // - All dependencies has "1.2.3" requirement and not "^1.2.3" used by npm. // It prevents unexpected updates on lock file issues. @@ -21,14 +22,18 @@ function error(msg: string): void { } function getVersion(content: string, regexp: RegExp): string { - return content.match(regexp)![1]! + let match = content.match(regexp) + if (!match) error(`Can not find ${regexp} version`) + return match![1]! } +const VERSION = String.raw`\d+\.\d+\.\d+(?:-[\w.]+)?` + let dockerfile = read('.devcontainer/Dockerfile') let nodeFull = getVersion(dockerfile, /NODE_VERSION=(\d+\.\d+\.\d+)/) let nodeMinor = nodeFull.split('.').slice(0, 2).join('.') -let pnpmFull = getVersion(dockerfile, /PNPM_VERSION=(\d+\.\d+\.\d+)/) +let pnpmFull = getVersion(dockerfile, new RegExp(`PNPM_VERSION=(${VERSION})`)) let pnpmMajor = pnpmFull.split('.')[0] let nodeVersion = read('.node-version').trim() @@ -39,7 +44,7 @@ if (nodeVersion !== nodeFull) { } let packageManager = getVersion( read('package.json'), - /"packageManager": "pnpm@(\d+\.\d+\.\d+)"/ + new RegExp(`"packageManager": "pnpm@(${VERSION})"`) ) if (packageManager !== pnpmFull) { error('.devcontainer/Dockerfile and package.json have different pnpm version') @@ -65,7 +70,10 @@ for (let file of globSync(['./*/package.json', 'package.json'])) { } for (let file of globSync('**/Dockerfile')) { - let match = read(file).match(/NODE_VERSION=(\d+\.\d+\.\d+)/) + let content = read(file) + let match = + content.match(/NODE_VERSION=(\d+\.\d+\.\d+)/) || + content.match(/nodejs:(\d+\.\d+\.\d+)/) if (match && match[1] !== nodeFull) { error( `Different Node.js version in ${file}: ${styleText('yellow', match[1]!)}` diff --git a/scripts/progress.ts b/scripts/progress.ts new file mode 100644 index 00000000..7f467e08 --- /dev/null +++ b/scripts/progress.ts @@ -0,0 +1,135 @@ +// Progress bar for long scripts. It is rendered in stderr to keep stdout +// for the script’s result. + +import readline from 'node:readline' +import { isatty } from 'node:tty' +import { styleText } from 'node:util' + +const COLORS = styleText('bgGreen', ' ', { stream: process.stderr }) !== ' ' + +let total = 0 +let current = 0 +let enabled = false + +function render(): void { + let width = process.stderr.columns + let filled = Math.floor((width * current) / total) + let rest = width - filled + let bar: string + if (COLORS) { + bar = + styleText('bgGreen', ' '.repeat(filled), { stream: process.stderr }) + + styleText('bgWhite', ' '.repeat(rest), { stream: process.stderr }) + } else { + bar = '█'.repeat(filled) + '▒'.repeat(rest) + } + process.stderr.write(`${bar}\n`) + readline.moveCursor(process.stderr, 0, 0) +} + +function rendered(): boolean { + return enabled && total > 0 && current < total +} + +export function startProgress(steps: number): void { + enabled = isatty(2) && !process.env.CI + if (!enabled) return + total = steps + current = 0 + render() +} + +export function nextProgress(): void { + if (!rendered()) return + current += 1 + readline.moveCursor(process.stderr, 0, -1) + readline.clearLine(process.stderr, 0) + if (current < total) render() +} + +export function printAboveProgress( + message: string, + mode: 'error' | 'status' | 'text' | 'warning' = 'text' +): void { + let text = message + if (mode === 'error') { + text = styleText('red', message, { stream: process.stderr }) + } else if (mode === 'warning') { + text = styleText('yellow', message, { stream: process.stderr }) + } else if (mode === 'status') { + text = styleText('gray', message, { stream: process.stderr }) + } + if (rendered()) { + readline.moveCursor(process.stderr, 0, -1) + readline.clearLine(process.stderr, 0) + process.stderr.write(`${text}\n`) + render() + } else { + process.stderr.write(`${text}\n`) + } +} + +interface NoFileError extends Error { + code: string + path: string +} + +function isNoFileError(e: unknown): e is NoFileError { + return e instanceof Error && `code` in e && e.code === 'ENOENT' +} + +let errors = 0 + +export function warning(text: string): void { + printAboveProgress(text, 'warning') + nextProgress() +} + +export function error(err: unknown, details?: string): void { + errors += 1 + let msg: string + if (isNoFileError(err)) { + msg = `File not found: ${err.path}` + } else if (err instanceof Error) { + msg = err.stack ?? err.message + } else { + msg = String(err) + } + printAboveProgress('') + printAboveProgress( + styleText('bold', styleText('bgRed', ' ERROR ')) + + ' ' + + styleText('bold', styleText('red', msg)) + ) + if (details) printAboveProgress(details) + printAboveProgress('') + nextProgress() +} + +export function success(msg: string, details?: string): void { + let text = msg + if (details) { + text += ` ${styleText('gray', details)}` + } + printAboveProgress(styleText('green', styleText('bold', '✓ ') + text)) + nextProgress() +} + +export function semiSuccess(msg: string, note: string): void { + printAboveProgress( + styleText('bold', '✓ ') + msg + ' ' + styleText('bold', note), + 'warning' + ) + nextProgress() +} + +export function finish(msg: string): void { + printAboveProgress('') + let postfix = '' + if (errors > 0) { + postfix = + ', ' + styleText('red', styleText('bold', `${errors} errors found`)) + } + printAboveProgress(msg + postfix, 'status') + process.exit(errors > 0 ? 1 : 0) +} diff --git a/scripts/update-docker.sh b/scripts/update-docker.sh deleted file mode 100755 index d19e06b8..00000000 --- a/scripts/update-docker.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash -# Update or pin images in FROM and COPY --from= inside Dockerfile to a specific -# hash for security and having the same environment everywhere - -set -e - -source "$(dirname "$0")/utils.sh" - -tool=$(get_container_tool) - -# Read tag from comment on previous line if image has no tag -resolve_tag_from_comment() { - local file="$1" line_num="$2" image="$3" - if [[ "$image" == *":"* ]]; then - echo "$image" - return - fi - local prev_line_num=$((line_num - 1)) - if [[ $prev_line_num -gt 0 ]]; then - local prev_line - prev_line=$(sed -n "${prev_line_num}p" "$file") - if [[ "$prev_line" =~ ^[[:space:]]*#[[:space:]]*(.+:[^@]+)([[:space:]].*)?$ ]]; then - local comment_image="${BASH_REMATCH[1]}" - if [[ "${comment_image%%:*}" == "$image" ]]; then - echo -e " ${NOTE}Using tag from comment: $comment_image${NC}" >&2 - echo "$comment_image" - return - fi - fi - fi - echo "$image" -} - -# Update image reference to latest digest -update_image_ref() { - local file="$1" line_num="$2" image_ref="$3" - - if [[ ! "$image_ref" == *"/"* ]]; then - return - fi - - echo "$file:$line_num" - - local origin_image_tag="${image_ref%%@*}" - local image_tag_part - image_tag_part=$(resolve_tag_from_comment "$file" "$line_num" "$origin_image_tag") - - local latest_digest - latest_digest=$(skopeo inspect "docker://$image_tag_part" --raw 2>/dev/null | sha256sum | awk '{print "sha256:" $1}') - - if [[ -z "$latest_digest" ]] || [[ "$latest_digest" == "sha256:" ]]; then - echo -e " ${BAD}Could not get manifest digest for $image_tag_part${NC}" - return - fi - - local latest_image_ref="${origin_image_tag}@${latest_digest}" - if [[ "$image_ref" != "$latest_image_ref" ]]; then - echo -e " ${BAD}$image_ref${NC} → ${GOOD}$latest_image_ref${NC}" - local escaped_image_ref escaped_latest_image_ref - escaped_image_ref=$(printf '%s\n' "$image_ref" | sed 's:[][\\/.^$*]:\\&:g') - escaped_latest_image_ref=$(printf '%s\n' "$latest_image_ref" | sed 's:[][\\/.^$*]:\\&:g') - sed -i "${line_num}s#${escaped_image_ref}#${escaped_latest_image_ref}#" "$file" - else - echo -e " ${NOTE}Already pinned to latest manifest digest${NC}" - fi -} - -find . -type f -name Dockerfile -print0 | while IFS= read -r -d $'\0' dockerfile; do - grep -nE '^[[:space:]]*FROM[[:space:]]+' "$dockerfile" | grep -vE '^[[:space:]]*#[[:space:]]*FROM[[:space:]]+' | while IFS=: read -r line_num from_content; do - ref=$(echo "$from_content" | sed -E 's/^[[:space:]]*FROM[[:space:]]+(--platform=[^[:space:]]+[[:space:]]+)?//i' | awk '{print $1}') - update_image_ref "$dockerfile" "$line_num" "$ref" - done - - grep -nE 'COPY[[:space:]]+--from=' "$dockerfile" | while IFS=: read -r line_num copy_content; do - ref=$(echo "$copy_content" | sed -E 's/.*--from=([^[:space:]]+).*/\1/') - update_image_ref "$dockerfile" "$line_num" "$ref" - done - echo "" - -done diff --git a/scripts/update-docker.ts b/scripts/update-docker.ts new file mode 100755 index 00000000..f215d801 --- /dev/null +++ b/scripts/update-docker.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// Update images in FROM and COPY --from= inside Dockerfile to a specific +// hash for security and having the same environment everywhere. + +import { createHash } from 'node:crypto' +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { styleText } from 'node:util' + +const ROOT = join(import.meta.dirname, '..') + +const ACCEPT = [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v2+json' +].join(', ') + +const FROM = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i +const COPY = /^\s*COPY\s+--from=(\S+)/i + +function print(message: string): void { + process.stdout.write(message + '\n') +} + +function splitName(name: string): [string, string] { + let slash = name.indexOf('/') + let registry = name.slice(0, slash) + if (!registry.includes('.') && !registry.includes(':')) { + return ['registry-1.docker.io', name] + } + return [registry, name.slice(slash + 1)] +} + +async function getToken( + challenge: string, + repository: string +): Promise { + let realm = challenge.match(/realm="([^"]+)"/)?.[1] + if (!realm) return undefined + let url = new URL(realm) + let service = challenge.match(/service="([^"]+)"/)?.[1] + if (service) url.searchParams.set('service', service) + let scope = challenge.match(/scope="([^"]+)"/)?.[1] + url.searchParams.set('scope', scope ?? `repository:${repository}:pull`) + let response = await fetch(url) + if (!response.ok) return undefined + let data = (await response.json()) as { + access_token?: string + token?: string + } + return data.token ?? data.access_token +} + +async function getDigest( + name: string, + tag: string +): Promise { + let [registry, repository] = splitName(name) + let url = `https://${registry}/v2/${repository}/manifests/${tag}` + let headers: Record = { accept: ACCEPT } + let response = await fetch(url, { headers }) + if (response.status === 401) { + let token = await getToken( + response.headers.get('www-authenticate') ?? '', + repository + ) + if (token) { + headers.authorization = `Bearer ${token}` + response = await fetch(url, { headers }) + } + } + if (!response.ok) return undefined + let manifest = Buffer.from(await response.arrayBuffer()) + return 'sha256:' + createHash('sha256').update(manifest).digest('hex') +} + +// Read tag from comment on previous line if image has no tag +function getTag(previous: string | undefined, name: string): string { + let comment = previous?.match(/^\s*#\s*(\S+):([^@\s]+)\s*$/) + if (comment && comment[1] === name) { + print(styleText('gray', ` Using tag from comment: ${comment[2]}`)) + return comment[2]! + } + return 'latest' +} + +async function updateReference( + file: string, + line: number, + lines: string[], + reference: string +): Promise { + if (!reference.includes('/')) return undefined + + print(`${file}:${line + 1}`) + + let [origin, pinned] = reference.split('@') as [string, string | undefined] + let colon = origin.lastIndexOf(':') + let name = origin + let tag: string + if (colon > origin.lastIndexOf('/')) { + name = origin.slice(0, colon) + tag = origin.slice(colon + 1) + } else { + tag = getTag(lines[line - 1], origin) + } + + let digest = await getDigest(name, tag) + if (!digest) { + print( + styleText(['red', 'bold'], ` Could not get digest for ${name}:${tag}`) + ) + return undefined + } + + if (digest === pinned) { + print(styleText('gray', ' Already pinned to latest manifest digest')) + return undefined + } + + let updated = `${origin}@${digest}` + print( + ` ${styleText(['red', 'bold'], reference)} → ` + + styleText(['green', 'bold'], updated) + ) + return updated +} + +let files = globSync(['**/Dockerfile', '.devcontainer/Dockerfile'], { + cwd: ROOT, + exclude: ['**/node_modules/**', '**/dist/**'] +}) + +for (let file of files) { + let path = join(ROOT, file) + let lines = readFileSync(path, 'utf-8').split('\n') + let changed = false + for (let i = 0; i < lines.length; i++) { + let reference = lines[i]!.match(FROM)?.[1] ?? lines[i]!.match(COPY)?.[1] + if (!reference) continue + let updated = await updateReference(file, i, lines, reference) + if (updated) { + lines[i] = lines[i]!.replace(reference, updated) + changed = true + } + } + if (changed) writeFileSync(path, lines.join('\n')) + print('') +} diff --git a/scripts/update-env.ts b/scripts/update-env.ts index 44db3a41..a24486dc 100644 --- a/scripts/update-env.ts +++ b/scripts/update-env.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node // Script to update Node.js and pnpm everywhere. // // By default it will keep Node.js major version, but can update to next major @@ -50,18 +51,33 @@ async function getLatestNodeVersion( return filtered[0]!.version.slice(1) } -async function getLatestPnpm(): Promise<[string, Architectures]> { +async function getPnpmRelease(current: string): Promise { + if (!current.includes('-')) { + let response = await fetch( + 'https://api.github.com/repos/pnpm/pnpm/releases/latest' + ) + return (await response.json()) as GitHubReleases + } + // Stay on pre-release branch until it will have stable release + let major = current.split('.')[0] let response = await fetch( - 'https://api.github.com/repos/pnpm/pnpm/releases/latest' + 'https://api.github.com/repos/pnpm/pnpm/releases?per_page=10' ) - let data = (await response.json()) as GitHubReleases + let releases = (await response.json()) as GitHubReleases[] + return releases.find(i => i.tag_name.startsWith(`v${major}.`))! +} + +async function getLatestPnpm( + current: string +): Promise<[string, Architectures]> { + let release = await getPnpmRelease(current) return [ - data.tag_name.slice(1), + release.tag_name.slice(1), { - arm64: data.assets + arm64: release.assets .find(i => i.name === 'pnpm-linux-arm64.tar.gz')! .digest.replace(/^sha256:/, ''), - x64: data.assets + x64: release.assets .find(i => i.name === 'pnpm-linux-x64.tar.gz')! .digest.replace(/^sha256:/, '') } @@ -128,6 +144,15 @@ function replaceVersionEnv( return fixed } +// Pre-release versions do not match `^12.0.0` range +function getPnpmRange(version: string): string { + if (version.includes('-')) { + return `^${version}` + } else { + return `^${version.split('.')[0]}.0.0` + } +} + function replaceKey(file: string, key: string, value: string): string { return file.replace( new RegExp(`"${key}": "[^"]+"`, 'g'), @@ -142,7 +167,7 @@ let currentPnpm = dockerfile.match(/PNPM_VERSION=(\S+)/)![1]! let latestNode = await getLatestNodeVersion( getMajor(currentNode.split('.')[0]!) ) -let [latestPnpm, pnpmChecksums] = await getLatestPnpm() +let [latestPnpm, pnpmChecksums] = await getLatestPnpm(currentPnpm) if (currentNode !== latestNode || FORCE) { printUpdate('Node.js', currentNode, latestNode) @@ -153,7 +178,8 @@ if (currentNode !== latestNode || FORCE) { writeFileSync(join(ROOT, '.node-version'), latestNode + '\n') updateProjectDockerfiles(projectDocker => { - return replaceVersionEnv(projectDocker, 'NODE', latestNode, checksums) + let fixed = replaceVersionEnv(projectDocker, 'NODE', latestNode, checksums) + return fixed.replace(/nodejs:\d+\.\d+\.\d+/g, `nodejs:${latestNode}`) }) let minor = latestNode.split('.').slice(0, 2).join('.') @@ -169,11 +195,7 @@ if (currentPnpm !== latestPnpm || FORCE) { updatePackages(pkg => { pkg = replaceKey(pkg, 'packageManager', `pnpm@${latestPnpm}`) - let major = latestPnpm.split('.')[0] - if (currentPnpm.split('.')[0] !== major) { - pkg = replaceKey(pkg, 'pnpm', `^${major}.0.0`) - } - return pkg + return replaceKey(pkg, 'pnpm', getPnpmRange(latestPnpm)) }) } diff --git a/server/Dockerfile b/server/Dockerfile index 5658aaa1..bfd435bd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,19 +1,10 @@ -FROM cgr.dev/chainguard/wolfi-base@sha256:003627df3c1e1bba0c4116afcddb314aca9594ee2328c7e876a8081a6c988b2e AS base +# registry.access.redhat.com/hi/nodejs:26.7.0 +FROM registry.access.redhat.com/hi/nodejs@sha256:410701fb1ee01c897134734259770f94529921c9ef4a26af43d9f37bf52b0c76 LABEL org.opencontainers.image.source=https://github.com/hplush/slowreader LABEL org.opencontainers.image.description="Slow Reader" LABEL org.opencontainers.image.licenses=AGPL-3.0-or-later -ENV NODE_VERSION=26.5.1 \ - NODE_CHECKSUM=sha256:cc7b3484ade63bd203a9d304f21ec37a3b622b988d7bdecf1dc4d68fc44a91b7 - -ADD --checksum=$NODE_CHECKSUM https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /node.tar.xz -RUN tar -xf "node.tar.xz" --strip-components=1 -C /usr/local/ \ - "node-v${NODE_VERSION}-linux-x64/bin/node" -RUN apk add --no-cache binutils -RUN strip /usr/local/bin/node - -FROM cgr.dev/chainguard/glibc-dynamic@sha256:57e5704e70a85b90191182eb6110d1c817df0d8e96035cb041195c5a351f0861 WORKDIR /var/app ENV NODE_ENV=production \ LOGUX_HOST=0.0.0.0 \ @@ -22,14 +13,13 @@ ENV NODE_ENV=production \ COPY --from=ghcr.io/tarampampam/microcheck@sha256:c9f79cd408626de7c10f2d487d67339f49adf0ba61dde96ede65343269db1f85 /bin/httpcheck /usr/bin/httpcheck -COPY --from=base /usr/local/bin/node /usr/local/bin/node COPY ./dist/ /var/app/ COPY ./web/ /var/web/ -USER nonroot +USER 65532 -ENTRYPOINT ["/usr/local/bin/node"] -CMD ["--import", "tsx", "index.ts"] +ENTRYPOINT ["/usr/bin/node"] +CMD ["index.ts"] HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD ["/usr/bin/httpcheck", "http://localhost:2554/health"] diff --git a/server/README.md b/server/README.md index 54ca7746..3eb528ca 100644 --- a/server/README.md +++ b/server/README.md @@ -11,17 +11,17 @@ and uses end-to-end encryption not to know what users read and like. - [`db/`](./db/): database migrations and configs. - [`lib/`](./lib/): shared helpers for features. - [`test/`](./test/): unit tests for each feature. -- [`scripts/`](./scripts/): script to test production mode. +- [`scripts/`](./scripts/): scripts to prepare and test production mode. - [`drizzle.config.ts`](./drizzle.config.ts): config for [Drizzle Kit CLI](https://orm.drizzle.team/docs/kit-overview). - [`Dockerfile`](./Dockerfile): build image to deploy server. ## Scripts -- `cd server && pnpm start`: start server in development mode. -- `cd server && pnpm migration`: generate migration based on DB schema changes. -- `cd server && pnpm database`: see database content. -- `cd server && pnpm build`: prepare deploy files with production dependencies only. -- `cd server && pnpm production`: start production build of the server. +- `pnpm -F server start`: start server in development mode. +- `pnpm -F server migration`: generate migration based on DB schema changes. +- `pnpm -F server database`: see database content. +- `pnpm -F server build`: prepare deploy files with production dependencies only. +- `pnpm -F server production`: start production build of the server. ## Environment Variables @@ -63,13 +63,13 @@ To use SQL with TypeScript we are using [Drizzle](https://orm.drizzle.team/docs/ To change database schema: 1. Change [`./db/schema.ts`](./db/schema.ts). -2. Run `cd server && pnpm migration` to generate new migration. +2. Run `pnpm -F server migration` to generate new migration. 3. Restart server. It will apply all new migrations automatically. You can see local database content by running: ```sh -cd server && pnpm database +pnpm -F server database ``` ## Deploy @@ -77,8 +77,9 @@ cd server && pnpm database For deploy we: 1. Use `pnpm deploy` to create `dist/` only with production dependencies. -2. Build Docker image with Node.js. -3. Run this image on our Dokploy server. +2. Move workspace packages to `dist/vendor/` by [`scripts/vendor-workspace.sh`](./scripts/vendor-workspace.sh) and link them back to `node_modules/`, because Node.js can’t strip types inside `node_modules/`. +3. Build Docker image with Node.js. +4. Run this image on our Dokploy server. We deploy server to: diff --git a/server/lib/exit.ts b/server/lib/exit.ts index 2e9a8e62..ceead1ba 100644 --- a/server/lib/exit.ts +++ b/server/lib/exit.ts @@ -1,10 +1,13 @@ let listeners: (() => void)[] = [] +let exiting = false export function onExit(cb: () => void): void { listeners.push(cb) } function exit(): void { + if (exiting) return + exiting = true for (let listener of listeners) { listener() } diff --git a/server/modules/sync.ts b/server/modules/sync.ts index e722531b..4cb305bb 100644 --- a/server/modules/sync.ts +++ b/server/modules/sync.ts @@ -15,10 +15,10 @@ export default (server: BaseServer): void => { async process(ctx, action, meta) { await db.insert(actions).values({ added: await server.log.store.getLastAdded(), - compressed: action.z, - encrypted: Buffer.from(action.d, 'base64'), + compressed: action.compressed, + encrypted: Buffer.from(action.d), id: meta.id, - iv: Buffer.from(action.iv, 'base64'), + iv: Buffer.from(action.iv), subprotocol: meta.subprotocol ?? SUBPROTOCOL, time: meta.time - EPOCH, userId: ctx.userId @@ -55,9 +55,9 @@ export default (server: BaseServer): void => { return list.map(column => { return [ zero({ - d: Buffer.from(column.encrypted).toString('base64'), - iv: Buffer.from(column.iv).toString('base64'), - z: column.compressed + compressed: column.compressed, + d: column.encrypted, + iv: column.iv }), { added: column.added, diff --git a/server/package.json b/server/package.json index 712c6fbd..759bb2c5 100644 --- a/server/package.json +++ b/server/package.json @@ -14,31 +14,30 @@ "database": "drizzle-kit studio --host 0.0.0.0 --port 2557", "build": "node --run clean:build && pnpm run /^build:/", "production": "node --run build && ./scripts/run-image.sh", - "build:server": "pnpm -F server --prod deploy dist", - "build:assets": "mkdir -p web/ && cp -fr ../web/dist web/ && cp ../web/routes.regexp web/", + "build:server": "pnpm -F server --prod deploy dist && ./scripts/vendor-workspace.sh", + "build:assets": "mkdir -p web/ && cp -fr ../web/dist web/ && cp ../web/routes.regexp ../web/nginx.conf web/", "test:server-coverage": "bnt --coverage 100 --coverage-exclude '{lib/exit.ts,db/index.ts,../api/**,../proxy/**,test.ts}'", "clean:build": "rm -rf dist/ web/" }, "dependencies": { - "@electric-sql/pglite": "0.5.4", - "@logux/actions": "0.5.0", - "@logux/server": "0.14.0", + "@electric-sql/pglite": "0.5.5", + "@logux/actions": "github:logux/actions#next", + "@logux/server": "github:logux/server", "@slowreader/api": "workspace:*", "@slowreader/proxy": "workspace:*", "argon2": "0.45.1", "cookie": "2.0.1", "drizzle-orm": "0.45.2", - "nanoid": "6.0.0", - "postgres": "3.4.9", - "tsx": "4.23.1" + "nanoid": "6.0.1", + "postgres": "3.4.9" }, "devDependencies": { - "@logux/client": "0.23.2", + "@logux/client": "github:logux/client#next", "better-node-test": "1.0.0", "drizzle-kit": "0.31.10" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/server/scripts/vendor-workspace.sh b/server/scripts/vendor-workspace.sh new file mode 100755 index 00000000..33cf90ce --- /dev/null +++ b/server/scripts/vendor-workspace.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Copy workspace packages from node_modules/ to vendor/, because Node.js +# can’t strip types inside node_modules/ + +set -e + +cd "$(dirname "$0")/../dist" + +rm -rf vendor/ +mkdir vendor/ + +for package in node_modules/@slowreader/*; do + name=${package##*/} + cp -r "$(readlink -f "$package")" "vendor/$name" + rm -rf "vendor/$name/node_modules" "vendor/$name/dist" "vendor/$name/test" \ + "vendor/$name/tsconfig.tsbuildinfo" "$package" + ln -s "../../vendor/$name" "$package" +done diff --git a/server/test/assets.test.ts b/server/test/assets.test.ts index 8e20fb25..d6b167ed 100644 --- a/server/test/assets.test.ts +++ b/server/test/assets.test.ts @@ -76,8 +76,10 @@ describe('server assets', () => { let index1 = await server.fetch('/') checkHeaders(index1, { 'content-security-policy': - "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'; require-trusted-types-for 'script'; trusted-types dompurify slowreader-rich svelte-trusted-html slowreader-parse", + "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'wasm-unsafe-eval' 'self'; require-trusted-types-for 'script'; trusted-types default dompurify slowreader-rich svelte-trusted-html slowreader-parse", 'content-type': 'text/html', + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', 'x-content-type-options': 'nosniff' @@ -87,8 +89,10 @@ describe('server assets', () => { let html = await server.fetch('/404.html') checkHeaders(html, { 'content-security-policy': - "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'; require-trusted-types-for 'script'; trusted-types dompurify slowreader-rich svelte-trusted-html slowreader-parse", + "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'wasm-unsafe-eval' 'self'; require-trusted-types-for 'script'; trusted-types default dompurify slowreader-rich svelte-trusted-html slowreader-parse", 'content-type': 'text/html', + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', 'x-content-type-options': 'nosniff' diff --git a/server/test/users.test.ts b/server/test/users.test.ts index f9c8f5d7..9560b4ee 100644 --- a/server/test/users.test.ts +++ b/server/test/users.test.ts @@ -47,8 +47,12 @@ describe('server users', () => { cookie: { session: userB.session } }) - await clientA1.process(zero({ d: 'a', iv: 'a', z: false })) - await clientB.process(zero({ d: 'b', iv: 'b', z: false })) + await clientA1.process( + zero({ compressed: false, d: Buffer.from('a'), iv: Buffer.from('a') }) + ) + await clientB.process( + zero({ compressed: false, d: Buffer.from('b'), iv: Buffer.from('b') }) + ) await clientA1.process(deleteUser({})) await setTimeout(100) diff --git a/web/.browserslistrc b/web/.browserslistrc index 29e1d35c..c952f97c 100644 --- a/web/.browserslistrc +++ b/web/.browserslistrc @@ -1,6 +1,6 @@ # Browsers that we support. See actual browsers: -# https://browsersl.ist/#q=baseline+widely+available+on+2026-07-28+with+downstream +# https://browsersl.ist/#q=baseline+widely+available+on+2026-08-15+with+downstream # # Don’t forget to update links on changes. -baseline widely available on 2026-07-28 with downstream +baseline widely available on 2026-08-15 with downstream diff --git a/web/.size-limit.json b/web/.size-limit.json index a8b9537a..4dd7505e 100644 --- a/web/.size-limit.json +++ b/web/.size-limit.json @@ -8,6 +8,7 @@ "!dist/{404,500}.html", "!dist/icon-*.{png,svg}", "!dist/favicon.ico", + "!dist/assets/benchmark-*.js", "!dist/assets/devtools-*.js", "!dist/assets/*.woff", "!dist/assets/*.map", @@ -16,12 +17,12 @@ "!dist/assets/playpen-sans-*.woff2", "!dist/assets/*-{italic,math,latin-ext}-*.woff2" ], - "limit": "190 KB" + "limit": "720 KB" }, { "name": "Core scripts to execute", "path": "dist/assets/index-*.js", "brotli": false, - "limit": "315 KB" + "limit": "360 KB" } ] diff --git a/web/Dockerfile b/web/Dockerfile index 5e7d8b95..533aae1b 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,6 +1,6 @@ # Web server to serve web client for staging and pull request previews -FROM cgr.dev/chainguard/nginx@sha256:e4ff957080737c90a9ecfeaa40e3d19ea9d687e9cacda2f2a031c75ffcdd72b7 +FROM cgr.dev/chainguard/nginx@sha256:d826cd7cff4e5a8f9477e685696cbddb47c33df1b8dc22c7d581e16ff70e44aa LABEL org.opencontainers.image.source=https://github.com/hplush/slowreader LABEL org.opencontainers.image.description="Web client assets of Slow Reader" diff --git a/web/README.md b/web/README.md index de524356..b44e2c47 100644 --- a/web/README.md +++ b/web/README.md @@ -6,6 +6,7 @@ _See the [full architecture guide](../README.md) first._ - [Tools](#tools) - [Scripts](#scripts) - [Design System](#design-system) +- [DOM Anchors](#dom-anchors) - [Test Strategy](#test-strategy) - [Deploy](#deploy) @@ -21,6 +22,7 @@ We use **[Svelte](https://joyofcode.xyz/learn-svelte)** as the UI framework and - [`index.ts`](./main/index.ts): JS entry point. - [`environment.ts`](./main/environment.ts): how client core should work with browser environment. - [`browser.ts`](./main/browser.ts): connect core stores to global browser settings like `document.title`. +- [`benchmark/`](./benchmark/): UI performance benchmark, which is loaded only by `?benchmark` in URL. See [benchmark guide](../docs/benchmark.md). - [`pages/`](./pages/): Svelte components for pages. - [`ui/`](./ui/): shared components between different pages. Some people call it “UI kit”. - [`public/`](./public/): static files like favicon and manifests. @@ -45,12 +47,13 @@ We use **[Svelte](https://joyofcode.xyz/learn-svelte)** as the UI framework and ## Scripts -- `cd web && pnpm test`: run all web client tests. -- `cd web && pnpm visual`: run visual test server. -- `cd web && pnpm chromatic`: publish visual tests and generate diffs. -- `cd web && pnpm production`: start web client production build locally. -- `cd web && pnpm build`: build production files in `web/dist/`. -- `cd web && pnpm size`: check the JS bundle size of the production build. +- `pnpm -F web test`: run all web client tests. +- `pnpm -F web visual`: run visual test server. +- `pnpm -F web chromatic`: publish visual tests and generate diffs. +- `pnpm -F web production`: start web client production build locally. +- `pnpm -F web build`: build production files in `web/dist/`. +- `pnpm -F web size`: check the JS bundle size of the production build. +- `pnpm -F web benchmark`: run [UI performance benchmark](../docs/benchmark.md). ## Design System @@ -68,6 +71,18 @@ For **icons**, we use [Material Design Icons](https://pictogrammers.com/library/ ``` +## DOM Anchors + +`data-anchor` marks elements for external code: [benchmark](../docs/benchmark.md) scenarios, e2e tests, and user plugins (scripts and user styles). + +```svelte +
+``` + +```js +document.querySelectorAll('[data-anchor="post"]') +``` + ## Test Strategy Since clients don’t have much logic (we moved logic to the client core), we don’t need to use unit tests. diff --git a/web/benchmark/benchmark.ts b/web/benchmark/benchmark.ts new file mode 100644 index 00000000..38039ada --- /dev/null +++ b/web/benchmark/benchmark.ts @@ -0,0 +1,214 @@ +import { signOut } from '@slowreader/core' +import { + BENCHMARK_GIVE_UP, + benchmarkStatistics, + fillBenchmarkData, + type FillStatistics, + getBenchmarkRuns, + mockBenchmarkRequests, + setBenchmarkDebug, + signInBenchmark +} from '@slowreader/core/benchmark' + +import { + measure, + type Measurement, + median, + START_TIMEOUT, + waitIdle +} from './measure.ts' +import { createScenarios } from './scenarios.ts' +import { measureStorage, type StorageSize } from './storage.ts' + +interface ScenarioResults { + domSize: number + duration: number + failed: number + freezes: number + loaders: Record + longestFrame: number + longestTask: number + memory: number +} + +interface BenchmarkTotal { + domSize: number + duration: number + failed: number + freezes: number + loaders: number + longestFrame: number + longestTask: number + memory: number +} + +interface BenchmarkResults { + feeds: number + fill: number + posts: number + scenarios: Record + start: number + storage: { end: StorageSize; start: StorageSize } + total: BenchmarkTotal +} + +function total(scenarios: ScenarioResults[]): BenchmarkTotal { + function sum(cb: (scenario: ScenarioResults) => number): number { + return scenarios.reduce((all, i) => all + cb(i), 0) + } + function max(cb: (scenario: ScenarioResults) => number): number { + return Math.max(0, ...scenarios.map(cb)) + } + return { + domSize: sum(i => i.domSize), + duration: sum(i => i.duration), + failed: sum(i => i.failed), + freezes: sum(i => i.freezes), + loaders: sum(i => Object.values(i.loaders).reduce((all, j) => all + j, 0)), + longestFrame: max(i => i.longestFrame), + longestTask: max(i => i.longestTask), + memory: max(i => i.memory) + } +} + +async function prepare(): Promise { + mockBenchmarkRequests() + await signInBenchmark() + try { + await waitIdle(START_TIMEOUT) + } catch { + throw new Error( + `App did not load database in ${START_TIMEOUT / 1000} seconds` + ) + } +} + +function merge(runs: Measurement[], failed: number): ScenarioResults { + let loaders: Record = {} + for (let measurement of runs) { + for (let loader of measurement.loaders) { + loaders[loader.name] = Math.max(loaders[loader.name] ?? 0, loader.median) + } + } + return { + domSize: median(runs.map(i => i.domSize)), + duration: median(runs.map(i => i.duration)), + failed, + freezes: median(runs.map(i => i.freezes)), + loaders, + longestFrame: Math.max(0, ...runs.map(i => i.longestFrame)), + longestTask: Math.max(0, ...runs.map(i => i.longestTask)), + memory: Math.max(0, ...runs.map(i => i.memory)) + } +} + +async function fill(): Promise { + mockBenchmarkRequests() + await signInBenchmark() + let statistics = await fillBenchmarkData() + return statistics +} + +function clean(): Promise { + return signOut() +} + +function end(): void { + sessionStorage.removeItem('benchmark') + location.reload() +} + +async function waitStart(): Promise { + try { + await waitIdle(START_TIMEOUT) + return Math.round(performance.now()) + } catch { + return 0 + } +} + +setBenchmarkDebug(sessionStorage.getItem('benchmark') === 'debug') + +let startTime = waitStart() + +async function fillOnFirstOpen(): Promise { + if (!benchmarkStatistics.get()) { + await fill() + location.reload() + } +} + +async function run(only?: string): Promise { + let statistics = benchmarkStatistics.get() + if (!statistics) { + throw new Error('Run await benchmark.fill() and reload the page') + } + await prepare() + let storageStart = await measureStorage() + + let scenarios = createScenarios( + statistics.biggestCategory, + statistics.readerFeed, + statistics.slowFeeds + ).filter(i => (only ? i.name === only : i.name !== 'freeze')) + + let list: Record = {} + for (let scenario of scenarios) { + let runs: Measurement[] = [] + let failed = 0 + for (let i = 0; i < getBenchmarkRuns() && failed < BENCHMARK_GIVE_UP; i++) { + try { + await scenario.prepare?.() + runs.push(await measure(() => scenario.run())) + } catch (e) { + failed += 1 + let message = e instanceof Error ? e.message : String(e) + console.error(`${scenario.name} failed: ${message}`) + } + } + let merged = merge(runs, failed) + list[scenario.name] = merged + console.log(`${scenario.name}: ${merged.duration} ms, ${failed} failed`) + } + + window.benchmark.results = { + feeds: statistics.feeds, + fill: Math.round(statistics.duration), + posts: statistics.posts, + scenarios: list, + start: await startTime, + storage: { end: await measureStorage(), start: storageStart }, + total: total(Object.values(list)) + } + return window.benchmark.results +} + +declare global { + interface Window { + benchmark: { + clean(): Promise + data: FillStatistics | undefined + end(): void + fill(): Promise + results: BenchmarkResults | undefined + run(only?: string): Promise + scenarios: string[] + storage(): Promise + } + } +} + +window.benchmark = { + clean, + data: benchmarkStatistics.get(), + end, + fill, + results: undefined, + run, + scenarios: createScenarios('', '', ['', '']) + .map(i => i.name) + .filter(i => i !== 'freeze'), + storage: measureStorage +} + +void fillOnFirstOpen() diff --git a/web/benchmark/measure.ts b/web/benchmark/measure.ts new file mode 100644 index 00000000..6bca47d3 --- /dev/null +++ b/web/benchmark/measure.ts @@ -0,0 +1,189 @@ +import { + getRenderedLoaders, + type LoaderSpan, + recordLoaders +} from '@slowreader/core/benchmark' + +export interface LoaderReport { + count: number + max: number + median: number + name: string +} + +export interface Measurement { + domSize: number + duration: number + freezes: number + loaders: LoaderReport[] + longestFrame: number + longestTask: number + memory: number +} + +const FREEZE = 50 + +const IDLE_TIMEOUT = 60000 + +export const START_TIMEOUT = 600000 + +const CALM = 30 + +// Counting all elements is slow, so we do it only on every 10th frame +const DOM_SAMPLE = 10 + +export function nextFrame(): Promise { + return new Promise(resolve => { + requestAnimationFrame(resolve) + }) +} + +export function median(list: number[]): number { + if (list.length === 0) return 0 + let sorted = list.toSorted((a, b) => a - b) + return Math.round(sorted[Math.floor(sorted.length / 2)]!) +} + +export function percentile(list: number[], share: number): number { + if (list.length === 0) return 0 + let sorted = list.toSorted((a, b) => a - b) + return Math.round( + sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * share))]! + ) +} + +interface FramesReport { + domSize: number + freezes: number + longestFrame: number + memory: number +} + +function domSize(): number { + return document.querySelectorAll('*').length +} + +function trackFrames(): () => FramesReport { + let freezes = 0 + let longest = 0 + let memory = 0 + let nodes = 0 + let counter = 0 + let prev = performance.now() + let stopped = false + + function frame(time: number): void { + if (stopped) return + let gap = time - prev + prev = time + if (gap > longest) longest = gap + if (gap > FREEZE) freezes += 1 + let used = performance.memory?.usedJSHeapSize ?? 0 + if (used > memory) memory = used + counter += 1 + if (counter % DOM_SAMPLE === 0) nodes = Math.max(nodes, domSize()) + requestAnimationFrame(frame) + } + requestAnimationFrame(frame) + + return () => { + stopped = true + return { + domSize: Math.max(nodes, domSize()), + freezes, + longestFrame: Math.round(longest), + memory: Math.round(memory / 1024 / 1024) + } + } +} + +function trackLongTasks(): () => number { + let longest = 0 + let observer: PerformanceObserver | undefined + try { + observer = new PerformanceObserver(list => { + for (let entry of list.getEntries()) { + if (entry.duration > longest) longest = entry.duration + } + }) + observer.observe({ type: 'longtask' }) + } catch { + observer = undefined + } + return () => { + observer?.disconnect() + return Math.round(longest) + } +} + +function reportLoaders(spans: LoaderSpan[]): LoaderReport[] { + let byName = new Map() + for (let span of spans) { + let durations = byName.get(span.name) ?? [] + durations.push((span.end ?? performance.now()) - span.start) + byName.set(span.name, durations) + } + return [...byName.entries()].map(([name, durations]) => ({ + count: durations.length, + max: Math.round(Math.max(...durations)), + median: median(durations), + name + })) +} + +export function isBusy(): boolean { + return getRenderedLoaders().length > 0 || !!document.getElementById('loader') +} + +export async function waitIdle(timeout = IDLE_TIMEOUT): Promise { + let finish = performance.now() + timeout + let ended = performance.now() + let calm = 0 + while (calm < CALM) { + await nextFrame() + if (!isBusy()) { + if (calm === 0) ended = performance.now() + calm += 1 + } else { + calm = 0 + } + if (performance.now() > finish) { + let names = getRenderedLoaders().map(i => i.name) + if (document.getElementById('loader')) names.push('app') + throw new Error( + `Loaders are still rendered after ${timeout} ms: ${names.join(', ')}` + ) + } + } + return ended +} + +export async function measure( + action: () => Promise | void +): Promise { + let stopLoaders = recordLoaders() + let stopFrames = trackFrames() + let stopTasks = trackLongTasks() + let started = performance.now() + let ended: number + let frames: FramesReport + let loaders: LoaderSpan[] + let longestTask: number + try { + await action() + ended = await waitIdle() + } finally { + loaders = stopLoaders() + frames = stopFrames() + longestTask = stopTasks() + } + return { + domSize: frames.domSize, + duration: Math.round(ended - started), + freezes: frames.freezes, + loaders: reportLoaders(loaders), + longestFrame: frames.longestFrame, + longestTask, + memory: frames.memory + } +} diff --git a/web/benchmark/scenarios.ts b/web/benchmark/scenarios.ts new file mode 100644 index 00000000..5532cf58 --- /dev/null +++ b/web/benchmark/scenarios.ts @@ -0,0 +1,199 @@ +import { closeAllPopups } from '@slowreader/core' + +import { getURL, pathRouter } from '../stores/url-router.ts' +import { nextFrame, waitIdle } from './measure.ts' + +export interface Scenario { + name: string + prepare?: () => Promise + run: () => Promise +} + +const ADDED = 'slowreader:benchmark-added' + +function nextFeedUrl(): string { + let count = Number(localStorage.getItem(ADDED) ?? 0) + 1 + localStorage.setItem(ADDED, String(count)) + return `https://example.com/new-feed-${count}.xml` +} + +const WAIT_TIMEOUT = 30000 + +const FREEZE_TEST = 200 + +function byAnchor(anchor: string): string { + return `[data-anchor="${anchor}"]` +} + +async function waitFor( + selector: string +): Promise { + let finish = performance.now() + WAIT_TIMEOUT + let element = document.querySelector(selector) + while (!element) { + if (performance.now() > finish) { + throw new Error(`No ${selector} on the page`) + } + await nextFrame() + element = document.querySelector(selector) + } + return element +} + +async function click(selector: string, index = 0): Promise { + await waitFor(selector) + let elements = document.querySelectorAll(selector) + let element = elements[index] + if (!element) throw new Error(`No ${selector} number ${index} on the page`) + element.click() +} + +async function open(url: string): Promise { + closeAllPopups() + pathRouter.open(url) + await waitIdle() +} + +async function typeIn(selector: string, text: string): Promise { + let input = await waitFor(selector) + input.value = text + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function slowPage(feed: string, from?: number): string { + return getURL({ params: { feed, from }, route: 'slow' }) +} + +function fastPage(category: string): string { + return getURL({ params: { category, from: undefined }, route: 'fast' }) +} + +export function createScenarios( + category: string, + readerFeed: string, + slowFeeds: string[] +): Scenario[] { + let feed = slowFeeds[0]! + return [ + { + name: 'open-slow-feed', + async prepare() { + await open(getURL('about')) + }, + async run() { + await open(slowPage(feed)) + } + }, + { + name: 'open-fast-category', + async prepare() { + await open(getURL('about')) + }, + async run() { + await open(fastPage(category)) + } + }, + { + name: 'paginate', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + await click(byAnchor('next-page')) + } + }, + { + name: 'switch-reader', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + await click(byAnchor('reader-feed')) + await waitIdle() + await click(byAnchor('reader-list')) + } + }, + { + name: 'open-post', + async prepare() { + closeAllPopups() + await open(slowPage(feed, 0)) + }, + async run() { + await click(byAnchor('post')) + await waitFor(byAnchor('popup')) + } + }, + { + name: 'menu-navigation', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + for (let id of slowFeeds) { + await open(slowPage(id)) + } + await open(fastPage(category)) + await open(getURL('feedsByCategories')) + } + }, + { + name: 'feeds-by-categories', + async prepare() { + await open(getURL('about')) + }, + async run() { + await open(getURL('feedsByCategories')) + } + }, + { + name: 'read-page', + async prepare() { + await open(slowPage(readerFeed)) + }, + async run() { + await click(byAnchor('read-page')) + } + }, + { + name: 'add-feed', + async prepare() { + closeAllPopups() + await open( + getURL({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + ) + }, + async run() { + let url = nextFeedUrl() + await typeIn(byAnchor('add-url'), url) + await click(`${byAnchor('feed')}[href*="${encodeURIComponent(url)}"]`) + await click(byAnchor('add-feed')) + } + }, + { + name: 'refresh', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + await click(byAnchor('refresh')) + } + }, + { + name: 'freeze', + async prepare() { + await open(getURL('about')) + }, + run() { + let finish = Date.now() + FREEZE_TEST + while (Date.now() < finish) { + document.body.getBoundingClientRect() + } + return Promise.resolve() + } + } + ] +} diff --git a/web/benchmark/storage.ts b/web/benchmark/storage.ts new file mode 100644 index 00000000..b3140402 --- /dev/null +++ b/web/benchmark/storage.ts @@ -0,0 +1,43 @@ +export interface StorageSize { + indexedDB: number + localStorage: number + opfs: number + total: number +} + +async function opfsSize(dir: FileSystemDirectoryHandle): Promise { + let size = 0 + for await (let handle of dir.values()) { + if (handle.kind === 'file') { + size += (await handle.getFile()).size + } else { + size += await opfsSize(handle) + } + } + return size +} + +function localStorageSize(): number { + let size = 0 + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i)! + size += (key.length + (localStorage.getItem(key) ?? '').length) * 2 + } + return size +} + +export async function measureStorage(): Promise { + let estimate = await navigator.storage.estimate() + let opfs = 0 + try { + opfs = await opfsSize(await navigator.storage.getDirectory()) + } catch { + // OPFS is not supported + } + return { + indexedDB: estimate.usageDetails?.indexedDB ?? 0, + localStorage: localStorageSize(), + opfs, + total: estimate.usage ?? 0 + } +} diff --git a/web/global.d.ts b/web/global.d.ts index 54827e3a..b0de2991 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -20,6 +20,22 @@ interface Navigator { | undefined } +interface Performance { + memory: + | { + usedJSHeapSize: number + } + | undefined +} + +interface StorageEstimate { + usageDetails: + | { + indexedDB?: number + } + | undefined +} + declare module '*.avif' declare module '*.png' diff --git a/web/main/browser.ts b/web/main/browser.ts index 624f2871..9d994392 100644 --- a/web/main/browser.ts +++ b/web/main/browser.ts @@ -100,6 +100,14 @@ window.addEventListener('load', () => { void import('./devtools.ts').then(() => {}) }) +if (location.search.includes('benchmark')) { + let debug = location.search.includes('benchmark=debug') + sessionStorage.setItem('benchmark', debug ? 'debug' : '1') +} +if (sessionStorage.getItem('benchmark')) { + void import('../benchmark/benchmark.ts').then(() => {}) +} + startKeyUX(window, [ pressKeyUX('is-pseudo-active'), focusGroupKeyUX(), diff --git a/web/main/environment.ts b/web/main/environment.ts index c3092568..06bc7ccb 100644 --- a/web/main/environment.ts +++ b/web/main/environment.ts @@ -1,7 +1,8 @@ // Dependency Injection of unique behavior for web client. -import { IndexedStore } from '@logux/client' import { windowPersistentEvents } from '@nanostores/persistent' +import { openDb } from '@nanostores/sql' +import { sqlocalDriver } from '@nanostores/sql/sqlocal' import { type NetworkType, type NetworkTypeDetector, @@ -68,13 +69,15 @@ setupEnvironment({ cleanStorage() { localStorage.clear() }, + databaseCreator() { + return openDb(sqlocalDriver('slowreader.sqlite')) + }, errorEvents: window, getSession() { // Browser will use session from http-only cookie return undefined }, locale, - logStoreCreator: () => new IndexedStore(), networkType: detectNetworkType, openRoute, persistentEvents: windowPersistentEvents, diff --git a/web/main/index.ts b/web/main/index.ts index 06677135..bc95fa52 100644 --- a/web/main/index.ts +++ b/web/main/index.ts @@ -1,3 +1,4 @@ +import './trusted-types.ts' import './environment.ts' import './browser.ts' import './updater.ts' diff --git a/web/main/trusted-types.ts b/web/main/trusted-types.ts new file mode 100644 index 00000000..29737c4e --- /dev/null +++ b/web/main/trusted-types.ts @@ -0,0 +1,13 @@ +// SQLocal creates Web Worker for SQLite database by itself, so we can’t wrap +// worker’s URL into own Trusted Types policy. Default policy allows scripts +// only from our own origin. +if (window.trustedTypes && !window.trustedTypes.defaultPolicy) { + window.trustedTypes.createPolicy('default', { + createScriptURL(url) { + if (new URL(url, location.href).origin !== location.origin) { + throw new Error(`Blocked script from ${url}`) + } + return url + } + }) +} diff --git a/web/nginx.conf b/web/nginx.conf index 688684af..94c97a77 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -37,7 +37,10 @@ http { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header X-Content-Type-Options "nosniff"; - add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'; require-trusted-types-for 'script'; trusted-types dompurify slowreader-rich svelte-trusted-html slowreader-parse"; + # SQLite in OPFS needs cross-origin isolation to save the database + add_header Cross-Origin-Opener-Policy "same-origin"; + add_header Cross-Origin-Embedder-Policy "credentialless"; + add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'wasm-unsafe-eval' 'self'; require-trusted-types-for 'script'; trusted-types default dompurify slowreader-rich svelte-trusted-html slowreader-parse"; location ~* __ROUTES__ { try_files /index.html =404; diff --git a/web/package.json b/web/package.json index 49d40720..cb9f59f5 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "test": "pnpm run /^test:/", "build": "pnpm run /^build:/ && mv ./storybook-static ./dist/ui", "start": "vite --host 0.0.0.0 --port 2553", + "benchmark": "node ./scripts/benchmark.ts", "visual": "storybook dev -p 2555 --no-open --host 0.0.0.0", "chromatic": "node --env-file-if-exists=../.env node_modules/chromatic/dist/bin.cjs --exit-zero-on-changes --skip-update-check --exit-once-uploaded", "production": "node --run build && ./scripts/run-image.sh", @@ -14,7 +15,7 @@ "test:css": "stylelint .", "test:svelte-types": "svelte-check --tsgo", "test:build": "node --run build && size-limit && node ./scripts/check-css-props.ts", - "build:routes": "tsx ./scripts/export-routes.ts", + "build:routes": "node ./scripts/export-routes.ts", "build:visual": "NODE_ENV=test storybook build --loglevel error", "build:web": "vite build && node scripts/generate-csp.ts", "format:css": "stylelint --fix .", @@ -27,53 +28,54 @@ "@fontsource-variable/noto-serif": "5.3.0", "@fontsource/inika": "5.3.0", "@fontsource/playpen-sans": "5.3.0", - "@logux/client": "0.23.2", - "@logux/core": "0.10.0", + "@logux/client": "github:logux/client#next", + "@logux/core": "github:logux/core#next", "@mdi/js": "7.4.47", "@nanostores/i18n": "1.3.3", "@nanostores/media-query": "0.1.2", "@nanostores/persistent": "1.3.5", "@nanostores/router": "1.1.0", + "@nanostores/sql": "0.4.1", "@slowreader/core": "workspace:*", - "@storybook/addon-a11y": "10.5.5", + "@storybook/addon-a11y": "10.5.8", "@storybook/addon-svelte-csf": "5.1.2", - "@storybook/addon-themes": "10.5.5", - "@storybook/svelte": "10.5.5", - "@storybook/svelte-vite": "10.5.5", - "@sveltejs/vite-plugin-svelte": "7.2.0", + "@storybook/addon-themes": "10.5.8", + "@storybook/svelte": "10.5.8", + "@storybook/svelte-vite": "10.5.8", + "@sveltejs/vite-plugin-svelte": "7.3.0", "autoprefixer": "10.5.4", "keyux": "0.11.6", "lightningcss": "1.33.0", - "nanostores": "1.4.1", - "postcss": "8.5.25", + "nanostores": "1.4.2", + "postcss": "8.5.26", "postcss-load-config": "6.0.1", "postcss-mixins": "12.1.2", "postcss-smooth-shadow": "0.3.2", - "rollup": "4.62.3", - "storybook": "10.5.5", - "svelte": "5.56.8", - "tsx": "4.23.1", - "vite": "8.1.5" + "rollup": "4.62.4", + "sqlocal": "0.18.0", + "storybook": "10.5.8", + "svelte": "5.56.9", + "vite": "8.2.1" }, "devDependencies": { "@logux/stylelint-config": "1.0.1", - "@size-limit/file": "13.0.2", + "@size-limit/file": "13.0.3", "@stylistic/stylelint-config": "5.0.0", - "@stylistic/stylelint-plugin": "5.2.1", + "@stylistic/stylelint-plugin": "5.3.0", "@types/trusted-types": "2.0.7", - "browserslist": "4.28.7", - "chromatic": "18.1.0", - "eslint": "10.8.0", - "postcss-html": "1.8.1", + "browserslist": "4.28.8", + "chromatic": "18.2.0", + "eslint": "10.8.1", + "postcss-html": "2.0.0", "postcss-nesting": "14.0.1", - "postcss-selector-parser": "7.1.4", - "size-limit": "13.0.2", + "postcss-selector-parser": "7.1.5", + "size-limit": "13.0.3", "stylelint": "17.14.1", "stylelint-use-logical": "2.1.3", - "svelte-check": "4.7.4" + "svelte-check": "4.7.6" }, "engines": { - "node": "^26.5.0", - "pnpm": "^11.0.0" + "node": "^26.7.0", + "pnpm": "^12.0.0-rc.6" } } diff --git a/web/pages/add.svelte b/web/pages/add.svelte index 4cbdccb7..33f47579 100644 --- a/web/pages/add.svelte +++ b/web/pages/add.svelte @@ -33,6 +33,7 @@ {:else if $searching} - + {:else if $error} {$t[$error]} {:else if $noResults} diff --git a/web/pages/busy.svelte b/web/pages/busy.svelte index 1b1374c7..ad6df280 100644 --- a/web/pages/busy.svelte +++ b/web/pages/busy.svelte @@ -1,8 +1,16 @@ - - - + + + diff --git a/web/pages/feeds/feed.svelte b/web/pages/feeds/feed.svelte index a55d953a..34057bfa 100644 --- a/web/pages/feeds/feed.svelte +++ b/web/pages/feeds/feed.svelte @@ -23,6 +23,7 @@ {/if} {:else} @@ -101,7 +106,7 @@ {#if $posts.isLoading} - + {:else} {#if $posts.error} diff --git a/web/popups/loading.svelte b/web/popups/loading.svelte index 65d3aeff..4fd42746 100644 --- a/web/popups/loading.svelte +++ b/web/popups/loading.svelte @@ -10,6 +10,6 @@ - + diff --git a/web/popups/refresh.svelte b/web/popups/refresh.svelte index 4534cff0..2b80f38a 100644 --- a/web/popups/refresh.svelte +++ b/web/popups/refresh.svelte @@ -61,6 +61,7 @@ diff --git a/web/scripts/benchmark.ts b/web/scripts/benchmark.ts new file mode 100644 index 00000000..6a576a83 --- /dev/null +++ b/web/scripts/benchmark.ts @@ -0,0 +1,561 @@ +// Script to run UI benchmark in Chromium and print results. +// See docs/benchmark.md + +import { spawn, spawnSync } from 'node:child_process' +import { globSync, readdirSync, readFileSync, rmSync, writeSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + error, + finish, + printAboveProgress, + semiSuccess, + startProgress, + success, + warning +} from '../../scripts/progress.ts' + +const ROOT = join(import.meta.dirname, '..') + +const PROFILE = join(tmpdir(), 'slowreader-benchmark') + +const PORT = 2557 + +const FILL_ATTEMPTS = 900 + +const PEAK_INTERVAL = 500 + +const CLOSE_TIMEOUT = 5000 + +interface FillStatistics { + debug: boolean + duration: number + feeds: number + posts: number +} + +interface StorageSize { + indexedDB: number + localStorage: number + opfs: number + total: number +} + +interface Peaks { + documents: number + listeners: number + renderer: number +} + +interface BenchmarkResults { + feeds: number + fill: number + posts: number + scenarios: Record> + start: number + storage: { end: StorageSize; start: StorageSize } + total: Record +} + +const FLAGS = ['--clean', '--dev', '--help', '--json', '-h'] + +const OPTIONS = ['--scenario', '--url'] + +let args = process.argv.slice(2) + +function option(name: string): string | undefined { + let index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] ?? '' +} + +let debug = process.env.DEBUG === '1' +let dev = args.includes('--dev') +let clean = args.includes('--clean') +let json = args.includes('--json') +let scenario = option('--scenario') +let url = option('--url') + +const HELP = `Run UI benchmark in Chromium and print the report. + +Usage: + pnpm -F web benchmark [options] + +Options: + --clean Remove browser profile with database before the run + --scenario NAME Run only this scenario + --json Print full report as JSON instead of total numbers + --dev Use Vite dev server instead of production build + --url URL Use already running server instead of starting new one + --help Show this help + +Environment: + DEBUG=1 Small database and single run of every scenario + CHROMIUM_PATH Path to Chromium binary instead of downloaded one` + +if (args.includes('--help') || args.includes('-h')) { + process.stdout.write(`${HELP}\n`) + process.exit(0) +} + +function print(message: string): void { + writeSync(1, `${message}\n`) +} + +function status(message: string): void { + if (json) return + printAboveProgress(message, 'status') +} + +function fail(message: string): never { + error(message) + finish('Benchmark failed') + process.exit(1) +} + +for (let i = 0; i < args.length; i++) { + let arg = args[i]! + if (OPTIONS.includes(arg)) { + let value = args[i + 1] + if (!value || value.startsWith('-')) fail(`${arg} needs a value`) + i += 1 + } else if (!FLAGS.includes(arg)) { + fail(`Unknown argument ${arg}\n\n${HELP}`) + } +} + +function installedChromium(): string | undefined { + return globSync( + join(homedir(), '.cache/ms-playwright/chromium-*/chrome-linux*/chrome') + ) + .toSorted() + .pop() +} + +function findChromium(): string { + if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH + let installed = installedChromium() + if (installed) return installed + status('Downloading Chromium…') + let install = spawnSync( + join(ROOT, '../node_modules/.bin/playwright-mcp'), + ['install-browser', 'chromium', '--no-shell'], + { stdio: json ? 'ignore' : 'inherit' } + ) + if (install.status !== 0) fail('Chromium install failed. Set CHROMIUM_PATH') + let binary = installedChromium() + if (!binary) fail('No Chromium after install. Set CHROMIUM_PATH') + return binary +} + +async function waitServer(address: string): Promise { + for (let i = 0; i < 300; i++) { + try { + await fetch(address) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 200)) + } + } + fail(`Server ${address} did not start`) +} + +async function startServer(): Promise<[string, () => void]> { + if (url) return [url, () => {}] + let address = `http://localhost:${PORT}` + try { + await fetch(address) + fail(`Port ${PORT} is busy. Stop that server or use --url`) + } catch (e) { + if (e instanceof Error && e.message.includes('busy')) throw e + } + + let serverArgs = ['--port', String(PORT), '--strictPort'] + if (!dev) { + status('Building app…') + let build = spawnSync(join(ROOT, 'node_modules/.bin/vite'), ['build'], { + cwd: ROOT, + stdio: 'ignore' + }) + if (build.status !== 0) fail('Vite build failed') + serverArgs.unshift('preview') + } + let child = spawn(join(ROOT, 'node_modules/.bin/vite'), serverArgs, { + cwd: ROOT, + stdio: 'ignore' + }) + + await waitServer(address) + return [ + address, + () => { + child.kill() + } + ] +} + +class Chromium { + private closed = false + + private handlers = new Map< + number, + { reject: (error: Error) => void; resolve: (result: unknown) => void } + >() + + private last = 0 + + private session = '' + + private socket: WebSocket + + private constructor(socket: WebSocket) { + this.socket = socket + socket.addEventListener('message', event => { + if (this.closed) return + let message = JSON.parse(String(event.data)) as { + error?: { message: string } + id?: number + method?: string + params?: Record + result?: unknown + } + if (message.id) { + let handler = this.handlers.get(message.id) + this.handlers.delete(message.id) + if (message.error) { + handler?.reject(new Error(`Chromium: ${message.error.message}`)) + } else { + handler?.resolve(message.result) + } + } else if (message.method === 'Page.javascriptDialogOpening') { + void this.send('Page.handleJavaScriptDialog', { accept: true }) + } else if (message.method === 'Runtime.consoleAPICalled') { + let params = message.params as { + args: { description?: string; value?: string }[] + type: string + } + let text = params.args + .map(i => i.value ?? i.description ?? '') + .join(' ') + let scenarioLine = text.match(/^(\S+): (\d+ ms), (\d+) failed$/) + if (params.type === 'error') { + error(text) + } else if (json) { + // Only errors in JSON mode + } else if (scenarioLine) { + let [, name, time, failed] = scenarioLine + if (failed === '0') { + success(name!, time) + } else if (time === '0 ms') { + warning(`${name} failed in every run`) + } else { + semiSuccess(name!, `${failed} failed`) + } + } else { + status(text) + } + } + }) + } + + static async start(binary: string): Promise { + let child = spawn(binary, [ + '--headless=new', + '--remote-debugging-port=0', + `--user-data-dir=${PROFILE}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-gpu', + '--disable-dev-shm-usage', + '--hide-scrollbars', + '--window-size=1280,900', + '--enable-precise-memory-info' + ]) + let endpoint = await new Promise(resolve => { + child.stderr.on('data', (chunk: Buffer) => { + let match = chunk.toString().match(/ws:\/\/\S+/) + if (match) resolve(match[0]) + }) + }) + let socket = new WebSocket(endpoint) + await new Promise(resolve => { + socket.addEventListener('open', resolve, { once: true }) + }) + let chromium = new Chromium(socket) + chromium.kill = () => { + child.kill() + } + chromium.exit = () => { + if (child.exitCode !== null) return Promise.resolve() + return new Promise(resolve => { + child.once('exit', () => { + resolve() + }) + }) + } + return chromium + } + + exit: () => Promise = () => Promise.resolve() + + kill: () => void = () => {} + + close(): void { + this.closed = true + this.socket.close() + this.kill() + } + + async closeSaving(): Promise { + this.session = '' + function timeout(): Promise { + return new Promise(resolve => { + setTimeout(resolve, CLOSE_TIMEOUT) + }) + } + await Promise.race([this.send('Browser.close', {}), timeout()]) + await Promise.race([this.exit(), timeout()]) + this.close() + } + + async evaluate(expression: string): Promise { + let result = (await this.send('Runtime.evaluate', { + awaitPromise: true, + expression, + returnByValue: true + })) as { + exceptionDetails?: { exception?: { description?: string } } + result: { value: Value } + } + if (result.exceptionDetails) { + let details = result.exceptionDetails.exception?.description ?? '' + throw new Error(`Page: ${details.split('\n')[0]}`) + } + return result.result.value + } + + async metrics(): Promise> { + let result = (await this.send('Performance.getMetrics', {})) as { + metrics: { name: string; value: number }[] + } + let values: Record = {} + for (let metric of result.metrics) values[metric.name] = metric.value + return values + } + + async open(address: string): Promise { + let target = (await this.send('Target.createTarget', { + url: 'about:blank' + })) as { targetId: string } + let attached = (await this.send('Target.attachToTarget', { + flatten: true, + targetId: target.targetId + })) as { sessionId: string } + this.session = attached.sessionId + await this.send('Page.enable', {}) + await this.send('Runtime.enable', {}) + await this.send('Performance.enable', {}) + await this.send('Page.navigate', { url: address }) + await this.waitApi() + } + + async reload(): Promise { + await this.send('Page.reload', {}) + await this.waitApi() + } + + async waitFilled(): Promise { + for (let i = 0; i < FILL_ATTEMPTS; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)) + try { + if (await this.evaluate('!!window.benchmark?.data')) return + } catch { + continue + } + } + fail('Database was not filled') + } + + private send(method: string, params: object): Promise { + this.last += 1 + let id = this.last + let message: Record = { id, method, params } + if (this.session) message.sessionId = this.session + this.socket.send(JSON.stringify(message)) + return new Promise((resolve, reject) => { + this.handlers.set(id, { reject, resolve }) + }) + } + + private async waitApi(): Promise { + for (let i = 0; i < 300; i++) { + try { + if (await this.evaluate('!!window.benchmark')) return + } catch { + // Page is still navigating + } + await new Promise(resolve => setTimeout(resolve, 200)) + } + fail('Benchmark API did not load') + } +} + +// performance.memory shows only JS heap of the main isolate. Renderer is +// killed by the whole process memory: DOM, IndexedDB, images, workers. +function rendererMemory(): number { + let total = 0 + let processes: string[] + try { + processes = readdirSync('/proc') + } catch { + return 0 + } + for (let pid of processes) { + try { + let cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8') + if (!cmdline.includes(PROFILE)) continue + if (!cmdline.includes('--type=renderer')) continue + let used = readFileSync(`/proc/${pid}/status`, 'utf8').match( + /VmRSS:\s+(\d+) kB/ + ) + if (used) total += Number(used[1]) * 1024 + } catch { + // Process is already dead or is not a process directory + } + } + return total +} + +function trackPeaks(browser: Chromium): () => Promise { + let peaks: Peaks = { documents: 0, listeners: 0, renderer: 0 } + + async function sample(): Promise { + try { + let metrics = await browser.metrics() + peaks.documents = Math.max(peaks.documents, metrics.Documents ?? 0) + peaks.listeners = Math.max(peaks.listeners, metrics.JSEventListeners ?? 0) + } catch { + // Page is navigating or browser was closed + } + peaks.renderer = Math.max(peaks.renderer, rendererMemory()) + } + + let sampling = Promise.resolve() + let busy = false + let timer = setInterval(() => { + if (busy) return + busy = true + sampling = sample().finally(() => { + busy = false + }) + }, PEAK_INTERVAL) + + return async () => { + clearInterval(timer) + await sampling + return peaks + } +} + +function size(bytes: number): string { + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${Math.round(bytes / 1024 / 1024)} MB` +} + +let [address, stopServer] = await startServer() +if (clean) rmSync(PROFILE, { force: true, recursive: true }) +let binary = findChromium() +let chromium = await Chromium.start(binary) +let stopPeaks: (() => Promise) | undefined + +try { + if (debug) warning('Debug mode: small database and single run of scenarios') + let page = `${address}/?benchmark${debug ? '=debug' : ''}` + await chromium.open(page) + + if (!(await chromium.evaluate('!!window.benchmark.data'))) { + status('Filling database, it will take a while…') + await chromium.waitFilled() + status('Restarting browser to forget memory used by filling') + await chromium.closeSaving() + chromium = await Chromium.start(binary) + await chromium.open(page) + } + stopPeaks = trackPeaks(chromium) + + let saved = await chromium.evaluate('window.benchmark.storage()') + if (saved.opfs === 0) { + throw new Error( + 'Database was not saved to OPFS. Server should send ' + + 'Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers' + ) + } + + let data = await chromium.evaluate('window.benchmark.data') + status( + `Data: ${data.feeds} feeds, ${data.posts} posts, ` + + `filled in ${Math.round(data.duration)} ms` + ) + if (data.debug !== debug) { + warning( + `Database was filled in ${data.debug ? 'debug' : 'normal'} mode. ` + + 'Use --clean to fill it again' + ) + } + + let names = await chromium.evaluate('window.benchmark.scenarios') + if (scenario && scenario !== 'freeze' && !names.includes(scenario)) { + throw new Error(`Unknown scenario ${scenario}`) + } + if (!json) { + startProgress(scenario ? 1 : names.length) + } + let results = await chromium.evaluate( + scenario + ? `window.benchmark.run(${JSON.stringify(scenario)})` + : 'window.benchmark.run()' + ) + let peaks = await stopPeaks() + stopPeaks = undefined + results.total.documents = peaks.documents + results.total.listeners = peaks.listeners + results.total.renderer = Math.round(peaks.renderer / 1024 / 1024) + + if (json) { + print(JSON.stringify(results, null, 2)) + } else { + let storage = results.storage.end + let growth = storage.total - results.storage.start.total + print('\n') + print(`Duration: ${results.total.duration} ms (sum)`) + print(`Loaders: ${results.total.loaders} ms (sum)`) + print(`Freezes: ${results.total.freezes} (sum)`) + print(`Longest frame: ${results.total.longestFrame} ms (max)`) + print(`Longest task: ${results.total.longestTask} ms (max)`) + print(`DOM size: ${results.total.domSize} nodes (sum)`) + print(`Documents: ${results.total.documents} (max)`) + print(`Listeners: ${results.total.listeners} (max)`) + print(`JS heap: ${results.total.memory} MB (max)`) + print(`Renderer memory: ${results.total.renderer} MB (max)`) + print( + `Storage: ${size(storage.total)} ` + + `(IndexedDB ${size(storage.indexedDB)}, ` + + `OPFS ${size(storage.opfs)}, ` + + `localStorage ${size(storage.localStorage)})` + ) + print(`Storage growth: ${size(growth)} during scenarios`) + print(`Failed: ${results.total.failed} (sum)`) + } +} catch (e) { + void stopPeaks?.() + await chromium.closeSaving() + stopServer() + fail(e instanceof Error ? e.message : String(e)) +} finally { + void stopPeaks?.() + await chromium.closeSaving() + stopServer() +} + +if (!json) finish('Benchmark finished') diff --git a/web/stores/url-router.ts b/web/stores/url-router.ts index 1735261a..9208d2a6 100644 --- a/web/stores/url-router.ts +++ b/web/stores/url-router.ts @@ -93,10 +93,9 @@ export const urlRouter = computed(pathRouter, path => { function moveToSearch( page: Page, move: { - [key in Exclude< - keyof Page['params'], - keyof PathParams[Page['route']] - >]: true + [ + key in Exclude + ]: true } ): string { let search = {} diff --git a/web/stories/environment.ts b/web/stories/environment.ts index 10e29a46..1d55fbf2 100644 --- a/web/stories/environment.ts +++ b/web/stories/environment.ts @@ -1,3 +1,5 @@ +import { openDb } from '@nanostores/sql' +import { sqlocalDriver } from '@nanostores/sql/sqlocal' import { type BaseRoute, type NetworkTypeDetector, @@ -46,6 +48,7 @@ effect([mobileMedia, tabletMedia], (mobile, tablet) => { setupEnvironment({ ...getTestEnvironment(), baseRouter, + databaseCreator: () => openDb(sqlocalDriver(':memory:')), errorEvents: window, getSession() { return undefined diff --git a/web/stories/pages/busy.stories.svelte b/web/stories/pages/busy.stories.svelte index f494a2b3..898d187a 100644 --- a/web/stories/pages/busy.stories.svelte +++ b/web/stories/pages/busy.stories.svelte @@ -1,7 +1,9 @@ - + + { + busy.set({ label: '' }) + }} + > + + + + + + { + busy.set({ label: 'Creating database' }) + }} + > + + + + + + { + busy.set({ + label: 'Creating benchmark data', + progress: 0.72 + }) + }} + > + + + + + + { + busy.set({ + label: + 'Creating benchmark data with a very long description, ' + + 'which should be wrapped to a few lines on any screen', + progress: 0.72 + }) + }} + > + + + diff --git a/web/stories/pages/feeds.stories.svelte b/web/stories/pages/feeds.stories.svelte index 0da24a3f..a72c9081 100644 --- a/web/stories/pages/feeds.stories.svelte +++ b/web/stories/pages/feeds.stories.svelte @@ -141,8 +141,8 @@ feeds={[{ id: 'feed', reading: 'slow' }]} oninit={() => { setTimeout(() => { - changePost('post-2', { read: true }) - changePost('post-3', { read: true }) + changePost('post-2', { read: 1 }) + changePost('post-3', { read: 1 }) }, 1) }} posts={POSTS} @@ -184,8 +184,8 @@ feeds={[{ id: 'feed', reading: 'fast' }]} oninit={() => { setTimeout(() => { - changePost('post-2', { read: true }) - changePost('post-3', { read: true }) + changePost('post-2', { read: 1 }) + changePost('post-3', { read: 1 }) }, 1) }} posts={POSTS.map(i => ({ ...i, reading: 'fast' }))} diff --git a/web/stories/scene.svelte b/web/stories/scene.svelte index 576ce2c0..9c057e22 100644 --- a/web/stories/scene.svelte +++ b/web/stories/scene.svelte @@ -4,20 +4,17 @@ addFeed, addPost, type BaseRoute, - Category, - type CategoryValue, + cleanDatabase, client, closedCategories, currentPage, DEFAULT_REFRESH_STATISTICS, - Feed, type FeedValue, - Filter, hasPassword, needWelcome, + type NewCategory, pages, type ParamlessRouteName, - Post, type PostValue, refreshErrors, refreshStatistics, @@ -32,7 +29,6 @@ useReducedMotion } from '@slowreader/core' import { addHashToBaseRoute, testCredentials } from '@slowreader/core/test' - import { cleanStores } from 'nanostores' import { onDestroy, type Snippet } from 'svelte' import { systemReducedMotion } from '../stores/media-queries.ts' @@ -52,7 +48,7 @@ route, user = true }: { - categories?: CategoryValue[] + categories?: NewCategory[] children: Snippet feeds?: Partial[] oninit?: () => void @@ -62,9 +58,29 @@ user?: boolean } = $props() - function cleanLogux(): void { - client.get()?.clean() - cleanStores(Feed, Filter, Category, Post) + async function fillScene(): Promise { + // Waits for the database too, so the app will not reset `busy` + // and other stores, which the story sets in `oninit()` + await cleanDatabase() + for (let category of categories ?? []) { + await addCategory(category) + } + for (let feed of feeds ?? []) { + await addFeed(testFeed(feed)) + } + for (let post of posts ?? []) { + await addPost(testPost(post)) + } + + oninit() + + if (typeof route === 'string') { + baseRouter.set({ hash: '', params: {}, route }) + } else { + baseRouter.set( + addHashToBaseRoute(route) ?? { hash: '', params: {}, route: 'slow' } + ) + } } let unbindSyncStatus = syncStatus.listen(() => {}) @@ -77,7 +93,6 @@ } else if (client.get()) { signOut() } - cleanLogux() prepareResponses(responses) stopRefreshing() refreshStatus.set('start') @@ -111,31 +126,12 @@ htmlObserver.disconnect() }) - for (let category of categories ?? []) { - addCategory(category) - } - for (let feed of feeds ?? []) { - addFeed(testFeed(feed)) - } - for (let post of posts ?? []) { - addPost(testPost(post)) - } - - oninit() - - if (typeof route === 'string') { - baseRouter.set({ hash: '', params: {}, route }) - } else { - baseRouter.set( - addHashToBaseRoute(route) ?? { hash: '', params: {}, route: 'slow' } - ) - } + fillScene() }) onDestroy(() => { unbindSyncStatus() baseRouter.set({ hash: '', params: {}, route: 'slow' }) - cleanLogux() for (let page of Object.values(pages)) { if (page.cache) page.cache = undefined } diff --git a/web/tsconfig.json b/web/tsconfig.json index 54440205..c64e9775 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -3,9 +3,15 @@ "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", - "lib": ["ES2024", "DOM", "DOM.Iterable"], + "lib": ["ES2024", "DOM", "DOM.AsyncIterable", "DOM.Iterable"], "types": ["trusted-types"] }, - "include": ["**/*.ts", "**/*.cts", "**/*.svelte", ".storybook/*.ts"], + "include": [ + "../scripts/progress.ts", + "**/*.ts", + "**/*.cts", + "**/*.svelte", + ".storybook/*.ts" + ], "exclude": ["dist", "storybook-static"] } diff --git a/web/ui/button.svelte b/web/ui/button.svelte index ba700c2e..6b54ccff 100644 --- a/web/ui/button.svelte +++ b/web/ui/button.svelte @@ -30,6 +30,7 @@ import Loader from './loader.svelte' let { + anchor, children, disabled, icon, @@ -43,6 +44,7 @@ | ({ href: string } & HTMLAnchorAttributes) | ({ href?: undefined } & HTMLButtonAttributes) ) & { + anchor?: string children: Snippet disabled?: boolean icon?: string @@ -76,6 +78,7 @@ 'is-secondary-dangerous': variant === 'secondary-dangerous', 'is-wide': size === 'wide' || size === 'big' }} + data-anchor={anchor ?? null} disabled={!!loader || !!disabled} {onclick} title={size === 'icon' ? title : undefined} @@ -84,6 +87,7 @@ {#if loader} diff --git a/web/ui/captioned.svelte b/web/ui/captioned.svelte new file mode 100644 index 00000000..8d9fb223 --- /dev/null +++ b/web/ui/captioned.svelte @@ -0,0 +1,51 @@ + + +
+
+ {@render children()} +
+ {#if caption} +

+ {caption} +

+ {/if} +
+ + diff --git a/web/ui/feed/index.svelte b/web/ui/feed/index.svelte index fc2e9b36..4ef4d55b 100644 --- a/web/ui/feed/index.svelte +++ b/web/ui/feed/index.svelte @@ -1,5 +1,4 @@
    - {#each posts as post (post.get().id)} - + {#each posts as post (post.id)} + {/each}
diff --git a/web/ui/feed/post.svelte b/web/ui/feed/post.svelte index 1447ab6d..ba371421 100644 --- a/web/ui/feed/post.svelte +++ b/web/ui/feed/post.svelte @@ -1,5 +1,4 @@
  • -
    +
    {#if author} {/if} - {#if $post.title} + {#if post.title}

    - +

    {/if} - - {#each parseMedia($post.media) as media, index (`${media.url}${index}`)} + + {#each parseMedia(post.media) as media, index (`${media.url}${index}`)} {#if !media.fromText && media.type.startsWith('image')} {/if} {/each}