From cd2b9006397d324f2c618f2e3c9675452c3a4a18 Mon Sep 17 00:00:00 2001 From: Robert Goodall Date: Fri, 24 Jul 2026 13:47:53 -0400 Subject: [PATCH] Initial commit: Nosterm client (terminal-style Nostr chat SPA) --- .dockerignore | 10 + .env.example | 36 + .gitea/PULL_REQUEST_TEMPLATE.md | 11 + .gitea/workflows/ci.yml | 110 + .gitignore | 17 + .npmrc | 1 + .prettierignore | 7 + .prettierrc | 8 + CONTRIBUTING.md | 47 + Caddyfile | 75 + Dockerfile | 57 + README.md | 110 + assets/nosterm-banner.svg | 44 + docker-compose.yml | 33 + docker-entrypoint.sh | 36 + eslint.config.js | 40 + package-lock.json | 7156 +++++++++++++++++ package.json | 52 + postcss.config.js | 6 + src/app.css | 101 + src/app.d.ts | 19 + src/app.html | 31 + src/components/AuthLanding.svelte | 447 + src/components/CommandInput.svelte | 128 + src/components/RoomSidebar.svelte | 343 + src/components/StatusBar.svelte | 73 + src/components/Terminal.svelte | 144 + src/components/UserSidebar.svelte | 177 + src/lib/brand.ts | 8 + src/lib/commands/app-dispatcher.ts | 84 + src/lib/commands/command-parser.ts | 34 + src/lib/commands/command-registry.ts | 65 + src/lib/commands/commands/clear.ts | 47 + src/lib/commands/commands/connect.ts | 27 + src/lib/commands/commands/disconnect.ts | 23 + src/lib/commands/commands/dm.ts | 53 + src/lib/commands/commands/help.ts | 46 + src/lib/commands/commands/join.ts | 35 + src/lib/commands/commands/login.ts | 130 + src/lib/commands/commands/logout.ts | 17 + src/lib/commands/commands/me.ts | 32 + src/lib/commands/commands/moderate.ts | 89 + src/lib/commands/commands/msg.ts | 30 + src/lib/commands/commands/mute.ts | 57 + src/lib/commands/commands/nick.ts | 35 + src/lib/commands/commands/part.ts | 42 + src/lib/commands/commands/plugins.ts | 66 + src/lib/commands/commands/relays.ts | 38 + src/lib/commands/commands/rooms.ts | 23 + src/lib/commands/commands/settings.ts | 128 + src/lib/commands/commands/status.ts | 39 + src/lib/commands/commands/switch.ts | 57 + src/lib/commands/commands/theme.ts | 147 + src/lib/commands/commands/topic.ts | 47 + src/lib/commands/commands/who.ts | 78 + src/lib/commands/commands/whois.ts | 55 + src/lib/commands/create-registry.ts | 69 + src/lib/commands/dispatcher.ts | 65 + src/lib/commands/output.ts | 24 + src/lib/commands/tokenize.ts | 44 + src/lib/config.ts | 40 + src/lib/db/database.ts | 169 + src/lib/db/persistence-service.ts | 181 + .../repositories/custom-theme-repository.ts | 18 + .../db/repositories/identity-repository.ts | 27 + src/lib/db/repositories/index.ts | 13 + src/lib/db/repositories/message-repository.ts | 59 + .../pending-message-repository.ts | 28 + .../repositories/plugin-state-repository.ts | 13 + .../db/repositories/preference-repository.ts | 51 + src/lib/db/repositories/relay-repository.ts | 18 + src/lib/db/repositories/room-repository.ts | 26 + src/lib/nostr/auth-service.ts | 99 + src/lib/nostr/chat-transport.ts | 57 + src/lib/nostr/dm-service.ts | 149 + src/lib/nostr/event-mapper.ts | 80 + src/lib/nostr/ndk-chat-transport.ts | 237 + src/lib/nostr/ndk-client.ts | 28 + src/lib/nostr/ndk-relay-driver.ts | 79 + src/lib/nostr/nip29.ts | 57 + src/lib/nostr/profile-service.ts | 162 + src/lib/nostr/relay-capabilities.ts | 31 + src/lib/nostr/relay-list-service.ts | 32 + src/lib/nostr/relay-manager.ts | 264 + src/lib/nostr/relay-service.ts | 196 + src/lib/nostr/room-address.ts | 65 + src/lib/nostr/room-service.ts | 276 + src/lib/nostr/signer-service.ts | 170 + src/lib/notifications/notification-service.ts | 73 + src/lib/plugins/load-plugins.ts | 32 + src/lib/plugins/plugin-host.ts | 235 + src/lib/plugins/plugin-types.ts | 66 + src/lib/runtime-config.ts | 39 + src/lib/stores/auth-store.ts | 34 + src/lib/stores/dm-store.ts | 78 + src/lib/stores/mute-store.ts | 30 + src/lib/stores/profile-store.ts | 18 + src/lib/stores/relay-store.ts | 46 + src/lib/stores/room-store.ts | 69 + src/lib/stores/server-store.ts | 38 + src/lib/stores/settings-effects.ts | 21 + src/lib/stores/settings-store.ts | 23 + src/lib/stores/terminal-store.ts | 116 + src/lib/stores/theme-store.ts | 109 + src/lib/stores/ui-store.ts | 27 + src/lib/themes/contrast.ts | 35 + src/lib/themes/theme-registry.ts | 153 + src/lib/themes/theme-service.ts | 117 + src/lib/themes/theme-types.ts | 91 + src/lib/themes/themes/catppuccin-mocha.ts | 41 + src/lib/themes/themes/classic-irc.ts | 41 + src/lib/themes/themes/dracula.ts | 41 + src/lib/themes/themes/gruvbox-dark.ts | 41 + src/lib/themes/themes/high-contrast.ts | 41 + src/lib/themes/themes/molokai.ts | 41 + src/lib/themes/themes/monokai.ts | 41 + src/lib/themes/themes/nord.ts | 41 + src/lib/themes/themes/solarized-dark.ts | 41 + src/lib/themes/themes/solarized-light.ts | 43 + src/lib/themes/themes/system-default.ts | 24 + src/lib/themes/themes/tokyo-night.ts | 42 + src/lib/themes/username-color.ts | 25 + src/lib/types/chat.ts | 24 + src/lib/types/command.ts | 26 + src/lib/types/nostr.ts | 42 + src/lib/types/relay.ts | 55 + src/lib/types/room.ts | 31 + src/lib/types/settings.ts | 24 + src/lib/types/terminal.ts | 28 + src/lib/utils/id.ts | 8 + src/lib/utils/mention.ts | 34 + src/lib/utils/npub.ts | 26 + src/lib/utils/pubkey.test.ts | 26 + src/lib/utils/pubkey.ts | 22 + src/lib/utils/relay-url.ts | 54 + src/plugins/README.md | 79 + src/plugins/emoji.ts | 129 + src/plugins/example-slap.ts | 37 + src/routes/+layout.svelte | 30 + src/routes/+layout.ts | 4 + src/routes/+page.svelte | 100 + static/config.json | 3 + static/favicon.svg | 4 + svelte.config.js | 21 + tailwind.config.ts | 34 + tsconfig.json | 15 + vite.config.ts | 17 + 147 files changed, 16504 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CONTRIBUTING.md create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/nosterm-banner.svg create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/components/AuthLanding.svelte create mode 100644 src/components/CommandInput.svelte create mode 100644 src/components/RoomSidebar.svelte create mode 100644 src/components/StatusBar.svelte create mode 100644 src/components/Terminal.svelte create mode 100644 src/components/UserSidebar.svelte create mode 100644 src/lib/brand.ts create mode 100644 src/lib/commands/app-dispatcher.ts create mode 100644 src/lib/commands/command-parser.ts create mode 100644 src/lib/commands/command-registry.ts create mode 100644 src/lib/commands/commands/clear.ts create mode 100644 src/lib/commands/commands/connect.ts create mode 100644 src/lib/commands/commands/disconnect.ts create mode 100644 src/lib/commands/commands/dm.ts create mode 100644 src/lib/commands/commands/help.ts create mode 100644 src/lib/commands/commands/join.ts create mode 100644 src/lib/commands/commands/login.ts create mode 100644 src/lib/commands/commands/logout.ts create mode 100644 src/lib/commands/commands/me.ts create mode 100644 src/lib/commands/commands/moderate.ts create mode 100644 src/lib/commands/commands/msg.ts create mode 100644 src/lib/commands/commands/mute.ts create mode 100644 src/lib/commands/commands/nick.ts create mode 100644 src/lib/commands/commands/part.ts create mode 100644 src/lib/commands/commands/plugins.ts create mode 100644 src/lib/commands/commands/relays.ts create mode 100644 src/lib/commands/commands/rooms.ts create mode 100644 src/lib/commands/commands/settings.ts create mode 100644 src/lib/commands/commands/status.ts create mode 100644 src/lib/commands/commands/switch.ts create mode 100644 src/lib/commands/commands/theme.ts create mode 100644 src/lib/commands/commands/topic.ts create mode 100644 src/lib/commands/commands/who.ts create mode 100644 src/lib/commands/commands/whois.ts create mode 100644 src/lib/commands/create-registry.ts create mode 100644 src/lib/commands/dispatcher.ts create mode 100644 src/lib/commands/output.ts create mode 100644 src/lib/commands/tokenize.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/db/database.ts create mode 100644 src/lib/db/persistence-service.ts create mode 100644 src/lib/db/repositories/custom-theme-repository.ts create mode 100644 src/lib/db/repositories/identity-repository.ts create mode 100644 src/lib/db/repositories/index.ts create mode 100644 src/lib/db/repositories/message-repository.ts create mode 100644 src/lib/db/repositories/pending-message-repository.ts create mode 100644 src/lib/db/repositories/plugin-state-repository.ts create mode 100644 src/lib/db/repositories/preference-repository.ts create mode 100644 src/lib/db/repositories/relay-repository.ts create mode 100644 src/lib/db/repositories/room-repository.ts create mode 100644 src/lib/nostr/auth-service.ts create mode 100644 src/lib/nostr/chat-transport.ts create mode 100644 src/lib/nostr/dm-service.ts create mode 100644 src/lib/nostr/event-mapper.ts create mode 100644 src/lib/nostr/ndk-chat-transport.ts create mode 100644 src/lib/nostr/ndk-client.ts create mode 100644 src/lib/nostr/ndk-relay-driver.ts create mode 100644 src/lib/nostr/nip29.ts create mode 100644 src/lib/nostr/profile-service.ts create mode 100644 src/lib/nostr/relay-capabilities.ts create mode 100644 src/lib/nostr/relay-list-service.ts create mode 100644 src/lib/nostr/relay-manager.ts create mode 100644 src/lib/nostr/relay-service.ts create mode 100644 src/lib/nostr/room-address.ts create mode 100644 src/lib/nostr/room-service.ts create mode 100644 src/lib/nostr/signer-service.ts create mode 100644 src/lib/notifications/notification-service.ts create mode 100644 src/lib/plugins/load-plugins.ts create mode 100644 src/lib/plugins/plugin-host.ts create mode 100644 src/lib/plugins/plugin-types.ts create mode 100644 src/lib/runtime-config.ts create mode 100644 src/lib/stores/auth-store.ts create mode 100644 src/lib/stores/dm-store.ts create mode 100644 src/lib/stores/mute-store.ts create mode 100644 src/lib/stores/profile-store.ts create mode 100644 src/lib/stores/relay-store.ts create mode 100644 src/lib/stores/room-store.ts create mode 100644 src/lib/stores/server-store.ts create mode 100644 src/lib/stores/settings-effects.ts create mode 100644 src/lib/stores/settings-store.ts create mode 100644 src/lib/stores/terminal-store.ts create mode 100644 src/lib/stores/theme-store.ts create mode 100644 src/lib/stores/ui-store.ts create mode 100644 src/lib/themes/contrast.ts create mode 100644 src/lib/themes/theme-registry.ts create mode 100644 src/lib/themes/theme-service.ts create mode 100644 src/lib/themes/theme-types.ts create mode 100644 src/lib/themes/themes/catppuccin-mocha.ts create mode 100644 src/lib/themes/themes/classic-irc.ts create mode 100644 src/lib/themes/themes/dracula.ts create mode 100644 src/lib/themes/themes/gruvbox-dark.ts create mode 100644 src/lib/themes/themes/high-contrast.ts create mode 100644 src/lib/themes/themes/molokai.ts create mode 100644 src/lib/themes/themes/monokai.ts create mode 100644 src/lib/themes/themes/nord.ts create mode 100644 src/lib/themes/themes/solarized-dark.ts create mode 100644 src/lib/themes/themes/solarized-light.ts create mode 100644 src/lib/themes/themes/system-default.ts create mode 100644 src/lib/themes/themes/tokyo-night.ts create mode 100644 src/lib/themes/username-color.ts create mode 100644 src/lib/types/chat.ts create mode 100644 src/lib/types/command.ts create mode 100644 src/lib/types/nostr.ts create mode 100644 src/lib/types/relay.ts create mode 100644 src/lib/types/room.ts create mode 100644 src/lib/types/settings.ts create mode 100644 src/lib/types/terminal.ts create mode 100644 src/lib/utils/id.ts create mode 100644 src/lib/utils/mention.ts create mode 100644 src/lib/utils/npub.ts create mode 100644 src/lib/utils/pubkey.test.ts create mode 100644 src/lib/utils/pubkey.ts create mode 100644 src/lib/utils/relay-url.ts create mode 100644 src/plugins/README.md create mode 100644 src/plugins/emoji.ts create mode 100644 src/plugins/example-slap.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+layout.ts create mode 100644 src/routes/+page.svelte create mode 100644 static/config.json create mode 100644 static/favicon.svg create mode 100644 svelte.config.js create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0e81481 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.svelte-kit +build +coverage +test-results +playwright-report +.env +.env.* +!.env.example +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6335751 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Nosterm client configuration. Copy to `.env` and edit: +# +# cp .env.example .env +# +# Nothing here is secret — every value is either browser-visible (PUBLIC_*, baked +# into the client bundle at build time) or plain deployment wiring. Do NOT put +# private keys or credentials in this file. + +# --- Runtime (docker-compose, no rebuild needed) --------------------------- + +# Relay websocket url(s) the client preloads, comma-separated. Written to +# /config.json by the container entrypoint on startup. For a same-origin home +# relay proxied by Caddy use "ws://localhost:8080/relay". Leave blank to start +# with no default relays. +NOSTERM_DEFAULT_RELAYS= + +# Upstream address for Caddy's optional /relay proxy. Only used when the client +# reaches a relay at ws:///relay. Harmless if unused. +RELAY_UPSTREAM=relay:3334 + +# --- Build-time (PUBLIC_*, baked into the client bundle) ------------------- + +# Fallback relay list used only when no runtime /config.json relays are set. +PUBLIC_DEFAULT_RELAYS= + +# Default UI theme id. +PUBLIC_DEFAULT_THEME=nord + +# Max lines kept in the terminal scrollback. +PUBLIC_MAX_TERMINAL_ENTRIES=1000 + +# Max characters allowed in a single message. +PUBLIC_MAX_MESSAGE_LENGTH=2000 + +# Max messages cached per room in IndexedDB (older ones get pruned). +PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM=500 diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ebddbac --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ + + +## Summary + +## Checklist + +- [ ] `npm run lint` passes +- [ ] `npm run check` passes +- [ ] `npm test` passes +- [ ] `npm run build` succeeds +- [ ] No secrets committed (PUBLIC_* args are browser-visible by design) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..90ca940 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,110 @@ +# CI/CD for the public Nosterm client. +# +# - On every push/PR: install, lint, type-check, test, and build the SPA. +# - On push to main / a v* tag: additionally build the container image and +# push it to ECR Public (anonymous pulls) so anyone can `docker pull` it. +# +# This is the PUBLIC contribution repo — it builds and ships ONLY the client +# image to the public registry. It deliberately does not touch private ECR or +# redeploy any environment; promoting an image to a live site is handled out of +# band by the private ops pipeline. +# +# Targets Gitea 1.21 Actions on a self-hosted (EC2) runner. AWS auth uses the +# runner's ambient IAM role (no stored credentials): `aws ecr-public +# get-login-password` mints a short-lived token for `docker login`. The runner +# role needs ECR Public push permissions (ecr-public:GetAuthorizationToken, +# sts:GetServiceBearerToken, BatchCheckLayerAvailability, Put*/Upload*/Complete*). +name: ci + +env: + # ECR Public is always authenticated via the us-east-1 endpoint, regardless + # of where images are served from. + AWS_REGION: us-east-1 + # public mirror for anonymous `docker pull`. k3k1z1x5 is the account's ECR + # Public registry alias; the repo name (nosterm) is provisioned out of band. + ECR_PUBLIC_REGISTRY: public.ecr.aws/k3k1z1x5 + ECR_PUBLIC_REPO: nosterm + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint (prettier + eslint) + run: npm run lint + + - name: Type-check (svelte-check) + run: npm run check + + - name: Unit tests (vitest) + run: npm test + + - name: Build (static SPA) + run: npm run build + + image: + needs: build + # Publish only from the default branch and version tags, never from PRs. + if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v')) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install AWS CLI + # runner image ships without aws; install v2 if it's missing. + run: | + if ! command -v aws >/dev/null 2>&1; then + apt-get update -qq + apt-get install -y -qq curl unzip + curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip + unzip -q /tmp/awscliv2.zip -d /tmp + /tmp/aws/install + fi + aws --version + + - name: Log in to Amazon ECR Public + # ECR Public auth is a separate endpoint (always us-east-1) and its own + # login user (AWS). This is push auth; anonymous pulls need no login. + run: | + aws ecr-public get-login-password --region us-east-1 \ + | docker login --username AWS --password-stdin public.ecr.aws + + - name: Build and push image + shell: bash + run: | + set -eo pipefail + IMAGE="$ECR_PUBLIC_REGISTRY/$ECR_PUBLIC_REPO" + # tag by commit sha for traceability. + docker build -t "$IMAGE:${{ gitea.sha }}" . + docker push "$IMAGE:${{ gitea.sha }}" + # move :latest only on main. + if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then + docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:latest" + docker push "$IMAGE:latest" + fi + # on a v* tag, also push that version tag. + case "${{ gitea.ref }}" in + refs/tags/v*) + VERSION="${{ gitea.ref_name }}" + docker tag "$IMAGE:${{ gitea.sha }}" "$IMAGE:$VERSION" + docker push "$IMAGE:$VERSION" + ;; + esac diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..668c3d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules + +# build output / framework caches +.svelte-kit/ +build/ + +# test + coverage artifacts +coverage/ +test-results/ +playwright-report/ + +# local env (keep .env.example) +.env +.env.* +!.env.example + +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c0c80ba --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fb0fe41 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +.svelte-kit +build +coverage +test-results +playwright-report +node_modules +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..9573023 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..de09b61 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to Nosterm + +Thanks for helping improve the Nosterm client. This repo is the public home for +client development — the browser SPA and its container image. There is no +backend here; Nostr is a client-side protocol. + +## Workflow + +1. Fork the repo (or branch, if you have push access). +2. Create a topic branch off `main`. +3. Make your change, keeping commits focused. +4. Open a pull request against `main`. + +Every push and pull request runs CI: install, lint, type-check, unit tests, and +a full static build. Get these green before requesting review: + +```bash +npm install +npm run lint # prettier --check + eslint +npm run check # svelte-check (type-check) +npm test # vitest +npm run build # static build → ./build +``` + +`npm run format` auto-fixes most lint issues. + +## What happens on merge + +Merging to `main` builds the container image and pushes it to ECR Public +(`public.ecr.aws/k3k1z1x5/nosterm:latest`), so anyone can pull the latest client +anonymously. Tagged releases (`v*`) publish a matching versioned image. This +repo does not deploy to any live environment — promotion is handled separately. + +## Guidelines + +- Match the surrounding code style; the linter is the source of truth. +- Keep pull requests scoped to one change; smaller is easier to review. +- Add or update tests when you change behavior. +- Never commit secrets. All `PUBLIC_*` build args are browser-visible by design. +- Relays are configured at runtime, not baked in — don't hard-code a relay as a + dependency. + +## Reporting issues + +Open an issue with steps to reproduce, what you expected, and what happened. +For security-sensitive reports, please disclose privately rather than in a +public issue. diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..0175ac2 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,75 @@ +# Caddy config for serving the standalone Nosterm client SPA. +# +# In production, replace `:80` with your domain to get automatic HTTPS, e.g.: +# nosterm.example.com { ... } +# +# The `/relay*` proxy is OPTIONAL. It lets the client reach a relay on the same +# origin (ws:///relay), which sidesteps mixed-content and CORS concerns. +# The upstream is configurable via the RELAY_UPSTREAM env var (default +# `relay:3334`, the service name in this dir's docker-compose.yml). If you point +# the client directly at an external relay via PUBLIC_DEFAULT_RELAYS, you can +# delete the `handle /relay*` block. + +:80 { + encode gzip zstd + + # Tor-overlay relay bridge: proxy to a clearnet→onion bridge sidecar that + # forwards through Tor to an onion-only relay. Exposing it at a same-origin + # wss:// path lets a plain browser reach the onion relay without Tor Browser + # and without an HTTPS-page/insecure-ws mixed-content block. Only wired when + # RELAY_TOR_UPSTREAM is set (the demo deploy sets it); otherwise it points at + # a dead default and simply 502s, which is harmless if unused. MUST precede + # the /relay* block below, which would otherwise swallow this prefix. + handle /relay-tor* { + reverse_proxy {$RELAY_TOR_UPSTREAM:tor-bridge:3335} + } + + # Home relay: proxy WebSocket + NIP-11 requests to the configured relay. + handle /relay* { + reverse_proxy {$RELAY_UPSTREAM:relay:3334} + } + + # Runtime client config: regenerated by the entrypoint from env on startup. + # Never cache it so per-deployment relay changes take effect immediately. + handle /config.json { + root * /srv + header Cache-Control "no-store" + file_server + } + + # Everything else: serve the static SPA with client-side-routing fallback. + handle { + root * /srv + try_files {path} /index.html + file_server + } + + # Baseline security headers. No third-party analytics are ever included. + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy no-referrer + + # Content-Security-Policy for a client-only SPA: + # - default-src 'self' only same-origin by default + # - connect-src wss:/ws: reach ANY relay the user configures (core) + # - connect-src https: NIP-05 verification fetches a domain's + # /.well-known/nostr.json over HTTPS. Read-only + # JSON GETs; no credentials are ever sent. + # - script-src 'unsafe-inline' required for SvelteKit's hydration + # bootstrap + the anti-FOUC theme script, + # which are inlined into the static fallback + # page (not prerendered, so hashes cannot be + # pinned at build time). + # - script-src 'unsafe-eval' required by NDK's event-emitter dependency + # (tseep), which JIT-compiles listener + # dispatch via `new Function`. Only triggers + # once a relay subscription opens. No REMOTE + # scripts are ever allowed, and remote Nostr + # content is never rendered as HTML, so it + # cannot inject code. + # - object-src 'none' no plugins + Content-Security-Policy "default-src 'self'; connect-src 'self' wss: ws: https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + -Server + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21a2673 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 +# +# Standalone Nosterm client image. +# +# The full SvelteKit source lives alongside this Dockerfile, so the build has no +# dependency on anything outside this directory. Build context is THIS +# directory: +# +# docker build -t nosterm:latest . +# +# The output is a static SPA served by Caddy. All PUBLIC_ build args are baked +# into the client bundle (they are browser-visible, so never put secrets here). +# +# Relays are configured at RUNTIME, not build time: the entrypoint regenerates +# /config.json from NOSTERM_DEFAULT_RELAYS on startup, so one prebuilt image can +# be deployed with different relays per environment. PUBLIC_DEFAULT_RELAYS +# remains a build-time fallback for images run without the runtime var. + +# --- Build stage ----------------------------------------------------------- +FROM node:20-alpine AS build +WORKDIR /app + +# Install dependencies against a clean lockfile for reproducible builds. +# Copied separately so the dependency layer caches independently of source. +COPY package.json package-lock.json .npmrc ./ +RUN npm ci + +# Build the static SPA. +COPY . ./ +ARG PUBLIC_DEFAULT_RELAYS="" +ARG PUBLIC_DEFAULT_THEME="nord" +ARG PUBLIC_MAX_TERMINAL_ENTRIES="1000" +ARG PUBLIC_MAX_MESSAGE_LENGTH="2000" +ARG PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM="500" +ENV PUBLIC_DEFAULT_RELAYS=$PUBLIC_DEFAULT_RELAYS \ + PUBLIC_DEFAULT_THEME=$PUBLIC_DEFAULT_THEME \ + PUBLIC_MAX_TERMINAL_ENTRIES=$PUBLIC_MAX_TERMINAL_ENTRIES \ + PUBLIC_MAX_MESSAGE_LENGTH=$PUBLIC_MAX_MESSAGE_LENGTH \ + PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM=$PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM +RUN npm run build + +# --- Runtime stage (Caddy serves the static build) ------------------------- +FROM caddy:2-alpine AS runtime +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/build /srv + +# Entrypoint regenerates /srv/config.json from NOSTERM_DEFAULT_RELAYS at start. +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Liveness check against the served shell. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:80/ || exit 1 + +EXPOSE 80 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..586654c --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Nosterm + +

+ Nosterm — a terminal-style Nostr chat application +

+ +**A terminal-style Nostr chat client.** + +The Nosterm web client: a terminal-style Nostr chat SPA built with +[SvelteKit](https://kit.svelte.dev/) and served as static files by +[Caddy](https://caddyserver.com/). Nostr is a client-side protocol, so there is +no application backend here — the browser talks directly to relays over +WebSocket. Run a relay from the companion +[nosterm-relay](https://git.nerdworks.io/nerdworks/nosterm-relay) repository if +you want your own home relay. + +This is the **public, contribution-focused** repository for the Nosterm client. +Open a pull request against `main` — see [CONTRIBUTING.md](CONTRIBUTING.md). Each +merge to `main` builds the container image and publishes it to ECR Public for +anonymous pulls (see [Deploy](#deploy)). + +## Layout + +``` +nosterm-client/ + src/ # SvelteKit source (components, stores, nostr, commands…) + static/ # static assets + Dockerfile # build the SPA → serve with Caddy + Caddyfile # static file server + security headers + optional /relay proxy + docker-compose.yml # one-command deploy on port 8080 + .env.example # copy to .env and configure + package.json # dev/build/test scripts +``` + +## Develop + +```bash +npm install +npm run dev # vite dev server +npm run check # svelte-check (type-check) +npm run lint # prettier --check + eslint +npm test # vitest +npm run test:e2e # playwright +npm run build # static build → ./build +``` + +## Deploy + +```bash +cp .env.example .env # then set NOSTERM_DEFAULT_RELAYS at minimum +docker compose up -d --build +``` + +The client is then served at . + +To run the prebuilt public image with your own relays — no rebuild, no AWS +account (anonymous pull from ECR Public): + +```bash +docker run -p 8080:80 \ + -e NOSTERM_DEFAULT_RELAYS=wss://relay.example.com \ + public.ecr.aws/k3k1z1x5/nosterm:latest +``` + +`NOSTERM_DEFAULT_RELAYS` accepts a comma-separated list. On startup the +container's entrypoint writes it to `/config.json`, which the SPA fetches on +load — so one shared image serves every deployment with different relays. + +## Configuration + +Relays are configured at **runtime** (`NOSTERM_DEFAULT_RELAYS`), so a single +prebuilt image works for any deployment. The remaining `PUBLIC_*` tunables are +**build-time** args baked into the browser bundle (client-visible, never +secrets) — change one → rebuild the image. + +**Runtime** (set on the running container, no rebuild): + +| Variable | Default | Purpose | +| ------------------------ | ------------ | ------------------------------------------------------------ | +| `NOSTERM_DEFAULT_RELAYS` | _empty_ | Comma-separated relay WebSocket URLs to preload | +| `RELAY_UPSTREAM` | `relay:3334` | Upstream for Caddy's optional `/relay` reverse-proxy (below) | + +**Build-time** (baked into the bundle; rebuild to change): + +| Variable | Default | Purpose | +| ------------------------------------- | ------- | --------------------------------------------- | +| `PUBLIC_DEFAULT_RELAYS` | _empty_ | Fallback relays when no runtime config is set | +| `PUBLIC_DEFAULT_THEME` | `nord` | Theme applied before the user picks one | +| `PUBLIC_MAX_TERMINAL_ENTRIES` | `1000` | Terminal entries kept in memory / rendered | +| `PUBLIC_MAX_MESSAGE_LENGTH` | `2000` | Max outbound message length (characters) | +| `PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM` | `500` | Messages retained per room in IndexedDB | + +## Talking to a relay + +The client connects directly to whatever relays are configured in +`NOSTERM_DEFAULT_RELAYS` (and any the user adds at runtime). Two common setups: + +- **External relay** — set `NOSTERM_DEFAULT_RELAYS=wss://relay.example.com`. + You can delete the `handle /relay*` block from the `Caddyfile`. +- **Same-origin home relay** — run + [nosterm-relay](https://git.nerdworks.io/nerdworks/nosterm-relay), set + `NOSTERM_DEFAULT_RELAYS=ws://localhost:8080/relay` (or `wss://…` behind TLS), + and set `RELAY_UPSTREAM` to the relay's address so Caddy proxies `/relay` to + it. Put both services on a shared Docker network for name resolution. + +## Production TLS + +For a real domain, edit the `Caddyfile`: replace `:80` with your hostname (e.g. +`nosterm.example.com { … }`) and Caddy will obtain and renew a certificate +automatically. Expose ports 80 and 443 in `docker-compose.yml`. diff --git a/assets/nosterm-banner.svg b/assets/nosterm-banner.svg new file mode 100644 index 0000000..5b41e82 --- /dev/null +++ b/assets/nosterm-banner.svg @@ -0,0 +1,44 @@ + + Nosterm — a terminal-style Nostr chat application + + + + + + + + + + + + + + + + + + + + + + nosterm — /home/you + + + + + >_ + nosterm + + + # a terminal-style Nostr chat client + home relay + + + $ + /join + #general + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2037126 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# Standalone Nosterm client deployment. +# +# Serves the static SvelteKit SPA via Caddy on port 8080. This directory deploys +# ONLY the client — point it at a relay of your choice with NOSTERM_DEFAULT_RELAYS +# (runtime, see .env.example). To also run a home relay, use the nosterm-relay +# repo (or set RELAY_UPSTREAM so Caddy's /relay proxy reaches it). +# +# cp .env.example .env # then edit +# docker compose up -d --build + +services: + nosterm: + build: + context: . + args: + # Non-relay tunables are still baked in at build time. Relays are set at + # runtime via NOSTERM_DEFAULT_RELAYS below (no rebuild to change them). + PUBLIC_DEFAULT_THEME: ${PUBLIC_DEFAULT_THEME:-nord} + PUBLIC_MAX_TERMINAL_ENTRIES: ${PUBLIC_MAX_TERMINAL_ENTRIES:-1000} + PUBLIC_MAX_MESSAGE_LENGTH: ${PUBLIC_MAX_MESSAGE_LENGTH:-2000} + PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM: ${PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM:-500} + image: nosterm:latest + environment: + # Relay(s) the client preloads, written to /config.json on startup. For a + # same-origin home relay use "ws://localhost:8080/relay" (proxied by Caddy) + # and set RELAY_UPSTREAM to its address. Otherwise point at an external relay. + NOSTERM_DEFAULT_RELAYS: ${NOSTERM_DEFAULT_RELAYS:-} + # Upstream for Caddy's optional /relay proxy. Only used if the client + # reaches the relay at ws:///relay. Harmless if unused. + RELAY_UPSTREAM: ${RELAY_UPSTREAM:-relay:3334} + ports: + - '8080:80' + restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..be4fbbe --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# regenerate runtime client config from env before caddy starts. +# +# the SPA fetches /config.json at startup, so this lets one prebuilt image be +# deployed with different relays per environment without a rebuild. set +# NOSTERM_DEFAULT_RELAYS to a comma-separated list of relay websocket urls. +set -eu + +CONFIG_PATH=/srv/config.json +RELAYS="${NOSTERM_DEFAULT_RELAYS:-}" + +# build a json array from the comma-separated list, trimming blanks. +relays_json="" +IFS=',' +for relay in $RELAYS; do + # trim surrounding whitespace. + trimmed="$(printf '%s' "$relay" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -z "$trimmed" ] && continue + if [ -z "$relays_json" ]; then + relays_json="\"$trimmed\"" + else + relays_json="$relays_json,\"$trimmed\"" + fi +done +unset IFS + +printf '{\n "relays": [%s]\n}\n' "$relays_json" > "$CONFIG_PATH" + +if [ -n "$relays_json" ]; then + echo "[entrypoint] wrote $CONFIG_PATH with relays: $RELAYS" +else + echo "[entrypoint] no NOSTERM_DEFAULT_RELAYS set; wrote empty relay list" +fi + +# hand off to the container command (caddy). +exec "$@" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f9e1c6d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,40 @@ +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import svelte from 'eslint-plugin-svelte'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs['flat/recommended'], + prettier, + ...svelte.configs['flat/prettier'], + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + { + files: ['**/*.svelte'], + languageOptions: { + parserOptions: { + parser: ts.parser + } + } + }, + { + ignores: [ + '.svelte-kit/', + 'build/', + 'coverage/', + 'test-results/', + 'playwright-report/', + 'node_modules/' + ] + } +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bb09642 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7156 @@ +{ + "name": "nosterm", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nosterm", + "version": "0.1.0", + "dependencies": { + "@nostr-dev-kit/ndk": "^2.10.7", + "dexie": "^4.0.10" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@playwright/test": "^1.49.1", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.15.1", + "@sveltejs/vite-plugin-svelte": "^4.0.4", + "@types/eslint": "^9.6.1", + "@vitest/coverage-v8": "^2.1.9", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.46.1", + "fake-indexeddb": "^6.2.5", + "globals": "^15.14.0", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.2", + "sirv-cli": "^3.0.0", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "typescript-eslint": "^8.19.0", + "vite": "^5.4.11", + "vitest": "^2.1.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@codesandbox/nodebox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@codesandbox/nodebox/-/nodebox-0.1.8.tgz", + "integrity": "sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg==", + "license": "SEE LICENSE IN ./LICENSE", + "dependencies": { + "outvariant": "^1.4.0", + "strict-event-emitter": "^0.4.3" + } + }, + "node_modules/@codesandbox/sandpack-client": { + "version": "2.19.8", + "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-client/-/sandpack-client-2.19.8.tgz", + "integrity": "sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==", + "license": "Apache-2.0", + "dependencies": { + "@codesandbox/nodebox": "0.1.8", + "buffer": "^6.0.3", + "dequal": "^2.0.2", + "mime-db": "^1.52.0", + "outvariant": "1.4.0", + "static-browser-server": "1.0.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/secp256k1": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.3.0.tgz", + "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nostr-dev-kit/ndk": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.18.1.tgz", + "integrity": "sha512-LTXXheGfmyN1y8x+8v/Dmkx8YX7LqaoVk0DTSaigETB5RZsxw7dLBKK++kZd4DVIxtj0tRfmSOsTr1E+M4653Q==", + "license": "MIT", + "dependencies": { + "@codesandbox/sandpack-client": "^2.19.8", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@noble/secp256k1": "^2.1.0", + "@scure/base": "^1.1.9", + "debug": "^4.3.7", + "light-bolt11-decoder": "^3.2.0", + "shiki": "^3.13.0", + "tseep": "^1.3.1", + "typescript-lru-cache": "^2.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "nostr-tools": "^2.17.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.0.tgz", + "integrity": "sha512-5pBnJwdNzxbrxp1TLK1NPMFF0Cx57iZUDKInznKcfifYR9m9poWfZI2Tfhw6BZIjYor5dXvcibt4EQgar3k6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/console-clear": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz", + "integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dexie": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz", + "integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==", + "license": "Apache-2.0" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", + "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@jridgewell/sourcemap-codec": "^1.4.15", + "eslint-compat-utils": "^0.5.1", + "esutils": "^2.0.3", + "known-css-properties": "^0.35.0", + "postcss": "^8.4.38", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.1.0", + "semver": "^7.6.2", + "svelte-eslint-parser": "^0.43.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-bolt11-decoder": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz", + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "license": "MIT", + "dependencies": { + "@scure/base": "1.1.1" + } + }, + "node_modules/light-bolt11-decoder/node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-access": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz", + "integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nostr-tools": { + "version": "2.23.12", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.12.tgz", + "integrity": "sha512-dLE9r0b4pCmrOKLUPD0KhUj9IXeh6RwiYoonEWeBh2AaDmlczUjJqJC8pQfggXzKUZ7+uZvhcrKkfwuZk0/e1g==", + "license": "Unlicense", + "peer": true, + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT", + "peer": true + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-safe-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", + "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", + "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semiver": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", + "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sirv-cli": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz", + "integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "console-clear": "^1.1.0", + "get-port": "^5.1.1", + "kleur": "^4.1.4", + "local-access": "^1.0.1", + "sade": "^1.6.0", + "semiver": "^1.0.0", + "sirv": "^3.0.0", + "tinydate": "^1.0.0" + }, + "bin": { + "sirv": "bin.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/static-browser-server": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz", + "integrity": "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==", + "license": "Apache-2.0", + "dependencies": { + "@open-draft/deferred-promise": "^2.1.0", + "dotenv": "^16.0.3", + "mime-db": "^1.52.0", + "outvariant": "^1.3.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", + "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.56.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.6.tgz", + "integrity": "sha512-p4HDLDogGHKRKCrgckQHNs5PEfXkju6JI5jTywueaKJI5hAdjPohEhRtQ0M1SWC/+TA73SPln+r7srr+7e4nZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", + "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "postcss": "^8.4.39", + "postcss-scss": "^4.0.9" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinydate": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", + "integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tseep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.3.1.tgz", + "integrity": "sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==", + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-lru-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typescript-lru-cache/-/typescript-lru-cache-2.0.0.tgz", + "integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==", + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c354681 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "nosterm", + "version": "0.1.0", + "private": true, + "description": "A terminal-style Nostr chat client.", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "sirv build --single --port 4173", + "prepare": "svelte-kit sync", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@playwright/test": "^1.49.1", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.15.1", + "@sveltejs/vite-plugin-svelte": "^4.0.4", + "@types/eslint": "^9.6.1", + "@vitest/coverage-v8": "^2.1.9", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.46.1", + "fake-indexeddb": "^6.2.5", + "globals": "^15.14.0", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.2", + "sirv-cli": "^3.0.0", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "typescript-eslint": "^8.19.0", + "vite": "^5.4.11", + "vitest": "^2.1.8" + }, + "dependencies": { + "@nostr-dev-kit/ndk": "^2.10.7", + "dexie": "^4.0.10" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..0f77216 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..c82ef19 --- /dev/null +++ b/src/app.css @@ -0,0 +1,101 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* + * Semantic design tokens. These defaults mirror the Nord theme so the UI is + * fully styled before JavaScript applies the persisted theme (no FOUC). + * The theme service overwrites these on :root at runtime. + */ +:root { + --color-background: #2e3440; + --color-surface: #3b4252; + --color-surface-raised: #434c5e; + --color-border: #4c566a; + + --color-text-primary: #eceff4; + --color-text-secondary: #d8dee9; + --color-text-muted: #7b88a1; + + --color-accent: #88c0d0; + --color-link: #8fbcbb; + --color-focus: #88c0d0; + + --color-success: #a3be8c; + --color-warning: #ebcb8b; + --color-error: #bf616a; + --color-info: #81a1c1; + + --color-terminal-system: #81a1c1; + --color-terminal-notice: #88c0d0; + --color-terminal-command: #b48ead; + --color-terminal-action: #ebcb8b; + --color-terminal-timestamp: #7b88a1; + + --color-relay-connected: #a3be8c; + --color-relay-connecting: #ebcb8b; + --color-relay-disconnected: #7b88a1; + --color-relay-error: #bf616a; + + --font-terminal: + 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --terminal-font-size: 14px; + --terminal-line-height: 1.5; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--color-background); + color: var(--color-text-primary); + font-family: var(--font-terminal); + font-size: var(--terminal-font-size); + line-height: var(--terminal-line-height); + -webkit-font-smoothing: antialiased; +} + +/* Themed scrollbars. */ +* { + scrollbar-color: var(--color-border) var(--color-surface); + scrollbar-width: thin; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-track { + background: var(--color-surface); +} +*::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 5px; +} + +/* Visible, theme-aware focus indicator on all interactive elements. */ +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + +/* Respect the reduced-motion preference and the app setting (via data attr). */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} +:root[data-reduced-motion='true'] *, +:root[data-reduced-motion='true'] *::before, +:root[data-reduced-motion='true'] *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..0da410e --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,19 @@ +// see https://svelte.dev/docs/kit/types#app.d.ts +import type { Nip07Signer } from '$lib/types/nostr'; + +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } + + // nip-07 browser extension surface. optional — not every browser has one. + interface Window { + nostr?: Nip07Signer; + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..0bad113 --- /dev/null +++ b/src/app.html @@ -0,0 +1,31 @@ + + + + + + + + + Nosterm + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/components/AuthLanding.svelte b/src/components/AuthLanding.svelte new file mode 100644 index 0000000..f0629b6 --- /dev/null +++ b/src/components/AuthLanding.svelte @@ -0,0 +1,447 @@ + + + diff --git a/src/components/CommandInput.svelte b/src/components/CommandInput.svelte new file mode 100644 index 0000000..f2eff51 --- /dev/null +++ b/src/components/CommandInput.svelte @@ -0,0 +1,128 @@ + + +
{ + e.preventDefault(); + submit(); + }} +> + + + {#if showCounter} + + {remaining} + + {/if} +
diff --git a/src/components/RoomSidebar.svelte b/src/components/RoomSidebar.svelte new file mode 100644 index 0000000..95856c7 --- /dev/null +++ b/src/components/RoomSidebar.svelte @@ -0,0 +1,343 @@ + + + +{#snippet onionIcon(color: string)} + +{/snippet} + + +{#snippet relayIcon(color: string)} + +{/snippet} + + +{#snippet arrowIcon()} + +{/snippet} + +
+

+ Relays +

+ {#if relayRows.length === 0} +

No relays. /connect <url>

+ {:else} +
    + {#each relayRows as row (row.key)} + {@const active = rowActive(row)} +
  • + +
  • + {/each} +
+ {/if} +
+ +
+

+ Rooms +

+ {#if rooms.length === 0} +

No rooms. /join #room

+ {:else} +
    + {#each rooms as room (room.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ +{#if dms.length > 0} +
+

+ Direct Messages +

+
    + {#each dms as dm (dm.pubkey)} +
  • + +
  • + {/each} +
+
+{/if} diff --git a/src/components/StatusBar.svelte b/src/components/StatusBar.svelte new file mode 100644 index 0000000..fa28c6a --- /dev/null +++ b/src/components/StatusBar.svelte @@ -0,0 +1,73 @@ + + +
+ + relays: + + {connected}/{total} connected + + + + {scopeKind} + + {scopeLabel} + + + + id: + {identity} + + + theme: + {$activeThemeId} + +
diff --git a/src/components/Terminal.svelte b/src/components/Terminal.svelte new file mode 100644 index 0000000..2f31128 --- /dev/null +++ b/src/components/Terminal.svelte @@ -0,0 +1,144 @@ + + +
+ {#if activeRoomForTopic} + +
+ Topic for #{activeRoomForTopic.name}: + {#if activeRoomForTopic.topic} + {activeRoomForTopic.topic} + {:else} + (none — set with /topic <text>) + {/if} +
+ {/if} + + +
+ {#each entries as entry (entry.id)} +
+ [{formatTime(entry.timestamp)}] + {#if entry.type === 'message'} + <{authorLabel(entry)}> + {entry.content} + {:else if entry.type === 'action'} + * {authorLabel(entry)} {entry.content} + {:else if entry.type === 'command'} + {entry.content} + {:else} + *** {entry.content} + {/if} +
+ {/each} +
+
+ + diff --git a/src/components/UserSidebar.svelte b/src/components/UserSidebar.svelte new file mode 100644 index 0000000..994cdbe --- /dev/null +++ b/src/components/UserSidebar.svelte @@ -0,0 +1,177 @@ + + + diff --git a/src/lib/brand.ts b/src/lib/brand.ts new file mode 100644 index 0000000..38cbb24 --- /dev/null +++ b/src/lib/brand.ts @@ -0,0 +1,8 @@ +/** + * product naming, kept off on its own so it can change later without touching + * protocol or app logic. + */ +export const BRAND = { + name: 'Nosterm', + tagline: 'A terminal-style Nostr chat client.' +} as const; diff --git a/src/lib/commands/app-dispatcher.ts b/src/lib/commands/app-dispatcher.ts new file mode 100644 index 0000000..4c52883 --- /dev/null +++ b/src/lib/commands/app-dispatcher.ts @@ -0,0 +1,84 @@ +import { get } from 'svelte/store'; +import type { CommandContext } from '$lib/types/command'; +import { CommandDispatcher } from './dispatcher'; +import { createRegistry } from './create-registry'; +import { roomStore } from '$lib/stores/room-store'; +import { relayStore } from '$lib/stores/relay-store'; +import { authStore } from '$lib/stores/auth-store'; +import { dmStore } from '$lib/stores/dm-store'; +import { roomService } from '$lib/nostr/room-service'; +import { dmService } from '$lib/nostr/dm-service'; +import { pluginHost } from '$lib/plugins/plugin-host'; +import { out } from './output'; + +/** + * puts the app dispatcher together: a shared registry plus wiring that reads + * live context from the stores. plain-text messaging goes to the active room. + */ +export function createAppDispatcher(): { + dispatcher: CommandDispatcher; + registry: ReturnType; +} { + const registry = createRegistry(); + // let plugins register commands into this registry. + pluginHost.useRegistry(registry); + + const getContext = (): CommandContext => { + const { rooms, activeRoomId } = get(roomStore); + const activeRoom = activeRoomId ? rooms[activeRoomId] : undefined; + // prefer the active room's relay; else the first connected one. + const relays = Object.values(get(relayStore)); + const activeRelay = + activeRoom?.address.relayUrl ?? relays.find((r) => r.connection === 'connected')?.url; + return { + activeRoom: activeRoomId, + activeRelay, + currentUser: get(authStore).pubkey + }; + }; + + const dispatcher = new CommandDispatcher({ + registry, + getContext, + async sendMessage(raw) { + // let plugins rewrite outgoing text (e.g. :shrug: → ¯\_(ツ)_/¯) on the + // send path, for both room messages and dms. + const content = pluginHost.applyOutgoing(raw); + + // a focused dm conversation wins: plain text goes to them. + const dm = get(dmStore).activePubkey; + if (dm) { + try { + await dmService.send(dm, content); + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } + + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + return { + success: false, + output: [out.warning('No active room. Join one with /join first, or /dm someone.')] + }; + } + try { + await roomService.send(active.address, content); + // the message echoes back via the room subscription — no local echo. + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } + }); + + return { dispatcher, registry }; +} diff --git a/src/lib/commands/command-parser.ts b/src/lib/commands/command-parser.ts new file mode 100644 index 0000000..1d04fe6 --- /dev/null +++ b/src/lib/commands/command-parser.ts @@ -0,0 +1,34 @@ +import { tokenize } from './tokenize'; + +/** classified user input. plain text is a room message; `/x` is a command. */ +export type ParsedInput = + | { kind: 'empty' } + | { kind: 'message'; content: string } + | { kind: 'command'; name: string; args: string[]; raw: string }; + +/** + * classify a raw input line without running anything (pure + easy to test). + * - empty / whitespace-only input is ignored. + * - a leading '/' marks a command; the rest is tokenized (quotes honored). + * - a leading '//' is an escaped literal message starting with '/'. + * - anything else is a plain room message. + */ +export function parseInput(raw: string): ParsedInput { + const trimmed = raw.trim(); + if (trimmed.length === 0) return { kind: 'empty' }; + + // escaped slash: "//foo" -> literal message "/foo". + if (trimmed.startsWith('//')) { + return { kind: 'message', content: trimmed.slice(1) }; + } + + if (trimmed.startsWith('/')) { + const tokens = tokenize(trimmed.slice(1)); + const name = tokens[0] ?? ''; + // a lone "/" with no command name counts as a message. + if (name.length === 0) return { kind: 'message', content: trimmed }; + return { kind: 'command', name, args: tokens.slice(1), raw: trimmed }; + } + + return { kind: 'message', content: trimmed }; +} diff --git a/src/lib/commands/command-registry.ts b/src/lib/commands/command-registry.ts new file mode 100644 index 0000000..9ebf764 --- /dev/null +++ b/src/lib/commands/command-registry.ts @@ -0,0 +1,65 @@ +import type { ChatCommand } from '$lib/types/command'; + +/** + * keeps the registered commands and looks up names/aliases without a giant + * conditional. add new commands via `register` — no core edits needed. + */ +export class CommandRegistry { + private readonly byName = new Map(); + private readonly commands: ChatCommand[] = []; + + register(command: ChatCommand): void { + this.assertUnique(command.name); + this.byName.set(command.name.toLowerCase(), command); + for (const alias of command.aliases ?? []) { + this.assertUnique(alias); + this.byName.set(alias.toLowerCase(), command); + } + this.commands.push(command); + } + + registerAll(commands: ChatCommand[]): void { + for (const command of commands) this.register(command); + } + + /** + * drop a previously-registered command (by its primary name) plus all its + * aliases. used when a plugin is disabled at runtime so its commands stop + * resolving. no-op if the name isn't registered. + */ + unregister(name: string): void { + const command = this.byName.get(name.toLowerCase()); + if (!command) return; + this.byName.delete(command.name.toLowerCase()); + for (const alias of command.aliases ?? []) this.byName.delete(alias.toLowerCase()); + const idx = this.commands.indexOf(command); + if (idx !== -1) this.commands.splice(idx, 1); + } + + /** look up a command by its name or any alias (case-insensitive). */ + resolve(nameOrAlias: string): ChatCommand | undefined { + return this.byName.get(nameOrAlias.toLowerCase()); + } + + has(nameOrAlias: string): boolean { + return this.byName.has(nameOrAlias.toLowerCase()); + } + + /** all registered commands, in registration order (for /help). */ + list(): readonly ChatCommand[] { + return this.commands; + } + + /** command names + aliases, for tab completion. */ + completions(prefix: string): string[] { + const p = prefix.toLowerCase(); + return [...this.byName.keys()].filter((name) => name.startsWith(p)).sort(); + } + + private assertUnique(key: string): void { + const existing = this.byName.get(key.toLowerCase()); + if (existing) { + throw new Error(`Command name/alias "${key}" already registered by "${existing.name}".`); + } + } +} diff --git a/src/lib/commands/commands/clear.ts b/src/lib/commands/commands/clear.ts new file mode 100644 index 0000000..1c6a366 --- /dev/null +++ b/src/lib/commands/commands/clear.ts @@ -0,0 +1,47 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { roomStore } from '$lib/stores/room-store'; +import { messageRepository } from '$lib/db/repositories'; +import { out } from '../output'; + +/** + * `/clear` — wipe the terminal view (leaves stored history alone). + * `/clear history` — also purge the active room's cached messages from local + * storage (indexeddb), so they don't come back on reload. doesn't touch the + * relay's copy; a re-join replays whatever the relay still has. + */ +export const clearCommand: ChatCommand = { + name: 'clear', + aliases: ['cls'], + description: 'Clear the terminal view, or "/clear history" to purge cached room history.', + usage: '/clear [history]', + async execute(args) { + const sub = (args[0] ?? '').toLowerCase(); + + if (sub === 'history') { + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + return { success: false, output: [out.warning('No active room to clear history for.')] }; + } + await messageRepository.clearRoom(active.id); + terminalStore.clearRoom(active.id); + return { + success: true, + output: [ + out.system( + `Cleared cached history for #${active.name}. (The relay's copy is unaffected.)` + ) + ] + }; + } + + if (sub) { + return { success: false, output: [out.warning('Usage: /clear [history]')] }; + } + + terminalStore.clear(); + return { success: true, output: [] }; + } +}; diff --git a/src/lib/commands/commands/connect.ts b/src/lib/commands/commands/connect.ts new file mode 100644 index 0000000..af49b33 --- /dev/null +++ b/src/lib/commands/commands/connect.ts @@ -0,0 +1,27 @@ +import type { ChatCommand } from '$lib/types/command'; +import { relayService } from '$lib/nostr/relay-service'; +import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url'; +import { out } from '../output'; + +/** `/connect ` — connect to a relay over websocket. */ +export const connectCommand: ChatCommand = { + name: 'connect', + description: 'Connect to a relay.', + usage: '/connect ', + async execute(args) { + const url = args[0]; + if (!url) { + return { success: false, output: [out.warning('Usage: /connect ')] }; + } + if (!isValidRelayUrl(url)) { + return { + success: false, + output: [out.error(`Invalid relay URL "${url}". Use ws:// or wss://.`)] + }; + } + const normalized = normalizeRelayUrl(url); + // fire-and-forget connect; state changes show up as terminal notices. + void relayService.connect(normalized); + return { success: true, output: [out.system(`Connecting to ${normalized}…`)] }; + } +}; diff --git a/src/lib/commands/commands/disconnect.ts b/src/lib/commands/commands/disconnect.ts new file mode 100644 index 0000000..a866ed7 --- /dev/null +++ b/src/lib/commands/commands/disconnect.ts @@ -0,0 +1,23 @@ +import type { ChatCommand } from '$lib/types/command'; +import { relayService } from '$lib/nostr/relay-service'; +import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url'; +import { out } from '../output'; + +/** `/disconnect ` — drop a relay connection. */ +export const disconnectCommand: ChatCommand = { + name: 'disconnect', + description: 'Disconnect from a relay.', + usage: '/disconnect ', + async execute(args) { + const url = args[0]; + if (!url) { + return { success: false, output: [out.warning('Usage: /disconnect ')] }; + } + if (!isValidRelayUrl(url)) { + return { success: false, output: [out.error(`Invalid relay URL "${url}".`)] }; + } + const normalized = normalizeRelayUrl(url); + relayService.disconnect(normalized); + return { success: true, output: [out.system(`Disconnecting from ${normalized}.`)] }; + } +}; diff --git a/src/lib/commands/commands/dm.ts b/src/lib/commands/commands/dm.ts new file mode 100644 index 0000000..2b6e0a7 --- /dev/null +++ b/src/lib/commands/commands/dm.ts @@ -0,0 +1,53 @@ +import type { ChatCommand } from '$lib/types/command'; +import { dmService } from '$lib/nostr/dm-service'; +import { dmStore } from '$lib/stores/dm-store'; +import { decodeNpub } from '$lib/utils/npub'; +import { out } from '../output'; + +/** + * `/dm [message]` — open a direct-message conversation. + * with a message, sends it too. once open, plain text goes to that person until + * you `/switch` to a room or `/dm` someone else. encrypted via nip-17. + */ +export const dmCommand: ChatCommand = { + name: 'dm', + description: 'Open (and optionally send to) a direct-message conversation.', + usage: '/dm [message]', + async execute(args) { + const target = args[0]; + if (!target) { + return { success: false, output: [out.warning('Usage: /dm [message]')] }; + } + + let pubkey: string; + try { + pubkey = decodeNpub(target); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')] + }; + } + + // focus the conversation so its view shows and plain text goes here. + dmStore.open(pubkey); + + const message = args.slice(1).join(' ').trim(); + if (!message) { + return { + success: true, + output: [out.system('Opened DM. Type to send; /switch to leave.')] + }; + } + + try { + await dmService.send(pubkey, message); + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Failed to send DM.')] + }; + } + } +}; diff --git a/src/lib/commands/commands/help.ts b/src/lib/commands/commands/help.ts new file mode 100644 index 0000000..9ab25de --- /dev/null +++ b/src/lib/commands/commands/help.ts @@ -0,0 +1,46 @@ +import type { ChatCommand } from '$lib/types/command'; +import type { CommandRegistry } from '../command-registry'; +import { out } from '../output'; + +/** `/help [command]` — list all commands, or show usage for one. */ +export function createHelpCommand(registry: () => CommandRegistry): ChatCommand { + return { + name: 'help', + aliases: ['h', '?'], + description: 'List available commands, or show usage for one command.', + usage: '/help [command]', + async execute(args) { + const reg = registry(); + const target = args[0]; + + if (target) { + const cmd = reg.resolve(target.replace(/^\//, '')); + if (!cmd) { + return { success: false, output: [out.error(`Unknown command: ${target}`)] }; + } + return { + success: true, + output: [ + out.system(`/${cmd.name} — ${cmd.description}`), + out.system(` usage: ${cmd.usage}`), + ...(cmd.aliases && cmd.aliases.length + ? [out.system(` aliases: ${cmd.aliases.map((a) => `/${a}`).join(', ')}`)] + : []) + ] + }; + } + + const lines = reg + .list() + .map((cmd) => out.system(` /${cmd.name.padEnd(12)} ${cmd.description}`)); + return { + success: true, + output: [ + out.notice('Available commands:'), + ...lines, + out.system('Type /help for usage.') + ] + }; + } + }; +} diff --git a/src/lib/commands/commands/join.ts b/src/lib/commands/commands/join.ts new file mode 100644 index 0000000..05134ab --- /dev/null +++ b/src/lib/commands/commands/join.ts @@ -0,0 +1,35 @@ +import type { ChatCommand } from '$lib/types/command'; +import { roomService } from '$lib/nostr/room-service'; +import { dmStore } from '$lib/stores/dm-store'; +import { parseRoomReference } from '$lib/nostr/room-address'; +import { out } from '../output'; + +/** + * `/join ` — join a nip-29 room. + * takes `#general`, `general` (uses active relay), or + * `wss://relay.example/#general`. + */ +export const joinCommand: ChatCommand = { + name: 'join', + description: 'Join a room.', + usage: '/join <#room | room | wss://relay/#room>', + async execute(args, context) { + const ref = args[0]; + if (!ref) { + return { success: false, output: [out.warning('Usage: /join ')] }; + } + let address; + try { + address = parseRoomReference(ref, context.activeRelay); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + dmStore.close(); // focus the room, leaving any dm view + await roomService.join(address); + // join emits its own terminal notices via the room service. + return { success: true, output: [] }; + } +}; diff --git a/src/lib/commands/commands/login.ts b/src/lib/commands/commands/login.ts new file mode 100644 index 0000000..5002c1e --- /dev/null +++ b/src/lib/commands/commands/login.ts @@ -0,0 +1,130 @@ +import type { ChatCommand } from '$lib/types/command'; +import { authService } from '$lib/nostr/auth-service'; +import { relayService } from '$lib/nostr/relay-service'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { openAuth } from '$lib/stores/ui-store'; +import { out } from '../output'; + +/** + * `/login [nip07|bunker |nsec |generate|dev]` — sign in. + * /login open the sign-in screen + * /login nip07 use a nip-07 browser extension + * /login bunker connect a remote signer (nip-46) + * /login nsec import an nsec (discouraged; in-memory only) + * /login generate make a brand-new key + * /login dev make a throwaway ephemeral identity + */ +export const loginCommand: ChatCommand = { + name: 'login', + description: 'Log in (NIP-07, bunker, nsec, or a new key). Run bare to open the sign-in screen.', + usage: '/login [nip07 | bunker | nsec | generate | dev]', + async execute(args) { + const mode = (args[0] ?? '').toLowerCase(); + + // bare /login opens the friendly sign-in modal. + if (!mode) { + openAuth(); + return { success: true, output: [out.system('Opening the sign-in screen…')] }; + } + + try { + switch (mode) { + case 'nip07': { + if (!authService.hasNip07()) { + return { + success: false, + output: [ + out.error('No NIP-07 extension detected.'), + out.system('Install a signer extension, or try /login bunker, nsec, or generate.') + ] + }; + } + const pk = await authService.loginNip07(); + void relayService.discoverAndConnect(pk); + return { + success: true, + output: [out.system(`Logged in as ${shortenPubkey(pk)} (NIP-07).`)] + }; + } + + case 'bunker': { + const token = args.slice(1).join(' ').trim(); + if (!token) { + return { + success: false, + output: [out.warning('Usage: /login bunker ')] + }; + } + const pk = await authService.loginBunker(token); + void relayService.discoverAndConnect(pk); + return { + success: true, + output: [out.system(`Logged in as ${shortenPubkey(pk)} (remote signer).`)] + }; + } + + case 'nsec': { + const key = args[1]; + if (!key) { + return { success: false, output: [out.warning('Usage: /login nsec ')] }; + } + const pk = authService.loginNsec(key); + void relayService.discoverAndConnect(pk); + return { + success: true, + output: [ + out.warning( + 'Your private key is now held in this browser tab. Prefer an extension or bunker.' + ), + out.system(`Logged in as ${shortenPubkey(pk)} (nsec, in-memory).`) + ] + }; + } + + case 'generate': { + const pk = authService.generate(); + const pair = authService.exportKeyPair(); + const lines = [ + out.notice('A new identity was generated (kept in memory for this session).'), + out.system(` npub: ${pair?.npub ?? '(unknown)'}`) + ]; + if (pair?.nsec) { + lines.push(out.warning(` nsec: ${pair.nsec}`)); + lines.push( + out.warning( + 'Save the nsec now — it is shown once and not stored unless you choose to.' + ) + ); + } + lines.push(out.system(`Logged in as ${shortenPubkey(pk)}.`)); + return { success: true, output: lines }; + } + + case 'dev': + case 'ephemeral': { + const pk = authService.loginEphemeral(); + return { + success: true, + output: [ + out.warning('Using a TEMPORARY ephemeral identity — not for real use.'), + out.system(`Logged in as ${shortenPubkey(pk)} (ephemeral).`) + ] + }; + } + + default: + return { + success: false, + output: [ + out.warning('Usage: /login [nip07 | bunker | nsec | generate | dev]') + ] + }; + } + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Login failed.')] + }; + } + } +}; diff --git a/src/lib/commands/commands/logout.ts b/src/lib/commands/commands/logout.ts new file mode 100644 index 0000000..e10c410 --- /dev/null +++ b/src/lib/commands/commands/logout.ts @@ -0,0 +1,17 @@ +import type { ChatCommand } from '$lib/types/command'; +import { authService } from '$lib/nostr/auth-service'; +import { out } from '../output'; + +/** `/logout` — drop the current identity and detach the signer. */ +export const logoutCommand: ChatCommand = { + name: 'logout', + description: 'Log out of the current identity.', + usage: '/logout', + async execute() { + if (!authService.pubkey) { + return { success: true, output: [out.notice('Not logged in.')] }; + } + authService.logout(); + return { success: true, output: [out.system('Logged out.')] }; + } +}; diff --git a/src/lib/commands/commands/me.ts b/src/lib/commands/commands/me.ts new file mode 100644 index 0000000..ef93d35 --- /dev/null +++ b/src/lib/commands/commands/me.ts @@ -0,0 +1,32 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomService } from '$lib/nostr/room-service'; +import { roomStore } from '$lib/stores/room-store'; +import { out } from '../output'; + +/** `/me ` — fire off an action message to the active room. */ +export const meCommand: ChatCommand = { + name: 'me', + description: 'Send an action message to the active room.', + usage: '/me ', + async execute(args) { + const action = args.join(' ').trim(); + if (!action) { + return { success: false, output: [out.warning('Usage: /me ')] }; + } + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + return { success: false, output: [out.warning('No active room. Join one with /join.')] }; + } + try { + await roomService.send(active.address, action, true); + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } +}; diff --git a/src/lib/commands/commands/moderate.ts b/src/lib/commands/commands/moderate.ts new file mode 100644 index 0000000..81282c1 --- /dev/null +++ b/src/lib/commands/commands/moderate.ts @@ -0,0 +1,89 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomService } from '$lib/nostr/room-service'; +import { roomStore } from '$lib/stores/room-store'; +import { decodeNpub } from '$lib/utils/npub'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { out } from '../output'; + +/** grab the active room, if there is one. */ +function activeRoom() { + const { rooms, activeRoomId } = get(roomStore); + return activeRoomId ? rooms[activeRoomId] : undefined; +} + +/** + * shared guts for the two moderation commands. both target a user in the active + * room; the relay enforces that the caller is an admin (we also warn early when + * we already know they aren't one). + */ +function moderationCommand( + name: 'invite' | 'kick', + action: 'add' | 'remove', + verb: string +): ChatCommand { + return { + name, + aliases: name === 'kick' ? ['remove'] : ['add'], + description: + name === 'invite' + ? 'Admin: add a user to the active room.' + : 'Admin: remove a user from the active room.', + usage: `/${name} `, + async execute(args, context) { + const target = args[0]; + if (!target) { + return { success: false, output: [out.warning(`Usage: /${name} `)] }; + } + + const room = activeRoom(); + if (!room) { + return { success: false, output: [out.warning('No active room. Join one with /join.')] }; + } + + let pubkey: string; + try { + pubkey = decodeNpub(target); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')] + }; + } + + // friendly early guard: if we already know the caller isn't an admin, + // don't bother the relay. (isAdmin returns true when the list isn't + // known yet, so the relay stays the final say.) + const me = context.currentUser; + if (me && !roomService.isAdmin(room.id, me)) { + return { + success: false, + output: [out.error(`You are not an admin of #${room.name}.`)] + }; + } + + try { + await roomService.moderate(room.address, action, pubkey); + return { + success: true, + output: [ + out.system( + `${verb} ${shortenPubkey(pubkey)} ${name === 'invite' ? 'to' : 'from'} #${room.name}.` + ) + ] + }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : `Failed to ${name}.`)] + }; + } + } + }; +} + +/** `/invite ` — admin adds a user to the active room (nip-29 kind 9000). */ +export const inviteCommand = moderationCommand('invite', 'add', 'Invited'); + +/** `/kick ` — admin removes a user from the active room (nip-29 kind 9001). */ +export const kickCommand = moderationCommand('kick', 'remove', 'Removed'); diff --git a/src/lib/commands/commands/msg.ts b/src/lib/commands/commands/msg.ts new file mode 100644 index 0000000..fde5400 --- /dev/null +++ b/src/lib/commands/commands/msg.ts @@ -0,0 +1,30 @@ +import type { ChatCommand } from '$lib/types/command'; +import { dmService } from '$lib/nostr/dm-service'; +import { out } from '../output'; + +/** + * `/msg ` — send an encrypted direct message (nip-17). + * the message is gift-wrapped so relays can't see sender, recipient, or content. + */ +export const msgCommand: ChatCommand = { + name: 'msg', + description: 'Send an encrypted direct message (NIP-17).', + usage: '/msg ', + async execute(args) { + const recipient = args[0]; + const message = args.slice(1).join(' ').trim(); + if (!recipient || !message) { + return { success: false, output: [out.warning('Usage: /msg ')] }; + } + try { + await dmService.send(recipient, message); + // the outgoing dm is rendered by the service; no extra output needed. + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Failed to send DM.')] + }; + } + } +}; diff --git a/src/lib/commands/commands/mute.ts b/src/lib/commands/commands/mute.ts new file mode 100644 index 0000000..2057eb0 --- /dev/null +++ b/src/lib/commands/commands/mute.ts @@ -0,0 +1,57 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { muteStore } from '$lib/stores/mute-store'; +import { decodeNpub } from '$lib/utils/npub'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { out } from '../output'; + +/** `/mute ` — hide a user's messages, just for you. */ +export const muteCommand: ChatCommand = { + name: 'mute', + description: 'Hide messages from a user (local only).', + usage: '/mute ', + async execute(args) { + const target = args[0]; + if (!target) { + return { success: false, output: [out.warning('Usage: /mute ')] }; + } + let pubkey: string; + try { + pubkey = decodeNpub(target); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')] + }; + } + muteStore.add(pubkey); + return { success: true, output: [out.system(`Muted ${shortenPubkey(pubkey)}.`)] }; + } +}; + +/** `/unmute ` — stop hiding a user's messages. */ +export const unmuteCommand: ChatCommand = { + name: 'unmute', + description: 'Unhide messages from a previously muted user.', + usage: '/unmute ', + async execute(args) { + const target = args[0]; + if (!target) { + return { success: false, output: [out.warning('Usage: /unmute ')] }; + } + let pubkey: string; + try { + pubkey = decodeNpub(target); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')] + }; + } + if (!get(muteStore).has(pubkey)) { + return { success: true, output: [out.notice(`${shortenPubkey(pubkey)} is not muted.`)] }; + } + muteStore.remove(pubkey); + return { success: true, output: [out.system(`Unmuted ${shortenPubkey(pubkey)}.`)] }; + } +}; diff --git a/src/lib/commands/commands/nick.ts b/src/lib/commands/commands/nick.ts new file mode 100644 index 0000000..701f874 --- /dev/null +++ b/src/lib/commands/commands/nick.ts @@ -0,0 +1,35 @@ +import type { ChatCommand } from '$lib/types/command'; +import { profileService } from '$lib/nostr/profile-service'; +import { out } from '../output'; + +/** + * `/nick ` — set your public profile display name (nip-01 kind 0). + * gets published to connected relays and shown instead of your pubkey. + * quotes let you use spaces: `/nick "Ada Lovelace"`. + */ +export const nickCommand: ChatCommand = { + name: 'nick', + aliases: ['name'], + description: 'Set your public profile display name.', + usage: '/nick ', + async execute(args) { + const name = args.join(' ').trim(); + if (!name) { + return { success: false, output: [out.warning('Usage: /nick ')] }; + } + try { + await profileService.publishName(name); + return { + success: true, + output: [ + out.system(`Display name set to "${name}". It may take a moment to appear for others.`) + ] + }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Failed to set display name.')] + }; + } + } +}; diff --git a/src/lib/commands/commands/part.ts b/src/lib/commands/commands/part.ts new file mode 100644 index 0000000..0d556f0 --- /dev/null +++ b/src/lib/commands/commands/part.ts @@ -0,0 +1,42 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomService } from '$lib/nostr/room-service'; +import { roomStore } from '$lib/stores/room-store'; +import { parseRoomReference } from '$lib/nostr/room-address'; +import { out } from '../output'; + +/** `/part [room]` — leave a room, defaulting to the active one. */ +export const partCommand: ChatCommand = { + name: 'part', + aliases: ['leave'], + description: 'Leave a room (defaults to the active room).', + usage: '/part [room]', + async execute(args, context) { + const ref = args[0]; + + // no argument: leave the active room. + if (!ref) { + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + return { + success: false, + output: [out.warning('No active room to part. Usage: /part [room]')] + }; + } + await roomService.leave(active.address); + return { success: true, output: [] }; + } + + try { + const address = parseRoomReference(ref, context.activeRelay); + await roomService.leave(address); + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } +}; diff --git a/src/lib/commands/commands/plugins.ts b/src/lib/commands/commands/plugins.ts new file mode 100644 index 0000000..b8b9a9f --- /dev/null +++ b/src/lib/commands/commands/plugins.ts @@ -0,0 +1,66 @@ +import type { ChatCommand } from '$lib/types/command'; +import { pluginHost } from '$lib/plugins/plugin-host'; +import { out } from '../output'; + +/** + * `/plugins` — list plugins and whether they're on or off. + * `/plugins disable ` — turn a plugin off (drops its commands; persisted). + * `/plugins enable ` — turn a disabled plugin back on. + */ +export const pluginsCommand: ChatCommand = { + name: 'plugins', + aliases: ['plugin'], + description: 'List plugins, or enable/disable one.', + usage: '/plugins [enable|disable ]', + async execute(args) { + const sub = args[0]?.toLowerCase(); + + if (sub === 'enable' || sub === 'disable') { + const id = args[1]; + if (!id) { + return { success: false, output: [out.warning(`Usage: /plugins ${sub} `)] }; + } + const ok = sub === 'enable' ? await pluginHost.enable(id) : await pluginHost.disable(id); + if (!ok) { + return { + success: false, + output: [out.error(`No plugin with id "${id}". Type /plugins to list them.`)] + }; + } + return { + success: true, + output: [out.system(`Plugin "${id}" ${sub}d.`)] + }; + } + + if (sub) { + return { + success: false, + output: [out.warning('Usage: /plugins [enable|disable ]')] + }; + } + + // no subcommand: list every known plugin with its state. + const plugins = pluginHost.status(); + if (plugins.length === 0) { + return { + success: true, + output: [out.notice('No plugins found. Add one under src/plugins/ — see its README.')] + }; + } + return { + success: true, + output: [ + out.notice(`Plugins (${plugins.length}):`), + ...plugins.map(({ plugin, enabled }) => + out.system( + ` ${enabled ? '[on] ' : '[off]'} ${plugin.id.padEnd(16)} ${plugin.name}${ + plugin.description ? ` — ${plugin.description}` : '' + }` + ) + ), + out.system('Toggle with /plugins enable or /plugins disable .') + ] + }; + } +}; diff --git a/src/lib/commands/commands/relays.ts b/src/lib/commands/commands/relays.ts new file mode 100644 index 0000000..354623d --- /dev/null +++ b/src/lib/commands/commands/relays.ts @@ -0,0 +1,38 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { relayStore } from '$lib/stores/relay-store'; +import { relayService } from '$lib/nostr/relay-service'; +import { out } from '../output'; + +/** + * `/relays` — list configured relays and their connection state. + * `/relays discover` — grab the logged-in user's nip-65 relay list and connect. + */ +export const relaysCommand: ChatCommand = { + name: 'relays', + description: 'List relays, or "/relays discover" to connect your NIP-65 relays.', + usage: '/relays [discover]', + async execute(args, context) { + if ((args[0] ?? '').toLowerCase() === 'discover') { + if (!context.currentUser) { + return { success: false, output: [out.warning('Log in first to discover your relays.')] }; + } + const added = await relayService.discoverAndConnect(context.currentUser); + return { + success: true, + output: added > 0 ? [] : [out.system('No new relays to connect from your NIP-65 list.')] + }; + } + + const relays = Object.values(get(relayStore)); + if (relays.length === 0) { + return { success: true, output: [out.notice('No relays. Use /connect .')] }; + } + const lines = relays.map((r) => { + const nip29 = + r.supportsNip29 === undefined ? '' : r.supportsNip29 ? ' [NIP-29]' : ' [no NIP-29]'; + return out.system(` ${r.connection.padEnd(12)} ${r.url}${nip29}`); + }); + return { success: true, output: [out.notice('Relays:'), ...lines] }; + } +}; diff --git a/src/lib/commands/commands/rooms.ts b/src/lib/commands/commands/rooms.ts new file mode 100644 index 0000000..d98bb73 --- /dev/null +++ b/src/lib/commands/commands/rooms.ts @@ -0,0 +1,23 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomStore } from '$lib/stores/room-store'; +import { out } from '../output'; + +/** `/rooms` — list joined rooms across every relay. */ +export const roomsCommand: ChatCommand = { + name: 'rooms', + description: 'List joined rooms.', + usage: '/rooms', + async execute() { + const { rooms, activeRoomId } = get(roomStore); + const list = Object.values(rooms); + if (list.length === 0) { + return { success: true, output: [out.notice('No rooms. Use /join .')] }; + } + const lines = list.map((room) => { + const marker = room.id === activeRoomId ? '*' : ' '; + return out.system(` ${marker} #${room.name} (${room.address.relayUrl})`); + }); + return { success: true, output: [out.notice('Rooms:'), ...lines] }; + } +}; diff --git a/src/lib/commands/commands/settings.ts b/src/lib/commands/commands/settings.ts new file mode 100644 index 0000000..6cce44b --- /dev/null +++ b/src/lib/commands/commands/settings.ts @@ -0,0 +1,128 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { settingsStore } from '$lib/stores/settings-store'; +import { notificationService } from '$lib/notifications/notification-service'; +import { out } from '../output'; + +/** + * `/settings` — view or tweak saved preferences. + * /settings show current settings + * /settings fontsize set terminal font size (8–32) + * /settings lineheight set line height (1.0–2.5) + * /settings motion toggle reduced-motion + * /settings usersidebar toggle the right sidebar + * /settings notifications desktop notifications for dms/mentions + * /settings reset back to defaults + */ +export const settingsCommand: ChatCommand = { + name: 'settings', + description: 'View or change persisted preferences.', + usage: + '/settings [fontsize | lineheight | motion | usersidebar | notifications | reset]', + async execute(args) { + const [key, value] = args; + + if (!key) { + const s = get(settingsStore); + return { + success: true, + output: [ + out.notice('Settings:'), + out.system(` theme : ${s.themeId}`), + out.system(` fontsize : ${s.fontSize}px`), + out.system(` lineheight : ${s.lineHeight}`), + out.system(` motion : ${s.reducedMotion ? 'reduced' : 'normal'}`), + out.system(` usersidebar : ${s.showUserSidebar ? 'on' : 'off'}`), + out.system(` notifications : ${s.notifications ? 'on' : 'off'}`), + out.system('Change with /settings . See /help settings.') + ] + }; + } + + switch (key.toLowerCase()) { + case 'fontsize': { + const px = Number.parseInt(value ?? '', 10); + if (!Number.isFinite(px) || px < 8 || px > 32) { + return { success: false, output: [out.warning('fontsize must be 8–32.')] }; + } + settingsStore.patch({ fontSize: px }); + return { success: true, output: [out.system(`Font size set to ${px}px.`)] }; + } + case 'lineheight': { + const n = Number.parseFloat(value ?? ''); + if (!Number.isFinite(n) || n < 1 || n > 2.5) { + return { success: false, output: [out.warning('lineheight must be 1.0–2.5.')] }; + } + settingsStore.patch({ lineHeight: n }); + return { success: true, output: [out.system(`Line height set to ${n}.`)] }; + } + case 'motion': { + const on = parseToggle(value); + if (on === null) + return { success: false, output: [out.warning('Usage: /settings motion ')] }; + settingsStore.patch({ reducedMotion: on }); + return { + success: true, + output: [out.system(`Reduced motion ${on ? 'enabled' : 'disabled'}.`)] + }; + } + case 'usersidebar': { + const on = parseToggle(value); + if (on === null) + return { success: false, output: [out.warning('Usage: /settings usersidebar ')] }; + settingsStore.patch({ showUserSidebar: on }); + return { success: true, output: [out.system(`User sidebar ${on ? 'shown' : 'hidden'}.`)] }; + } + case 'notifications': { + const on = parseToggle(value); + if (on === null) + return { + success: false, + output: [out.warning('Usage: /settings notifications ')] + }; + if (!on) { + settingsStore.patch({ notifications: false }); + return { success: true, output: [out.system('Desktop notifications disabled.')] }; + } + if (!notificationService.isSupported()) { + return { + success: false, + output: [out.warning('This browser does not support desktop notifications.')] + }; + } + // turning it on needs os permission (this runs from a user action). + const perm = await notificationService.requestPermission(); + if (perm !== 'granted') { + return { + success: false, + output: [ + out.warning(`Notification permission ${perm}. Enable it in your browser settings.`) + ] + }; + } + settingsStore.patch({ notifications: true }); + return { + success: true, + output: [ + out.system('Desktop notifications enabled (shown when the tab is in the background).') + ] + }; + } + case 'reset': { + settingsStore.reset(); + return { success: true, output: [out.system('Settings reset to defaults.')] }; + } + default: + return { + success: false, + output: [out.error(`Unknown setting "${key}". See /help settings.`)] + }; + } + } +}; + +function parseToggle(value: string | undefined): boolean | null { + if (value === 'on' || value === 'true' || value === 'reduced') return true; + if (value === 'off' || value === 'false' || value === 'normal') return false; + return null; +} diff --git a/src/lib/commands/commands/status.ts b/src/lib/commands/commands/status.ts new file mode 100644 index 0000000..dee4ed0 --- /dev/null +++ b/src/lib/commands/commands/status.ts @@ -0,0 +1,39 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { relayStore } from '$lib/stores/relay-store'; +import { roomStore } from '$lib/stores/room-store'; +import { authStore } from '$lib/stores/auth-store'; +import { activeThemeId } from '$lib/stores/theme-store'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { out } from '../output'; + +/** `/status` — quick rundown of identity, relays, rooms, and theme. */ +export const statusCommand: ChatCommand = { + name: 'status', + description: 'Show connection, identity, and session status.', + usage: '/status', + async execute() { + const relays = Object.values(get(relayStore)); + const connected = relays.filter((r) => r.connection === 'connected').length; + const { rooms, activeRoomId } = get(roomStore); + const auth = get(authStore); + const theme = get(activeThemeId); + + const identity = auth.pubkey + ? `${auth.kind}${auth.ephemeral ? ' (temporary)' : ''} ${shortenPubkey(auth.pubkey)}` + : 'not logged in'; + const active = activeRoomId ? rooms[activeRoomId]?.name : undefined; + + return { + success: true, + output: [ + out.notice('Status:'), + out.system(` identity : ${identity}`), + out.system(` relays : ${connected}/${relays.length} connected`), + out.system(` rooms : ${Object.keys(rooms).length} joined`), + out.system(` active : ${active ? `#${active}` : '(none)'}`), + out.system(` theme : ${theme}`) + ] + }; + } +}; diff --git a/src/lib/commands/commands/switch.ts b/src/lib/commands/commands/switch.ts new file mode 100644 index 0000000..3eab226 --- /dev/null +++ b/src/lib/commands/commands/switch.ts @@ -0,0 +1,57 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomStore } from '$lib/stores/room-store'; +import { dmStore } from '$lib/stores/dm-store'; +import { parseRoomReference, roomId } from '$lib/nostr/room-address'; +import { out } from '../output'; + +/** `/switch ` — make an already-joined room the active one. */ +export const switchCommand: ChatCommand = { + name: 'switch', + aliases: ['sw'], + description: 'Switch the active room to another joined room.', + usage: '/switch ', + async execute(args, context) { + const ref = args[0]; + if (!ref) { + return { success: false, output: [out.warning('Usage: /switch ')] }; + } + + const { rooms } = get(roomStore); + + // first try an exact name match (common case: "/switch general"). + const byName = Object.values(rooms).filter((r) => r.name === ref.replace(/^#/, '')); + let targetId: string | undefined; + if (byName.length === 1) { + targetId = byName[0]!.id; + } else { + // fall back to resolving a full/qualified reference to a canonical id. + try { + targetId = roomId(parseRoomReference(ref, context.activeRelay)); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } + + if (!targetId || !rooms[targetId]) { + if (byName.length > 1) { + return { + success: false, + output: [ + out.error( + `Ambiguous room "${ref}" exists on multiple relays. Use /switch wss://relay/#${ref.replace(/^#/, '')}.` + ) + ] + }; + } + return { success: false, output: [out.error(`Not joined to "${ref}". Use /join first.`)] }; + } + + dmStore.close(); // drop any focused dm view for a room + roomStore.setActive(targetId); + return { success: true, output: [out.system(`Now in #${rooms[targetId]!.name}.`)] }; + } +}; diff --git a/src/lib/commands/commands/theme.ts b/src/lib/commands/commands/theme.ts new file mode 100644 index 0000000..c3796be --- /dev/null +++ b/src/lib/commands/commands/theme.ts @@ -0,0 +1,147 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import type { ThemeInput } from '$lib/themes/theme-types'; +import { allThemes, isBuiltInTheme } from '$lib/themes/theme-registry'; +import { + setTheme, + previewTheme, + restoreTheme, + addCustomTheme, + removeCustomTheme, + activeThemeId +} from '$lib/stores/theme-store'; +import { DEFAULT_THEME_ID } from '$lib/config'; +import { out } from '../output'; + +/** + * `/theme` — manage the active color theme. + * /theme show current theme + usage + * /theme list list all themes (custom marked +) + * /theme current show the active theme + * /theme set set and save a theme + * /theme preview preview without saving + * /theme reset back to the default theme + * /theme add add a custom theme from a json definition + * /theme remove drop a custom theme + */ +export const themeCommand: ChatCommand = { + name: 'theme', + description: 'View, change, or add color themes.', + usage: '/theme [list|current|set |preview |reset|add |remove ]', + async execute(args) { + const sub = (args[0] ?? '').toLowerCase(); + + if (!sub) { + return { + success: true, + output: [ + out.notice(`Current theme: ${get(activeThemeId)}`), + out.system( + 'Usage: /theme list | set | preview | reset | add | remove ' + ) + ] + }; + } + + if (sub === 'list') { + const lines = allThemes().map((t) => { + const active = t.id === get(activeThemeId) ? '*' : ' '; + const custom = isBuiltInTheme(t.id) ? ' ' : '+'; + return out.system( + ` ${active}${custom} ${t.id.padEnd(20)} ${t.mode.padEnd(6)} ${t.description}` + ); + }); + return { + success: true, + output: [out.notice('Themes (* active, + custom):'), ...lines] + }; + } + + if (sub === 'current') { + return { success: true, output: [out.notice(`Current theme: ${get(activeThemeId)}`)] }; + } + + if (sub === 'reset') { + const theme = setTheme(DEFAULT_THEME_ID); + return { success: true, output: [out.system(`Theme reset to ${theme.name}.`)] }; + } + + if (sub === 'add') { + // everything after "add" is a json theme definition. + const json = args.slice(1).join(' ').trim(); + if (!json) { + return { + success: false, + output: [ + out.warning('Usage: /theme add '), + out.system('Provide a TerminalTheme object with id, name, and all colors.*') + ] + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return { success: false, output: [out.error('Invalid JSON.')] }; + } + try { + const theme = await addCustomTheme(parsed as ThemeInput); + setTheme(theme.id); + return { + success: true, + output: [out.system(`Added custom theme "${theme.name}" and applied it.`)] + }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Could not add theme.')] + }; + } + } + + if (sub === 'remove') { + const id = args[1]; + if (!id) return { success: false, output: [out.warning('Usage: /theme remove ')] }; + if (isBuiltInTheme(id)) { + return { success: false, output: [out.error(`"${id}" is a built-in theme.`)] }; + } + const removed = await removeCustomTheme(id); + return removed + ? { success: true, output: [out.system(`Removed custom theme "${id}".`)] } + : { success: false, output: [out.error(`No custom theme "${id}".`)] }; + } + + if (sub === 'set' || sub === 'preview') { + const name = args.slice(1).join(' ').trim(); + if (!name) { + return { success: false, output: [out.warning(`Usage: /theme ${sub} `)] }; + } + try { + if (sub === 'set') { + const theme = setTheme(name); + return { success: true, output: [out.system(`Theme set to ${theme.name}.`)] }; + } + const theme = previewTheme(name); + return { + success: true, + output: [ + out.system( + `Previewing ${theme.name}. Use /theme set ${theme.id} to keep it, or /theme current to revert.` + ) + ] + }; + } catch (err) { + restoreTheme(); + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } + + return { + success: false, + output: [out.error(`Unknown /theme subcommand: ${sub}. Try /theme list.`)] + }; + } +}; diff --git a/src/lib/commands/commands/topic.ts b/src/lib/commands/commands/topic.ts new file mode 100644 index 0000000..0c63570 --- /dev/null +++ b/src/lib/commands/commands/topic.ts @@ -0,0 +1,47 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomService } from '$lib/nostr/room-service'; +import { roomStore } from '$lib/stores/room-store'; +import { out } from '../output'; + +/** + * `/topic` — show the active room's topic. + * `/topic ` — set it (admin-only; the relay rejects everyone else). + */ +export const topicCommand: ChatCommand = { + name: 'topic', + description: "View or set the active room's topic.", + usage: '/topic [text]', + async execute(args) { + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + return { success: false, output: [out.warning('No active room. Join one with /join.')] }; + } + + const text = args.join(' ').trim(); + if (!text) { + return { + success: true, + output: [ + out.notice( + active.topic + ? `Topic for #${active.name}: ${active.topic}` + : `#${active.name} has no topic. Set one with /topic .` + ) + ] + }; + } + + try { + await roomService.setTopic(active.address, text); + // the relay re-publishes metadata; the room store updates reactively. + return { success: true, output: [out.system(`Topic set for #${active.name}.`)] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Failed to set topic.')] + }; + } + } +}; diff --git a/src/lib/commands/commands/who.ts b/src/lib/commands/commands/who.ts new file mode 100644 index 0000000..cec794c --- /dev/null +++ b/src/lib/commands/commands/who.ts @@ -0,0 +1,78 @@ +import { get } from 'svelte/store'; +import type { ChatCommand } from '$lib/types/command'; +import { roomStore } from '$lib/stores/room-store'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { profileService } from '$lib/nostr/profile-service'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { out } from '../output'; + +/** + * `/who` — list the active room's participants. + * uses the relay's authoritative nip-29 member (39002) and admin (39001) lists + * when we have them (admins marked with ★). falls back to authors seen this + * session only when the relay hasn't handed over a member list. + */ +export const whoCommand: ChatCommand = { + name: 'who', + description: 'List participants in the active room.', + usage: '/who', + async execute() { + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + return { success: false, output: [out.warning('No active room. Join one with /join.')] }; + } + + const admins = new Set(active.admins ?? []); + const members = active.members ?? []; + + const format = (pubkey: string): string => { + const marker = admins.has(pubkey) ? '★ ' : ' '; + const name = profileService.cachedName(pubkey); + return `${marker}${name ? `${name} ` : ''}${shortenPubkey(pubkey)}`; + }; + + if (members.length > 0) { + // admins first, then roughly alphabetical by display label. + const sorted = [...members].sort((a, b) => { + const aAdmin = admins.has(a); + const bAdmin = admins.has(b); + if (aAdmin !== bAdmin) return aAdmin ? -1 : 1; + return format(a).localeCompare(format(b)); + }); + return { + success: true, + output: [ + out.notice(`Members of #${active.name} (${members.length}):`), + ...sorted.map((pk) => out.system(` ${format(pk)}`)) + ] + }; + } + + // fallback: no relay member list yet — piece it together from messages seen locally. + const seen = new Set(); + const authors: string[] = []; + const entries = terminalStore.snapshot(); + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (!e || e.roomId !== activeRoomId || !e.author) continue; + if (seen.has(e.author)) continue; + seen.add(e.author); + authors.push(e.author); + } + + if (authors.length === 0) { + return { + success: true, + output: [out.notice(`No member list or activity seen yet in #${active.name}.`)] + }; + } + return { + success: true, + output: [ + out.notice(`Recently active in #${active.name} (${authors.length}, from local activity):`), + ...authors.map((pk) => out.system(` ${format(pk)}`)) + ] + }; + } +}; diff --git a/src/lib/commands/commands/whois.ts b/src/lib/commands/commands/whois.ts new file mode 100644 index 0000000..4d3287c --- /dev/null +++ b/src/lib/commands/commands/whois.ts @@ -0,0 +1,55 @@ +import type { ChatCommand } from '$lib/types/command'; +import { profileService } from '$lib/nostr/profile-service'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { decodeNpub } from '$lib/utils/npub'; +import { out } from '../output'; + +/** `/whois ` — look up and show a user's profile details. */ +export const whoisCommand: ChatCommand = { + name: 'whois', + description: 'Show profile details for a user.', + usage: '/whois ', + async execute(args) { + const target = args[0]; + if (!target) { + return { success: false, output: [out.warning('Usage: /whois ')] }; + } + + let pubkey: string; + try { + pubkey = decodeNpub(target); + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : 'Invalid identifier.')] + }; + } + + await profileService.resolve(pubkey); + const name = profileService.cachedName(pubkey); + const nip05 = profileService.cachedNip05(pubkey); + + const lines = [ + out.notice(`whois ${shortenPubkey(pubkey)}`), + out.system(` name : ${name ?? '(unknown)'}`) + ]; + + if (!nip05) { + lines.push(out.system(' nip05 : (none)')); + } else { + // a claimed nip-05 means nothing until checked against its domain. + const status = await profileService.verifyNip05(pubkey); + if (status === 'verified') { + lines.push(out.system(` nip05 : ${nip05} (✓ verified)`)); + } else if (status === 'mismatch') { + // the domain points the name at a DIFFERENT key — likely impersonation. + lines.push(out.warning(` nip05 : ${nip05} (✗ does NOT match this pubkey)`)); + } else { + lines.push(out.system(` nip05 : ${nip05} (unverified — could not confirm)`)); + } + } + + lines.push(out.system(` pubkey: ${pubkey}`)); + return { success: true, output: lines }; + } +}; diff --git a/src/lib/commands/create-registry.ts b/src/lib/commands/create-registry.ts new file mode 100644 index 0000000..8057416 --- /dev/null +++ b/src/lib/commands/create-registry.ts @@ -0,0 +1,69 @@ +import { CommandRegistry } from './command-registry'; +import { createHelpCommand } from './commands/help'; +import { clearCommand } from './commands/clear'; +import { statusCommand } from './commands/status'; +import { relaysCommand } from './commands/relays'; +import { roomsCommand } from './commands/rooms'; +import { switchCommand } from './commands/switch'; +import { themeCommand } from './commands/theme'; +import { loginCommand } from './commands/login'; +import { logoutCommand } from './commands/logout'; +import { connectCommand } from './commands/connect'; +import { disconnectCommand } from './commands/disconnect'; +import { joinCommand } from './commands/join'; +import { partCommand } from './commands/part'; +import { meCommand } from './commands/me'; +import { whoCommand } from './commands/who'; +import { whoisCommand } from './commands/whois'; +import { muteCommand, unmuteCommand } from './commands/mute'; +import { settingsCommand } from './commands/settings'; +import { nickCommand } from './commands/nick'; +import { inviteCommand, kickCommand } from './commands/moderate'; +import { msgCommand } from './commands/msg'; +import { dmCommand } from './commands/dm'; +import { topicCommand } from './commands/topic'; +import { pluginsCommand } from './commands/plugins'; + +/** + * build a registry with all the mvp commands wired up. finished commands work + * now; ones slated for later phases return a clear notice. + * `/help` gets a getter for the registry so it can list every command. + */ +export function createRegistry(): CommandRegistry { + const registry = new CommandRegistry(); + registry.registerAll([ + createHelpCommand(() => registry), + clearCommand, + statusCommand, + relaysCommand, + roomsCommand, + switchCommand, + themeCommand, + // identity & relays (phase 3). + loginCommand, + logoutCommand, + connectCommand, + disconnectCommand, + // rooms & messaging (phase 4). + joinCommand, + partCommand, + meCommand, + whoCommand, + whoisCommand, + // profile & moderation. + nickCommand, + inviteCommand, + kickCommand, + // persistence & preferences (phase 5). + muteCommand, + unmuteCommand, + settingsCommand, + // direct messages & room topic. + msgCommand, + dmCommand, + topicCommand, + // plugins. + pluginsCommand + ]); + return registry; +} diff --git a/src/lib/commands/dispatcher.ts b/src/lib/commands/dispatcher.ts new file mode 100644 index 0000000..0650c95 --- /dev/null +++ b/src/lib/commands/dispatcher.ts @@ -0,0 +1,65 @@ +import type { CommandContext, CommandResult } from '$lib/types/command'; +import type { TerminalEntry } from '$lib/types/terminal'; +import { CommandRegistry } from './command-registry'; +import { parseInput } from './command-parser'; +import { out } from './output'; + +/** deps injected into the dispatcher (keeps it unit-testable). */ +export interface DispatcherDeps { + registry: CommandRegistry; + /** snapshot the current context (active room/relay/user) at dispatch time. */ + getContext: () => CommandContext; + /** send plain text (non-command) input to the active room. */ + sendMessage: (content: string, context: CommandContext) => Promise; +} + +/** + * turns a raw input line into terminal output: classifies it, then either + * routes it to the active room or resolves + runs a command. + * + * errors thrown by a command become a friendly error entry — the dispatcher + * never rejects, so the ui can always render a result. + */ +export class CommandDispatcher { + constructor(private readonly deps: DispatcherDeps) {} + + async dispatch(raw: string): Promise { + const parsed = parseInput(raw); + const context = this.deps.getContext(); + + if (parsed.kind === 'empty') { + return { success: true, output: [] }; + } + + if (parsed.kind === 'message') { + try { + return await this.deps.sendMessage(parsed.content, context); + } catch (err) { + return { success: false, output: [out.error(errorMessage(err))] }; + } + } + + const command = this.deps.registry.resolve(parsed.name); + if (!command) { + return { + success: false, + output: [out.error(`Unknown command: /${parsed.name}. Type /help for a list.`)] + }; + } + + try { + return await command.execute(parsed.args, context); + } catch (err) { + return { success: false, output: [out.error(errorMessage(err))] }; + } + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** shared helper: spit out a command's usage as a warning entry. */ +export function usageError(usage: string): TerminalEntry { + return out.warning(`Usage: ${usage}`); +} diff --git a/src/lib/commands/output.ts b/src/lib/commands/output.ts new file mode 100644 index 0000000..ec436c9 --- /dev/null +++ b/src/lib/commands/output.ts @@ -0,0 +1,24 @@ +import type { TerminalEntry, TerminalEntryType } from '$lib/types/terminal'; +import { generateId } from '$lib/utils/id'; + +/** + * build a trusted local terminal entry for command output. all command output + * is local/system-originated — remote chat content never flows through here. + */ +export function entry(type: TerminalEntryType, content: string, roomId?: string): TerminalEntry { + return { + id: generateId(), + type, + timestamp: Date.now(), + roomId, + content + }; +} + +/** handy builders for the common command-output entry types. */ +export const out = { + system: (content: string, roomId?: string) => entry('system', content, roomId), + notice: (content: string, roomId?: string) => entry('notice', content, roomId), + warning: (content: string, roomId?: string) => entry('warning', content, roomId), + error: (content: string, roomId?: string) => entry('error', content, roomId) +}; diff --git a/src/lib/commands/tokenize.ts b/src/lib/commands/tokenize.ts new file mode 100644 index 0000000..48652cc --- /dev/null +++ b/src/lib/commands/tokenize.ts @@ -0,0 +1,44 @@ +/** + * tokenize a command argument string, honoring double and single quotes so + * args with spaces can come through as a single token. + * + * parse('set "Solarized Dark"') -> ['set', 'Solarized Dark'] + * + * an unterminated quote just runs to end-of-input (lenient, so someone + * mid-typing never hits a hard error from the tokenizer itself). + */ +export function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + let hasToken = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + hasToken = true; + continue; + } + if (ch === ' ' || ch === '\t') { + if (hasToken) { + tokens.push(current); + current = ''; + hasToken = false; + } + continue; + } + current += ch; + hasToken = true; + } + if (hasToken) tokens.push(current); + return tokens; +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..f802ad3 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,40 @@ +import { env } from '$env/dynamic/public'; + +/** + * runtime config pulled from PUBLIC_ env vars. + * uses `$env/dynamic/public` so every var is optional — the app runs on sane + * defaults with no `.env`. product naming lives in `src/lib/brand.ts` so it can + * change without touching protocol or app logic. + */ + +function parseIntOr(value: string | undefined, fallback: number): number { + const n = Number.parseInt(value ?? '', 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +// build-time fallback only; runtime /config.json (see runtime-config.ts) wins. +export const DEFAULT_RELAYS: string[] = (env.PUBLIC_DEFAULT_RELAYS ?? '') + .split(',') + .map((r: string) => r.trim()) + .filter((r: string) => r.length > 0); + +export const DEFAULT_THEME_ID: string = (env.PUBLIC_DEFAULT_THEME ?? 'nord').trim() || 'nord'; + +export const MAX_TERMINAL_ENTRIES: number = parseIntOr(env.PUBLIC_MAX_TERMINAL_ENTRIES, 1000); + +export const MAX_MESSAGE_LENGTH: number = parseIntOr(env.PUBLIC_MAX_MESSAGE_LENGTH, 2000); + +/** how many past commands the input box remembers. */ +export const MAX_COMMAND_HISTORY = 200; + +/** most cached messages kept per room in indexeddb (older ones get pruned). */ +export const MAX_CACHED_MESSAGES_PER_ROOM: number = parseIntOr( + env.PUBLIC_MAX_CACHED_MESSAGES_PER_ROOM, + 500 +); + +/** how long (ms) a cached profile stays fresh before we refetch. */ +export const PROFILE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** how many recent messages per room we replay into the terminal at startup. */ +export const RESTORE_MESSAGES_PER_ROOM = 50; diff --git a/src/lib/db/database.ts b/src/lib/db/database.ts new file mode 100644 index 0000000..da5ca66 --- /dev/null +++ b/src/lib/db/database.ts @@ -0,0 +1,169 @@ +import Dexie, { type Table } from 'dexie'; +import type { RoomAddress } from '$lib/types/room'; +import type { AppSettings } from '$lib/types/settings'; +import type { TerminalTheme } from '$lib/themes/theme-types'; + +/** + * saved record shapes. these are storage models — kept plain on purpose and + * separate from the runtime store/ndk types. cached events are UNTRUSTED on + * load until the nostr lib re-checks them. + */ + +export interface RelayRecord { + /** normalized relay url (primary key). */ + url: string; + addedAt: number; +} + +export interface RoomRecord { + /** room id: normalizedRelayUrl#groupId (primary key). */ + id: string; + relayUrl: string; + groupId: string; + name: string; + topic?: string; + joinedAt: number; +} + +export interface MessageRecord { + /** nostr event id (primary key). */ + id: string; + /** room id (indexed for per-room queries). */ + roomId: string; + authorPubkey: string; + content: string; + createdAt: number; + eventKind: number; + action: boolean; + replyTo?: string; + /** + * was this event signature-verified WHEN CACHED. just a hint — never shown + * as trusted without re-checking on load. + */ + verifiedWhenCached: boolean; +} + +export interface ReadMarkerRecord { + /** room id (primary key). */ + roomId: string; + /** createdAt (unix seconds) of the last read message. */ + lastReadAt: number; +} + +export interface MutedPubkeyRecord { + pubkey: string; + mutedAt: number; +} + +export interface ProfileRecord { + pubkey: string; + name?: string; + nip05?: string; + fetchedAt: number; +} + +export interface PendingMessageRecord { + /** local id (primary key, auto-incremented). */ + localId?: number; + roomId: string; + relayUrl: string; + groupId: string; + content: string; + action: boolean; + queuedAt: number; +} + +export interface SettingRecord { + /** fixed key; we stash one settings blob under "app". */ + key: string; + value: AppSettings; +} + +/** + * a nip-49 encrypted private key the user chose to remember on this device. + * we keep ONLY the ncryptsec (passphrase-encrypted) — never a plaintext key. + * decrypting needs the user's passphrase and happens in memory at login. + */ +export interface StoredIdentityRecord { + /** fixed key "primary" — the mvp remembers one device identity. */ + key: string; + /** public key (hex) to show before unlock. */ + pubkey: string; + /** nip-49 ncryptsec blob (passphrase-encrypted secret key). */ + ncryptsec: string; + savedAt: number; +} + +/** a user- (or plugin-) defined theme saved for reuse across sessions. */ +export interface CustomThemeRecord { + /** theme id (primary key). */ + id: string; + /** the whole TerminalTheme object, stored as-is. */ + theme: TerminalTheme; + savedAt: number; +} + +/** plugin-scoped saved state, keyed by ":". */ +export interface PluginStateRecord { + /** composite key `${pluginId}:${key}` (primary key). */ + key: string; + value: unknown; +} + +/** handy: rebuild a RoomAddress from a stored room record. */ +export function recordToAddress(record: RoomRecord): RoomAddress { + return { relayUrl: record.relayUrl, groupId: record.groupId }; +} + +/** + * the nosterm indexeddb database. schema is versioned; bump the version and + * add a `.stores()` block to run a migration. + */ +export class NostermDatabase extends Dexie { + relays!: Table; + rooms!: Table; + messages!: Table; + readMarkers!: Table; + mutedPubkeys!: Table; + profiles!: Table; + pendingMessages!: Table; + settings!: Table; + identities!: Table; + customThemes!: Table; + pluginState!: Table; + + constructor() { + super('nosterm'); + this.version(1).stores({ + relays: 'url, addedAt', + rooms: 'id, relayUrl, joinedAt', + // compound index [roomId+createdAt] drives ordered per-room history. + messages: 'id, roomId, createdAt, [roomId+createdAt]', + readMarkers: 'roomId', + mutedPubkeys: 'pubkey, mutedAt', + profiles: 'pubkey, fetchedAt', + pendingMessages: '++localId, roomId, queuedAt', + settings: 'key' + }); + // v2 adds the optional encrypted-identity store (remember-on-device). + this.version(2).stores({ + identities: 'key' + }); + // v3 adds user- / plugin-defined custom themes. + this.version(3).stores({ + customThemes: 'id, savedAt' + }); + // v4 adds plugin-scoped key/value state. + this.version(4).stores({ + pluginState: 'key' + }); + } +} + +let dbInstance: NostermDatabase | null = null; + +/** lazily-made database singleton (client-only). */ +export function getDb(): NostermDatabase { + if (!dbInstance) dbInstance = new NostermDatabase(); + return dbInstance; +} diff --git a/src/lib/db/persistence-service.ts b/src/lib/db/persistence-service.ts new file mode 100644 index 0000000..b08fa6c --- /dev/null +++ b/src/lib/db/persistence-service.ts @@ -0,0 +1,181 @@ +import { get } from 'svelte/store'; +import { + relayRepository, + roomRepository, + messageRepository, + muteRepository, + profileRepository, + settingsRepository +} from './repositories'; +import { recordToAddress } from './database'; +import { settingsStore } from '$lib/stores/settings-store'; +import { relayStore } from '$lib/stores/relay-store'; +import { roomStore } from '$lib/stores/room-store'; +import { muteStore } from '$lib/stores/mute-store'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { relayService } from '$lib/nostr/relay-service'; +import { roomService } from '$lib/nostr/room-service'; +import { profileService } from '$lib/nostr/profile-service'; +import { initTheme, setTheme, loadCustomThemes } from '$lib/stores/theme-store'; +import { resolveTheme } from '$lib/themes/theme-registry'; +import { RESTORE_MESSAGES_PER_ROOM } from '$lib/config'; +import { shortenPubkey } from '$lib/utils/pubkey'; + +/** + * loads saved state at startup and keeps indexeddb in sync with the runtime + * stores after that. lives outside the stores so they stay framework-pure and + * unit-testable without indexeddb. + * + * heads up (security): cached events are UNTRUSTED on load. we show restored + * messages as history, but re-validation happens live when the room's sub + * re-delivers events; restored records look exactly like remote chat (never + * system lines) and their content is plain text only. + */ +/** + * read a `?theme=` query param and return the matching theme id, or null. + * checked against the registry (built-in + custom, alias-aware) so an unknown + * or junk value just gets ignored. then we strip the param from the url so a + * reload/bookmark doesn't keep clobbering the user's later choice. + */ +function readThemeParam(): string | null { + if (typeof window === 'undefined') return null; + try { + const raw = new URLSearchParams(window.location.search).get('theme'); + if (!raw) return null; + const theme = resolveTheme(raw); + if (!theme) return null; + // drop the param without a navigation/reload. + const url = new URL(window.location.href); + url.searchParams.delete('theme'); + window.history.replaceState({}, '', url); + return theme.id; + } catch { + return null; + } +} + +class PersistenceService { + private started = false; + + /** load saved settings, mutes, profiles, relays, and rooms. */ + async restore(): Promise { + // 1) settings first, so the theme lands before the ui is interactive. + const savedSettings = await settingsRepository.load(); + if (savedSettings) settingsStore.set(savedSettings); + // register saved custom themes before applying, so a custom active theme + // resolves instead of falling back to the default. + await loadCustomThemes(); + // a `?theme=` query param (e.g. from the marketing site's "launch + // client" link) beats the saved theme so a visitor's pick follows them + // into the app. only a known theme wins; anything else is ignored and the + // saved/default theme applies as usual. + const urlThemeId = readThemeParam(); + initTheme(get(settingsStore).themeId); + if (urlThemeId) setTheme(urlThemeId); // resolve + apply + save + + // 2) muted pubkeys. + const muted = await muteRepository.list(); + muteStore.setAll(muted); + + // 3) cached profiles (so restored/live messages show names right away). + for (const profile of await profileRepository.all()) { + profileService.hydrate(profile.pubkey, profile.name, profile.nip05); + } + + // 4) reconnect to relays we used before. + const relays = await relayRepository.list(); + for (const relay of relays) { + void relayService.connect(relay.url); + } + + // 5) rejoin rooms we were in and restore their recent history. + const rooms = await roomRepository.list(); + for (const room of rooms) { + await this.restoreRoom(room.id, room.name); + // rejoin re-opens the live sub (which re-validates events). + void roomService.join(recordToAddress(room)); + } + + if (relays.length > 0 || rooms.length > 0) { + terminalStore.system( + `Restored ${relays.length} relay(s) and ${rooms.length} room(s) from your last session.`, + 'system' + ); + } + + this.start(); + } + + /** dump a room's cached messages back into the terminal as history. */ + private async restoreRoom(roomId: string, roomName: string): Promise { + const messages = await messageRepository.recent(roomId, RESTORE_MESSAGES_PER_ROOM); + if (messages.length === 0) return; + terminalStore.system(`--- ${roomName} history (${messages.length}) ---`, 'system', roomId); + for (const m of messages) { + if (get(muteStore).has(m.authorPubkey)) continue; + const name = profileService.cachedName(m.authorPubkey) ?? shortenPubkey(m.authorPubkey); + terminalStore.push({ + type: m.action ? 'action' : 'message', + author: m.authorPubkey, + authorDisplayName: name, + content: m.content, + roomId, + timestamp: m.createdAt ? m.createdAt * 1000 : Date.now() + }); + } + } + + /** start mirroring runtime store changes into indexeddb. idempotent. */ + private start(): void { + if (this.started) return; + this.started = true; + + // save settings on every change. + settingsStore.subscribe((settings) => { + void settingsRepository.save(settings); + }); + + // save the relay set (added/removed) reactively. + relayStore.subscribe((relays) => { + void this.syncRelays(Object.keys(relays)); + }); + + // save joined rooms reactively. + roomStore.subscribe((state) => { + void this.syncRooms(state.rooms); + }); + + // save mute list reactively. + muteStore.subscribe((set) => { + void this.syncMutes([...set]); + }); + } + + private async syncRelays(current: string[]): Promise { + const stored = new Set((await relayRepository.list()).map((r) => r.url)); + for (const url of current) if (!stored.has(url)) await relayRepository.add(url); + for (const url of stored) if (!current.includes(url)) await relayRepository.remove(url); + } + + private async syncRooms( + rooms: Record< + string, + { id: string; address: { relayUrl: string; groupId: string }; name: string; topic?: string } + > + ): Promise { + const stored = new Set((await roomRepository.list()).map((r) => r.id)); + const currentIds = new Set(Object.keys(rooms)); + for (const room of Object.values(rooms)) { + await roomRepository.add(room.address, room.name, room.topic); + } + for (const id of stored) if (!currentIds.has(id)) await roomRepository.remove(id); + } + + private async syncMutes(current: string[]): Promise { + const stored = new Set(await muteRepository.list()); + for (const pk of current) if (!stored.has(pk)) await muteRepository.add(pk); + for (const pk of stored) if (!current.includes(pk)) await muteRepository.remove(pk); + } +} + +export const persistenceService = new PersistenceService(); diff --git a/src/lib/db/repositories/custom-theme-repository.ts b/src/lib/db/repositories/custom-theme-repository.ts new file mode 100644 index 0000000..53162d4 --- /dev/null +++ b/src/lib/db/repositories/custom-theme-repository.ts @@ -0,0 +1,18 @@ +import { getDb, type CustomThemeRecord } from '../database'; +import type { TerminalTheme } from '$lib/themes/theme-types'; + +/** storage for user-defined / plugin-provided themes. */ +export const customThemeRepository = { + async list(): Promise { + return (await getDb().customThemes.orderBy('savedAt').toArray()).map((r) => r.theme); + }, + + async save(theme: TerminalTheme): Promise { + const record: CustomThemeRecord = { id: theme.id, theme, savedAt: Date.now() }; + await getDb().customThemes.put(record); + }, + + async remove(id: string): Promise { + await getDb().customThemes.delete(id); + } +}; diff --git a/src/lib/db/repositories/identity-repository.ts b/src/lib/db/repositories/identity-repository.ts new file mode 100644 index 0000000..0bc371a --- /dev/null +++ b/src/lib/db/repositories/identity-repository.ts @@ -0,0 +1,27 @@ +import { getDb, type StoredIdentityRecord } from '../database'; + +const PRIMARY = 'primary'; + +/** + * storage for a remembered device identity. keeps ONLY the nip-49 ncryptsec + * (passphrase-encrypted secret key) — never a plaintext key. the mvp remembers + * a single identity under a fixed key. + */ +export const identityRepository = { + async get(): Promise { + return getDb().identities.get(PRIMARY); + }, + + async save(pubkey: string, ncryptsec: string): Promise { + await getDb().identities.put({ + key: PRIMARY, + pubkey, + ncryptsec, + savedAt: Date.now() + }); + }, + + async clear(): Promise { + await getDb().identities.delete(PRIMARY); + } +}; diff --git a/src/lib/db/repositories/index.ts b/src/lib/db/repositories/index.ts new file mode 100644 index 0000000..4b95cc8 --- /dev/null +++ b/src/lib/db/repositories/index.ts @@ -0,0 +1,13 @@ +export { relayRepository } from './relay-repository'; +export { roomRepository } from './room-repository'; +export { messageRepository } from './message-repository'; +export { + readMarkerRepository, + muteRepository, + profileRepository, + settingsRepository +} from './preference-repository'; +export { pendingMessageRepository } from './pending-message-repository'; +export { identityRepository } from './identity-repository'; +export { customThemeRepository } from './custom-theme-repository'; +export { pluginStateRepository } from './plugin-state-repository'; diff --git a/src/lib/db/repositories/message-repository.ts b/src/lib/db/repositories/message-repository.ts new file mode 100644 index 0000000..6b0925d --- /dev/null +++ b/src/lib/db/repositories/message-repository.ts @@ -0,0 +1,59 @@ +import { getDb, type MessageRecord } from '../database'; +import type { ChatMessage } from '$lib/types/chat'; +import { roomId } from '$lib/nostr/room-address'; +import { MAX_CACHED_MESSAGES_PER_ROOM } from '$lib/config'; + +/** storage for cached chat messages, with per-room retention pruning. */ +export const messageRepository = { + /** save a message (idempotent by event id) and prune old ones for its room. */ + async save(message: ChatMessage): Promise { + const id = roomId(message.room); + const record: MessageRecord = { + id: message.id, + roomId: id, + authorPubkey: message.authorPubkey, + content: message.content, + createdAt: message.createdAt, + eventKind: message.eventKind, + action: message.action ?? false, + replyTo: message.replyTo, + verifiedWhenCached: message.verified + }; + await getDb().messages.put(record); + await this.prune(id); + }, + + /** newest `limit` messages for a room, in chronological order. */ + async recent(roomIdValue: string, limit: number): Promise { + const rows = await getDb() + .messages.where('[roomId+createdAt]') + .between([roomIdValue, Dexie_minKey], [roomIdValue, Dexie_maxKey]) + .reverse() + .limit(limit) + .toArray(); + return rows.reverse(); + }, + + /** keep only the newest MAX_CACHED_MESSAGES_PER_ROOM messages for a room. */ + async prune(roomIdValue: string): Promise { + const db = getDb(); + const count = await db.messages.where('roomId').equals(roomIdValue).count(); + if (count <= MAX_CACHED_MESSAGES_PER_ROOM) return; + const excess = count - MAX_CACHED_MESSAGES_PER_ROOM; + const oldest = await db.messages + .where('[roomId+createdAt]') + .between([roomIdValue, Dexie_minKey], [roomIdValue, Dexie_maxKey]) + .limit(excess) + .primaryKeys(); + await db.messages.bulkDelete(oldest); + }, + + /** wipe all messages for a room (e.g. on part). */ + async clearRoom(roomIdValue: string): Promise { + await getDb().messages.where('roomId').equals(roomIdValue).delete(); + } +}; + +// dexie key bounds for compound-index range queries. +const Dexie_minKey = -Infinity; +const Dexie_maxKey = Infinity; diff --git a/src/lib/db/repositories/pending-message-repository.ts b/src/lib/db/repositories/pending-message-repository.ts new file mode 100644 index 0000000..9192ea6 --- /dev/null +++ b/src/lib/db/repositories/pending-message-repository.ts @@ -0,0 +1,28 @@ +import { getDb, type PendingMessageRecord } from '../database'; +import type { RoomAddress } from '$lib/types/room'; +import { roomId } from '$lib/nostr/room-address'; + +/** + * storage for outbound messages we couldn't publish (e.g. offline or relay + * rejected). lets a later retry/flush pass pick them up. + */ +export const pendingMessageRepository = { + async enqueue(room: RoomAddress, content: string, action: boolean): Promise { + return getDb().pendingMessages.add({ + roomId: roomId(room), + relayUrl: room.relayUrl, + groupId: room.groupId, + content, + action, + queuedAt: Date.now() + }); + }, + + async list(): Promise { + return getDb().pendingMessages.orderBy('queuedAt').toArray(); + }, + + async remove(localId: number): Promise { + await getDb().pendingMessages.delete(localId); + } +}; diff --git a/src/lib/db/repositories/plugin-state-repository.ts b/src/lib/db/repositories/plugin-state-repository.ts new file mode 100644 index 0000000..a1d464a --- /dev/null +++ b/src/lib/db/repositories/plugin-state-repository.ts @@ -0,0 +1,13 @@ +import { getDb } from '../database'; + +/** namespaced key/value storage for plugins (":"). */ +export const pluginStateRepository = { + async get(pluginId: string, key: string): Promise { + const record = await getDb().pluginState.get(`${pluginId}:${key}`); + return record?.value as T | undefined; + }, + + async set(pluginId: string, key: string, value: T): Promise { + await getDb().pluginState.put({ key: `${pluginId}:${key}`, value }); + } +}; diff --git a/src/lib/db/repositories/preference-repository.ts b/src/lib/db/repositories/preference-repository.ts new file mode 100644 index 0000000..eacc16e --- /dev/null +++ b/src/lib/db/repositories/preference-repository.ts @@ -0,0 +1,51 @@ +import { getDb, type ProfileRecord } from '../database'; +import type { AppSettings } from '$lib/types/settings'; + +const SETTINGS_KEY = 'app'; + +/** storage for read markers, muted pubkeys, profiles, and settings. */ + +export const readMarkerRepository = { + async set(roomId: string, lastReadAt: number): Promise { + await getDb().readMarkers.put({ roomId, lastReadAt }); + }, + async get(roomId: string): Promise { + return (await getDb().readMarkers.get(roomId))?.lastReadAt; + } +}; + +export const muteRepository = { + async list(): Promise { + return (await getDb().mutedPubkeys.toArray()).map((r) => r.pubkey); + }, + async add(pubkey: string): Promise { + await getDb().mutedPubkeys.put({ pubkey, mutedAt: Date.now() }); + }, + async remove(pubkey: string): Promise { + await getDb().mutedPubkeys.delete(pubkey); + }, + async has(pubkey: string): Promise { + return (await getDb().mutedPubkeys.get(pubkey)) !== undefined; + } +}; + +export const profileRepository = { + async get(pubkey: string): Promise { + return getDb().profiles.get(pubkey); + }, + async save(record: ProfileRecord): Promise { + await getDb().profiles.put(record); + }, + async all(): Promise { + return getDb().profiles.toArray(); + } +}; + +export const settingsRepository = { + async load(): Promise { + return (await getDb().settings.get(SETTINGS_KEY))?.value; + }, + async save(value: AppSettings): Promise { + await getDb().settings.put({ key: SETTINGS_KEY, value }); + } +}; diff --git a/src/lib/db/repositories/relay-repository.ts b/src/lib/db/repositories/relay-repository.ts new file mode 100644 index 0000000..996b969 --- /dev/null +++ b/src/lib/db/repositories/relay-repository.ts @@ -0,0 +1,18 @@ +import { getDb, type RelayRecord } from '../database'; +import { normalizeRelayUrl } from '$lib/utils/relay-url'; + +/** storage for the relays the user has added. */ +export const relayRepository = { + async list(): Promise { + return getDb().relays.orderBy('addedAt').toArray(); + }, + + async add(url: string): Promise { + const normalized = normalizeRelayUrl(url); + await getDb().relays.put({ url: normalized, addedAt: Date.now() }); + }, + + async remove(url: string): Promise { + await getDb().relays.delete(normalizeRelayUrl(url)); + } +}; diff --git a/src/lib/db/repositories/room-repository.ts b/src/lib/db/repositories/room-repository.ts new file mode 100644 index 0000000..b5d9625 --- /dev/null +++ b/src/lib/db/repositories/room-repository.ts @@ -0,0 +1,26 @@ +import { getDb, type RoomRecord } from '../database'; +import type { RoomAddress } from '$lib/types/room'; +import { roomId } from '$lib/nostr/room-address'; + +/** storage for joined rooms. */ +export const roomRepository = { + async list(): Promise { + return getDb().rooms.orderBy('joinedAt').toArray(); + }, + + async add(address: RoomAddress, name: string, topic?: string): Promise { + const id = roomId(address); + await getDb().rooms.put({ + id, + relayUrl: address.relayUrl, + groupId: address.groupId, + name, + topic, + joinedAt: Date.now() + }); + }, + + async remove(id: string): Promise { + await getDb().rooms.delete(id); + } +}; diff --git a/src/lib/nostr/auth-service.ts b/src/lib/nostr/auth-service.ts new file mode 100644 index 0000000..7c8afec --- /dev/null +++ b/src/lib/nostr/auth-service.ts @@ -0,0 +1,99 @@ +import { SignerService } from './signer-service'; +import { authStore } from '$lib/stores/auth-store'; +import { identityRepository } from '$lib/db/repositories'; + +/** + * app-facing identity service. wraps SignerService and keeps the auth store in + * sync. the command/ui layers call this — never ndk/signer classes. + */ +class AuthService { + private readonly signer = new SignerService(); + + hasNip07(): boolean { + return SignerService.hasNip07(); + } + + async loginNip07(): Promise { + const active = await this.signer.loginNip07(); + authStore.setNip07(active.pubkey); + return active.pubkey; + } + + async loginBunker(connectionToken: string): Promise { + const active = await this.signer.loginBunker(connectionToken); + authStore.setBunker(active.pubkey); + return active.pubkey; + } + + loginNsec(nsecOrHex: string): string { + const active = this.signer.loginNsec(nsecOrHex); + authStore.setNsec(active.pubkey); + return active.pubkey; + } + + generate(): string { + const active = this.signer.generateAndLogin(); + authStore.setNsec(active.pubkey); + return active.pubkey; + } + + loginEphemeral(): string { + const active = this.signer.loginEphemeral(); + authStore.setEphemeral(active.pubkey); + return active.pubkey; + } + + logout(): void { + this.signer.logout(); + authStore.logout(); + } + + // --- one-time key material access (for the landing ui only) -------------- + + /** the active in-memory key as nsec/npub, for one-time display after generate. */ + exportKeyPair(): { nsec: string; npub: string } | null { + const nsec = this.signer.exportNsec(); + const npub = this.signer.exportNpub(); + return nsec && npub ? { nsec, npub } : null; + } + + // --- remember-on-device (nip-49 encrypted) ------------------------------- + + /** encrypt the active in-memory key with a passphrase and stash it locally. */ + async rememberOnDevice(passphrase: string): Promise { + const pubkey = this.signer.current?.pubkey; + if (!pubkey) throw new Error('No active in-memory identity to remember.'); + const ncryptsec = this.signer.exportNcryptsec(passphrase); + await identityRepository.save(pubkey, ncryptsec); + } + + /** is there a remembered (encrypted) identity on this device? */ + async hasRememberedIdentity(): Promise { + return (await identityRepository.get()) !== undefined; + } + + /** public key (hex) of the remembered identity, for display before unlock. */ + async rememberedPubkey(): Promise { + return (await identityRepository.get())?.pubkey; + } + + /** unlock and log in with the remembered identity using its passphrase. */ + async unlockRemembered(passphrase: string): Promise { + const record = await identityRepository.get(); + if (!record) throw new Error('No remembered identity on this device.'); + const active = this.signer.loginNcryptsec(record.ncryptsec, passphrase); + authStore.setNsec(active.pubkey); + return active.pubkey; + } + + /** forget the remembered identity (doesn't log out the current session). */ + async forgetRemembered(): Promise { + await identityRepository.clear(); + } + + get pubkey(): string | undefined { + return this.signer.current?.pubkey; + } +} + +export const authService = new AuthService(); diff --git a/src/lib/nostr/chat-transport.ts b/src/lib/nostr/chat-transport.ts new file mode 100644 index 0000000..d4bef20 --- /dev/null +++ b/src/lib/nostr/chat-transport.ts @@ -0,0 +1,57 @@ +import type { RoomAddress } from '$lib/types/room'; +import type { ChatMessage } from '$lib/types/chat'; + +/** a relay-published snapshot of a group's admins/members (from 39001/39002). */ +export type GroupRoster = + | { + /** an admin (39001) or member (39002) list update. */ + kind: 'admins' | 'members'; + /** pubkeys (hex) in the list. */ + pubkeys: string[]; + } + | { + /** a group metadata (39000) update. */ + kind: 'metadata'; + name?: string; + /** the group's `about` — shown as the room topic. */ + about?: string; + }; + +/** nip-29 moderation actions an admin can do. */ +export type ModerationAction = 'add' | 'remove'; + +/** + * the app's chat transport. the ui and command layer lean on this interface + * only — never on ndk objects directly. NdkChatTransport in prod, easy to fake + * in tests. + */ +export interface ChatTransport { + connectRelay(relayUrl: string): Promise; + disconnectRelay(relayUrl: string): Promise; + joinRoom(room: RoomAddress): Promise; + leaveRoom(room: RoomAddress): Promise; + /** publish a chat/action message; resolves with the signed event id. */ + sendRoomMessage(room: RoomAddress, content: string, action?: boolean): Promise; + /** + * subscribe to a room's messages. the handler fires for each new, deduped, + * verified-or-not message (verification status is on the message). returns + * an unsub fn that cleans up the relay sub. + */ + subscribeToRoom(room: RoomAddress, handler: (message: ChatMessage) => void): () => void; + /** + * subscribe to a room's nip-29 metadata + admin/member lists (kinds + * 39000/39001/39002). the handler fires on each update. returns an unsub fn. + */ + subscribeToRoster(room: RoomAddress, handler: (roster: GroupRoster) => void): () => void; + /** + * publish a moderation event: add (kind 9000) or remove (kind 9001) a user. + * the relay has the final say — it rejects the event if the sender isn't an + * admin, which comes back as a thrown error. + */ + moderateUser(room: RoomAddress, action: ModerationAction, targetPubkey: string): Promise; + /** + * set the group's metadata (kind 9002) — used to change the room topic + * (`about`). admin-only; the relay rejects non-admins. + */ + setMetadata(room: RoomAddress, fields: { name?: string; about?: string }): Promise; +} diff --git a/src/lib/nostr/dm-service.ts b/src/lib/nostr/dm-service.ts new file mode 100644 index 0000000..39599aa --- /dev/null +++ b/src/lib/nostr/dm-service.ts @@ -0,0 +1,149 @@ +import NDK, { NDKEvent, giftWrap, giftUnwrap, NDKUser } from '@nostr-dev-kit/ndk'; +import type { NDKSubscription } from '@nostr-dev-kit/ndk'; +import { getNdk } from './ndk-client'; +import { authStore } from '$lib/stores/auth-store'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { dmStore, dmRoomId } from '$lib/stores/dm-store'; +import { profileService } from './profile-service'; +import { notificationService } from '$lib/notifications/notification-service'; +import { shortenPubkey } from '$lib/utils/pubkey'; +import { decodeNpub } from '$lib/utils/npub'; +import { get } from 'svelte/store'; + +// nip-17 private dms. a message is an unsigned kind-14 "rumor", gift-wrapped +// (nip-59) into a kind-1059 event addressed to the recipient. the wrapper hides +// sender, recipient, and content from relays. +const KIND_PRIVATE_DM = 14; +const KIND_GIFT_WRAP = 1059; + +/** + * dm service. publishes gift-wrapped dms and subscribes to incoming ones, + * decrypting and showing them in the terminal. the command/ui layers call + * this — never ndk directly. + */ +class DmService { + private subscription: NDKSubscription | null = null; + /** gift-wrap event ids we've already handled, for dedup across reconnects. */ + private readonly seen = new Set(); + + private currentPubkey: string | undefined; + + private get ndk(): NDK { + return getNdk(); + } + + /** + * tie dm listening to the auth lifecycle: (re)subscribe when the logged-in + * identity changes, tear down on logout. call once at app startup. + */ + init(): void { + authStore.subscribe((auth) => { + if (auth.pubkey === this.currentPubkey) return; + this.currentPubkey = auth.pubkey; + if (auth.pubkey) this.start(); + else this.stop(); + }); + } + + /** + * start listening for incoming dms addressed to the logged-in user. fine to + * call over and over; it re-subscribes for the current identity. + */ + start(): void { + const me = get(authStore).pubkey; + if (!me) return; + this.stop(); + // new identity → dms to the old one aren't ours anymore. + this.seen.clear(); + // gift wraps are addressed to us via a `p` tag; content is opaque. + this.subscription = this.ndk.subscribe( + { kinds: [KIND_GIFT_WRAP], '#p': [me] }, + { closeOnEose: false } + ); + this.subscription.on('event', (event: NDKEvent) => void this.onGiftWrap(event)); + } + + stop(): void { + this.subscription?.stop(); + this.subscription = null; + } + + /** + * send a dm to someone (npub or hex). throws on bad input / no signer. + * returns the recipient's hex pubkey so callers can focus the convo. + */ + async send(recipient: string, content: string): Promise { + const text = content.trim(); + if (!text) throw new Error('Message is empty.'); + if (!this.ndk.signer) throw new Error('You must /login to send a direct message.'); + + let pubkey: string; + try { + pubkey = decodeNpub(recipient); + } catch { + throw new Error('Invalid recipient — expected an npub or hex pubkey.'); + } + + const recipientUser = new NDKUser({ pubkey }); + // build the UNSIGNED rumor (giftWrap signs the wrapper, not the rumor). + // created_at and pubkey have to be set — giftWrap serializes the rumor to + // compute its id, and ndk rejects a missing/bad created_at. + const me = get(authStore).pubkey; + const rumor = new NDKEvent(this.ndk); + rumor.kind = KIND_PRIVATE_DM; + rumor.content = text; + rumor.tags = [['p', pubkey]]; + rumor.created_at = Math.floor(Date.now() / 1000); + if (me) rumor.pubkey = me; + + const wrap = await giftWrap(rumor, recipientUser); + try { + await wrap.publish(); + } catch (err) { + throw new Error( + `Could not deliver the DM: ${err instanceof Error ? err.message : String(err)}` + ); + } + + // make sure the convo exists, then echo our own outgoing dm locally + // (gift wraps to self aren't reliably stored, so we just render it). + dmStore.ensure(pubkey); + this.render(pubkey, text, true); + return pubkey; + } + + private async onGiftWrap(event: NDKEvent): Promise { + if (this.seen.has(event.id)) return; + this.seen.add(event.id); + try { + const rumor = await giftUnwrap(event); + if (rumor.kind !== KIND_PRIVATE_DM) return; + dmStore.noteIncoming(rumor.pubkey); + this.render(rumor.pubkey, rumor.content, false); + const name = profileService.cachedName(rumor.pubkey) ?? shortenPubkey(rumor.pubkey); + notificationService.notifyDm(name, rumor.content); + void profileService.resolve(rumor.pubkey); + } catch { + // can't decrypt (not for us / malformed) — just ignore. + } + } + + /** + * render a dm into its per-convo view (roomId = dm:). incoming dm + * content is REMOTE and shows as remote chat (a "message" entry), never as a + * trusted system/notice line — so it can't be mistaken for local output. + */ + private render(counterpartyPubkey: string, content: string, outgoing: boolean): void { + const name = profileService.cachedName(counterpartyPubkey) ?? shortenPubkey(counterpartyPubkey); + const me = get(authStore).pubkey; + terminalStore.push({ + type: 'message', + author: outgoing ? me : counterpartyPubkey, + authorDisplayName: outgoing ? `you → ${name}` : `${name} → you`, + content, + roomId: dmRoomId(counterpartyPubkey) + }); + } +} + +export const dmService = new DmService(); diff --git a/src/lib/nostr/event-mapper.ts b/src/lib/nostr/event-mapper.ts new file mode 100644 index 0000000..3bee35f --- /dev/null +++ b/src/lib/nostr/event-mapper.ts @@ -0,0 +1,80 @@ +import type { ChatMessage } from '$lib/types/chat'; +import type { RoomAddress } from '$lib/types/room'; +import { NIP29_KINDS } from './nip29'; + +/** + * the bare-minimum shape of a nostr event we need to map — a structural subset + * of NDKEvent, so the mapper is unit-testable without building ndk objects. + */ +export interface MappableEvent { + id: string; + pubkey: string; + content: string; + created_at?: number; + kind?: number; + tags: string[][]; +} + +// action ("/me") encoding. nip-29 has no standard for actions. we must NOT use +// the classic ctcp \x01 framing here: control chars serialize ambiguously +// across JSON implementations ( vs a raw byte), so NDK's client-side event +// id would disagree with the relay's recomputed id and the event gets rejected +// as "id is computed incorrectly". so we use a plain, json-safe sentinel that +// hashes the same everywhere. +const ACTION_PREFIX = '/me '; +// still-readable handling of the old ctcp form for any already-stored events. +const LEGACY_CTCP = String.fromCharCode(1); +const LEGACY_PREFIX = `${LEGACY_CTCP}ACTION `; + +/** grab the first `e` tag as a reply reference (nip-10 style, best-effort). */ +function replyTarget(tags: string[][]): string | undefined { + const e = tags.find((t) => t[0] === 'e'); + return e?.[1]; +} + +/** spot an action message and return its inner text, or null if it's not one. */ +function parseAction(content: string): string | null { + if (content.startsWith(ACTION_PREFIX)) { + return content.slice(ACTION_PREFIX.length); + } + // legacy ctcp form (\x01ACTION …\x01) — kept readable for cached history. + if (content.startsWith(LEGACY_PREFIX)) { + const body = content.slice(LEGACY_PREFIX.length); + return body.endsWith(LEGACY_CTCP) ? body.slice(0, -1) : body; + } + return null; +} + +/** + * map a nostr event into the app's ChatMessage model. + * + * `verified` must come from the nostr lib's signature/id check — the caller + * passes the result of verifying the event. cached or relayed events are + * untrusted until that check passes. content is plain text and is never + * treated as html by the ui. + */ +export function mapEventToMessage( + event: MappableEvent, + room: RoomAddress, + verified: boolean +): ChatMessage { + const content = event.content ?? ''; + const actionText = parseAction(content); + + return { + id: event.id, + room, + authorPubkey: event.pubkey, + content: actionText ?? content, + createdAt: event.created_at ?? 0, + eventKind: event.kind ?? NIP29_KINDS.chat, + replyTo: replyTarget(event.tags), + action: actionText !== null, + verified + }; +} + +/** wrap plain text as a `/me` action payload (json-safe, no control chars). */ +export function encodeAction(text: string): string { + return `${ACTION_PREFIX}${text}`; +} diff --git a/src/lib/nostr/ndk-chat-transport.ts b/src/lib/nostr/ndk-chat-transport.ts new file mode 100644 index 0000000..302e97b --- /dev/null +++ b/src/lib/nostr/ndk-chat-transport.ts @@ -0,0 +1,237 @@ +import NDK, { NDKEvent, NDKRelaySet, NDKSubscription } from '@nostr-dev-kit/ndk'; +import type { NDKFilter } from '@nostr-dev-kit/ndk'; +import type { ChatTransport, GroupRoster, ModerationAction } from './chat-transport'; +import type { RoomAddress } from '$lib/types/room'; +import type { ChatMessage } from '$lib/types/chat'; +import { roomId } from './room-address'; +import { + CHAT_KINDS, + GROUP_META_KINDS, + NIP29_KINDS, + groupTag, + pubkeysFromTags, + tagValue +} from './nip29'; +import { mapEventToMessage, encodeAction } from './event-mapper'; +import { relayService } from './relay-service'; + +interface RoomSub { + subscription: NDKSubscription; + /** event ids we've already delivered, for cross-sub dedup. */ + seen: Set; +} + +/** + * ndk-backed ChatTransport for nip-29 managed groups. + * owns per-room subs and makes sure they get cleaned up on leave/unsub. + */ +export class NdkChatTransport implements ChatTransport { + private readonly subs = new Map(); + /** separate roster (39001/39002) subs, keyed by room id. */ + private readonly rosterSubs = new Map(); + + constructor(private readonly ndk: NDK) {} + + async connectRelay(relayUrl: string): Promise { + await relayService.connect(relayUrl); + } + + async disconnectRelay(relayUrl: string): Promise { + relayService.disconnect(relayUrl); + } + + /** publish a nip-29 join request (kind 9021) tagged with the group id. */ + async joinRoom(room: RoomAddress): Promise { + await this.publish(room, NIP29_KINDS.joinRequest, ''); + } + + /** publish a nip-29 leave request (kind 9022) and tear the sub down. */ + async leaveRoom(room: RoomAddress): Promise { + try { + await this.publish(room, NIP29_KINDS.leaveRequest, ''); + } finally { + this.teardown(roomId(room)); + } + } + + async sendRoomMessage(room: RoomAddress, content: string, action = false): Promise { + return this.publish(room, NIP29_KINDS.chat, action ? encodeAction(content) : content); + } + + subscribeToRoom(room: RoomAddress, handler: (message: ChatMessage) => void): () => void { + const id = roomId(room); + // drop any sub already open for this room before opening a fresh one. + this.teardown(id); + + const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk); + const filter: NDKFilter = { + kinds: CHAT_KINDS, + '#h': [room.groupId] + }; + + const seen = new Set(); + const subscription = this.ndk.subscribe(filter, { closeOnEose: false }, relaySet); + + subscription.on('event', (event: NDKEvent) => { + // dedup across relays/reconnects by event id. + if (seen.has(event.id)) return; + seen.add(event.id); + + // every event is untrusted until the lib validates it. + // verifySignature also checks the id matches the content hash. + const verified = event.verifySignature(false) === true; + const message = mapEventToMessage( + { + id: event.id, + pubkey: event.pubkey, + content: event.content, + created_at: event.created_at, + kind: event.kind, + tags: event.tags + }, + room, + verified + ); + handler(message); + }); + + this.subs.set(id, { subscription, seen }); + + return () => this.teardown(id); + } + + subscribeToRoster(room: RoomAddress, handler: (roster: GroupRoster) => void): () => void { + const id = roomId(room); + this.teardownRoster(id); + + const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk); + // addressable lists are tagged by group id via `d`, not `h`. + const filter: NDKFilter = { + kinds: GROUP_META_KINDS, + '#d': [room.groupId] + }; + const subscription = this.ndk.subscribe(filter, { closeOnEose: false }, relaySet); + + subscription.on('event', (event: NDKEvent) => { + if (event.verifySignature(false) !== true) return; + if (event.kind === NIP29_KINDS.admins) { + handler({ kind: 'admins', pubkeys: pubkeysFromTags(event.tags) }); + } else if (event.kind === NIP29_KINDS.members) { + handler({ kind: 'members', pubkeys: pubkeysFromTags(event.tags) }); + } else if (event.kind === NIP29_KINDS.metadata) { + handler({ + kind: 'metadata', + name: tagValue(event.tags, 'name'), + about: tagValue(event.tags, 'about') + }); + } + }); + + this.rosterSubs.set(id, subscription); + return () => this.teardownRoster(id); + } + + async moderateUser( + room: RoomAddress, + action: ModerationAction, + targetPubkey: string + ): Promise { + const kind = action === 'add' ? NIP29_KINDS.putUser : NIP29_KINDS.removeUser; + // the `p` tag names the target; the relay does the admin-only check. + await this.publish(room, kind, '', [['p', targetPubkey]]); + } + + async setMetadata(room: RoomAddress, fields: { name?: string; about?: string }): Promise { + const tags: string[][] = []; + if (fields.name !== undefined) tags.push(['name', fields.name]); + if (fields.about !== undefined) tags.push(['about', fields.about]); + // relay does the admin-only check for kind 9002. + await this.publish(room, NIP29_KINDS.editMetadata, '', tags); + } + + /** + * build, sign, and publish a nip-29 event with the group `h` tag. returns + * the signed event id. on rejection it throws a PublishRejection carrying + * that id so the caller can take back an optimistic echo. + */ + private async publish( + room: RoomAddress, + kind: number, + content: string, + extraTags: string[][] = [] + ): Promise { + if (!this.ndk.signer) { + throw new Error('You must /login before sending to a room.'); + } + const relaySet = NDKRelaySet.fromRelayUrls([room.relayUrl], this.ndk); + const event = new NDKEvent(this.ndk); + event.kind = kind; + event.content = content; + event.tags = [groupTag(room.groupId), ...extraTags]; + await event.sign(); + // sign() fills event.id; it's the same id ndk optimistically echoes. + const eventId = event.id; + try { + await event.publish(relaySet); + } catch (err) { + throw new PublishRejection(rejectionMessage(err), eventId); + } + return eventId; + } + + private teardown(id: string): void { + const existing = this.subs.get(id); + if (existing) { + existing.subscription.stop(); + this.subs.delete(id); + } + this.teardownRoster(id); + } + + private teardownRoster(id: string): void { + const existing = this.rosterSubs.get(id); + if (existing) { + existing.stop(); + this.rosterSubs.delete(id); + } + } + + /** clean up all subs (identity switch / teardown). */ + destroy(): void { + for (const id of [...this.subs.keys()]) this.teardown(id); + for (const id of [...this.rosterSubs.keys()]) this.teardownRoster(id); + } +} + +/** + * a publish the relay rejected. carries the (friendly) message plus the signed + * event id so the caller can take back an optimistic echo of that event. + */ +export class PublishRejection extends Error { + constructor( + message: string, + public readonly eventId: string + ) { + super(message); + this.name = 'PublishRejection'; + } +} + +/** + * turn ndk's raw publish failure into something worth showing a user. ndk says + * "not enough relays received the event (0 published, 1 required)" when the one + * relay rejected the write; the relay's real reason (e.g. a nip-29 "not a + * member" message) is usually tacked on, so show that when it's there. + */ +export function rejectionMessage(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + // ndk tacks on per-relay reasons like "relay rejected: " or "msg: …". + const reason = /(?:rejected:|msg:)\s*(.+)/i.exec(raw)?.[1]?.trim(); + if (reason && !/^\s*$/.test(reason)) { + return `Relay rejected the message: ${reason}`; + } + if (/0 published/i.test(raw) || /not enough relays/i.test(raw)) { + return 'The relay rejected your message. You may not be a member of this room, or the relay is unreachable.'; + } + return raw; +} diff --git a/src/lib/nostr/ndk-client.ts b/src/lib/nostr/ndk-client.ts new file mode 100644 index 0000000..a181c09 --- /dev/null +++ b/src/lib/nostr/ndk-client.ts @@ -0,0 +1,28 @@ +import NDK, { NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk'; + +/** + * lazily-made singleton ndk instance (client-only). we run relay connections + * ourselves via RelayManager, so ndk starts with no explicit relays and the + * outbox model off — it's just for signing, nip-11, and owning the relay pool. + */ +let ndk: NDK | null = null; + +export function getNdk(): NDK { + if (!ndk) { + ndk = new NDK({ + explicitRelayUrls: [], + enableOutboxModel: false, + autoConnectUserRelays: false + }); + // nip-42: when a relay asks for auth and a signer is present, ndk builds + // and sends the signed kind-22242 auth event. RelayManager also surfaces + // the challenge as a terminal notice. + ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); + } + return ndk; +} + +/** test/reset hook: drop the singleton so a fresh one gets made. */ +export function resetNdk(): void { + ndk = null; +} diff --git a/src/lib/nostr/ndk-relay-driver.ts b/src/lib/nostr/ndk-relay-driver.ts new file mode 100644 index 0000000..19b41cf --- /dev/null +++ b/src/lib/nostr/ndk-relay-driver.ts @@ -0,0 +1,79 @@ +import { fetchRelayInformation } from '@nostr-dev-kit/ndk'; +import type NDK from '@nostr-dev-kit/ndk'; +import type { NDKRelay } from '@nostr-dev-kit/ndk'; +import type { RelayConnection, RelayConnectionEvent } from './relay-manager'; +import type { RelayInformation } from '$lib/types/relay'; + +/** + * adapts an NDKRelay (from the shared pool) to the driver-agnostic + * RelayConnection interface the RelayManager leans on. keeping this thin adapter + * separate means the manager's logic never imports ndk directly. + */ +class NdkRelayConnection implements RelayConnection { + private readonly relay: NDKRelay; + /** ndk runs its own reconnection loop; the manager mustn't add a second. */ + readonly selfReconnecting = true; + + constructor( + public readonly url: string, + ndk: NDK + ) { + // `getRelay(url, false)` returns (or creates) the pool relay without + // eagerly connecting — we call connect() ourselves. + this.relay = ndk.pool.getRelay(url, false, false); + } + + async connect(): Promise { + // reconnect=true lets ndk retry, but our manager owns the higher-level + // backoff and loop prevention across the app. + await this.relay.connect(undefined, true); + } + + disconnect(): void { + this.relay.disconnect(); + } + + on(event: RelayConnectionEvent, handler: (payload?: string) => void): () => void { + // map our vocabulary onto NDKRelay events. + const wrapped = (payload?: unknown) => + handler(typeof payload === 'string' ? payload : undefined); + this.relay.on(event, wrapped as never); + return () => this.relay.off(event, wrapped as never); + } +} + +/** factory used by the app-level RelayManager wiring. */ +export function createNdkConnection(url: string, ndk: NDK): RelayConnection { + return new NdkRelayConnection(url, ndk); +} + +/** nip-11 fetch mapped into the app-level RelayInformation model. */ +export async function fetchNip11(url: string): Promise { + const info = await fetchRelayInformation(url); + // `features` and `motd` are nosterm extensions ndk's typed document doesn't + // know about, so read them defensively off the raw object. + const raw = info as unknown as { features?: unknown; motd?: unknown }; + const features = Array.isArray(raw.features) + ? raw.features.filter((f): f is string => typeof f === 'string') + : undefined; + const motd = typeof raw.motd === 'string' ? raw.motd : undefined; + return { + name: info.name, + description: info.description, + pubkey: info.pubkey, + contact: info.contact, + software: info.software, + version: info.version, + supportedNips: info.supported_nips, + features, + motd, + limitation: info.limitation + ? { + maxMessageLength: info.limitation.max_message_length, + maxSubscriptions: info.limitation.max_subscriptions, + authRequired: info.limitation.auth_required, + paymentRequired: info.limitation.payment_required + } + : undefined + }; +} diff --git a/src/lib/nostr/nip29.ts b/src/lib/nostr/nip29.ts new file mode 100644 index 0000000..e6f1fb7 --- /dev/null +++ b/src/lib/nostr/nip29.ts @@ -0,0 +1,57 @@ +/** + * nip-29 (managed relay-based groups) event kinds and tag helpers. + * managed groups: the relay keeps membership/metadata and enforces access. + */ +export const NIP29_KINDS = { + /** chat message posted to a group. */ + chat: 9, + /** threaded reply (we show it like chat for the mvp). */ + threadReply: 11, + /** admin adds a user to a group (kind 9000). */ + putUser: 9000, + /** admin removes a user from a group (kind 9001). */ + removeUser: 9001, + /** admin edits group metadata — name/about (kind 9002). */ + editMetadata: 9002, + /** join request (kind 9021). */ + joinRequest: 9021, + /** leave request (kind 9022). */ + leaveRequest: 9022, + /** group metadata (name/about/picture). */ + metadata: 39000, + /** group admins list. */ + admins: 39001, + /** group members list. */ + members: 39002 +} as const; + +/** addressable nip-29 list kinds the client subscribes to for group state. */ +export const GROUP_META_KINDS: number[] = [ + NIP29_KINDS.metadata, + NIP29_KINDS.admins, + NIP29_KINDS.members +]; + +/** pull all `p` tag pubkeys out of an event's tags. */ +export function pubkeysFromTags(tags: string[][]): string[] { + return tags.filter((t) => t[0] === 'p' && typeof t[1] === 'string').map((t) => t[1] as string); +} + +/** value of the first tag with the given name, or undefined. */ +export function tagValue(tags: string[][], name: string): string | undefined { + return tags.find((t) => t[0] === name)?.[1]; +} + +/** kinds we treat as showable chat messages in a room timeline. */ +export const CHAT_KINDS: number[] = [NIP29_KINDS.chat, NIP29_KINDS.threadReply]; + +/** the `h` tag carries the group id on every nip-29 event. */ +export function groupTag(groupId: string): string[] { + return ['h', groupId]; +} + +/** pull the group id out of an event's tags, if it's there. */ +export function groupIdFromTags(tags: string[][]): string | undefined { + const h = tags.find((t) => t[0] === 'h'); + return h?.[1]; +} diff --git a/src/lib/nostr/profile-service.ts b/src/lib/nostr/profile-service.ts new file mode 100644 index 0000000..4324bf6 --- /dev/null +++ b/src/lib/nostr/profile-service.ts @@ -0,0 +1,162 @@ +import { getNdk } from './ndk-client'; +import { profileRepository } from '$lib/db/repositories'; +import { profileStore } from '$lib/stores/profile-store'; +import { authStore } from '$lib/stores/auth-store'; + +interface CachedProfile { + name?: string; + nip05?: string; + fetchedAt: number; +} + +/** + * resolves and caches display names / nip-05 ids for pubkeys. names and nip-05 + * are NOT treated as globally unique — callers still tell people apart with + * shortened pubkeys. profile metadata never drives styling. + */ +class ProfileService { + /** how long a name-less (negative) cache entry is trusted before we retry. */ + private static readonly NEGATIVE_TTL_MS = 30_000; + + private readonly cache = new Map(); + private readonly inflight = new Map>(); + + private currentPubkey: string | undefined; + + /** + * watch the auth lifecycle so the logged-in user's own name resolves the + * moment they sign in (no room roster needed). call once at app startup. + */ + init(): void { + authStore.subscribe((auth) => { + if (auth.pubkey === this.currentPubkey) return; + this.currentPubkey = auth.pubkey; + if (auth.pubkey) void this.resolve(auth.pubkey); + }); + } + + /** grab a cached display name synchronously, if we have one. */ + cachedName(pubkey: string): string | undefined { + return this.cache.get(pubkey)?.name; + } + + /** cached nip-05 id, if any. */ + cachedNip05(pubkey: string): string | undefined { + return this.cache.get(pubkey)?.nip05; + } + + /** + * check a pubkey's claimed nip-05 against the domain's .well-known/nostr.json. + * returns: + * 'verified' — the domain maps the name to this exact pubkey, + * 'mismatch' — the domain maps it to a DIFFERENT pubkey (impersonation), + * 'unverified'— no nip-05 claimed, not found, or network hiccup (can't tell). + * a nip-05 is never proof of identity unless this returns 'verified'. + */ + async verifyNip05(pubkey: string): Promise<'verified' | 'mismatch' | 'unverified'> { + const nip05 = this.cachedNip05(pubkey); + if (!nip05) return 'unverified'; + try { + const user = getNdk().getUser({ pubkey }); + const result = await user.validateNip05(nip05); + if (result === true) return 'verified'; + if (result === false) return 'mismatch'; + return 'unverified'; + } catch { + return 'unverified'; + } + } + + /** seed the in-memory cache from storage (no network fetch). */ + hydrate(pubkey: string, name?: string, nip05?: string): void { + if (!this.cache.has(pubkey)) { + this.cache.set(pubkey, { name, nip05, fetchedAt: Date.now() }); + if (name) profileStore.setName(pubkey, name); + } + } + + /** force-set a cached display name (e.g. after publishing our own profile). */ + setName(pubkey: string, name: string): void { + const existing = this.cache.get(pubkey); + this.cache.set(pubkey, { ...existing, name, fetchedAt: Date.now() }); + profileStore.setName(pubkey, name); + void profileRepository.save({ pubkey, name, nip05: existing?.nip05, fetchedAt: Date.now() }); + } + + /** + * publish the logged-in user's display name (nip-01 kind 0). needs an active + * signer. merges with any existing profile fields, updates the local cache + * right away, and saves it. + */ + async publishName(name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) throw new Error('Enter a display name.'); + if (trimmed.length > 64) throw new Error('Display name must be 64 characters or fewer.'); + + const ndk = getNdk(); + if (!ndk.signer) throw new Error('You must /login before setting a profile name.'); + + const user = await ndk.signer.user(); + // merge onto any existing profile so we don't stomp other fields. + try { + await user.fetchProfile(); + } catch { + /* first-time profile; start fresh */ + } + user.profile = { ...(user.profile ?? {}), name: trimmed, displayName: trimmed }; + await user.publish(); + + this.setName(user.pubkey, trimmed); + return user.pubkey; + } + + /** + * fetch a profile once; later calls dedupe/no-op while cached. an entry that + * came back with *no name* (profile not found, or fetched before any relay + * connected) gets retried after a cooldown so a name that shows up later — + * or once relays connect — still lands. + */ + async resolve(pubkey: string): Promise { + if (this.inflight.has(pubkey)) return; + const cached = this.cache.get(pubkey); + if (cached?.name) return; // got a name already — nothing to do. + if (cached && Date.now() - cached.fetchedAt < ProfileService.NEGATIVE_TTL_MS) return; + const task = this.fetch(pubkey); + this.inflight.set(pubkey, task); + try { + await task; + } finally { + this.inflight.delete(pubkey); + } + } + + private async fetch(pubkey: string): Promise { + try { + const user = getNdk().getUser({ pubkey }); + const profile = await user.fetchProfile(); + const entry: CachedProfile = { + name: profile?.displayName || profile?.name || undefined, + nip05: profile?.nip05, + fetchedAt: Date.now() + }; + this.cache.set(pubkey, entry); + if (entry.name) profileStore.setName(pubkey, entry.name); + // save resolved profiles so they show fast next session. + void profileRepository.save({ + pubkey, + name: entry.name, + nip05: entry.nip05, + fetchedAt: entry.fetchedAt + }); + } catch { + // cache a negative result briefly so we don't hammer on failure. + this.cache.set(pubkey, { fetchedAt: Date.now() }); + } + } + + clear(): void { + this.cache.clear(); + } +} + +export const profileService = new ProfileService(); diff --git a/src/lib/nostr/relay-capabilities.ts b/src/lib/nostr/relay-capabilities.ts new file mode 100644 index 0000000..c8b6d2e --- /dev/null +++ b/src/lib/nostr/relay-capabilities.ts @@ -0,0 +1,31 @@ +import { get } from 'svelte/store'; +import { relayStore } from '$lib/stores/relay-store'; +import type { RelayInformation } from '$lib/types/relay'; + +/** + * nosterm relay capability handshake (client side). + * + * a `nosterm-relay` advertises a `features` array in its nip-11 doc. the ui + * feature-gates on these so it can light up extras on a home relay while falling + * back gracefully to plain nostr relays (which leave them out). + * + * known feature flags (extend as the relay implements them): + * channels · presence · typing · moderation · search · bots + */ +export type RelayFeature = 'channels' | 'presence' | 'typing' | 'moderation' | 'search' | 'bots'; + +/** true if the relay calls itself a nosterm-relay build. */ +export function isNostermRelay(info: RelayInformation | undefined): boolean { + return info?.software === 'nosterm-relay'; +} + +/** does a specific relay (by normalized url) advertise a feature? */ +export function relayHasFeature(url: string, feature: RelayFeature): boolean { + const state = get(relayStore)[url]; + return state?.info?.features?.includes(feature) ?? false; +} + +/** all features a relay advertises, or [] if none/plain relay. */ +export function relayFeatures(url: string): string[] { + return get(relayStore)[url]?.info?.features ?? []; +} diff --git a/src/lib/nostr/relay-list-service.ts b/src/lib/nostr/relay-list-service.ts new file mode 100644 index 0000000..803abb2 --- /dev/null +++ b/src/lib/nostr/relay-list-service.ts @@ -0,0 +1,32 @@ +import { getRelayListForUser } from '@nostr-dev-kit/ndk'; +import { getNdk } from './ndk-client'; +import { isValidRelayUrl, normalizeRelayUrl } from '$lib/utils/relay-url'; + +/** + * nip-65 (kind 10002) relay-list discovery. grabs a user's advertised relays so + * the client can connect to their "outbox" — the relays they actually read from + * and write to — instead of leaning on defaults alone. + */ +class RelayListService { + /** + * fetch a user's advertised relay urls (normalized, ws/wss only). + * returns [] if they have no list or it can't be fetched. + */ + async fetch(pubkey: string): Promise { + try { + const list = await getRelayListForUser(pubkey, getNdk()); + const urls = list?.relays ?? []; + // normalize + validate; drop anything unusable, dedupe. + const seen = new Set(); + for (const raw of urls) { + if (!isValidRelayUrl(raw)) continue; + seen.add(normalizeRelayUrl(raw)); + } + return [...seen]; + } catch { + return []; + } + } +} + +export const relayListService = new RelayListService(); diff --git a/src/lib/nostr/relay-manager.ts b/src/lib/nostr/relay-manager.ts new file mode 100644 index 0000000..238543c --- /dev/null +++ b/src/lib/nostr/relay-manager.ts @@ -0,0 +1,264 @@ +import type { RelayConnectionState, RelayInformation } from '$lib/types/relay'; +import { normalizeRelayUrl } from '$lib/utils/relay-url'; + +/** + * one relay connection as the manager sees it. an ndk-backed adapter in prod, + * fakes in tests — so the manager's state machine and backoff logic can be + * tested without real sockets. + */ +export interface RelayConnection { + readonly url: string; + connect(): Promise; + disconnect(): void; + /** register a listener; hands back an unsub fn. */ + on(event: RelayConnectionEvent, handler: (payload?: string) => void): () => void; + /** + * true if the driver runs its own reconnect loop (e.g. ndk). when set, the + * manager tracks state but skips its own backoff, so the two loops don't + * fight. defaults to false. + */ + readonly selfReconnecting?: boolean; +} + +export type RelayConnectionEvent = + 'connect' | 'disconnect' | 'auth' | 'authed' | 'auth:failed' | 'notice'; + +/** injected side-effects, so the manager stays pure and testable. */ +export interface RelayManagerDeps { + /** make (or find) a driver connection for a normalized url. */ + createConnection: (url: string) => RelayConnection; + /** grab nip-11 info; may reject if unsupported. */ + fetchInfo: (url: string) => Promise; + /** fire an app event (state change, notice, auth, error). */ + emit: (event: RelayManagerEvent) => void; + /** run a callback after `ms`; returns a cancel handle. swappable for tests. */ + setTimer?: (cb: () => void, ms: number) => number; + clearTimer?: (handle: number) => void; +} + +export type RelayManagerEvent = + | { type: 'state'; url: string; state: RelayConnectionState; error?: string } + | { type: 'info'; url: string; info: RelayInformation } + | { type: 'auth'; url: string; challenge?: string } + | { type: 'authed'; url: string } + | { type: 'auth:failed'; url: string; error?: string } + | { type: 'notice'; url: string; message: string } + // fires when a relay comes back after having been connected before, so + // consumers can re-sub and backfill whatever they missed during the outage. + | { type: 'reconnected'; url: string }; + +interface ManagedRelay { + url: string; + connection: RelayConnection; + state: RelayConnectionState; + reconnectAttempts: number; + /** pending backoff timer handle, if a reconnect is queued up. */ + reconnectTimer?: number; + /** true once the user hit disconnect on purpose — kills auto-reconnect. */ + intentionalDisconnect: boolean; + /** true once this relay has connected at least once (tells an unexpected + * drop-then-reconnect apart from a first-time connect). */ + everConnected: boolean; + /** listener unsub fns, cleaned up on removal. */ + unsubscribers: (() => void)[]; +} + +const MAX_RECONNECT_ATTEMPTS = 8; +const BASE_BACKOFF_MS = 1000; +const MAX_BACKOFF_MS = 60_000; + +/** + * owns the relay connection lifecycle: normalization, dedup/reuse, state + * tracking, exponential backoff that won't loop forever, nip-11 fetching, and + * tidy unsubscribing. never touches raw websockets — that's the driver's job. + */ +export class RelayManager { + private readonly relays = new Map(); + private readonly setTimer: (cb: () => void, ms: number) => number; + private readonly clearTimer: (handle: number) => void; + + constructor(private readonly deps: RelayManagerDeps) { + this.setTimer = deps.setTimer ?? ((cb, ms) => setTimeout(cb, ms) as unknown as number); + this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h)); + } + + /** urls of every managed relay (normalized). */ + list(): string[] { + return [...this.relays.keys()]; + } + + stateOf(url: string): RelayConnectionState | undefined { + return this.relays.get(this.key(url))?.state; + } + + /** + * connect to a relay. reuses an existing connection unless it errored or + * disconnected. returns right after kicking off the async connect. + */ + async connect(rawUrl: string): Promise { + const url = this.key(rawUrl); + const existing = this.relays.get(url); + + if (existing) { + // reuse: don't double up an in-flight or live connection. + if (existing.state === 'connecting' || existing.state === 'connected') { + return; + } + existing.intentionalDisconnect = false; + await this.openConnection(existing); + return; + } + + const connection = this.deps.createConnection(url); + const managed: ManagedRelay = { + url, + connection, + state: 'disconnected', + reconnectAttempts: 0, + intentionalDisconnect: false, + everConnected: false, + unsubscribers: [] + }; + this.relays.set(url, managed); + this.wireEvents(managed); + await this.openConnection(managed); + // nip-11 is best-effort — must not block or sink the connection. + void this.loadInfo(url); + } + + /** disconnect on purpose and stop auto-reconnect for this relay. */ + disconnect(rawUrl: string): void { + const url = this.key(rawUrl); + const managed = this.relays.get(url); + if (!managed) return; + managed.intentionalDisconnect = true; + this.cancelReconnect(managed); + managed.connection.disconnect(); + this.setState(managed, 'disconnected'); + } + + /** drop a relay entirely, cleaning up all listeners and timers. */ + remove(rawUrl: string): void { + const url = this.key(rawUrl); + const managed = this.relays.get(url); + if (!managed) return; + managed.intentionalDisconnect = true; + this.cancelReconnect(managed); + managed.connection.disconnect(); + for (const off of managed.unsubscribers) off(); + managed.unsubscribers = []; + this.relays.delete(url); + } + + /** clean up every relay (e.g. on identity switch or teardown). */ + destroy(): void { + for (const url of this.list()) this.remove(url); + } + + private async openConnection(managed: ManagedRelay): Promise { + this.setState(managed, 'connecting'); + try { + await managed.connection.connect(); + // the driver's 'connect' event is the source of truth for 'connected'; + // if it never fires, state sits at 'connecting' until timeout/close. + } catch (err) { + this.setState(managed, 'error', this.friendlyError(err)); + if (!managed.connection.selfReconnecting) this.scheduleReconnect(managed); + } + } + + private wireEvents(managed: ManagedRelay): void { + const { connection } = managed; + managed.unsubscribers.push( + connection.on('connect', () => { + const wasReconnecting = managed.everConnected; + managed.reconnectAttempts = 0; + managed.everConnected = true; + this.cancelReconnect(managed); + this.setState(managed, 'connected'); + // back after a previous connection → let consumers backfill. + if (wasReconnecting) this.deps.emit({ type: 'reconnected', url: managed.url }); + }), + connection.on('disconnect', () => { + if (managed.intentionalDisconnect) { + this.setState(managed, 'disconnected'); + return; + } + // unexpected drop: a reconnect is on the way (the driver's own loop + // or our scheduled backoff), so show "reconnecting" instead of a bare + // "disconnected" that looks final. + this.setState(managed, 'reconnecting'); + if (!connection.selfReconnecting) this.scheduleReconnect(managed); + }), + connection.on('auth', (challenge) => { + // nip-42: the signer policy does the crypto; we just show a notice. + this.deps.emit({ type: 'auth', url: managed.url, challenge }); + }), + connection.on('authed', () => { + this.deps.emit({ type: 'authed', url: managed.url }); + }), + connection.on('auth:failed', (error) => { + this.deps.emit({ type: 'auth:failed', url: managed.url, error }); + }), + connection.on('notice', (message) => { + this.deps.emit({ type: 'notice', url: managed.url, message: message ?? '' }); + }) + ); + } + + /** exponential backoff with jitter and a hard attempt cap so it can't loop forever. */ + private scheduleReconnect(managed: ManagedRelay): void { + if (managed.intentionalDisconnect) return; + if (managed.reconnectTimer !== undefined) return; // already queued + if (managed.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + this.setState( + managed, + 'error', + `Giving up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts.` + ); + return; + } + + const attempt = managed.reconnectAttempts; + const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS); + managed.reconnectAttempts += 1; + + managed.reconnectTimer = this.setTimer(() => { + managed.reconnectTimer = undefined; + if (managed.intentionalDisconnect) return; + void this.openConnection(managed); + }, backoff); + } + + private cancelReconnect(managed: ManagedRelay): void { + if (managed.reconnectTimer !== undefined) { + this.clearTimer(managed.reconnectTimer); + managed.reconnectTimer = undefined; + } + } + + private async loadInfo(url: string): Promise { + try { + const info = await this.deps.fetchInfo(url); + this.deps.emit({ type: 'info', url, info }); + } catch { + // nip-11 is optional; just skip. supportsNip29 stays undefined. + } + } + + private setState(managed: ManagedRelay, state: RelayConnectionState, error?: string): void { + if (managed.state === state && !error) return; + managed.state = state; + this.deps.emit({ type: 'state', url: managed.url, state, error }); + } + + private key(rawUrl: string): string { + return normalizeRelayUrl(rawUrl); + } + + private friendlyError(err: unknown): string { + // never show raw websocket errors; keep the techy bits short. + if (err instanceof Error) return err.message; + return 'Connection failed.'; + } +} diff --git a/src/lib/nostr/relay-service.ts b/src/lib/nostr/relay-service.ts new file mode 100644 index 0000000..f21f839 --- /dev/null +++ b/src/lib/nostr/relay-service.ts @@ -0,0 +1,196 @@ +import { RelayManager } from './relay-manager'; +import type { RelayManagerEvent } from './relay-manager'; +import { createNdkConnection, fetchNip11 } from './ndk-relay-driver'; +import { relayListService } from './relay-list-service'; +import { pluginHost } from '$lib/plugins/plugin-host'; +import { getNdk } from './ndk-client'; +import { relayStore } from '$lib/stores/relay-store'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { relayRoomId } from '$lib/stores/server-store'; +import type { RelayInformation } from '$lib/types/relay'; + +/** + * the app's relay service. owns one RelayManager wired to the ndk driver and + * turns manager events into store updates and trusted terminal notices. the + * command layer calls this — never ndk directly. + */ +class RelayService { + private manager: RelayManager | null = null; + /** callbacks fired when a relay reconnects after an outage (normalized url). */ + private readonly reconnectHandlers = new Set<(url: string) => void>(); + + /** subscribe to relay-reconnect events; hands back an unsub fn. */ + onReconnect(handler: (url: string) => void): () => void { + this.reconnectHandlers.add(handler); + return () => this.reconnectHandlers.delete(handler); + } + + private ensure(): RelayManager { + if (!this.manager) { + const ndk = getNdk(); + this.manager = new RelayManager({ + createConnection: (url) => createNdkConnection(url, ndk), + fetchInfo: (url) => fetchNip11(url), + emit: (event) => this.onEvent(event) + }); + } + return this.manager; + } + + async connect(url: string): Promise { + await this.ensure().connect(url); + } + + disconnect(url: string): void { + this.ensure().disconnect(url); + } + + list(): string[] { + return this.ensure().list(); + } + + /** + * nip-65 outbox discovery: grab a user's advertised relay list and connect + * to any relays we're not on yet. returns how many were newly connected. + * best-effort — shows progress as terminal notices. + */ + async discoverAndConnect(pubkey: string): Promise { + const discovered = await relayListService.fetch(pubkey); + if (discovered.length === 0) { + terminalStore.system('No NIP-65 relay list found for this identity.', 'system'); + return 0; + } + const known = new Set(this.list()); + const toAdd = discovered.filter((url) => !known.has(url)); + if (toAdd.length === 0) { + terminalStore.system( + `NIP-65: already connected to all ${discovered.length} advertised relay(s).`, + 'system' + ); + return 0; + } + terminalStore.system(`NIP-65: connecting to ${toAdd.length} advertised relay(s)…`, 'notice'); + for (const url of toAdd) void this.connect(url); + return toAdd.length; + } + + private onEvent(event: RelayManagerEvent): void { + // all relay-lifecycle output goes to that relay's server window + // (irc-style server buffer), not the global one. + const scope = relayRoomId(event.url); + switch (event.type) { + case 'state': { + relayStore.setState(event.url, event.state, event.error); + pluginHost.emit('relayState', event.url, event.state); + if (event.state === 'connected') { + terminalStore.system(`Connected to ${event.url}`, 'notice', scope); + } else if (event.state === 'error') { + terminalStore.system( + `Relay ${event.url}: ${event.error ?? 'connection error'}`, + 'error', + scope + ); + } else if (event.state === 'reconnecting') { + terminalStore.system( + `Connection to ${event.url} dropped — reconnecting…`, + 'warning', + scope + ); + } else if (event.state === 'disconnected') { + terminalStore.system(`Disconnected from ${event.url}`, 'system', scope); + } + break; + } + case 'reconnected': { + terminalStore.system(`Reconnected to ${event.url} — syncing…`, 'notice', scope); + for (const handler of this.reconnectHandlers) handler(event.url); + break; + } + case 'info': { + relayStore.setInfo(event.url, event.info); + this.renderMotd(event.url, event.info, scope); + break; + } + case 'auth': { + terminalStore.system( + `Relay ${event.url} requested authentication (NIP-42).`, + 'notice', + scope + ); + // the signIn policy signs with the active signer; without one, auth + // can't finish — tell the user why so it's not a silent flop. + if (!getNdk().signer) { + terminalStore.system( + `Log in (/login) to authenticate with ${event.url}.`, + 'warning', + scope + ); + } + break; + } + case 'authed': { + terminalStore.system(`Authenticated with ${event.url} (NIP-42).`, 'notice', scope); + break; + } + case 'auth:failed': { + terminalStore.system( + `Authentication with ${event.url} failed${event.error ? `: ${event.error}` : ''}.`, + 'error', + scope + ); + break; + } + case 'notice': { + terminalStore.system(`Relay ${event.url}: ${event.message}`, 'notice', scope); + break; + } + } + } + + /** + * dump an irc-style message-of-the-day into a relay's server window from its + * nip-11 doc: a header, the relay's motd (or description as a fallback), + * supported nips, and the nosterm-relay capability handshake. + */ + private renderMotd(url: string, info: RelayInformation, scope: string): void { + const name = info.name?.trim() || url; + terminalStore.system(`── ${name} ──`, 'notice', scope); + + // the motd itself: an explicit `motd`, else the nip-11 description. + const motd = info.motd?.trim() || info.description?.trim(); + if (motd) { + for (const line of motd.split('\n')) { + terminalStore.system(line, 'system', scope); + } + } + + if (info.software) { + const version = info.version ? ` ${info.version}` : ''; + terminalStore.system(`software: ${info.software}${version}`, 'system', scope); + } + if (info.contact) { + terminalStore.system(`contact: ${info.contact}`, 'system', scope); + } + + const nips = info.supportedNips; + if (nips && nips.length > 0) { + terminalStore.system(`supported NIPs: ${nips.join(', ')}`, 'system', scope); + } + if (nips && !nips.includes(29)) { + terminalStore.system('This relay does not advertise NIP-29 group support.', 'warning', scope); + } + + // nosterm capability handshake: announce a home relay's features. + if (info.software === 'nosterm-relay') { + const features = info.features ?? []; + terminalStore.system( + `nosterm-relay features: ${features.length ? features.join(', ') : 'none'}`, + 'notice', + scope + ); + } + terminalStore.system('── end of MOTD ──', 'notice', scope); + } +} + +export const relayService = new RelayService(); diff --git a/src/lib/nostr/room-address.ts b/src/lib/nostr/room-address.ts new file mode 100644 index 0000000..c4b8edd --- /dev/null +++ b/src/lib/nostr/room-address.ts @@ -0,0 +1,65 @@ +import type { RoomAddress } from '$lib/types/room'; +import { normalizeRelayUrl } from '$lib/utils/relay-url'; + +/** + * internal room id built from BOTH the relay url and the group id. `#general` + * on two different relays gives you two different ids. + */ +export function roomId(address: RoomAddress): string { + return `${normalizeRelayUrl(address.relayUrl)}#${address.groupId}`; +} + +/** are these two room addresses the same room? */ +export function roomsEqual(a: RoomAddress, b: RoomAddress): boolean { + return roomId(a) === roomId(b); +} + +/** strip one leading '#' and any surrounding whitespace off a group name. */ +function cleanGroupId(raw: string): string { + const g = raw.trim().replace(/^#/, '').trim(); + return g; +} + +/** + * parse a user-typed room reference into a RoomAddress. + * + * takes: + * - "#general" -> uses activeRelay + * - "general" -> uses activeRelay + * - "wss://relay.example/#general" -> explicit relay + group + * - "wss://relay.example#general" -> explicit relay + group + * + * if you only give a group name, `activeRelay` is required. + * throws with a clear message on bad input. + */ +export function parseRoomReference(input: string, activeRelay?: string): RoomAddress { + const raw = input.trim(); + if (raw.length === 0) throw new Error('Room name is empty.'); + + // explicit relay form: has a ws(s) scheme. + if (/^wss?:\/\//i.test(raw)) { + // the group rides along as a url fragment (#general). + const hashIndex = raw.indexOf('#'); + if (hashIndex === -1) { + throw new Error('Relay-qualified room must include a group, e.g. wss://relay/#general.'); + } + const relayPart = raw.slice(0, hashIndex); + const groupPart = cleanGroupId(raw.slice(hashIndex)); + if (groupPart.length === 0) throw new Error('Group name is empty.'); + return { + relayUrl: normalizeRelayUrl(relayPart), + groupId: groupPart + }; + } + + // bare group name form: needs an active relay. + const groupId = cleanGroupId(raw); + if (groupId.length === 0) throw new Error('Group name is empty.'); + if (!activeRelay) { + throw new Error('No active relay. Connect to a relay first or use wss://relay/#room.'); + } + return { + relayUrl: normalizeRelayUrl(activeRelay), + groupId + }; +} diff --git a/src/lib/nostr/room-service.ts b/src/lib/nostr/room-service.ts new file mode 100644 index 0000000..d4fc436 --- /dev/null +++ b/src/lib/nostr/room-service.ts @@ -0,0 +1,276 @@ +import { get } from 'svelte/store'; +import { NdkChatTransport, PublishRejection } from './ndk-chat-transport'; +import type { ChatTransport } from './chat-transport'; +import { getNdk } from './ndk-client'; +import { roomId } from './room-address'; +import { NIP29_KINDS } from './nip29'; +import { relayService } from './relay-service'; +import type { RoomAddress } from '$lib/types/room'; +import type { ChatMessage } from '$lib/types/chat'; +import { roomStore } from '$lib/stores/room-store'; +import { relayStore } from '$lib/stores/relay-store'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { muteStore } from '$lib/stores/mute-store'; +import { authStore } from '$lib/stores/auth-store'; +import { profileService } from './profile-service'; +import { messageRepository, readMarkerRepository } from '$lib/db/repositories'; +import { notificationService } from '$lib/notifications/notification-service'; +import { pluginHost } from '$lib/plugins/plugin-host'; +import { isMention } from '$lib/utils/mention'; +import { shortenPubkey } from '$lib/utils/pubkey'; + +/** + * the app's room service. owns the chat transport and maps incoming messages + * to terminal entries + store updates. command layer talks to this, never to + * ndk or the transport directly. + */ +class RoomService { + private transport: ChatTransport | null = null; + /** room id -> unsub fn. */ + private readonly unsubscribers = new Map void>(); + /** room id -> address, so we can re-sub after a reconnect. */ + private readonly joinedRooms = new Map(); + /** + * event ids we've already shown this session. stops dupes when a reconnect + * re-subs a room and the relay replays old events. + */ + private readonly renderedEventIds = new Set(); + /** + * event ids the relay rejected. lets us take back an optimistic echo: ndk + * might echo our own event before OR after the rejection lands, so we both + * yank an already-shown entry and suppress one that hasn't shown up yet. + */ + private readonly rejectedEventIds = new Set(); + + constructor() { + // on reconnect, re-sub every room on that relay so it replays whatever we + // missed during the outage (renderedEventIds dedupes). + relayService.onReconnect((url) => this.resubscribeRelay(url)); + } + + private ensure(): ChatTransport { + if (!this.transport) { + this.transport = new NdkChatTransport(getNdk()); + } + return this.transport; + } + + /** join a nip-29 room: warn if unsupported, subscribe, then ask to join. */ + async join(room: RoomAddress): Promise { + const id = roomId(room); + if (this.unsubscribers.has(id)) { + roomStore.setActive(id); + terminalStore.system(`Already joined #${room.groupId}.`, 'notice'); + return; + } + + // warn (but don't block) if the relay doesn't advertise nip-29. + const relay = get(relayStore)[room.relayUrl]; + if (relay?.supportsNip29 === false) { + terminalStore.system( + `Relay ${room.relayUrl} does not advertise NIP-29; the room may not work.`, + 'warning' + ); + } else if (!relay || relay.connection !== 'connected') { + terminalStore.system(`Not connected to ${room.relayUrl}. Connecting…`, 'notice'); + await this.ensure().connectRelay(room.relayUrl); + } + + roomStore.add(room, room.groupId); + this.joinedRooms.set(id, room); + this.subscribeRoom(room); + + try { + await this.ensure().joinRoom(room); + } catch (err) { + // reading still works even if the join request flops. + terminalStore.system( + `Join request failed: ${err instanceof Error ? err.message : String(err)}`, + 'warning' + ); + } + terminalStore.system(`Joined #${room.groupId}`, 'notice', id); + } + + /** leave a room: unsub, send leave request, drop it from the store. */ + async leave(room: RoomAddress): Promise { + const id = roomId(room); + const unsub = this.unsubscribers.get(id); + if (!unsub) { + terminalStore.system(`Not joined to #${room.groupId}.`, 'notice'); + return; + } + unsub(); + this.unsubscribers.delete(id); + this.joinedRooms.delete(id); + try { + await this.ensure().leaveRoom(room); + } catch { + /* leave request is best-effort, whatever */ + } + roomStore.remove(id); + terminalStore.system(`Left #${room.groupId}`, 'system'); + } + + /** open message + roster subs for a room (used by join and reconnect). */ + private subscribeRoom(room: RoomAddress): void { + const id = roomId(room); + // ditch any existing subs first (reconnect re-subs cleanly). + this.unsubscribers.get(id)?.(); + + const transport = this.ensure(); + const unsubMsg = transport.subscribeToRoom(room, (message) => this.onMessage(message)); + // track the relay's metadata + admins/members lists so the ui can show + // the topic/participants and gate moderation. relay has the final say. + const unsubRoster = transport.subscribeToRoster(room, (roster) => { + if (roster.kind === 'metadata') { + roomStore.patch(id, { + ...(roster.name ? { name: roster.name } : {}), + topic: roster.about ?? undefined + }); + return; + } + roomStore.patch( + id, + roster.kind === 'admins' ? { admins: roster.pubkeys } : { members: roster.pubkeys } + ); + // resolve names for listed folks so the member list shows nicks not + // hashes (best-effort; updates reactively via profileStore). + for (const pubkey of roster.pubkeys) void profileService.resolve(pubkey); + }); + this.unsubscribers.set(id, () => { + unsubMsg(); + unsubRoster(); + }); + } + + /** re-sub every joined room on a relay that just came back. */ + private resubscribeRelay(relayUrl: string): void { + for (const room of this.joinedRooms.values()) { + if (room.relayUrl === relayUrl) this.subscribeRoom(room); + } + } + + /** + * send a chat (or action) message. ndk echoes the event locally right away; + * if the relay then rejects the publish we take that echo back (or suppress + * it if it hasn't landed yet) so a rejected message never sits there looking + * sent. + */ + async send(room: RoomAddress, content: string, action = false): Promise { + try { + await this.ensure().sendRoomMessage(room, content, action); + } catch (err) { + if (err instanceof PublishRejection) { + // yank it if already echoed; else mark it so onMessage drops it. + if (!terminalStore.removeByEventId(err.eventId)) { + this.rejectedEventIds.add(err.eventId); + // don't let the set grow forever; a late echo shows up in seconds. + setTimeout(() => this.rejectedEventIds.delete(err.eventId), 30_000); + } + } + throw err; + } + } + + /** is this pubkey a known room admin (from the relay's 39001 list)? */ + isAdmin(id: string, pubkey: string): boolean { + return get(roomStore).rooms[id]?.admins?.includes(pubkey) ?? true; + } + + /** + * invite (add) or kick (remove) someone via nip-29 moderation. the relay + * does the admin-only enforcing and rejects non-admins; we just surface it. + */ + async moderate(room: RoomAddress, action: 'add' | 'remove', targetPubkey: string): Promise { + await this.ensure().moderateUser(room, action, targetPubkey); + } + + /** set the room topic (nip-29 metadata `about`). admin-only at the relay. */ + async setTopic(room: RoomAddress, topic: string): Promise { + await this.ensure().setMetadata(room, { about: topic }); + } + + private onMessage(message: ChatMessage): void { + const id = roomId(message.room); + + // drop an optimistic echo of an event the relay already rejected + // (rejection beat the local echo). don't cache or render it. + if (this.rejectedEventIds.delete(message.id)) return; + + // session dedup: a reconnect re-subs and the relay replays stored + // events; show each one only once. + if (this.renderedEventIds.has(message.id)) return; + this.renderedEventIds.add(message.id); + + // cache every message (best-effort) so history survives reloads. + // cached events stay untrusted: the flag just records verify-at-cache. + void messageRepository.save(message); + + // locally muted authors don't show in the terminal (relay doesn't care). + if (get(muteStore).has(message.authorPubkey)) return; + + // let plugins peek at the message (after mute filtering). + pluginHost.emit('message', message); + + const active = get(roomStore).activeRoomId; + // bump unread when the message is for some other room. + if (id !== active) { + const room = get(roomStore).rooms[id]; + if (room) roomStore.patch(id, { unread: room.unread + 1 }); + } else { + // move the read marker for the active room. + void readMarkerRepository.set(id, message.createdAt); + } + + // grab a display name if we have one; else a shortened pubkey. + const displayName = + profileService.cachedName(message.authorPubkey) ?? shortenPubkey(message.authorPubkey); + + // flag mentions of the logged-in user (never self-mentions). + const me = get(authStore).pubkey; + const mention = + !!me && + message.authorPubkey !== me && + isMention(message.content, me, profileService.cachedName(me)); + + terminalStore.push({ + type: message.action ? 'action' : 'message', + author: message.authorPubkey, + authorDisplayName: displayName, + content: message.content, + roomId: id, + eventId: message.id, + mention, + timestamp: message.createdAt ? message.createdAt * 1000 : Date.now() + }); + + // desktop ping on a mention while the tab is in the background. + if (mention) { + const room = get(roomStore).rooms[id]; + notificationService.notifyMention(displayName, message.content, room?.name ?? id); + } + + // resolve the profile in the background; if a name shows up, patch this + // (and any earlier) entry so the hash gets swapped in place. + void profileService.resolve(message.authorPubkey).then(() => { + const resolved = profileService.cachedName(message.authorPubkey); + if (resolved) terminalStore.setAuthorName(message.authorPubkey, resolved); + }); + } + + /** rip down all subs (e.g. on logout/identity switch). */ + destroy(): void { + for (const unsub of this.unsubscribers.values()) unsub(); + this.unsubscribers.clear(); + this.joinedRooms.clear(); + this.renderedEventIds.clear(); + if (this.transport && 'destroy' in this.transport) { + (this.transport as NdkChatTransport).destroy(); + } + this.transport = null; + } +} + +export const roomService = new RoomService(); +export { NIP29_KINDS }; diff --git a/src/lib/nostr/signer-service.ts b/src/lib/nostr/signer-service.ts new file mode 100644 index 0000000..d93000a --- /dev/null +++ b/src/lib/nostr/signer-service.ts @@ -0,0 +1,170 @@ +import { NDKNip07Signer, NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; +import type { NDKSigner } from '@nostr-dev-kit/ndk'; +import { getNdk } from './ndk-client'; +import type { SignerKind } from '$lib/types/nostr'; + +export interface ActiveSigner { + kind: Exclude; + pubkey: string; + signer: NDKSigner; + /** true when the raw private key lives in this app's memory (nsec/ephemeral). */ + ephemeral: boolean; +} + +/** + * handles identity/signing only — never relays or messages. on login it hooks + * the signer onto the shared ndk instance so publishing and nip-42 auth can + * use it; on logout it unhooks. + * + * heads up (security): on the nsec and ephemeral paths the private key sits in + * browser memory. this service never logs it, never ships it anywhere, and + * only hands it out through the explicit `exportNsec`/`exportNcryptsec` methods + * the ui uses to show or (optionally, encrypted) save it when the user asks. + */ +export class SignerService { + private active: ActiveSigner | null = null; + /** kept only for in-memory key signers, to support export/persist. */ + private privateKeySigner: NDKPrivateKeySigner | null = null; + + /** true if a nip-07 browser extension (window.nostr) is around. */ + static hasNip07(): boolean { + return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'; + } + + get current(): ActiveSigner | null { + return this.active; + } + + /** + * log in with a nip-07 browser extension. the extension keeps the private + * key — we only ever get the public key and signatures. + */ + async loginNip07(): Promise { + if (!SignerService.hasNip07()) { + throw new Error( + 'No NIP-07 extension found. Install a Nostr signer extension (e.g. Alby, nos2x).' + ); + } + const ndk = getNdk(); + const signer = new NDKNip07Signer(3000, ndk); + let user; + try { + user = await signer.blockUntilReady(); + } catch { + throw new Error('NIP-07 login was rejected or timed out.'); + } + this.attach({ kind: 'nip07', pubkey: user.pubkey, signer, ephemeral: false }); + return this.active as ActiveSigner; + } + + /** + * log in with a remote signer (nip-46 "bunker"). the key stays on the remote + * signer; we talk to it via a connection token (bunker://…) or a nip-05. + */ + async loginBunker(connectionToken: string): Promise { + const token = connectionToken.trim(); + if (!token) throw new Error('Enter a bunker:// connection string or a NIP-05 identifier.'); + + const ndk = getNdk(); + let signer: NDKNip46Signer; + try { + signer = NDKNip46Signer.bunker(ndk, token); + } catch { + throw new Error('Invalid bunker connection string.'); + } + + let user; + try { + user = await signer.blockUntilReady(); + } catch { + throw new Error('Remote signer did not approve the connection (rejected or timed out).'); + } + this.attach({ kind: 'bunker', pubkey: user.pubkey, signer, ephemeral: false }); + return this.active as ActiveSigner; + } + + /** + * log in by importing an nsec (or hex) private key. the key stays ONLY in + * memory unless the caller saves it separately via exportNcryptsec. + * less safe than extension/bunker — the ui should warn about it. + */ + loginNsec(nsecOrHex: string): ActiveSigner { + const value = nsecOrHex.trim(); + if (!value) throw new Error('Enter an nsec (or hex) private key.'); + let signer: NDKPrivateKeySigner; + try { + // ndk takes nsec bech32 or hex straight up. + signer = new NDKPrivateKeySigner(value, getNdk()); + } catch { + throw new Error('That does not look like a valid nsec or hex private key.'); + } + this.privateKeySigner = signer; + this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true }); + return this.active as ActiveSigner; + } + + /** bring back an nsec identity from a nip-49 encrypted key (ncryptsec) + passphrase. */ + loginNcryptsec(ncryptsec: string, passphrase: string): ActiveSigner { + let signer: NDKPrivateKeySigner; + try { + signer = NDKPrivateKeySigner.fromNcryptsec(ncryptsec, passphrase, getNdk()); + } catch { + throw new Error('Could not decrypt the stored key — wrong passphrase?'); + } + this.privateKeySigner = signer; + this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true }); + return this.active as ActiveSigner; + } + + /** + * make a brand-new keypair and log in with it. the key lives in memory for + * the session; the ui shows the nsec once so the user can stash it. + */ + generateAndLogin(): ActiveSigner { + const signer = NDKPrivateKeySigner.generate(); + this.privateKeySigner = signer; + this.attach({ kind: 'nsec', pubkey: signer.pubkey, signer, ephemeral: true }); + return this.active as ActiveSigner; + } + + /** + * spin up a throwaway in-memory identity for dev/testing. + * NOT for anything you care about — gone on logout/reload. + */ + loginEphemeral(): ActiveSigner { + const signer = NDKPrivateKeySigner.generate(); + this.privateKeySigner = signer; + this.attach({ kind: 'ephemeral', pubkey: signer.pubkey, signer, ephemeral: true }); + return this.active as ActiveSigner; + } + + /** the active in-memory key as an nsec, if any (for one-time display). */ + exportNsec(): string | null { + return this.privateKeySigner?.nsec ?? null; + } + + /** the active in-memory key as an npub, if any. */ + exportNpub(): string | null { + return this.privateKeySigner?.npub ?? null; + } + + /** encrypt the active in-memory key with a passphrase (nip-49 ncryptsec). */ + exportNcryptsec(passphrase: string): string { + if (!this.privateKeySigner) throw new Error('No in-memory key to encrypt.'); + if (passphrase.length < 8) throw new Error('Use a passphrase of at least 8 characters.'); + return this.privateKeySigner.encryptToNcryptsec(passphrase); + } + + /** unhook the signer from ndk and wipe local identity state. */ + logout(): void { + const ndk = getNdk(); + ndk.signer = undefined; + this.active = null; + this.privateKeySigner = null; + } + + private attach(active: ActiveSigner): void { + getNdk().signer = active.signer; + this.active = active; + } +} diff --git a/src/lib/notifications/notification-service.ts b/src/lib/notifications/notification-service.ts new file mode 100644 index 0000000..9560826 --- /dev/null +++ b/src/lib/notifications/notification-service.ts @@ -0,0 +1,73 @@ +import { get } from 'svelte/store'; +import { settingsStore } from '$lib/stores/settings-store'; +import { BRAND } from '$lib/brand'; + +/** + * desktop notifications (web notifications api) for dms and mentions. fires only + * when the tab is backgrounded, notifications are on in settings, and the + * browser has granted permission. content is shown truncated; nothing gets sent + * anywhere — it's a local os notification, nothing more. + */ +class NotificationService { + private supported = + typeof window !== 'undefined' && 'Notification' in window && 'hidden' in document; + + /** can notifications fire right now (enabled + permitted)? */ + private get active(): boolean { + return ( + this.supported && get(settingsStore).notifications && Notification.permission === 'granted' + ); + } + + /** does this environment support notifications at all? */ + isSupported(): boolean { + return this.supported; + } + + /** current browser permission state ('default' | 'granted' | 'denied'). */ + permission(): NotificationPermission { + return this.supported ? Notification.permission : 'denied'; + } + + /** + * ask for os permission (must come from a user gesture, e.g. toggling the + * setting). returns the resulting permission state. + */ + async requestPermission(): Promise { + if (!this.supported) return 'denied'; + try { + return await Notification.requestPermission(); + } catch { + return this.permission(); + } + } + + notifyMention(from: string, content: string, room: string): void { + this.show(`${from} mentioned you in #${room}`, content); + } + + notifyDm(from: string, content: string): void { + this.show(`DM from ${from}`, content); + } + + /** show a notification, but only when the tab is hidden and we're allowed to. */ + private show(title: string, body: string): void { + if (!this.active || !document.hidden) return; + try { + const n = new Notification(`${BRAND.name} — ${title}`, { + body: body.length > 140 ? `${body.slice(0, 140)}…` : body, + // coalesce rapid notifications from the same source. + tag: title + }); + // focus the app when the user clicks the notification. + n.onclick = () => { + window.focus(); + n.close(); + }; + } catch { + /* notification construction can throw in some browsers; ignore */ + } + } +} + +export const notificationService = new NotificationService(); diff --git a/src/lib/plugins/load-plugins.ts b/src/lib/plugins/load-plugins.ts new file mode 100644 index 0000000..7650d7f --- /dev/null +++ b/src/lib/plugins/load-plugins.ts @@ -0,0 +1,32 @@ +import { pluginHost } from './plugin-host'; +import type { NostermPlugin } from './plugin-types'; + +/** + * auto-find and register every plugin in `src/plugins/`. each plugin module + * should export a NostermPlugin as its default export or as a named `plugin` + * export. dropping a new file into that folder is all it takes to add one + * (vite's import.meta.glob picks it up at build time). + * + * loaded eagerly so plugins can register commands/events before first render. + */ +export async function loadPlugins(): Promise { + // load the user's disabled-plugin set FIRST so disabled plugins get skipped + // during registration instead of loaded and immediately torn down. + await pluginHost.loadDisabled().catch((err) => { + console.error('[plugin] could not load disabled set (all plugins enabled):', err); + }); + + // vite statically analyzes this glob; the path is relative to THIS file. + const modules = import.meta.glob('../../plugins/*.{ts,js}', { eager: true }); + + for (const [path, mod] of Object.entries(modules)) { + const candidate = + (mod as { default?: NostermPlugin; plugin?: NostermPlugin }).default ?? + (mod as { plugin?: NostermPlugin }).plugin; + if (!candidate) { + console.warn(`[plugin] ${path} exports no default/plugin — skipped.`); + continue; + } + await pluginHost.register(candidate); + } +} diff --git a/src/lib/plugins/plugin-host.ts b/src/lib/plugins/plugin-host.ts new file mode 100644 index 0000000..5d2fbce --- /dev/null +++ b/src/lib/plugins/plugin-host.ts @@ -0,0 +1,235 @@ +import type { ChatCommand } from '$lib/types/command'; +import type { TerminalEntryType } from '$lib/types/terminal'; +import type { CommandRegistry } from '$lib/commands/command-registry'; +import { terminalStore } from '$lib/stores/terminal-store'; +import { addCustomTheme } from '$lib/stores/theme-store'; +import { pluginStateRepository } from '$lib/db/repositories'; +import type { NostermPlugin, PluginContext, PluginEventName, PluginEvents } from './plugin-types'; + +interface LoadedPlugin { + plugin: NostermPlugin; + unsubscribers: (() => void)[]; + /** names of commands this plugin registered, so we can yank them on disable. */ + commandNames: string[]; +} + +/** pluginStateRepository namespace + key for the saved disabled-plugin set. */ +const HOST_NS = '__host__'; +const DISABLED_KEY = 'disabled'; + +/** + * owns the plugin lifecycle: registration, the event bus plugins subscribe to, + * and the PluginContext each plugin's setup() gets. one host per app; the + * command registry gets injected once the dispatcher exists. + */ +class PluginHost { + private registry: CommandRegistry | null = null; + private readonly loaded = new Map(); + /** + * every plugin we've seen this session, on or off, keyed by id. lets + * `/plugins enable` re-run setup for one that was disabled at boot. + */ + private readonly known = new Map(); + /** ids the user turned off; saved so the choice sticks across reloads. */ + private disabled = new Set(); + // event name -> set of handlers. + private readonly listeners: { [E in PluginEventName]: Set } = { + message: new Set(), + roomChanged: new Set(), + relayState: new Set() + }; + // outgoing-message transforms, applied in registration order on the send path. + private readonly outgoingTransforms = new Set<(content: string) => string>(); + + /** wire up the command registry (called by the dispatcher factory). */ + useRegistry(registry: CommandRegistry): void { + this.registry = registry; + } + + /** list loaded plugins (for `/plugins`). */ + list(): NostermPlugin[] { + return [...this.loaded.values()].map((l) => l.plugin); + } + + /** fire an app event at every subscribed handler (never throws). */ + emit(event: E, ...args: Parameters): void { + for (const handler of this.listeners[event]) { + try { + (handler as (...a: unknown[]) => void)(...args); + } catch (err) { + console.error(`[plugin] ${event} handler failed:`, err); + } + } + } + + /** + * run every outgoing-message transform in order. never throws: a busted + * transform gets logged and skipped so a plugin can't block sending. + */ + applyOutgoing(content: string): string { + let result = content; + for (const transform of this.outgoingTransforms) { + try { + const next = transform(result); + if (typeof next === 'string') result = next; + } catch (err) { + console.error('[plugin] outgoing transform failed:', err); + } + } + return result; + } + + /** + * tear down a loaded plugin: drop its commands, run its unsubscribers + + * optional teardown(). it stays in `known` so it can be re-enabled. + */ + unregister(id: string): void { + const entry = this.loaded.get(id); + if (!entry) return; + // pull any commands the plugin registered so they stop resolving. + if (this.registry) { + for (const name of entry.commandNames) this.registry.unregister(name); + } + for (const off of entry.unsubscribers) { + try { + off(); + } catch (err) { + console.error(`[plugin] "${id}" unsubscribe failed:`, err); + } + } + try { + entry.plugin.teardown?.(); + } catch (err) { + console.error(`[plugin] "${id}" teardown failed:`, err); + } + this.loaded.delete(id); + } + + /** + * register and set up one plugin. safe: failures are boxed off + logged. + * a disabled plugin is remembered (so it can be turned on later) but its + * setup() is skipped. + */ + async register(plugin: NostermPlugin): Promise { + if (!plugin?.id || typeof plugin.setup !== 'function') { + console.error('[plugin] ignored: missing id or setup()', plugin); + return; + } + this.known.set(plugin.id, plugin); + if (this.disabled.has(plugin.id)) { + // known but turned off on purpose; don't run setup. + return; + } + if (this.loaded.has(plugin.id)) { + console.warn(`[plugin] "${plugin.id}" already registered; skipping duplicate.`); + return; + } + const entry: LoadedPlugin = { plugin, unsubscribers: [], commandNames: [] }; + this.loaded.set(plugin.id, entry); + try { + await plugin.setup(this.makeContext(plugin.id, entry)); + } catch (err) { + console.error(`[plugin] "${plugin.id}" setup failed:`, err); + this.loaded.delete(plugin.id); + } + } + + /** + * load the saved disabled-plugin set. call once before loadPlugins() so + * disabled plugins get skipped at boot instead of loaded then torn down. + */ + async loadDisabled(): Promise { + const stored = await pluginStateRepository.get(HOST_NS, DISABLED_KEY); + if (Array.isArray(stored)) this.disabled = new Set(stored); + } + + private persistDisabled(): void { + void pluginStateRepository + .set(HOST_NS, DISABLED_KEY, [...this.disabled]) + .catch((err) => console.error('[plugin] failed to persist disabled set:', err)); + } + + /** is a known plugin currently loaded (on and set up)? */ + isEnabled(id: string): boolean { + return this.loaded.has(id); + } + + /** all known plugins with their current on/off state (for `/plugins`). */ + status(): { plugin: NostermPlugin; enabled: boolean }[] { + return [...this.known.values()].map((plugin) => ({ + plugin, + enabled: this.loaded.has(plugin.id) + })); + } + + /** + * disable a loaded plugin: tear it down and remember the choice. returns + * false if the id isn't a known plugin. no-op if it's already off. + */ + async disable(id: string): Promise { + if (!this.known.has(id)) return false; + this.disabled.add(id); + this.persistDisabled(); + if (this.loaded.has(id)) this.unregister(id); + return true; + } + + /** + * enable a previously-off plugin: clear the flag and re-run its setup. + * returns false if the id isn't a known plugin. + */ + async enable(id: string): Promise { + const plugin = this.known.get(id); + if (!plugin) return false; + const wasDisabled = this.disabled.delete(id); + if (wasDisabled) this.persistDisabled(); + if (!this.loaded.has(id)) await this.register(plugin); + return true; + } + + private makeContext(pluginId: string, entry: LoadedPlugin): PluginContext { + return { + pluginId, + registerCommand: (command: ChatCommand) => { + if (!this.registry) { + console.error( + `[plugin] "${pluginId}" registered a command before the registry was ready.` + ); + return; + } + try { + this.registry.register(command); + // remember it so disabling the plugin can pull it back out. + entry.commandNames.push(command.name); + } catch (err) { + console.error(`[plugin] "${pluginId}" command "${command.name}" rejected:`, err); + } + }, + print: (content: string, type: TerminalEntryType = 'system') => { + // plugin output is local/trusted, tagged so you know where it came from. + terminalStore.push({ type, content: `[${pluginId}] ${content}` }); + }, + on: (event: E, handler: PluginEvents[E]) => { + this.listeners[event].add(handler); + const off = () => this.listeners[event].delete(handler); + entry.unsubscribers.push(off); + return off; + }, + transformOutgoing: (transform: (content: string) => string) => { + this.outgoingTransforms.add(transform); + const off = () => this.outgoingTransforms.delete(transform); + entry.unsubscribers.push(off); + return off; + }, + registerTheme: (theme) => { + void addCustomTheme(theme).catch((err) => + console.error(`[plugin] "${pluginId}" theme rejected:`, err) + ); + }, + getState: (key: string) => pluginStateRepository.get(pluginId, key), + setState: (key: string, value: T) => pluginStateRepository.set(pluginId, key, value) + }; + } +} + +export const pluginHost = new PluginHost(); diff --git a/src/lib/plugins/plugin-types.ts b/src/lib/plugins/plugin-types.ts new file mode 100644 index 0000000..381efd3 --- /dev/null +++ b/src/lib/plugins/plugin-types.ts @@ -0,0 +1,66 @@ +import type { ChatCommand } from '$lib/types/command'; +import type { ChatMessage } from '$lib/types/chat'; +import type { TerminalEntryType } from '$lib/types/terminal'; +import type { ThemeInput } from '$lib/themes/theme-types'; + +/** + * events a plugin can subscribe to. kept small and stable on purpose — this is + * what plugins lean on. handlers run synchronously in registration order; a + * throwing handler gets caught and logged, never takes the app down. + */ +export interface PluginEvents { + /** a chat message came in (after mute filtering, around render time). */ + message: (message: ChatMessage) => void; + /** the active room changed (room id, or undefined when none). */ + roomChanged: (roomId: string | undefined) => void; + /** a relay connection state changed. */ + relayState: (url: string, state: string) => void; +} + +export type PluginEventName = keyof PluginEvents; + +/** + * the api a plugin's `setup()` gets. everything a plugin needs to be useful — + * add commands, print to the terminal, react to events, register themes, read + * settings — without poking at app internals. + */ +export interface PluginContext { + /** register an irc-style command (same shape as built-ins). */ + registerCommand(command: ChatCommand): void; + /** print a line to the terminal (trusted, local — never remote content). */ + print(content: string, type?: TerminalEntryType): void; + /** subscribe to an app event; hands back an unsub fn. */ + on(event: E, handler: PluginEvents[E]): () => void; + /** + * register a transform run on outgoing message text right before it's sent + * (room messages and dms). transforms run in registration order, each fed + * the previous result. return the (maybe rewritten) text. keep it pure and + * fast — it's on the send path. hands back an unsub fn. + */ + transformOutgoing(transform: (content: string) => string): () => void; + /** register a custom theme (saved + selectable). */ + registerTheme(theme: ThemeInput): void; + /** read a saved, plugin-scoped value (namespaced by plugin id). */ + getState(key: string): Promise; + /** save a plugin-scoped value. */ + setState(key: string, value: T): Promise; + /** the plugin's own id, for logging/namespacing. */ + readonly pluginId: string; +} + +/** + * a nosterm plugin. drop a module exporting one of these (as default or a named + * `plugin` export) into `src/plugins/` and it's auto-loaded at startup. + */ +export interface NostermPlugin { + /** unique, stable id (kebab-case). */ + id: string; + /** human-readable name. */ + name: string; + /** one-line description shown by `/plugins`. */ + description?: string; + /** called once at startup. wire up commands/events/themes here. */ + setup(ctx: PluginContext): void | Promise; + /** optional cleanup (e.g. on disable). */ + teardown?(): void; +} diff --git a/src/lib/runtime-config.ts b/src/lib/runtime-config.ts new file mode 100644 index 0000000..23fff56 --- /dev/null +++ b/src/lib/runtime-config.ts @@ -0,0 +1,39 @@ +import { DEFAULT_RELAYS } from './config'; + +/** + * runtime client config fetched from `/config.json`. + * + * the container entrypoint rewrites this file from env vars at startup, so one + * prebuilt image can ship with different relays per environment — no rebuild. + * the build-time `PUBLIC_DEFAULT_RELAYS` stays as a fallback for local dev and + * for images run without the runtime var set. + */ +interface RuntimeConfig { + relays: string[]; +} + +// tidy a raw relay list into trimmed, non-empty strings. +function normalizeRelays(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((r) => String(r).trim()).filter((r) => r.length > 0); +} + +/** + * work out the default relays to use. runtime `/config.json` wins when it lists + * any; otherwise fall back to the build-time defaults. a missing or busted + * config is harmless — we just use the fallback. + */ +export async function loadDefaultRelays(fetchFn: typeof fetch = fetch): Promise { + try { + // no-store so runtime config changes never come back stale. + const res = await fetchFn('/config.json', { cache: 'no-store' }); + if (res.ok) { + const cfg = (await res.json()) as Partial; + const relays = normalizeRelays(cfg.relays); + if (relays.length > 0) return relays; + } + } catch { + // missing/broken config.json: fall back to build-time defaults. + } + return DEFAULT_RELAYS; +} diff --git a/src/lib/stores/auth-store.ts b/src/lib/stores/auth-store.ts new file mode 100644 index 0000000..f3f3639 --- /dev/null +++ b/src/lib/stores/auth-store.ts @@ -0,0 +1,34 @@ +import { writable } from 'svelte/store'; +import type { AuthState } from '$lib/types/nostr'; + +const INITIAL: AuthState = { kind: 'none', ephemeral: false }; + +function createAuthStore() { + const { subscribe, set, update } = writable(INITIAL); + + function setNip07(pubkey: string) { + set({ kind: 'nip07', pubkey, ephemeral: false }); + } + + function setBunker(pubkey: string) { + // remote signer holds the key; not in this app's memory. + set({ kind: 'bunker', pubkey, ephemeral: false }); + } + + function setNsec(pubkey: string) { + // private key sits in this app's memory. + set({ kind: 'nsec', pubkey, ephemeral: true }); + } + + function setEphemeral(pubkey: string) { + set({ kind: 'ephemeral', pubkey, ephemeral: true }); + } + + function logout() { + set(INITIAL); + } + + return { subscribe, setNip07, setBunker, setNsec, setEphemeral, logout, update }; +} + +export const authStore = createAuthStore(); diff --git a/src/lib/stores/dm-store.ts b/src/lib/stores/dm-store.ts new file mode 100644 index 0000000..7d7f189 --- /dev/null +++ b/src/lib/stores/dm-store.ts @@ -0,0 +1,78 @@ +import { writable, derived, get } from 'svelte/store'; + +/** a dm conversation with one counterparty (their hex pubkey). */ +export interface DmConversation { + /** counterparty pubkey (hex). */ + pubkey: string; + /** terminal roomId used to scope this conversation's entries. */ + roomId: string; + /** unread incoming messages since this conversation was last viewed. */ + unread: number; +} + +/** terminal roomId marker for a dm conversation with a given pubkey. */ +export function dmRoomId(pubkey: string): string { + return `dm:${pubkey}`; +} + +interface DmStoreState { + /** pubkey -> conversation. */ + conversations: Record; + /** pubkey of the active dm conversation, if a dm view is focused. */ + activePubkey?: string; +} + +function createDmStore() { + const { subscribe, update } = writable({ conversations: {} }); + + /** make sure a conversation exists for a counterparty; returns its roomId. */ + function ensure(pubkey: string): string { + update((s) => { + if (s.conversations[pubkey]) return s; + return { + ...s, + conversations: { + ...s.conversations, + [pubkey]: { pubkey, roomId: dmRoomId(pubkey), unread: 0 } + } + }; + }); + return dmRoomId(pubkey); + } + + /** focus a dm conversation (making it if needed), clearing its unread. */ + function open(pubkey: string): void { + ensure(pubkey); + update((s) => { + const convo = s.conversations[pubkey] ?? { pubkey, roomId: dmRoomId(pubkey), unread: 0 }; + return { + conversations: { ...s.conversations, [pubkey]: { ...convo, unread: 0 } }, + activePubkey: pubkey + }; + }); + } + + /** leave the dm view (hand focus back to rooms). */ + function close(): void { + update((s) => ({ ...s, activePubkey: undefined })); + } + + /** log an incoming dm; bumps unread unless that conversation is active. */ + function noteIncoming(pubkey: string): void { + ensure(pubkey); + update((s) => { + const convo = s.conversations[pubkey] ?? { pubkey, roomId: dmRoomId(pubkey), unread: 0 }; + const unread = s.activePubkey === pubkey ? 0 : convo.unread + 1; + return { ...s, conversations: { ...s.conversations, [pubkey]: { ...convo, unread } } }; + }); + } + + return { subscribe, ensure, open, close, noteIncoming, snapshot: () => get({ subscribe }) }; +} + +export const dmStore = createDmStore(); + +/** the active dm conversation, if any. */ +export const activeDm = derived(dmStore, ($s) => + $s.activePubkey ? $s.conversations[$s.activePubkey] : undefined +); diff --git a/src/lib/stores/mute-store.ts b/src/lib/stores/mute-store.ts new file mode 100644 index 0000000..8f10491 --- /dev/null +++ b/src/lib/stores/mute-store.ts @@ -0,0 +1,30 @@ +import { writable } from 'svelte/store'; + +/** + * set of muted author pubkeys (hex). messages from these pubkeys are hidden from + * the terminal. muting is a local, client-side filter — it doesn't touch the + * relay or other users. + */ +function createMuteStore() { + const { subscribe, update, set } = writable>(new Set()); + + function add(pubkey: string) { + update((s) => new Set(s).add(pubkey)); + } + + function remove(pubkey: string) { + update((s) => { + const next = new Set(s); + next.delete(pubkey); + return next; + }); + } + + function setAll(pubkeys: string[]) { + set(new Set(pubkeys)); + } + + return { subscribe, add, remove, setAll }; +} + +export const muteStore = createMuteStore(); diff --git a/src/lib/stores/profile-store.ts b/src/lib/stores/profile-store.ts new file mode 100644 index 0000000..2561cf7 --- /dev/null +++ b/src/lib/stores/profile-store.ts @@ -0,0 +1,18 @@ +import { writable } from 'svelte/store'; + +/** + * reactive map of pubkey (hex) -> resolved display name. mirrors the + * ProfileService cache so ui (identity panel, participant lists) updates live + * when a name resolves or the user changes their own nick. + */ +function createProfileStore() { + const { subscribe, update } = writable>({}); + + function setName(pubkey: string, name: string) { + update((m) => (m[pubkey] === name ? m : { ...m, [pubkey]: name })); + } + + return { subscribe, setName }; +} + +export const profileStore = createProfileStore(); diff --git a/src/lib/stores/relay-store.ts b/src/lib/stores/relay-store.ts new file mode 100644 index 0000000..c033d2a --- /dev/null +++ b/src/lib/stores/relay-store.ts @@ -0,0 +1,46 @@ +import { writable } from 'svelte/store'; +import type { RelayState, RelayConnectionState, RelayInformation } from '$lib/types/relay'; +import { normalizeRelayUrl } from '$lib/utils/relay-url'; + +/** map of normalized relay url -> RelayState. */ +function createRelayStore() { + const { subscribe, update } = writable>({}); + + function upsert(url: string, partial: Partial) { + const key = normalizeRelayUrl(url); + update((relays) => { + const existing = relays[key]; + const next: RelayState = { + url: key, + connection: partial.connection ?? existing?.connection ?? 'disconnected', + info: partial.info ?? existing?.info, + supportsNip29: partial.supportsNip29 ?? existing?.supportsNip29, + lastError: partial.lastError ?? existing?.lastError, + connectedAt: partial.connectedAt ?? existing?.connectedAt, + reconnectAttempts: partial.reconnectAttempts ?? existing?.reconnectAttempts ?? 0 + }; + return { ...relays, [key]: next }; + }); + } + + function setState(url: string, connection: RelayConnectionState, lastError?: string) { + upsert(url, { connection, lastError }); + } + + function setInfo(url: string, info: RelayInformation) { + upsert(url, { info, supportsNip29: (info.supportedNips ?? []).includes(29) }); + } + + function remove(url: string) { + const key = normalizeRelayUrl(url); + update((relays) => { + const next = { ...relays }; + delete next[key]; + return next; + }); + } + + return { subscribe, upsert, setState, setInfo, remove }; +} + +export const relayStore = createRelayStore(); diff --git a/src/lib/stores/room-store.ts b/src/lib/stores/room-store.ts new file mode 100644 index 0000000..5cc63da --- /dev/null +++ b/src/lib/stores/room-store.ts @@ -0,0 +1,69 @@ +import { writable, derived, get } from 'svelte/store'; +import type { RoomState } from '$lib/types/room'; +import type { RoomAddress } from '$lib/types/room'; +import { roomId } from '$lib/nostr/room-address'; + +interface RoomStoreState { + /** canonical room id -> RoomState. */ + rooms: Record; + /** canonical id of the currently active room, if any. */ + activeRoomId?: string; +} + +function createRoomStore() { + const { subscribe, update } = writable({ rooms: {} }); + + function add(address: RoomAddress, name?: string): RoomState { + const id = roomId(address); + const room: RoomState = { + id, + address, + name: name ?? address.groupId, + joined: true, + joinedAt: Date.now(), + unread: 0 + }; + update((s) => ({ rooms: { ...s.rooms, [id]: room }, activeRoomId: id })); + return room; + } + + function remove(id: string) { + update((s) => { + const rooms = { ...s.rooms }; + delete rooms[id]; + const activeRoomId = s.activeRoomId === id ? Object.keys(rooms)[0] : s.activeRoomId; + return { rooms, activeRoomId }; + }); + } + + function setActive(id: string) { + update((s) => { + const room = s.rooms[id]; + if (!room) return s; + // opening a room clears its unread count. + const rooms = room.unread > 0 ? { ...s.rooms, [id]: { ...room, unread: 0 } } : s.rooms; + return { ...s, rooms, activeRoomId: id }; + }); + } + + function patch(id: string, partial: Partial) { + update((s) => { + const existing = s.rooms[id]; + if (!existing) return s; + return { ...s, rooms: { ...s.rooms, [id]: { ...existing, ...partial } } }; + }); + } + + function snapshot() { + return get({ subscribe }); + } + + return { subscribe, add, remove, setActive, patch, snapshot }; +} + +export const roomStore = createRoomStore(); + +/** derived: the currently active room, if any. */ +export const activeRoom = derived(roomStore, ($s) => + $s.activeRoomId ? $s.rooms[$s.activeRoomId] : undefined +); diff --git a/src/lib/stores/server-store.ts b/src/lib/stores/server-store.ts new file mode 100644 index 0000000..d40b7dc --- /dev/null +++ b/src/lib/stores/server-store.ts @@ -0,0 +1,38 @@ +import { writable, get } from 'svelte/store'; +import { normalizeRelayUrl } from '$lib/utils/relay-url'; + +/** + * a relay "server window" — the irc-style server buffer that holds a relay's + * connection lifecycle, motd, and nip-11 handshake output. unlike rooms and + * dms, servers aren't a persisted or subscribed resource: they derive purely + * from the relay set (`relayStore`), so this store only tracks which server + * window is focused. terminal lines are scoped to a server via `relayRoomId`. + */ + +/** terminal roomId marker for a relay's server window. */ +export function relayRoomId(url: string): string { + return `relay:${normalizeRelayUrl(url)}`; +} + +interface ServerStoreState { + /** normalized url of the focused server window, if a server view is active. */ + activeUrl?: string; +} + +function createServerStore() { + const { subscribe, update } = writable({}); + + /** focus a relay's server window. */ + function open(url: string): void { + update(() => ({ activeUrl: normalizeRelayUrl(url) })); + } + + /** leave the server view (hand focus back to rooms). */ + function close(): void { + update(() => ({})); + } + + return { subscribe, open, close, snapshot: () => get({ subscribe }) }; +} + +export const serverStore = createServerStore(); diff --git a/src/lib/stores/settings-effects.ts b/src/lib/stores/settings-effects.ts new file mode 100644 index 0000000..796cbf9 --- /dev/null +++ b/src/lib/stores/settings-effects.ts @@ -0,0 +1,21 @@ +import { get } from 'svelte/store'; +import { settingsStore } from './settings-store'; + +/** + * push non-color settings (font size, line height, reduced motion) onto the + * document root as css custom properties / attributes. colors are the theme + * service's job; this handles the rest — terminal typography + motion. + * returns an unsubscribe function. + */ +export function applySettingsEffects(): () => void { + const apply = (): void => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const s = get(settingsStore); + root.style.setProperty('--terminal-font-size', `${s.fontSize}px`); + root.style.setProperty('--terminal-line-height', String(s.lineHeight)); + root.setAttribute('data-reduced-motion', String(s.reducedMotion)); + }; + // subscribe fires right away with the current value, then on every change. + return settingsStore.subscribe(apply); +} diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts new file mode 100644 index 0000000..6e571a7 --- /dev/null +++ b/src/lib/stores/settings-store.ts @@ -0,0 +1,23 @@ +import { writable } from 'svelte/store'; +import type { AppSettings } from '$lib/types/settings'; +import { DEFAULT_SETTINGS } from '$lib/types/settings'; +import { DEFAULT_THEME_ID } from '$lib/config'; + +function createSettingsStore() { + const initial: AppSettings = { ...DEFAULT_SETTINGS, themeId: DEFAULT_THEME_ID }; + const { subscribe, update, set } = writable(initial); + + function patch(partial: Partial) { + update((s) => ({ ...s, ...partial })); + } + + function reset() { + // keep the active theme; theme changes go through the theme service (via + // /theme) so it stays in sync with what's applied to the document. + update((s) => ({ ...DEFAULT_SETTINGS, themeId: s.themeId })); + } + + return { subscribe, set, patch, reset }; +} + +export const settingsStore = createSettingsStore(); diff --git a/src/lib/stores/terminal-store.ts b/src/lib/stores/terminal-store.ts new file mode 100644 index 0000000..48736a4 --- /dev/null +++ b/src/lib/stores/terminal-store.ts @@ -0,0 +1,116 @@ +import { writable, get } from 'svelte/store'; +import type { TerminalEntry, TerminalEntryType } from '$lib/types/terminal'; +import { generateId } from '$lib/utils/id'; +import { MAX_TERMINAL_ENTRIES } from '$lib/config'; + +function createTerminalStore() { + const { subscribe, update, set } = writable([]); + + /** tack on an entry, trimming history to the configured retention limit. */ + function push( + entry: Omit & + Partial> + ): TerminalEntry { + const full: TerminalEntry = { + id: entry.id ?? generateId(), + timestamp: entry.timestamp ?? Date.now(), + type: entry.type, + roomId: entry.roomId, + author: entry.author, + authorDisplayName: entry.authorDisplayName, + eventId: entry.eventId, + mention: entry.mention, + content: entry.content + }; + update((entries) => { + const next = [...entries, full]; + return next.length > MAX_TERMINAL_ENTRIES + ? next.slice(next.length - MAX_TERMINAL_ENTRIES) + : next; + }); + return full; + } + + /** shortcut for a trusted local line of a given type. */ + function system(content: string, type: TerminalEntryType = 'system', roomId?: string) { + return push({ type, content, roomId }); + } + + function pushMany(entries: TerminalEntry[]) { + for (const e of entries) push(e); + } + + /** + * set the display name on every already-rendered entry for an author. + * used when a profile resolves after messages already showed with a + * shortened-pubkey fallback, so the name swaps in place. + */ + function setAuthorName(author: string, displayName: string) { + update((entries) => { + let changed = false; + const next = entries.map((e) => { + if (e.author === author && e.authorDisplayName !== displayName) { + changed = true; + return { ...e, authorDisplayName: displayName }; + } + return e; + }); + return changed ? next : entries; + }); + } + + /** + * drop a message/action entry by its nostr event id. used to take back an + * optimistically-echoed message when the relay rejects the publish. returns + * true if something was removed. + */ + function removeByEventId(eventId: string): boolean { + let removed = false; + update((entries) => { + const next = entries.filter((e) => { + if (e.eventId === eventId) { + removed = true; + return false; + } + return true; + }); + return removed ? next : entries; + }); + return removed; + } + + function clear() { + set([]); + } + + /** wipe all entries for a room (leaves global lines alone). */ + function clearRoom(roomId: string) { + update((entries) => entries.filter((e) => e.roomId !== roomId)); + } + + return { + subscribe, + push, + pushMany, + system, + setAuthorName, + removeByEventId, + clear, + clearRoom, + snapshot: () => get({ subscribe }) + }; +} + +export const terminalStore = createTerminalStore(); + +/** + * should an entry show while `activeRoomId` is focused? global entries + * (system/notice/command/dm — no roomId) always show; a room-scoped entry only + * shows while its room is active. keeps each room's log its own. + */ +export function isEntryVisible( + entry: Pick, + activeRoomId: string | undefined +): boolean { + return entry.roomId === undefined || entry.roomId === activeRoomId; +} diff --git a/src/lib/stores/theme-store.ts b/src/lib/stores/theme-store.ts new file mode 100644 index 0000000..3f9b09b --- /dev/null +++ b/src/lib/stores/theme-store.ts @@ -0,0 +1,109 @@ +import { writable, get } from 'svelte/store'; +import { ThemeService } from '$lib/themes/theme-service'; +import { + getThemeOrDefault, + registerTheme, + unregisterTheme, + allThemes, + isBuiltInTheme +} from '$lib/themes/theme-registry'; +import { settingsStore } from './settings-store'; +import { customThemeRepository } from '$lib/db/repositories'; +import { DEFAULT_THEME_ID } from '$lib/config'; +import type { TerminalTheme, ThemeInput } from '$lib/themes/theme-types'; + +/** reactive list of all themes (built-in + custom), for the settings ui. */ +export const themeList = writable(allThemes()); + +function refreshThemeList() { + themeList.set(allThemes()); +} + +/** + * bridges the ThemeService (dom side effects) with the settings store. + * one ThemeService instance, made lazily, client-only. + */ +let service: ThemeService | null = null; + +const activeThemeId = writable(DEFAULT_THEME_ID); + +function ensureService(initialId: string): ThemeService { + if (!service) { + service = new ThemeService(initialId); + service.init(); + } + return service; +} + +/** call once on the client after settings are hydrated. */ +export function initTheme(initialId: string) { + ensureService(initialId); + activeThemeId.set(initialId); +} + +/** set and save the theme. throws on unknown names. */ +export function setTheme(nameOrId: string): TerminalTheme { + const svc = ensureService(get(activeThemeId)); + const theme = svc.setTheme(nameOrId); + activeThemeId.set(theme.id); + settingsStore.patch({ themeId: theme.id }); + return theme; +} + +/** preview without saving. */ +export function previewTheme(nameOrId: string): TerminalTheme { + const svc = ensureService(get(activeThemeId)); + return svc.preview(nameOrId); +} + +/** undo a preview, putting back the saved theme. */ +export function restoreTheme() { + service?.restore(); +} + +/** current fully-resolved theme object. */ +export function currentTheme(): TerminalTheme { + return getThemeOrDefault(get(activeThemeId)); +} + +/** + * register a custom theme, save it, and make it selectable right away. + * throws on a bad theme or a built-in id clash. returns the stored theme. + */ +export async function addCustomTheme(input: ThemeInput): Promise { + const theme = registerTheme(input); // validates + fills defaults + await customThemeRepository.save(theme); + refreshThemeList(); + return theme; +} + +/** drop a custom theme by id (built-ins can't be removed). */ +export async function removeCustomTheme(id: string): Promise { + if (isBuiltInTheme(id)) return false; + const removed = unregisterTheme(id); + if (removed) { + await customThemeRepository.remove(id); + refreshThemeList(); + // if the removed theme was active, drop back to the default. + if (get(activeThemeId) === id) setTheme(DEFAULT_THEME_ID); + } + return removed; +} + +/** load saved custom themes into the registry (call once on startup). */ +export async function loadCustomThemes(): Promise { + try { + for (const theme of await customThemeRepository.list()) { + try { + registerTheme(theme); + } catch { + /* skip a stored theme that no longer checks out */ + } + } + refreshThemeList(); + } catch { + /* no indexeddb — built-in themes still work */ + } +} + +export { activeThemeId }; diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts new file mode 100644 index 0000000..88f0e3a --- /dev/null +++ b/src/lib/stores/ui-store.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +/** throwaway ui state we don't persist (modals, overlays). */ +interface UiState { + /** is the sign-in / onboarding overlay showing? */ + authOpen: boolean; +} + +function createUiStore() { + const { subscribe, update } = writable({ authOpen: false }); + + return { + subscribe, + openAuth: () => update((s) => ({ ...s, authOpen: true })), + closeAuth: () => update((s) => ({ ...s, authOpen: false })) + }; +} + +export const uiStore = createUiStore(); + +/** handy helpers for non-svelte callers (e.g. the /login command). */ +export function openAuth() { + uiStore.openAuth(); +} +export function closeAuth() { + uiStore.closeAuth(); +} diff --git a/src/lib/themes/contrast.ts b/src/lib/themes/contrast.ts new file mode 100644 index 0000000..448257f --- /dev/null +++ b/src/lib/themes/contrast.ts @@ -0,0 +1,35 @@ +/** + * wcag 2.1 luminance/contrast math for theme a11y checks. + * https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio + */ + +/** parse a #rgb or #rrggbb hex into [r, g, b] (0–255). */ +export function parseHex(hex: string): [number, number, number] { + const h = hex.trim().replace(/^#/, ''); + const full = h.length === 3 ? h.replace(/(.)/g, '$1$1') : h; + if (!/^[0-9a-fA-F]{6}$/.test(full)) { + throw new Error(`Not a hex color: "${hex}".`); + } + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + return [r, g, b]; +} + +/** relative luminance of an srgb color, per wcag. */ +export function relativeLuminance(hex: string): number { + const [r, g, b] = parseHex(hex).map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** contrast ratio (1–21) between two hex colors. */ +export function contrastRatio(a: string, b: string): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const lighter = Math.max(la, lb); + const darker = Math.min(la, lb); + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/src/lib/themes/theme-registry.ts b/src/lib/themes/theme-registry.ts new file mode 100644 index 0000000..2cca08e --- /dev/null +++ b/src/lib/themes/theme-registry.ts @@ -0,0 +1,153 @@ +import type { TerminalTheme, ThemeInput } from './theme-types'; +import { REQUIRED_TOKENS } from './theme-types'; +import { nord } from './themes/nord'; +import { molokai } from './themes/molokai'; +import { dracula } from './themes/dracula'; +import { solarizedDark } from './themes/solarized-dark'; +import { solarizedLight } from './themes/solarized-light'; +import { gruvboxDark } from './themes/gruvbox-dark'; +import { monokai } from './themes/monokai'; +import { tokyoNight } from './themes/tokyo-night'; +import { catppuccinMocha } from './themes/catppuccin-mocha'; +import { classicIrc } from './themes/classic-irc'; +import { highContrast } from './themes/high-contrast'; +import { systemDefault } from './themes/system-default'; + +/** built-in themes, in display order. custom themes register on top. */ +const BUILT_IN: readonly TerminalTheme[] = [ + nord, + molokai, + dracula, + solarizedDark, + solarizedLight, + gruvboxDark, + monokai, + tokyoNight, + catppuccinMocha, + classicIrc, + highContrast, + systemDefault +]; + +export const DEFAULT_THEME_ID = 'nord'; + +/** normalize a theme name/alias: lowercase, tidy up separators/whitespace. */ +export function normalizeThemeKey(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); +} + +// live registry. themes are kept by id (insertion order) and indexed by every +// id + alias for lookup. custom/plugin themes register into these. +const themesById = new Map(); +const lookup = new Map(); +const builtInIds = new Set(); + +function index(theme: TerminalTheme): void { + themesById.set(theme.id, theme); + lookup.set(normalizeThemeKey(theme.id), theme); + for (const alias of theme.aliases) lookup.set(normalizeThemeKey(alias), theme); +} + +for (const theme of BUILT_IN) { + index(theme); + builtInIds.add(theme.id); +} + +/** + * check a theme object: non-empty id/name and every required color token + * present. returns an error message, or null if it's fine. + */ +export function validateTheme(theme: Partial | undefined): string | null { + if (!theme || typeof theme !== 'object') return 'Theme must be an object.'; + if (!theme.id || typeof theme.id !== 'string') return 'Theme needs a string "id".'; + if (!theme.name || typeof theme.name !== 'string') return 'Theme needs a string "name".'; + if (!theme.colors || typeof theme.colors !== 'object') return 'Theme needs a "colors" object.'; + for (const token of REQUIRED_TOKENS) { + const v = theme.colors[token]; + if (typeof v !== 'string' || v.length === 0) { + return `Theme is missing color token "${token}".`; + } + } + if (theme.usernamePalette && !Array.isArray(theme.usernamePalette)) { + return 'usernamePalette must be an array of color strings.'; + } + return null; +} + +/** all registered themes, built-ins first then custom, in registration order. */ +export function allThemes(): TerminalTheme[] { + return [...themesById.values()]; +} + +/** + * register (or replace) a theme at runtime. custom themes and plugins call + * this. any color tokens you leave out are borrowed from a base theme (the one + * named/aliased by `input.base`, else the default), so a partial theme — even + * just `{ id, name, colors: { accent } }` — is valid and hackable. + * throws on a bad theme or if you try to overwrite a built-in id. + * returns the normalized theme (with defaults filled in). + */ +export function registerTheme(input: ThemeInput): TerminalTheme { + if (!input || typeof input !== 'object') throw new Error('Theme must be an object.'); + if (!input.id || typeof input.id !== 'string') throw new Error('Theme needs a string "id".'); + if (!input.name || typeof input.name !== 'string') + throw new Error('Theme needs a string "name".'); + if (builtInIds.has(input.id)) { + throw new Error(`"${input.id}" is a built-in theme and cannot be overwritten.`); + } + // borrow any missing color tokens from the chosen base theme. + const base = (input.base ? resolveTheme(input.base) : undefined) ?? nord; + const colors = { ...base.colors, ...(input.colors ?? {}) }; + const candidate: Partial = { ...input, colors }; + const err = validateTheme(candidate); + if (err) throw new Error(err); + const theme: TerminalTheme = { + id: input.id, + name: input.name, + description: input.description ?? '', + mode: input.mode ?? base.mode, + aliases: input.aliases ?? [], + colors, + usernamePalette: + input.usernamePalette && input.usernamePalette.length > 0 + ? input.usernamePalette + : base.usernamePalette + }; + // re-index: drop any earlier custom registration under this id first. + unregisterTheme(theme.id); + index(theme); + return theme; +} + +/** drop a custom theme by id. built-ins can't be removed. returns success. */ +export function unregisterTheme(id: string): boolean { + if (builtInIds.has(id)) return false; + const existing = themesById.get(id); + if (!existing) return false; + themesById.delete(id); + // rebuild the alias lookup from scratch (aliases may have pointed here). + lookup.clear(); + for (const theme of themesById.values()) { + lookup.set(normalizeThemeKey(theme.id), theme); + for (const alias of theme.aliases) lookup.set(normalizeThemeKey(alias), theme); + } + return true; +} + +/** does this id point at a built-in (non-removable) theme? */ +export function isBuiltInTheme(id: string): boolean { + return builtInIds.has(id); +} + +/** find a theme by id, display name, or alias (case/separator-insensitive). */ +export function resolveTheme(nameOrId: string): TerminalTheme | undefined { + return lookup.get(normalizeThemeKey(nameOrId)); +} + +/** find by id only, falling back to the default theme if not found. */ +export function getThemeOrDefault(id: string): TerminalTheme { + return resolveTheme(id) ?? nord; +} diff --git a/src/lib/themes/theme-service.ts b/src/lib/themes/theme-service.ts new file mode 100644 index 0000000..15f354a --- /dev/null +++ b/src/lib/themes/theme-service.ts @@ -0,0 +1,117 @@ +import type { TerminalTheme } from './theme-types'; +import { CSS_VAR_BY_TOKEN } from './theme-types'; +import { getThemeOrDefault, resolveTheme } from './theme-registry'; +import { SYSTEM_DARK_VARIANT, SYSTEM_LIGHT_VARIANT } from './themes/system-default'; + +const STORAGE_KEY = 'nosterm:active-theme'; + +/** + * figure out the actual palette to apply. in "system" mode we pick the dark or + * light variant off `prefers-color-scheme`. `prefersDark` is injected so this + * stays testable without a real matchMedia. + */ +export function resolveEffectiveTheme(theme: TerminalTheme, prefersDark: boolean): TerminalTheme { + if (theme.mode !== 'system') return theme; + return prefersDark ? SYSTEM_DARK_VARIANT : SYSTEM_LIGHT_VARIANT; +} + +/** write a theme's semantic tokens onto the given root element's style. */ +export function applyThemeToRoot( + theme: TerminalTheme, + root: HTMLElement, + selectedId: string +): void { + const effective = resolveEffectiveTheme(theme, prefersDarkScheme()); + for (const [token, cssVar] of Object.entries(CSS_VAR_BY_TOKEN)) { + root.style.setProperty(cssVar, effective.colors[token as keyof typeof effective.colors]); + } + // data-theme mirrors the user's *selected* id (might be "system-default"). + root.setAttribute('data-theme', selectedId); + root.setAttribute('data-theme-mode', effective.mode); +} + +function prefersDarkScheme(): boolean { + if (typeof window === 'undefined' || !window.matchMedia) return true; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +/** + * applies the active theme to the document and watches for os scheme changes + * when "system-default" is picked. framework-agnostic: the settings store calls + * `setTheme`; this service owns dom side effects only. + */ +export class ThemeService { + private selectedId: string; + private mediaQuery: MediaQueryList | null = null; + private readonly onSchemeChange = () => { + // re-apply only if the current pick actually follows the os. + const theme = getThemeOrDefault(this.selectedId); + if (theme.mode === 'system') this.apply(); + }; + + constructor(initialId: string) { + this.selectedId = initialId; + } + + /** apply the currently selected theme and start watching os scheme changes. */ + init(): void { + this.apply(); + if (typeof window !== 'undefined' && window.matchMedia) { + this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + this.mediaQuery.addEventListener('change', this.onSchemeChange); + } + } + + /** + * pick a theme by id/alias. throws on unknown names so callers can show a + * useful terminal error. saves and re-applies without a reload. + */ + setTheme(nameOrId: string): TerminalTheme { + const theme = resolveTheme(nameOrId); + if (!theme) { + throw new Error(`Unknown theme "${nameOrId}".`); + } + this.selectedId = theme.id; + this.persist(theme.id); + this.apply(); + return theme; + } + + /** preview a theme without saving the pick. */ + preview(nameOrId: string): TerminalTheme { + const theme = resolveTheme(nameOrId); + if (!theme) throw new Error(`Unknown theme "${nameOrId}".`); + if (typeof document !== 'undefined') { + applyThemeToRoot(theme, document.documentElement, theme.id); + } + return theme; + } + + /** re-apply the saved pick (undo a preview). */ + restore(): void { + this.apply(); + } + + getSelectedId(): string { + return this.selectedId; + } + + destroy(): void { + this.mediaQuery?.removeEventListener('change', this.onSchemeChange); + this.mediaQuery = null; + } + + private apply(): void { + if (typeof document === 'undefined') return; + const theme = getThemeOrDefault(this.selectedId); + applyThemeToRoot(theme, document.documentElement, this.selectedId); + } + + private persist(id: string): void { + try { + localStorage.setItem(STORAGE_KEY, id); + } catch { + /* localStorage might not be around; indexeddb persistence covers the rest. */ + } + } +} diff --git a/src/lib/themes/theme-types.ts b/src/lib/themes/theme-types.ts new file mode 100644 index 0000000..90983f8 --- /dev/null +++ b/src/lib/themes/theme-types.ts @@ -0,0 +1,91 @@ +export type ThemeMode = 'dark' | 'light' | 'system'; + +/** semantic color tokens. every theme fills all of these. */ +export interface ThemeColors { + background: string; + surface: string; + surfaceRaised: string; + border: string; + + textPrimary: string; + textSecondary: string; + textMuted: string; + + accent: string; + link: string; + focus: string; + + success: string; + warning: string; + error: string; + info: string; + + terminalSystem: string; + terminalNotice: string; + terminalCommand: string; + terminalAction: string; + terminalTimestamp: string; + + relayConnected: string; + relayConnecting: string; + relayDisconnected: string; + relayError: string; +} + +export interface TerminalTheme { + id: string; + name: string; + description: string; + mode: ThemeMode; + aliases: string[]; + colors: ThemeColors; + /** per-user name colors. gotta be legible on `background`. */ + usernamePalette: string[]; +} + +/** + * a theme from a user/plugin: `id` and `name` required; everything else — + * including individual color tokens — is optional and inherited from a base + * theme at registration time. + */ +export type ThemeInput = { + id: string; + name: string; + description?: string; + mode?: ThemeMode; + aliases?: string[]; + colors?: Partial; + usernamePalette?: string[]; + /** base theme (id/name/alias) to grab missing color tokens from. */ + base?: string; +}; + +/** maps ThemeColors keys to the css custom props applied to :root. */ +export const CSS_VAR_BY_TOKEN: Record = { + background: '--color-background', + surface: '--color-surface', + surfaceRaised: '--color-surface-raised', + border: '--color-border', + textPrimary: '--color-text-primary', + textSecondary: '--color-text-secondary', + textMuted: '--color-text-muted', + accent: '--color-accent', + link: '--color-link', + focus: '--color-focus', + success: '--color-success', + warning: '--color-warning', + error: '--color-error', + info: '--color-info', + terminalSystem: '--color-terminal-system', + terminalNotice: '--color-terminal-notice', + terminalCommand: '--color-terminal-command', + terminalAction: '--color-terminal-action', + terminalTimestamp: '--color-terminal-timestamp', + relayConnected: '--color-relay-connected', + relayConnecting: '--color-relay-connecting', + relayDisconnected: '--color-relay-disconnected', + relayError: '--color-relay-error' +}; + +/** all required token keys (tests use this for completeness checks). */ +export const REQUIRED_TOKENS = Object.keys(CSS_VAR_BY_TOKEN) as (keyof ThemeColors)[]; diff --git a/src/lib/themes/themes/catppuccin-mocha.ts b/src/lib/themes/themes/catppuccin-mocha.ts new file mode 100644 index 0000000..9e44949 --- /dev/null +++ b/src/lib/themes/themes/catppuccin-mocha.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// catppuccin mocha — https://catppuccin.com/ +export const catppuccinMocha: TerminalTheme = { + id: 'catppuccin-mocha', + name: 'Catppuccin Mocha', + description: 'Soothing pastel dark theme.', + mode: 'dark', + aliases: ['catppuccin-mocha', 'catppuccin mocha', 'catppuccin_mocha', 'catppuccin', 'mocha'], + colors: { + background: '#1e1e2e', + surface: '#28283e', + surfaceRaised: '#313244', + border: '#45475a', + + textPrimary: '#cdd6f4', + textSecondary: '#bac2de', + textMuted: '#7f849c', + + accent: '#89b4fa', + link: '#94e2d5', + focus: '#89b4fa', + + success: '#a6e3a1', + warning: '#f9e2af', + error: '#f38ba8', + info: '#89dceb', + + terminalSystem: '#89b4fa', + terminalNotice: '#94e2d5', + terminalCommand: '#cba6f7', + terminalAction: '#f9e2af', + terminalTimestamp: '#7f849c', + + relayConnected: '#a6e3a1', + relayConnecting: '#f9e2af', + relayDisconnected: '#7f849c', + relayError: '#f38ba8' + }, + usernamePalette: ['#89b4fa', '#94e2d5', '#a6e3a1', '#f9e2af', '#fab387', '#cba6f7', '#f38ba8'] +}; diff --git a/src/lib/themes/themes/classic-irc.ts b/src/lib/themes/themes/classic-irc.ts new file mode 100644 index 0000000..c557676 --- /dev/null +++ b/src/lib/themes/themes/classic-irc.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// classic irc — mirc-style white-on-black, that old 16-color feel. +export const classicIrc: TerminalTheme = { + id: 'classic-irc', + name: 'Classic IRC', + description: 'Old-school white-on-black IRC client look.', + mode: 'dark', + aliases: ['classic-irc', 'classic irc', 'classic_irc', 'irc', 'classic'], + colors: { + background: '#000000', + surface: '#0a0a0a', + surfaceRaised: '#141414', + border: '#333333', + + textPrimary: '#e0e0e0', + textSecondary: '#b0b0b0', + textMuted: '#808080', + + accent: '#00aaaa', + link: '#5555ff', + focus: '#00aaaa', + + success: '#00aa00', + warning: '#aaaa00', + error: '#ff5555', + info: '#00aaaa', + + terminalSystem: '#00aaaa', + terminalNotice: '#55ff55', + terminalCommand: '#aa00aa', + terminalAction: '#aaaa00', + terminalTimestamp: '#808080', + + relayConnected: '#00aa00', + relayConnecting: '#aaaa00', + relayDisconnected: '#808080', + relayError: '#ff5555' + }, + usernamePalette: ['#5555ff', '#00aaaa', '#00aa00', '#aaaa00', '#ff55ff', '#ff5555', '#55ffff'] +}; diff --git a/src/lib/themes/themes/dracula.ts b/src/lib/themes/themes/dracula.ts new file mode 100644 index 0000000..78d9b51 --- /dev/null +++ b/src/lib/themes/themes/dracula.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// dracula — https://draculatheme.com/ +export const dracula: TerminalTheme = { + id: 'dracula', + name: 'Dracula', + description: 'Dark theme with vivid purples and cyans.', + mode: 'dark', + aliases: ['dracula'], + colors: { + background: '#282a36', + surface: '#343746', + surfaceRaised: '#44475a', + border: '#6272a4', + + textPrimary: '#f8f8f2', + textSecondary: '#d5d8e0', + textMuted: '#6272a4', + + accent: '#bd93f9', + link: '#8be9fd', + focus: '#bd93f9', + + success: '#50fa7b', + warning: '#f1fa8c', + error: '#ff5555', + info: '#8be9fd', + + terminalSystem: '#8be9fd', + terminalNotice: '#50fa7b', + terminalCommand: '#bd93f9', + terminalAction: '#f1fa8c', + terminalTimestamp: '#6272a4', + + relayConnected: '#50fa7b', + relayConnecting: '#f1fa8c', + relayDisconnected: '#6272a4', + relayError: '#ff5555' + }, + usernamePalette: ['#8be9fd', '#50fa7b', '#f1fa8c', '#ffb86c', '#ff79c6', '#bd93f9', '#ff5555'] +}; diff --git a/src/lib/themes/themes/gruvbox-dark.ts b/src/lib/themes/themes/gruvbox-dark.ts new file mode 100644 index 0000000..663b74b --- /dev/null +++ b/src/lib/themes/themes/gruvbox-dark.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// gruvbox dark — pavel pertsev. +export const gruvboxDark: TerminalTheme = { + id: 'gruvbox-dark', + name: 'Gruvbox Dark', + description: 'Retro groove warm dark palette.', + mode: 'dark', + aliases: ['gruvbox-dark', 'gruvbox dark', 'gruvbox_dark', 'gruvbox'], + colors: { + background: '#282828', + surface: '#32302f', + surfaceRaised: '#3c3836', + border: '#504945', + + textPrimary: '#ebdbb2', + textSecondary: '#d5c4a1', + textMuted: '#928374', + + accent: '#83a598', + link: '#8ec07c', + focus: '#fabd2f', + + success: '#b8bb26', + warning: '#fabd2f', + error: '#fb4934', + info: '#83a598', + + terminalSystem: '#83a598', + terminalNotice: '#8ec07c', + terminalCommand: '#d3869b', + terminalAction: '#fabd2f', + terminalTimestamp: '#928374', + + relayConnected: '#b8bb26', + relayConnecting: '#fabd2f', + relayDisconnected: '#928374', + relayError: '#fb4934' + }, + usernamePalette: ['#83a598', '#8ec07c', '#b8bb26', '#fabd2f', '#fe8019', '#d3869b', '#fb4934'] +}; diff --git a/src/lib/themes/themes/high-contrast.ts b/src/lib/themes/themes/high-contrast.ts new file mode 100644 index 0000000..f0c31d9 --- /dev/null +++ b/src/lib/themes/themes/high-contrast.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// high contrast — max legibility, aiming for wcag aaa. +export const highContrast: TerminalTheme = { + id: 'high-contrast', + name: 'High Contrast', + description: 'Maximum-contrast theme for accessibility.', + mode: 'dark', + aliases: ['high-contrast', 'high contrast', 'high_contrast', 'hc'], + colors: { + background: '#000000', + surface: '#000000', + surfaceRaised: '#0d0d0d', + border: '#ffffff', + + textPrimary: '#ffffff', + textSecondary: '#f0f0f0', + textMuted: '#c0c0c0', + + accent: '#00ffff', + link: '#66ccff', + focus: '#ffff00', + + success: '#00ff00', + warning: '#ffff00', + error: '#ff4040', + info: '#00ffff', + + terminalSystem: '#00ffff', + terminalNotice: '#00ff00', + terminalCommand: '#ff66ff', + terminalAction: '#ffff00', + terminalTimestamp: '#c0c0c0', + + relayConnected: '#00ff00', + relayConnecting: '#ffff00', + relayDisconnected: '#c0c0c0', + relayError: '#ff4040' + }, + usernamePalette: ['#00ffff', '#00ff00', '#ffff00', '#ff66ff', '#66ccff', '#ffffff'] +}; diff --git a/src/lib/themes/themes/molokai.ts b/src/lib/themes/themes/molokai.ts new file mode 100644 index 0000000..684c034 --- /dev/null +++ b/src/lib/themes/themes/molokai.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// molokai — classic vim color scheme. +export const molokai: TerminalTheme = { + id: 'molokai', + name: 'Molokai', + description: 'High-energy dark theme popularized by Vim.', + mode: 'dark', + aliases: ['molokai'], + colors: { + background: '#1b1d1e', + surface: '#232526', + surfaceRaised: '#2d2f31', + border: '#3a3d3e', + + textPrimary: '#f8f8f2', + textSecondary: '#d0d0c0', + textMuted: '#808070', + + accent: '#66d9ef', + link: '#66d9ef', + focus: '#a6e22e', + + success: '#a6e22e', + warning: '#e6db74', + error: '#f92672', + info: '#66d9ef', + + terminalSystem: '#66d9ef', + terminalNotice: '#a6e22e', + terminalCommand: '#ae81ff', + terminalAction: '#e6db74', + terminalTimestamp: '#808070', + + relayConnected: '#a6e22e', + relayConnecting: '#e6db74', + relayDisconnected: '#808070', + relayError: '#f92672' + }, + usernamePalette: ['#66d9ef', '#a6e22e', '#e6db74', '#fd971f', '#f92672', '#ae81ff'] +}; diff --git a/src/lib/themes/themes/monokai.ts b/src/lib/themes/themes/monokai.ts new file mode 100644 index 0000000..2e87ccd --- /dev/null +++ b/src/lib/themes/themes/monokai.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// monokai — wimer hazenberg. +export const monokai: TerminalTheme = { + id: 'monokai', + name: 'Monokai', + description: 'Vibrant classic editor palette.', + mode: 'dark', + aliases: ['monokai'], + colors: { + background: '#272822', + surface: '#2f302a', + surfaceRaised: '#3a3b32', + border: '#49483e', + + textPrimary: '#f8f8f2', + textSecondary: '#cfcfc2', + textMuted: '#75715e', + + accent: '#66d9ef', + link: '#66d9ef', + focus: '#a6e22e', + + success: '#a6e22e', + warning: '#e6db74', + error: '#f92672', + info: '#66d9ef', + + terminalSystem: '#66d9ef', + terminalNotice: '#a6e22e', + terminalCommand: '#ae81ff', + terminalAction: '#fd971f', + terminalTimestamp: '#75715e', + + relayConnected: '#a6e22e', + relayConnecting: '#e6db74', + relayDisconnected: '#75715e', + relayError: '#f92672' + }, + usernamePalette: ['#66d9ef', '#a6e22e', '#e6db74', '#fd971f', '#f92672', '#ae81ff'] +}; diff --git a/src/lib/themes/themes/nord.ts b/src/lib/themes/themes/nord.ts new file mode 100644 index 0000000..9af6853 --- /dev/null +++ b/src/lib/themes/themes/nord.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// nord — https://www.nordtheme.com/ — the default theme. +export const nord: TerminalTheme = { + id: 'nord', + name: 'Nord', + description: 'Arctic, north-bluish clean and elegant palette.', + mode: 'dark', + aliases: ['nord'], + colors: { + background: '#2e3440', + surface: '#3b4252', + surfaceRaised: '#434c5e', + border: '#4c566a', + + textPrimary: '#eceff4', + textSecondary: '#d8dee9', + textMuted: '#7b88a1', + + accent: '#88c0d0', + link: '#8fbcbb', + focus: '#88c0d0', + + success: '#a3be8c', + warning: '#ebcb8b', + error: '#bf616a', + info: '#81a1c1', + + terminalSystem: '#81a1c1', + terminalNotice: '#88c0d0', + terminalCommand: '#b48ead', + terminalAction: '#ebcb8b', + terminalTimestamp: '#7b88a1', + + relayConnected: '#a3be8c', + relayConnecting: '#ebcb8b', + relayDisconnected: '#7b88a1', + relayError: '#bf616a' + }, + usernamePalette: ['#88c0d0', '#8fbcbb', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead', '#81a1c1'] +}; diff --git a/src/lib/themes/themes/solarized-dark.ts b/src/lib/themes/themes/solarized-dark.ts new file mode 100644 index 0000000..dadfb98 --- /dev/null +++ b/src/lib/themes/themes/solarized-dark.ts @@ -0,0 +1,41 @@ +import type { TerminalTheme } from '../theme-types'; + +// solarized dark — ethan schoonover. +export const solarizedDark: TerminalTheme = { + id: 'solarized-dark', + name: 'Solarized Dark', + description: 'Precision colors for machines and people, dark variant.', + mode: 'dark', + aliases: ['solarized-dark', 'solarized dark', 'solarized_dark', 'solarizeddark'], + colors: { + background: '#002b36', + surface: '#073642', + surfaceRaised: '#0a4553', + border: '#586e75', + + textPrimary: '#eee8d5', + textSecondary: '#93a1a1', + textMuted: '#657b83', + + accent: '#268bd2', + link: '#2aa198', + focus: '#268bd2', + + success: '#859900', + warning: '#b58900', + error: '#dc322f', + info: '#268bd2', + + terminalSystem: '#268bd2', + terminalNotice: '#2aa198', + terminalCommand: '#6c71c4', + terminalAction: '#b58900', + terminalTimestamp: '#657b83', + + relayConnected: '#859900', + relayConnecting: '#b58900', + relayDisconnected: '#657b83', + relayError: '#dc322f' + }, + usernamePalette: ['#268bd2', '#2aa198', '#859900', '#b58900', '#cb4b16', '#d33682', '#6c71c4'] +}; diff --git a/src/lib/themes/themes/solarized-light.ts b/src/lib/themes/themes/solarized-light.ts new file mode 100644 index 0000000..9224236 --- /dev/null +++ b/src/lib/themes/themes/solarized-light.ts @@ -0,0 +1,43 @@ +import type { TerminalTheme } from '../theme-types'; + +// solarized light — ethan schoonover. +export const solarizedLight: TerminalTheme = { + id: 'solarized-light', + name: 'Solarized Light', + description: 'Precision colors for machines and people, light variant.', + mode: 'light', + aliases: ['solarized-light', 'solarized light', 'solarized_light', 'solarizedlight'], + colors: { + background: '#fdf6e3', + surface: '#eee8d5', + surfaceRaised: '#e3ddc8', + border: '#93a1a1', + + textPrimary: '#073642', + textSecondary: '#586e75', + textMuted: '#93a1a1', + + accent: '#268bd2', + // darkened teal/yellow vs base solarized so text-weight uses clear the + // wcag 3:1 floor on the light (#fdf6e3) bg (see accessibility.test). + link: '#1f7a6f', + focus: '#268bd2', + + success: '#5f7000', + warning: '#8a6d00', + error: '#dc322f', + info: '#268bd2', + + terminalSystem: '#268bd2', + terminalNotice: '#1f7a6f', + terminalCommand: '#6c71c4', + terminalAction: '#8a6d00', + terminalTimestamp: '#657b83', + + relayConnected: '#5f7000', + relayConnecting: '#8a6d00', + relayDisconnected: '#657b83', + relayError: '#dc322f' + }, + usernamePalette: ['#268bd2', '#1f7a6f', '#5f7000', '#8a6d00', '#cb4b16', '#d33682', '#6c71c4'] +}; diff --git a/src/lib/themes/themes/system-default.ts b/src/lib/themes/themes/system-default.ts new file mode 100644 index 0000000..9fd1269 --- /dev/null +++ b/src/lib/themes/themes/system-default.ts @@ -0,0 +1,24 @@ +import type { TerminalTheme } from '../theme-types'; +import { nord } from './nord'; +import { solarizedLight } from './solarized-light'; + +/** + * system default — follows the os `prefers-color-scheme`. + * the theme service swaps between the dark/light variants below at apply time + * and re-applies when the os preference flips. `colors` here default to the + * dark variant so completeness checks and static consumers always see a fully + * populated palette. + */ +export const systemDefault: TerminalTheme = { + id: 'system-default', + name: 'System Default', + description: 'Follows your operating system light/dark preference.', + mode: 'system', + aliases: ['system-default', 'system default', 'system_default', 'system', 'default', 'auto'], + colors: nord.colors, + usernamePalette: nord.usernamePalette +}; + +/** the actual palettes picked based on the os preference. */ +export const SYSTEM_DARK_VARIANT = nord; +export const SYSTEM_LIGHT_VARIANT = solarizedLight; diff --git a/src/lib/themes/themes/tokyo-night.ts b/src/lib/themes/themes/tokyo-night.ts new file mode 100644 index 0000000..e3bb41a --- /dev/null +++ b/src/lib/themes/themes/tokyo-night.ts @@ -0,0 +1,42 @@ +import type { TerminalTheme } from '../theme-types'; + +// tokyo night — enkia. +export const tokyoNight: TerminalTheme = { + id: 'tokyo-night', + name: 'Tokyo Night', + description: 'Clean dark theme inspired by Tokyo at night.', + mode: 'dark', + aliases: ['tokyo-night', 'tokyo night', 'tokyo_night', 'tokyonight'], + colors: { + background: '#1a1b26', + surface: '#1f2335', + surfaceRaised: '#24283b', + border: '#414868', + + textPrimary: '#c0caf5', + textSecondary: '#a9b1d6', + textMuted: '#565f89', + + accent: '#7aa2f7', + link: '#7dcfff', + focus: '#7aa2f7', + + success: '#9ece6a', + warning: '#e0af68', + error: '#f7768e', + info: '#7aa2f7', + + terminalSystem: '#7aa2f7', + terminalNotice: '#7dcfff', + terminalCommand: '#bb9af7', + terminalAction: '#e0af68', + // lightened from #565f89 so timestamps clear the 3:1 floor (accessibility.test). + terminalTimestamp: '#7a83b3', + + relayConnected: '#9ece6a', + relayConnecting: '#e0af68', + relayDisconnected: '#7a83b3', + relayError: '#f7768e' + }, + usernamePalette: ['#7aa2f7', '#7dcfff', '#9ece6a', '#e0af68', '#ff9e64', '#bb9af7', '#f7768e'] +}; diff --git a/src/lib/themes/username-color.ts b/src/lib/themes/username-color.ts new file mode 100644 index 0000000..5f9b9c6 --- /dev/null +++ b/src/lib/themes/username-color.ts @@ -0,0 +1,25 @@ +import type { TerminalTheme } from './theme-types'; + +/** + * stable hash of a nostr pubkey (hex). + * fnv-1a 32-bit — fast, no deps, same every session. + * derived from the FULL pubkey, never the display name, so it can't be spoofed + * via profile metadata and stays stable per identity. + */ +export function stablePubkeyHash(pubkey: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < pubkey.length; i++) { + hash ^= pubkey.charCodeAt(i); + // fnv prime multiply via shifts to stay in int32 range. + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash >>> 0; +} + +/** grab a legible name color from the theme palette for a pubkey. */ +export function usernameColor(pubkey: string, theme: TerminalTheme): string { + const palette = theme.usernamePalette; + if (palette.length === 0) return theme.colors.textPrimary; + const index = stablePubkeyHash(pubkey) % palette.length; + return palette[index] as string; +} diff --git a/src/lib/types/chat.ts b/src/lib/types/chat.ts new file mode 100644 index 0000000..c1b2917 --- /dev/null +++ b/src/lib/types/chat.ts @@ -0,0 +1,24 @@ +import type { RoomAddress } from './room'; + +/** + * app-level chat message, kept apart from ndk event objects. + * the ui and command layers only ever see this shape. + */ +export interface ChatMessage { + /** nostr event id (hex). */ + id: string; + room: RoomAddress; + authorPubkey: string; + /** resolved profile / nip-05 display name, if we have one. */ + authorDisplayName?: string; + content: string; + /** unix seconds (nostr `created_at`). */ + createdAt: number; + eventKind: number; + /** event id this message replies to, if any. */ + replyTo?: string; + /** true for `/me` style action messages. */ + action?: boolean; + /** did the nostr library check the event id + signature? */ + verified: boolean; +} diff --git a/src/lib/types/command.ts b/src/lib/types/command.ts new file mode 100644 index 0000000..11abf8a --- /dev/null +++ b/src/lib/types/command.ts @@ -0,0 +1,26 @@ +import type { TerminalEntry } from './terminal'; + +/** ambient state a command can read/act on. kept small and serializable. */ +export interface CommandContext { + activeRoom?: string; + activeRelay?: string; + currentUser?: string; +} + +/** what running a command hands back. output entries get appended to the terminal. */ +export interface CommandResult { + success: boolean; + output?: TerminalEntry[]; +} + +/** + * a registerable chat command. add new commands by registering an object of + * this shape — never by growing some giant conditional. + */ +export interface ChatCommand { + name: string; + aliases?: string[]; + description: string; + usage: string; + execute(args: string[], context: CommandContext): Promise; +} diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts new file mode 100644 index 0000000..6d0b445 --- /dev/null +++ b/src/lib/types/nostr.ts @@ -0,0 +1,42 @@ +/** + * the slice of the nip-07 browser signer (window.nostr) we use. + * we never get or store a private key — the extension signs for us. + */ +export interface Nip07Signer { + getPublicKey(): Promise; + signEvent(event: UnsignedNostrEvent): Promise; + getRelays?(): Promise>; + nip44?: { + encrypt(pubkey: string, plaintext: string): Promise; + decrypt(pubkey: string, ciphertext: string): Promise; + }; +} + +export interface UnsignedNostrEvent { + kind: number; + created_at: number; + tags: string[][]; + content: string; + pubkey?: string; +} + +export interface SignedNostrEvent extends UnsignedNostrEvent { + id: string; + pubkey: string; + sig: string; +} + +/** how the current identity got set up. */ +export type SignerKind = 'nip07' | 'bunker' | 'nsec' | 'ephemeral' | 'none'; + +export interface AuthState { + kind: SignerKind; + /** logged-in user's public key (hex), if any. */ + pubkey?: string; + /** + * true when the private key sits in this app's memory (nsec/ephemeral), + * rather than being held by an extension or remote signer. drives the + * "your key is in the browser" warnings in the ui. + */ + ephemeral: boolean; +} diff --git a/src/lib/types/relay.ts b/src/lib/types/relay.ts new file mode 100644 index 0000000..2fcc0c4 --- /dev/null +++ b/src/lib/types/relay.ts @@ -0,0 +1,55 @@ +/** connection lifecycle states for one relay. */ +export type RelayConnectionState = + 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; + +/** + * nip-11 relay info document (just the bits we care about for the mvp). + * everything's optional: relays may skip the doc entirely. + */ +export interface RelayInformation { + name?: string; + description?: string; + pubkey?: string; + contact?: string; + software?: string; + version?: string; + /** nip numbers the relay says it supports. */ + supportedNips?: number[]; + /** + * nosterm capability handshake: a `nosterm-relay` advertises a `features` + * array (e.g. "channels", "presence", "moderation") so the client can light + * up matching ui. missing on plain nostr relays — treat as "no extras". + */ + features?: string[]; + /** + * optional relay message-of-the-day: a multi-line welcome/notice shown in + * the relay's server window on connect (irc-style motd). a nosterm extra, + * not in the base nip-11 doc; plain relays skip it and the client falls back + * to `description`. + */ + motd?: string; + limitation?: { + maxMessageLength?: number; + maxSubscriptions?: number; + authRequired?: boolean; + paymentRequired?: boolean; + [key: string]: unknown; + }; +} + +/** the app's view of a relay connection. */ +export interface RelayState { + /** normalized websocket url, used as the stable key. */ + url: string; + connection: RelayConnectionState; + /** nip-11 metadata, once fetched. */ + info?: RelayInformation; + /** does the relay advertise nip-29 (relay-based groups)? */ + supportsNip29?: boolean; + /** human-readable last error, if any (never a raw websocket error). */ + lastError?: string; + /** timestamp (ms) of the last good connection. */ + connectedAt?: number; + /** current reconnect attempt count, for backoff bookkeeping. */ + reconnectAttempts: number; +} diff --git a/src/lib/types/room.ts b/src/lib/types/room.ts new file mode 100644 index 0000000..8332e56 --- /dev/null +++ b/src/lib/types/room.ts @@ -0,0 +1,31 @@ +/** + * a nip-29 room is scoped to its relay: the same group id on two different + * relays is NOT the same room. + */ +export interface RoomAddress { + /** normalized relay websocket url. */ + relayUrl: string; + /** nip-29 group id (e.g. "general"). */ + groupId: string; +} + +/** app's view of a joined/known room. */ +export interface RoomState { + /** internal id built from relayUrl + groupId. */ + id: string; + address: RoomAddress; + /** display name (starts as the group id until metadata shows up). */ + name: string; + /** nip-29 group metadata topic/about, when known. */ + topic?: string; + /** are we currently joined? */ + joined: boolean; + /** when (ms) the room was joined. */ + joinedAt?: number; + /** unread messages since the last read marker. */ + unread: number; + /** admin pubkeys (hex) from the relay's nip-29 admins list (39001). */ + admins?: string[]; + /** member pubkeys (hex) from the relay's nip-29 members list (39002). */ + members?: string[]; +} diff --git a/src/lib/types/settings.ts b/src/lib/types/settings.ts new file mode 100644 index 0000000..f9d312c --- /dev/null +++ b/src/lib/types/settings.ts @@ -0,0 +1,24 @@ +/** user-tweakable app settings, saved locally. */ +export interface AppSettings { + /** active theme id (see theme registry). */ + themeId: string; + /** terminal font size in pixels. */ + fontSize: number; + /** terminal line height (unitless multiplier). */ + lineHeight: number; + /** respect the reduced-motion preference. */ + reducedMotion: boolean; + /** show the optional right-hand user/room sidebar. */ + showUserSidebar: boolean; + /** desktop notifications for dms + mentions while the tab is backgrounded. */ + notifications: boolean; +} + +export const DEFAULT_SETTINGS: AppSettings = { + themeId: 'nord', + fontSize: 14, + lineHeight: 1.5, + reducedMotion: false, + showUserSidebar: true, + notifications: false +}; diff --git a/src/lib/types/terminal.ts b/src/lib/types/terminal.ts new file mode 100644 index 0000000..643d861 --- /dev/null +++ b/src/lib/types/terminal.ts @@ -0,0 +1,28 @@ +/** + * terminal entry types. heads up: "system", "notice", "warning" and "error" + * are TRUSTED local origins — remote chat content only ever renders as + * "message" or "action" and can never pose as a system line. + */ +export type TerminalEntryType = + 'message' | 'action' | 'system' | 'notice' | 'warning' | 'error' | 'command'; + +export interface TerminalEntry { + id: string; + type: TerminalEntryType; + /** unix milliseconds. */ + timestamp: number; + /** room id this entry belongs to, if room-scoped. */ + roomId?: string; + /** author pubkey (hex) for message/action entries. */ + author?: string; + /** resolved display name for message/action entries, if we have one. */ + authorDisplayName?: string; + /** + * nostr event id for message/action entries. lets an optimistically-echoed + * message get taken back if the relay later rejects the publish. + */ + eventId?: string; + /** true when this message/action pings the logged-in user (highlighted). */ + mention?: boolean; + content: string; +} diff --git a/src/lib/utils/id.ts b/src/lib/utils/id.ts new file mode 100644 index 0000000..9882667 --- /dev/null +++ b/src/lib/utils/id.ts @@ -0,0 +1,8 @@ +/** make a locally-unique id for terminal entries and throwaway objects. */ +export function generateId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + // fallback for environments without crypto.randomUUID. + return `id-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`; +} diff --git a/src/lib/utils/mention.ts b/src/lib/utils/mention.ts new file mode 100644 index 0000000..7a2bd9d --- /dev/null +++ b/src/lib/utils/mention.ts @@ -0,0 +1,34 @@ +import { encodeNpub } from './npub'; + +/** + * does a message mention a user? matches (case-insensitively): + * - their hex pubkey, + * - their npub (bech32), + * - their display name / nick as a whole word, optionally with a leading `@`. + * + * the nick match needs a word boundary so "bob" doesn't hit "bobbing", and a + * nick under 2 chars is skipped to cut noise. nicks aren't unique, so a nick + * match is just a friendly highlight, never proof of identity. + */ +export function isMention(content: string, pubkey: string, nick?: string): boolean { + if (!content || !pubkey) return false; + const haystack = content.toLowerCase(); + + if (haystack.includes(pubkey.toLowerCase())) return true; + + try { + if (haystack.includes(encodeNpub(pubkey).toLowerCase())) return true; + } catch { + /* invalid pubkey → skip npub check */ + } + + const trimmed = nick?.trim(); + if (trimmed && trimmed.length >= 2) { + const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // @nick, or nick on its own as a word. + const re = new RegExp(`(^|[^\\w@])@?${escaped}\\b`, 'i'); + if (re.test(content)) return true; + } + + return false; +} diff --git a/src/lib/utils/npub.ts b/src/lib/utils/npub.ts new file mode 100644 index 0000000..4492cee --- /dev/null +++ b/src/lib/utils/npub.ts @@ -0,0 +1,26 @@ +import { nip19 } from '@nostr-dev-kit/ndk'; + +const HEX64 = /^[0-9a-f]{64}$/i; + +/** + * decode a user identifier into a hex pubkey. takes a raw 64-char hex pubkey or + * a bech32 `npub1…`. throws on anything else. + */ +export function decodeNpub(input: string): string { + const value = input.trim(); + if (HEX64.test(value)) return value.toLowerCase(); + + if (value.startsWith('npub1')) { + const decoded = nip19.decode(value); + if (decoded.type === 'npub' && typeof decoded.data === 'string') { + return decoded.data; + } + throw new Error('Invalid npub.'); + } + throw new Error('Expected an npub or 64-character hex pubkey.'); +} + +/** encode a hex pubkey as a bech32 npub for display/copy. */ +export function encodeNpub(pubkey: string): string { + return nip19.npubEncode(pubkey); +} diff --git a/src/lib/utils/pubkey.test.ts b/src/lib/utils/pubkey.test.ts new file mode 100644 index 0000000..beeb53f --- /dev/null +++ b/src/lib/utils/pubkey.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { shortenPubkey } from './pubkey'; +import { encodeNpub } from './npub'; + +// a valid 64-char hex pubkey for round-tripping through npub encoding. +const HEX = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; + +describe('shortenPubkey', () => { + it('abbreviates the npub data part as first4…last4 (no npub1 prefix)', () => { + const data = encodeNpub(HEX).replace(/^npub1/, ''); + const expected = `${data.slice(0, 4)}…${data.slice(-4)}`; + expect(shortenPubkey(HEX)).toBe(expected); + expect(shortenPubkey(HEX)).not.toContain('npub1'); + expect(shortenPubkey(HEX)).toContain('…'); + }); + + it('falls back to abbreviating the raw input when npub encoding throws', () => { + // a non-hex string npubEncode rejects; we abbreviate the raw input. + expect(shortenPubkey('not-a-pubkey-value')).toBe('not-…alue'); + }); + + it('returns already-short data unchanged', () => { + // encodeNpub throws on this, so the raw (short) input is returned as-is. + expect(shortenPubkey('xyz')).toBe('xyz'); + }); +}); diff --git a/src/lib/utils/pubkey.ts b/src/lib/utils/pubkey.ts new file mode 100644 index 0000000..3a14073 --- /dev/null +++ b/src/lib/utils/pubkey.ts @@ -0,0 +1,22 @@ +import { encodeNpub } from './npub'; + +/** + * shorten an identity for display when there's no nick. + * encodes the hex pubkey to an npub, drops the `npub1` prefix, and shows the + * first `head` and last `tail` chars of the bech32 data, e.g. "qh8k…9x7k". + * never treat display names as unique — use this. + */ +export function shortenPubkey(pubkey: string, head = 4, tail = 4): string { + // encode to npub and drop the human-readable prefix; the data part is what + // we shorten. fall back to the raw input if encoding fails (e.g. not a valid + // hex pubkey) so callers never blow up on display. + let data = pubkey; + try { + data = encodeNpub(pubkey).replace(/^npub1/, ''); + } catch { + /* not a valid pubkey; abbreviate whatever was passed. */ + } + + if (data.length <= head + tail + 1) return data; + return `${data.slice(0, head)}…${data.slice(-tail)}`; +} diff --git a/src/lib/utils/relay-url.ts b/src/lib/utils/relay-url.ts new file mode 100644 index 0000000..76a1757 --- /dev/null +++ b/src/lib/utils/relay-url.ts @@ -0,0 +1,54 @@ +/** + * normalize a relay url so it works as a stable connection key. + * - rejects non-ws(s) schemes. + * - lowercases the host, drops default ports, strips a trailing slash off the + * path root, and tosses fragments. + */ +export function normalizeRelayUrl(input: string): string { + const trimmed = input.trim(); + if (trimmed.length === 0) throw new Error('Relay URL is empty.'); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`Invalid relay URL: "${input}".`); + } + + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`Unsupported relay scheme "${url.protocol}". Use ws:// or wss://.`); + } + + url.hostname = url.hostname.toLowerCase(); + url.hash = ''; + + // drop default ports. + if ( + (url.protocol === 'ws:' && url.port === '80') || + (url.protocol === 'wss:' && url.port === '443') + ) { + url.port = ''; + } + + // strip a trailing slash off the path so wss://r.example, wss://r.example/, + // and wss://r.example/relay/ all normalize the same way (a trailing slash + // means nothing for a relay endpoint). keep any query string. + if (url.pathname.length > 1 && url.pathname.endsWith('/')) { + url.pathname = url.pathname.replace(/\/+$/, ''); + } + let normalized = url.toString(); + if (url.pathname === '/' && !url.search) { + normalized = normalized.replace(/\/$/, ''); + } + return normalized; +} + +/** check a relay url — true only if it normalizes cleanly. */ +export function isValidRelayUrl(input: string): boolean { + try { + normalizeRelayUrl(input); + return true; + } catch { + return false; + } +} diff --git a/src/plugins/README.md b/src/plugins/README.md new file mode 100644 index 0000000..af9148a --- /dev/null +++ b/src/plugins/README.md @@ -0,0 +1,79 @@ +# Nosterm plugins + +Drop a `.ts` (or `.js`) module in this folder that exports a `NostermPlugin` as +its **default** export (or a named `plugin` export) and it is auto-loaded at +startup — no registration list to edit. This is the IRC-script-style extension +point: small, hackable, and isolated (a plugin that throws is logged and +skipped, never breaking the app). + +## Anatomy + +```ts +import type { NostermPlugin } from '$lib/plugins/plugin-types'; + +const plugin: NostermPlugin = { + id: 'my-plugin', // unique, kebab-case + name: 'My Plugin', + description: 'What it does', + setup(ctx) { + // add a command + ctx.registerCommand({ + name: 'hello', + description: 'say hi', + usage: '/hello [name]', + async execute(args) { + return { success: true, output: [] }; + } + }); + + // react to events + ctx.on('message', (msg) => { + /* ... */ + }); + ctx.on('roomChanged', (roomId) => { + /* ... */ + }); + ctx.on('relayState', (url, state) => { + /* ... */ + }); + + // print to the terminal (local, trusted, prefixed with your plugin id) + ctx.print('ready'); + + // register a theme — omitted color tokens are inherited from a base + // theme (`base: 'nord'` by default), so a partial theme is valid. + ctx.registerTheme({ id: 'my-theme', name: 'Mine', colors: { accent: '#f0f' } }); + + // persist plugin-scoped state + await ctx.setState('count', 1); + const n = await ctx.getState('count'); + } +}; + +export default plugin; +``` + +## The `PluginContext` API + +| Method | Purpose | +| ------------------------------------------- | --------------------------------------------------------------------------------- | +| `registerCommand(cmd)` | Add an IRC-style command (same shape as built-ins). | +| `print(text, type?)` | Write a trusted local line to the terminal. | +| `on(event, handler)` | Subscribe to `message` / `roomChanged` / `relayState`; returns an unsubscribe fn. | +| `transformOutgoing(fn)` | Rewrite outgoing message text just before send (room + DM); returns unsubscribe. | +| `registerTheme(theme)` | Register + persist a custom theme (selectable via `/theme`). | +| `getState(key)` / `setState(key, value)` | Plugin-scoped persistence (namespaced by plugin id). | +| `pluginId` | Your plugin's id. | + +## Notes & safety + +- Plugin output is **local/trusted** and prefixed with `[]`. Never render + untrusted remote content through `print` as if it were a system line. +- Handlers run synchronously in registration order; throwing is caught + logged. +- `/plugins` lists everything currently loaded. +- Commands whose name/alias collides with an existing one are rejected (logged). + +See `example-slap.ts` in this folder for a working example plugin, +and `emoji.ts` for a shipped default (`/emoji` lists popular ASCII emoticons; +`/shrug`, `/lenny`, `/tableflip`, … send them; and `:shrug:` style shortcodes +expand inline anywhere in a message via `transformOutgoing`). diff --git a/src/plugins/emoji.ts b/src/plugins/emoji.ts new file mode 100644 index 0000000..da0049d --- /dev/null +++ b/src/plugins/emoji.ts @@ -0,0 +1,129 @@ +import { get } from 'svelte/store'; +import type { NostermPlugin } from '$lib/plugins/plugin-types'; +import { roomService } from '$lib/nostr/room-service'; +import { roomStore } from '$lib/stores/room-store'; +import { out } from '$lib/commands/output'; + +/** + * a pile of popular ascii text emoticons (kaomoji), all under one `/emoji` + * command: + * - `/emoji` lists them + * - `/emoji [msg]` tacks the emoticon on and sends it to the active room + * (the classic irc/slack `/shrug` move, e.g. `/emoji shrug oh well`) + * - `:name:` anywhere in a message expands inline + * + * stashing every emoticon under one command keeps the top-level command space + * (and tab-completion) tidy instead of registering ~20 separate `/shrug`, + * `/lenny`, … commands. + */ +const EMOTICONS: { name: string; art: string; aliases?: string[] }[] = [ + { name: 'shrug', art: '¯\\_(ツ)_/¯' }, + { name: 'lenny', art: '( ͡° ͜ʖ ͡°)' }, + { name: 'tableflip', art: '(╯°□°)╯︵ ┻━┻', aliases: ['flip'] }, + { name: 'unflip', art: '┬─┬ ノ( ゜-゜ノ)' }, + { name: 'disapproval', art: 'ಠ_ಠ', aliases: ['lookofdisapproval'] }, + { name: 'happy', art: '(◕‿◕)' }, + { name: 'sad', art: '(╥﹏╥)' }, + { name: 'wink', art: '(^_~)' }, + { name: 'cool', art: '(⌐■_■)' }, + { name: 'love', art: '(♥ω♥)' }, + { name: 'confused', art: '(・_・?)' }, + { name: 'angry', art: '(╬ ಠ益ಠ)' }, + { name: 'cry', art: 'ಥ_ಥ' }, + { name: 'dance', art: '⌐╦╦═─' }, + { name: 'bear', art: 'ʕ•ᴥ•ʔ' }, + { name: 'fight', art: '(ง •̀_•́)ง' }, + { name: 'yay', art: '\\(^o^)/' }, + { name: 'meh', art: '¯\\(°_o)/¯' } +]; + +// name/alias -> art, for `:shortcode:` expansion inside a message. +const BY_CODE = new Map(); +for (const emo of EMOTICONS) { + BY_CODE.set(emo.name, emo.art); + for (const alias of emo.aliases ?? []) BY_CODE.set(alias, emo.art); +} + +// swap any `:code:` token for its emoticon; unknown codes stay put. +function expandShortcodes(content: string): string { + return content.replace(/:([a-z0-9_]+):/gi, (whole, code: string) => { + return BY_CODE.get(code.toLowerCase()) ?? whole; + }); +} + +// name/alias -> the emoticon it resolves to, for `/emoji ` lookup. +const BY_NAME = new Map(); +for (const emo of EMOTICONS) { + BY_NAME.set(emo.name, emo); + for (const alias of emo.aliases ?? []) BY_NAME.set(alias, emo); +} + +// print the whole catalogue locally. +function listCatalogue() { + return { + success: true, + output: [ + out.notice( + `ASCII emoticons (${EMOTICONS.length}) — /emoji [message] sends one, or use :: inline in any message:` + ), + ...EMOTICONS.map((e) => { + const names = [e.name, ...(e.aliases ?? [])].join(', '); + return out.system(` ${names.padEnd(24)} ${e.art}`); + }) + ] + }; +} + +const plugin: NostermPlugin = { + id: 'emoji', + name: 'ASCII Emoji', + description: 'ASCII emoticons: /emoji to list, /emoji to send, :name: inline.', + setup(ctx) { + // inline expansion: `:shrug:` anywhere in a message becomes ¯\_(ツ)_/¯. + ctx.transformOutgoing(expandShortcodes); + + // one `/emoji` command holds the whole catalogue: + // /emoji → list + // /emoji [text] → send " " to the active room + ctx.registerCommand({ + name: 'emoji', + aliases: ['emoticons', 'kaomoji'], + description: 'List ASCII emoticons, or send one: /emoji [message].', + usage: '/emoji [name] [message]', + async execute(args) { + const name = args[0]?.toLowerCase(); + if (!name) return listCatalogue(); + + const emo = BY_NAME.get(name); + if (!emo) { + return { + success: false, + output: [out.warning(`Unknown emoticon ":${name}:". Type /emoji to list them.`)] + }; + } + + // any text after the name goes in front, so `/emoji shrug oh well` + // sends "oh well ¯\_(ツ)_/¯" to the active room. + const prefix = args.slice(1).join(' ').trim(); + const content = prefix ? `${prefix} ${emo.art}` : emo.art; + const { rooms, activeRoomId } = get(roomStore); + const active = activeRoomId ? rooms[activeRoomId] : undefined; + if (!active) { + // nowhere to send it — just show it locally so it's still handy. + return { success: true, output: [out.system(content)] }; + } + try { + await roomService.send(active.address, content); + return { success: true, output: [] }; + } catch (err) { + return { + success: false, + output: [out.error(err instanceof Error ? err.message : String(err))] + }; + } + } + }); + } +}; + +export default plugin; diff --git a/src/plugins/example-slap.ts b/src/plugins/example-slap.ts new file mode 100644 index 0000000..556b623 --- /dev/null +++ b/src/plugins/example-slap.ts @@ -0,0 +1,37 @@ +import type { NostermPlugin } from '$lib/plugins/plugin-types'; +import { out } from '$lib/commands/output'; + +/** + * the classic irc `/slap` — fires an action message at the active room. a tiny, + * complete example of a command plugin: register a command, use the ambient + * context (active room), and hand sending off to the app's own pipeline. + */ +const plugin: NostermPlugin = { + id: 'slap', + name: 'Slap', + description: 'Adds the classic IRC /slap action command.', + setup(ctx) { + ctx.registerCommand({ + name: 'slap', + description: 'Slap someone around a bit with a large trout.', + usage: '/slap ', + async execute(args, context) { + const target = args[0]; + if (!target) { + return { success: false, output: [out.warning('Usage: /slap ')] }; + } + if (!context.activeRoom) { + return { success: false, output: [out.warning('Join a room first with /join.')] }; + } + // just a local action line. actually sending to the room is the app's + // /me pipeline's job; we narrate locally to keep the example simple. + return { + success: true, + output: [out.system(`* slaps ${target} around a bit with a large trout`)] + }; + } + }); + } +}; + +export default plugin; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..0cd40dd --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,30 @@ + + +{@render children()} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..43d3a97 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,4 @@ +// client-only spa: no ssr, so browser-only apis (ndk, indexeddb, matchmedia) +// are safe to use anywhere in the app. +export const ssr = false; +export const prerender = true; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..3d11a64 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,100 @@ + + +
+
+ + + + +
+
+ +
+ +
+ + + +
+ +
+ +{#if authOpen} + +{/if} diff --git a/static/config.json b/static/config.json new file mode 100644 index 0000000..60265cb --- /dev/null +++ b/static/config.json @@ -0,0 +1,3 @@ +{ + "relays": [] +} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..6de2605 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,4 @@ + + + >_ + diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..8eba9fb --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + // Client-only SPA: prerender the shell, fall back to it for all routes. + adapter: adapter({ + fallback: 'index.html' + }), + prerender: { + entries: ['*'] + } + // CSP is served by Caddy (see Caddyfile): this is a client-only SPA served + // from a static fallback page, which is not prerendered, so SvelteKit's + // build-time CSP hashing does not apply here. + } +}; + +export default config; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..45cb936 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,34 @@ +import type { Config } from 'tailwindcss'; + +// Tailwind is used for layout/spacing utilities only. +// All colors are driven by CSS custom properties (semantic design tokens) +// defined by the theme system — never hard-coded here or in components. +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + fontFamily: { + mono: ['var(--font-terminal)'] + }, + colors: { + // Expose semantic tokens to Tailwind so utilities like `text-error` + // resolve to the active theme's CSS variable. + background: 'var(--color-background)', + surface: 'var(--color-surface)', + 'surface-raised': 'var(--color-surface-raised)', + border: 'var(--color-border)', + 'text-primary': 'var(--color-text-primary)', + 'text-secondary': 'var(--color-text-secondary)', + 'text-muted': 'var(--color-text-muted)', + accent: 'var(--color-accent)', + link: 'var(--color-link)', + focus: 'var(--color-focus)', + success: 'var(--color-success)', + warning: 'var(--color-warning)', + error: 'var(--color-error)', + info: 'var(--color-info)' + } + } + }, + plugins: [] +} satisfies Config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f1f5fb5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "moduleResolution": "bundler" + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2d234a8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,ts}'], + globals: true, + passWithNoTests: true, + coverage: { + provider: 'v8', + include: ['src/lib/**/*.ts'], + exclude: ['src/lib/**/*.test.ts', 'src/lib/types/**', 'src/**/*.d.ts'] + } + } +});