diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..30114e28 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,34 @@ +FROM ruby:4.0.1-alpine3.23 + +SHELL ["/bin/sh", "-o", "pipefail", "-c"] + +RUN apk add --no-cache \ + bash \ + build-base \ + curl \ + git \ + libxml2-dev \ + libxslt-dev \ + nodejs \ + npm \ + openssl-dev \ + python3 \ + tzdata + +ARG USER=vscode +ARG UID=1000 +ARG GID=1000 + +RUN addgroup -g "$GID" "$USER" \ + && adduser -D -G "$USER" -u "$UID" "$USER" + +WORKDIR /workspace +RUN chown -R "$USER":"$USER" /workspace + +ENV BUNDLE_PATH=/usr/local/bundle + +USER "$USER" + +EXPOSE 4000 + +CMD ["sleep", "infinity"] diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 00000000..4d7a5bbf --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,49 @@ +# Dev Container Workflow + +This repository ships a single Dev Container for development. Open the project in VS Code (Dev +Containers extension) or GitHub Codespaces and use that environment for all work. + +## What gets created + +The devcontainer starts one service named `app` and exposes: + +- **Port 4000:** Ruby app +- **Port 4001:** Astro dev server + +The repo is mounted at `/workspace`. Bundler gems are cached in a Docker volume to speed up +future launches. + +## Bootstrap + +On first open, the Dev Container runs: + +``` +make setup +``` + +This installs Ruby and frontend dependencies inside the container. + +If setup fails due to missing network access (e.g., GitHub DNS), rerun `make setup` once +network access is available. + +## Common commands (run inside the container) + +``` +make dev # Ruby + Astro +make dev-ruby # Ruby only +make dev-frontend # Astro only +make test # Ruby + frontend tests +make ready # RuboCop + RSpec (pre-commit gate) +``` + +## Lint and tests + +``` +bundle exec rubocop -F +bundle exec rspec +``` + +## Notes + +- All commands are expected to run inside the Dev Container. +- The default service command is `sleep infinity`; use the Make targets above to start servers. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b0cc2012..22e72156 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,41 +1,43 @@ { "name": "html2rss-web", - "image": "mcr.microsoft.com/devcontainers/ruby:3.4", - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} - }, + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "shutdownAction": "stopCompose", "customizations": { "vscode": { "extensions": [ - "redhat.vscode-yaml", - "esbenp.prettier-vscode", - "github.copilot", - "github.copilot-chat", - "shopify.ruby-lsp" + "rebornix.ruby", + "astro-build.astro-vscode", + "esbenp.prettier-vscode" ], "settings": { - "ruby.rubocop.executePath": "bundle exec", - "ruby.format": "rubocop", - "ruby.lint": { - "rubocop": true - }, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", "files.associations": { - "*.erb": "erb" - } + "*.astro": "astro" + }, + "prettier.configPath": "./frontend/prettier.config.js", + "ruby.format": "rubocop", + "ruby.lint": { "rubocop": true }, + "editor.tabSize": 2, + "editor.insertSpaces": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true } } }, - "postCreateCommand": "make setup", - "postStartCommand": "echo 'πŸš€ html2rss-web Development Environment Ready!' && echo '' && echo 'πŸ“‹ Quick Start Commands:' && echo ' make dev # Start development server' && echo ' make test # Run tests' && echo ' make lint # Run linter' && echo ' make fix # Auto-fix linting issues' && echo ' make help # Show all commands' && echo '' && echo '🌐 Server will be available at: http://localhost:3000' && echo 'πŸ“ Project files are in: /workspaces/html2rss-web' && echo '' && echo 'πŸ’‘ Tip: Use Ctrl+C to stop the development server' && echo ''", - "forwardPorts": [ - 3000 - ], + "forwardPorts": [4000, 4001], "portsAttributes": { - "3000": { - "label": "html2rss-web", + "4000": { + "label": "Ruby App", "onAutoForward": "notify" + }, + "4001": { + "label": "Astro Dev Server", + "onAutoForward": "silent" } }, + "postCreateCommand": "bash -lc \"make setup || (echo 'make setup failed; rerun once network access is available.' && exit 0)\"", "remoteUser": "vscode" } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..a994bdbb --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,21 @@ +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + volumes: + - ../:/workspace:cached + - bundle-cache:/usr/local/bundle + ports: + - "4000:4000" + - "4001:4001" + environment: + - RACK_ENV=development + - BUNDLE_PATH=/usr/local/bundle + - PORT=4000 + command: sleep infinity + user: vscode + working_dir: /workspace + +volumes: + bundle-cache: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9a3727a6..b64c3e60 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,36 +3,57 @@ ## Overview - Ruby web app that converts websites into RSS 2.0 feeds. -- Built with **Roda**, using the **html2rss** gem (+ `html2rss-configs`). -- **Principle:** _All features must work without JavaScript._ JS is only progressive enhancement. +- Built with **Roda** backend + **Preact** frontend, using the **html2rss** gem (+ `html2rss-configs`). +- **Frontend:** Vite-built Preact UI, served alongside Ruby backend. + +## Documentation website of core dependencies + +Search these pages before using them. Find examples, plugins, UI components, and configuration options. + +### Roda + +1. https://roda.jeremyevans.net/documentation.html + +### Preact & Vite + +1. https://preactjs.com/guide/v10/getting-started/ +2. https://vite.dev/guide/ + +### html2rss + +1. If available, find source locally in: `../html2rss`. +2. source code on github: https://github.com/html2rss/html2rss + +### Test and Linters + +1. https://docs.rubocop.org/rubocop/cops.html +2. https://docs.rubocop.org/rubocop-rspec/cops_rspec.html +3. https://rspec.info/features/3-13/rspec-expectations/built-in-matchers/ +4. https://www.betterspecs.org/ + +Fix rubocop `RSpec/MultipleExpectations` adding rspec tag `:aggregate_failures`. ## Core Rules -- βœ… Use **Roda routing with `hash_branch`**. Keep routes small. -- βœ… Put logic into `helpers/` or `app/`, not inline in routes. +- βœ… Organise Roda routes via dedicated modules (e.g. `Html2rss::Web::Routes::*`), keeping the main app class thin. +- βœ… Keep helper modules minimal: define entrypoints with `class << self` and push implementation helpers under `private`; avoid `module_function` unless mirroring existing conventions. - βœ… Validate all inputs. Pass outbound requests through **SSRF filter**. - βœ… Add caching headers where appropriate (`Rack::Cache`). - βœ… Errors: friendly messages for users, detailed logging internally. -- βœ… CSS: Water.css + small overrides in `public/styles.css`. -- βœ… Specs: RSpec, unit + integration, use VCR for external requests. - -## Don’t +- βœ… **Frontend**: Use Preact components in `frontend/src/`. Keep it simple. +- βœ… **CSS**: Use the app-owned frontend styles in `frontend/src/styles/`. +- βœ… **Specs**: RSpec for Ruby, build tests for frontend. +- βœ… When a spec needs to tweak environment variables, wrap the example in `ClimateControl.modify` so state is restored automatically. -- ❌ Don’t depend on JS for core flows. -- ❌ Don’t bypass SSRF filter or weaken CSP. -- ❌ Don’t add databases, ORMs, or background jobs. -- ❌ Don’t leak stack traces or secrets in responses. +## Don't -## Project Structure - -- `app.rb` – main Roda app -- `app/` – core modules (config, cache, ssrf, health) -- `routes/` – route handlers (`hash_branch`) -- `helpers/` – pure helper modules (`module_function`) -- `views/` – ERB templates -- `public/` – static assets (CSS/JS, minimal) -- `config/feeds.yml` – feed definitions -- `spec/` – RSpec tests + VCR cassettes +- ❌ Don't use Ruby's URI class or addressable gem directly. Strictly use `Html2rss::Url` only. +- ❌ Don't bypass SSRF filter or weaken CSP. +- ❌ Don't add databases, ORMs, or background jobs. +- ❌ Don't leak stack traces or secrets in responses. +- ❌ Don't reach into private API with `send(...)`; expose what you need at the module level instead. +- ❌ Don't modify `frontend/dist/` - it's generated by build process. +- ❌ NEVER expose the auth token a user provides. ## Environment @@ -41,6 +62,12 @@ - `HEALTH_CHECK_USERNAME`, `HEALTH_CHECK_PASSWORD` - `SENTRY_DSN` (optional) +### Verification Steps + +- Run `ruby -c app.rb` to check syntax +- Run `bundle exec rspec` to verify tests +- Check `bundle install` removes unused dependencies + ## Style - Add `# frozen_string_literal: true` diff --git a/.github/workflows/bundle-update.yml b/.github/workflows/bundle-update.yml deleted file mode 100644 index 1db06f30..00000000 --- a/.github/workflows/bundle-update.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: bundle update - -on: - workflow_dispatch: - -jobs: - bundle-update: - name: bundle update - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: pull request on bundle update - uses: supermanner/pull-request-on-bundle-update@v1.0.1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - git_user_name: ${{ secrets.HUB_USER }} - git_email: ${{ secrets.HUB_EMAIL }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2cdf60dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,234 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + hadolint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Lint Dockerfile + run: docker run --rm -i hadolint/hadolint < Dockerfile + + ruby: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version-file: ".tool-versions" + bundler-cache: true + + - name: Run RuboCop + run: bundle exec rubocop -F + + - name: Verify Yard documentation + run: bundle exec yard doc --fail-on-warning --no-output + + - name: Run RSpec + run: bundle exec rspec + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 30 + + openapi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version-file: ".tool-versions" + bundler-cache: true + + - name: Setup Node.js for OpenAPI lint tooling + uses: actions/setup-node@v4 + with: + node-version-file: ".tool-versions" + + - name: Verify generated OpenAPI is up to date + run: bundle exec rake openapi:verify + + - name: Lint OpenAPI contract + run: make openapi-lint + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".tool-versions" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Verify generated OpenAPI client is up to date + run: npm run openapi:verify + + - name: Typecheck frontend + run: npm run typecheck + + - name: Check formatting + run: npm run format:check + + - name: Audit dependencies + run: npm audit --audit-level=moderate + + - name: Run frontend tests + run: npm run test:ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run frontend smoke test + run: npm run test:e2e + + docker-test: + needs: + - hadolint + - ruby + - openapi + - frontend + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + smoke_auto_source_enabled: ["false", "true"] + steps: + - uses: actions/checkout@v5 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version-file: ".tool-versions" + bundler-cache: true + + - name: Setup Node.js for Docker smoke test + uses: actions/setup-node@v4 + with: + node-version-file: ".tool-versions" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build frontend static assets + run: npm run build + working-directory: frontend + + - name: Run Docker smoke test + env: + SMOKE_AUTO_SOURCE_ENABLED: ${{ matrix.smoke_auto_source_enabled }} + run: bundle exec rake + + docker-publish: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + needs: + - docker-test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMAGE_NAME: gilcreator/html2rss-web + TAG_SHA: ${{ github.sha }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js for Docker build + uses: actions/setup-node@v4 + with: + node-version-file: ".tool-versions" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build frontend static assets + run: npm run build + working-directory: frontend + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Get Git commit timestamps + run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Cache Docker layers + uses: actions/cache@v5 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + env: + SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }} + with: + context: . + push: true + tags: | + gilcreator/html2rss-web:latest + gilcreator/html2rss-web:${{ github.sha }} + ${{ steps.meta.outputs.tags }} + platforms: linux/amd64,linux/arm64 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + provenance: true + sbom: true + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.title=html2rss-web + org.opencontainers.image.description=Generates RSS feeds of any website & serves to the web! + org.opencontainers.image.sbom=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts + + - name: Move updated cache into place + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/test_build_push.yml b/.github/workflows/test_build_push.yml deleted file mode 100644 index dae75d87..00000000 --- a/.github/workflows/test_build_push.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: test, docker build & push - -on: [push] - -jobs: - hadolint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - run: docker run --rm -i hadolint/hadolint < Dockerfile - - ruby: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - run: bundle exec rubocop -F - - run: bundle exec yard doc --fail-on-warning --no-output - - rspec: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - run: bundle exec rspec - - docker-test: - needs: hadolint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - run: | - bundle exec rake - - docker-push: - if: ${{ github.ref == 'refs/heads/master' }} - needs: - - docker-test - - hadolint - - ruby - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - env: - IMAGE_NAME: gilcreator/html2rss-web - TAG_SHA: ${{ github.sha }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Get Git commit timestamps - run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - - - name: Log in to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Cache Docker layers - uses: actions/cache@v5 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - env: - SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }} - with: - context: . - push: true - tags: | - gilcreator/html2rss-web:latest - gilcreator/html2rss-web:${{ github.sha }} - ${{ steps.meta.outputs.tags }} - platforms: linux/amd64,linux/arm64 - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache-new - provenance: true - sbom: true - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.title=html2rss-web - org.opencontainers.image.description=Generates RSS feeds of any website & serves to the web! - org.opencontainers.image.sbom=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts - - - name: Move updated cache into place - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.gitignore b/.gitignore index cdcc6749..33c9874e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,3 @@ -# See https://help.github.com/articles/ignoring-files for more about ignoring files. -# -# If you find yourself ignoring temporary files generated by your text editor -# or operating system, you probably want to add a global ignore instead: -# git config --global core.excludesfile '~/.gitignore_global' - -# Ignore bundler config. /.bundle # Ignore the default SQLite database. @@ -39,27 +32,23 @@ # Ignore coverage reports /coverage/ -# Ignore VCR cassettes (they should be committed) -# /spec/fixtures/vcr_cassettes/ +# Ignore rack cache +/tmp/rack-cache-* + -# Ignore IDE files -.vscode/ -.idea/ -*.swp -*.swo -*~ +# Ignore Astro frontend build output +/frontend/dist/ +/frontend/.astro/ +/frontend/node_modules/ +/public/frontend +/frontend/playwright-report/ +/frontend/test-results/ -# Ignore OS generated files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db +# Frontend generated files +*.min.js +*.min.css -# Ignore rack cache -/tmp/rack-cache-* +# Frontend logs +*.log -# Ignore simplecov -/coverage/ \ No newline at end of file +.yardoc diff --git a/.redocly.yaml b/.redocly.yaml new file mode 100644 index 00000000..030d1424 --- /dev/null +++ b/.redocly.yaml @@ -0,0 +1,5 @@ +extends: + - recommended + +rules: + operation-4xx-response: off diff --git a/.rubocop.yml b/.rubocop.yml index 57ebd903..b9821c39 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,4 @@ -require: +plugins: - rubocop-performance - rubocop-rspec - rubocop-rake @@ -7,11 +7,18 @@ require: AllCops: DisplayCopNames: true NewCops: enable + Exclude: + - '**/*.yml' + - '**/*.yaml' + - '**/.tool-versions' + +Layout/LineLength: + Max: 120 Metrics/BlockLength: Exclude: - Rakefile - ExcludedMethods: + AllowedMethods: - route Naming/RescuedExceptionsVariableName: @@ -21,6 +28,7 @@ Layout/ClassStructure: Enabled: true Style/Documentation: + Enabled: false AllowedConstants: - App @@ -28,3 +36,12 @@ RSpec/SpecFilePathFormat: Exclude: - 'spec/html2rss/web/app/*_spec.rb' - 'spec/html2rss/web/app/health_check/auth_spec.rb' + +RSpec/DescribeClass: + Enabled: false + +RSpec/ExampleLength: + Max: 12 + +RSpec/MultipleExpectations: + Max: 2 diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index fcdb2e10..00000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -4.0.0 diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 00000000..df815227 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1 @@ +extends: spectral:oas diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..3cedb273 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +ruby 3.4.6 +nodejs 22.19.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..2befa5e2 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "shopify.ruby-lsp" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..afefb6bf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "files.associations": { + "*.yml": "yaml", + "*.yaml": "yaml", + "Rakefile": "ruby", + "Gemfile": "ruby", + "gemspec": "ruby" + }, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.rubocop": "explicit" + }, + "search.exclude": { + "**/coverage": true, + "**/tmp": true, + "**/vendor": true, + "**/.git": true, + "**/node_modules": true + }, + "files.exclude": { + "**/coverage": true, + "**/tmp": true + }, +} diff --git a/.yardopts b/.yardopts new file mode 100644 index 00000000..f3c98a33 --- /dev/null +++ b/.yardopts @@ -0,0 +1,10 @@ +--markup markdown +--charset utf-8 +--exclude spec/ +--exclude tmp/ +--exclude log/ +--exclude public/ +--exclude config/ +--exclude bin/ +--exclude .devcontainer/ +--exclude .github/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b7999f0b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# Agent Workflow (Dev Container) + +## Start the Dev Container + +```text +docker compose -f .devcontainer/docker-compose.yml up -d +``` + +## Commands (run inside the container) + +```text +docker compose -f .devcontainer/docker-compose.yml exec -T app make setup + +docker compose -f .devcontainer/docker-compose.yml exec -T app make dev + +docker compose -f .devcontainer/docker-compose.yml exec -T app make test + +docker compose -f .devcontainer/docker-compose.yml exec -T app make ready + +docker compose -f .devcontainer/docker-compose.yml exec -T app bundle exec rubocop -F + +docker compose -f .devcontainer/docker-compose.yml exec -T app bundle exec rspec +``` + +Pre-commit gate (required): + +```text +docker compose -f .devcontainer/docker-compose.yml exec -T app make ready +``` + +If you need an interactive shell: + +```text +docker compose -f .devcontainer/docker-compose.yml exec app bash +``` + +--- + +## Collaboration Agreement (Agent ↔ User) + +## Interview Answers (ID-able) + Expert Recommendations + +**DoD:** `make ready` in Dev Container; if applicable, user completes manual smoke test with agent-provided steps. +**Verification:** Always smoke Dev Container + `make ready`. +**Commits:** Group by logical unit after smoke-tested (feature / improvement / refactor). +**Responses:** Changes β†’ Commands β†’ Results β†’ Next steps, ending with a concise one-line summary. +**KISS vs Refactor:** KISS by default; boy-scout refactors allowed if low-risk and simplifying. +**Ambiguity:** Proceed with safest assumption, then confirm. +**Non-negotiables:** Dev Container only; security first. + +Expert recommendation: keep workflows terminal-first and keyboard-focused (clear commands, no GUI-only steps). + +## Definition of Done + +- Run `make ready` inside the Dev Container. +- If applicable, user completes manual smoke test; agent provides clear instructions. + +## Verification Rules + +- Always run Dev Container smoke + `make ready` for changes. + +## Commit Granularity + +- Group commits by logical units after they have grown and been smoke-tested (feature / improvement / refactor). + +## Response Format + +- Default: Changes β†’ Commands β†’ Results β†’ Next steps. +- End with a concise one-line summary. + +## KISS vs Refactor + +- KISS by default. +- Boy-scout refactors are allowed when they reduce complexity and are low-risk. + +## Ambiguity Handling + +- Proceed with the safest assumption, then confirm. + +## Non-Negotiables + +- Security first. +- YARD docs are strict for public Ruby methods in `app/`: every public method must have a YARD docstring with typed `@param` tags (for all params) and typed `@return`. +- When touching non-public methods, add YARD docs when it improves maintenance or clarifies invariants/edge handling. diff --git a/Dockerfile b/Dockerfile index 8beebaa8..bf654cc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,16 @@ -# Stage 1: Build -FROM ruby:4.0.1-alpine3.23 AS builder +ARG RUBY_BASE_IMAGE=ruby:4.0.1-alpine3.23 + +# Stage 1: Frontend Build +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Ruby Build +FROM ${RUBY_BASE_IMAGE} AS builder LABEL maintainer="Gil Desmarais " @@ -22,21 +33,22 @@ RUN apk add --no-cache \ && bundle install --retry=5 --jobs=$(nproc) \ && bundle binstubs bundler html2rss -# Stage 2: Runtime -FROM ruby:4.0.1-alpine3.23 +# Stage 3: Runtime +FROM ${RUBY_BASE_IMAGE} LABEL maintainer="Gil Desmarais " SHELL ["/bin/ash", "-o", "pipefail", "-c"] -ENV PORT=3000 \ - RACK_ENV=production \ - RUBY_YJIT_ENABLE=1 +ENV PORT=4000 \ + RACK_ENV=production \ + RUBY_YJIT_ENABLE=1 EXPOSE $PORT HEALTHCHECK --interval=30m --timeout=60s --start-period=5s \ - CMD curl -f http://${HEALTH_CHECK_USERNAME}:${HEALTH_CHECK_PASSWORD}@localhost:${PORT}/health_check.txt || exit 1 + CMD TOKEN="${HEALTH_CHECK_TOKEN:-CHANGE_ME_HEALTH_CHECK_TOKEN}" && \ + curl -f -H "Authorization: Bearer ${TOKEN}" http://localhost:${PORT}/api/v1/health || exit 1 ARG USER=html2rss ARG UID=991 @@ -67,5 +79,6 @@ USER html2rss COPY --from=builder /usr/local/bundle /usr/local/bundle COPY --chown=$USER:$USER . /app +COPY --from=frontend-builder --chown=$USER:$USER /app/public/frontend ./public/frontend CMD ["bundle", "exec", "puma", "-C", "./config/puma.rb"] diff --git a/Gemfile b/Gemfile index 282f86d2..8f9ae8c3 100644 --- a/Gemfile +++ b/Gemfile @@ -5,23 +5,19 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } # gem 'html2rss', '~> 0.14' -gem 'html2rss', github: 'html2rss/html2rss' - +gem 'html2rss', github: 'html2rss/html2rss', branch: :master gem 'html2rss-configs', github: 'html2rss/html2rss-configs' # Use these instead of the two above (uncomment them) when developing locally: # gem 'html2rss', path: '../html2rss' # gem 'html2rss-configs', path: '../html2rss-configs' -gem 'base64' -gem 'erubi' gem 'parallel' gem 'rack-cache' gem 'rack-timeout' -gem 'rack-unreloader' gem 'roda' gem 'ssrf_filter' -gem 'tilt' +gem 'zeitwerk' gem 'puma', require: false @@ -33,6 +29,7 @@ group :development do gem 'rubocop-rake', require: false gem 'rubocop-rspec', require: false gem 'rubocop-thread_safety', require: false + gem 'ruby-lsp', require: false gem 'yard', require: false end @@ -40,6 +37,7 @@ group :test do gem 'climate_control' gem 'rack-test' gem 'rspec' + gem 'rspec-openapi', require: false gem 'simplecov', require: false gem 'vcr' gem 'webmock' diff --git a/Gemfile.lock b/Gemfile.lock index d83930dd..c73a37ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GIT remote: https://github.com/html2rss/html2rss - revision: 400e796540e82a69e1f1e014b6f89c626acf32fd + revision: e0dca5bf74b17c1e2a0618fc0a4af27c16da1883 + branch: master specs: html2rss (0.17.0) addressable (~> 2.7) @@ -24,7 +25,7 @@ GIT GIT remote: https://github.com/html2rss/html2rss-configs - revision: 3a06d3fc9758d9e1f812ca9d7bdbaefa099f189e + revision: 4e401e6ed97f5e28da07978431500d7c39de8a41 specs: html2rss-configs (0.2.0) html2rss @@ -32,21 +33,82 @@ GIT GEM remote: https://rubygems.org/ specs: - addressable (2.8.8) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actionview (8.1.2) + activesupport (= 8.1.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activesupport (8.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + async (2.38.0) + console (~> 1.29) + fiber-annotation + io-event (~> 1.11) + metrics (~> 0.12) + traces (~> 0.18) + async-http (0.94.2) + async (>= 2.10.2) + async-pool (~> 0.11) + io-endpoint (~> 0.14) + io-stream (~> 0.6) + metrics (~> 0.12) + protocol-http (~> 0.58) + protocol-http1 (~> 0.36) + protocol-http2 (~> 0.22) + protocol-url (~> 0.2) + traces (~> 0.10) + async-pool (0.11.2) + async (>= 2.0) + async-websocket (0.30.0) + async-http (~> 0.76) + protocol-http (~> 0.34) + protocol-rack (~> 0.7) + protocol-websocket (~> 0.17) base64 (0.3.0) - bigdecimal (3.3.1) - brotli (0.7.0) - byebug (12.0.0) + bigdecimal (4.0.1) + brotli (0.8.0) + builder (3.3.0) + byebug (13.0.0) + reline (>= 0.6.0) climate_control (1.2.0) concurrent-ruby (1.3.6) + connection_pool (3.0.2) + console (1.34.3) + fiber-annotation + fiber-local (~> 1.1) + json crack (1.0.1) bigdecimal rexml crass (1.0.6) diff-lcs (1.6.2) docile (1.4.1) + drb (2.2.3) dry-configurable (1.3.0) dry-core (~> 1.1) zeitwerk (~> 2.6) @@ -54,23 +116,23 @@ GEM concurrent-ruby (~> 1.0) logger zeitwerk (~> 2.6) - dry-inflector (1.2.0) + dry-inflector (1.3.1) dry-initializer (3.2.0) dry-logic (1.6.0) bigdecimal concurrent-ruby (~> 1.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.14.1) + dry-schema (1.16.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.1) dry-initializer (~> 3.2) - dry-logic (~> 1.5) - dry-types (~> 1.8) + dry-logic (~> 1.6) + dry-types (~> 1.9, >= 1.9.1) zeitwerk (~> 2.6) - dry-types (1.8.3) - bigdecimal (~> 3.0) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -83,78 +145,126 @@ GEM dry-schema (~> 1.14) zeitwerk (~> 2.6) erubi (1.13.1) - faraday (2.14.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-gzip (3.0.4) + faraday-gzip (3.1.0) faraday (>= 2.0, < 3) zlib (~> 3.0) faraday-net_http (3.4.2) net-http (~> 0.5) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) hashdiff (1.2.1) - json (2.18.0) - kramdown (2.5.1) - rexml (>= 3.3.9) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + io-endpoint (0.17.2) + io-event (1.14.4) + io-stream (0.11.1) + json (2.19.1) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + kramdown (2.5.2) + rexml (>= 3.4.4) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) + loofah (2.25.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mcp (0.8.0) + json-schema (>= 4.1) + metrics (0.15.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0924) - mini_portile2 (2.8.9) + mime-types-data (3.2026.0303) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) net-http (0.9.1) uri (>= 0.11.1) nio4r (2.7.5) - nokogiri (1.19.0) - mini_portile2 (~> 2.8.2) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.0-arm64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.0-x86_64-darwin) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - prism (1.7.0) - public_suffix (7.0.0) + prism (1.9.0) + protocol-hpack (1.5.1) + protocol-http (0.60.0) + protocol-http1 (0.37.0) + protocol-http (~> 0.58) + protocol-http2 (0.24.0) + protocol-hpack (~> 1.4) + protocol-http (~> 0.47) + protocol-rack (0.21.1) + io-stream (>= 0.10) + protocol-http (~> 0.58) + rack (>= 1.0) + protocol-url (0.4.0) + protocol-websocket (0.20.2) + protocol-http (~> 0.2) + public_suffix (7.0.5) puma (7.2.0) nio4r (~> 2.0) - puppeteer-ruby (0.45.6) - concurrent-ruby (>= 1.1, < 1.4) + puppeteer-ruby (0.51.0) + async (>= 2.35.1, < 3.0) + async-http (>= 0.60, < 1.0) + async-websocket (>= 0.27, < 1.0) + base64 mime-types (>= 3.0) - websocket-driver (>= 0.6.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-cache (1.17.0) rack (>= 0.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) rack-timeout (0.7.0) - rack-unreloader (2.1.0) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) + rbs (3.10.3) + logger + tsort regexp_parser (2.11.3) - reverse_markdown (3.0.1) + reline (0.6.3) + io-console (~> 0.5) + reverse_markdown (3.0.2) nokogiri rexml (3.4.4) - roda (3.100.0) + roda (3.102.0) rack rspec (3.13.2) rspec-core (~> 3.13.0) @@ -165,24 +275,29 @@ GEM rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.7) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.6) + rspec-openapi (0.25.0) + actionpack (>= 5.2.0) + rails-dom-testing + rspec-core + rspec-support (3.13.7) rss (0.3.2) rexml - rubocop (1.82.1) + rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) + mcp (~> 0.6) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-performance (1.26.1) @@ -192,20 +307,26 @@ GEM rubocop-rake (0.7.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-rspec (3.8.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) rubocop (~> 1.81) rubocop-thread_safety (0.7.3) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-ast (>= 1.44.0, < 2.0) + ruby-lsp (0.26.8) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) ruby-progressbar (1.13.0) sanitize (7.0.0) crass (~> 1.0.2) nokogiri (>= 1.16.8) - sentry-ruby (6.2.0) + securerandom (0.4.1) + sentry-ruby (6.4.1) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + logger simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -213,27 +334,25 @@ GEM simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) ssrf_filter (1.3.0) - stackprof (0.2.27) - thor (1.4.0) - tilt (2.7.0) + stackprof (0.2.28) + thor (1.5.0) + traces (0.18.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) uri (1.1.1) + useragent (0.16.11) vcr (6.4.0) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.8.0) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) yard (0.9.38) - zeitwerk (2.7.4) - zlib (3.2.2) + zeitwerk (2.7.5) + zlib (3.2.3) PLATFORMS aarch64-linux-gnu @@ -241,16 +360,13 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin - ruby x86_64-darwin x86_64-linux-gnu x86_64-linux-musl DEPENDENCIES - base64 byebug climate_control - erubi html2rss! html2rss-configs! parallel @@ -258,123 +374,160 @@ DEPENDENCIES rack-cache rack-test rack-timeout - rack-unreloader rake roda rspec + rspec-openapi rubocop rubocop-performance rubocop-rake rubocop-rspec rubocop-thread_safety + ruby-lsp sentry-ruby simplecov ssrf_filter stackprof - tilt vcr webmock yard + zeitwerk CHECKSUMS - addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 + actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 + actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b + activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + async (2.38.0) sha256=f95d00da2eb72e2c5340a6d78c321ec70cec65cbeceb0dc2cb2a32ff17a0f4cf + async-http (0.94.2) sha256=c5ca94b337976578904a373833abe5b8dfb466a2946af75c4ae38c409c5c78b2 + async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed + async-websocket (0.30.0) sha256=55739954528ad8f87f7792d0452e1268d1ef2aa5b3719f79400a05a1a6202cdf base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218 - brotli (0.7.0) sha256=e9e4fa5036e59e344798be8e35f44301ed8d10dc94d68195b6587e9c78b63a41 - byebug (12.0.0) sha256=d4a150d291cca40b66ec9ca31f754e93fed8aa266a17335f71bb0afa7fca1a1e + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + brotli (0.8.0) sha256=0c5a42046b3b603fb109656881147fd76064c034b7d19c1b4fcc32a093a4d55d + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d climate_control (1.2.0) sha256=36b21896193fa8c8536fa1cd843a07cf8ddbd03aaba43665e26c53ec1bd70aa5 concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + console (1.34.3) sha256=869fbd74697efc4c606f102d2812b0b008e4e7fd738a91c591e8577140ec0dcc crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8 dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1 - dry-inflector (1.2.0) sha256=22f5d0b50fd57074ae57e2ca17e3b300e57564c218269dcf82ff3e42d3f38f2e + dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 - dry-schema (1.14.1) sha256=2fcd7539a7099cacae6a22f6a3a2c1846fe5afeb1c841cde432c89c6cb9b9ff1 - dry-types (1.8.3) sha256=b5d97a45e0ed273131c0c3d5bc9f5633c2d1242e092ee47401ce7d5eab65c1bc + dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 + dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 dry-validation (1.11.1) sha256=70900bb5a2d911c8aab566d3e360c6bff389b8bf92ea8e04885ce51c41ff8085 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad - faraday-gzip (3.0.4) sha256=3553d0e3805c060a680f1c2f47d6768482df1f68134c1233837f243f2e4051dc + faraday-gzip (3.1.0) sha256=320783690be169f9b7ddde11598b77156951343753f66a9ab98b1f6694433ff8 faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03 + fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06 + fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1 hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 html2rss (0.17.0) html2rss-configs (0.2.0) - json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 - kramdown (2.5.1) sha256=87bbb6abd9d3cebe4fc1f33e367c392b4500e6f8fa19dd61c0972cf4afe7368c + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338 + io-event (1.14.4) sha256=455a9e4fb4613d12867b90461c297af6993b400a521bf62046f83b27f9c6aa3d + io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87 + json (2.19.1) sha256=dd94fdc59e48bff85913829a32350b3148156bc4fd2a95a2568a78b11344082d + json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 + kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb + metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 - mime-types-data (3.2025.0924) sha256=f276bca15e59f35767cbcf2bc10e023e9200b30bd6a572c1daf7f4cc24994728 - mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 + mime-types-data (3.2026.0303) sha256=164af1de5824c5195d4b503b0a62062383b65c08671c792412450cd22d3bc224 + minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.0) sha256=e304d21865f62518e04f2bf59f93bd3a97ca7b07e7f03952946d8e1c05f45695 - nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 - nokogiri (1.19.0-aarch64-linux-musl) sha256=eb70507f5e01bc23dad9b8dbec2b36ad0e61d227b42d292835020ff754fb7ba9 - nokogiri (1.19.0-arm-linux-gnu) sha256=572a259026b2c8b7c161fdb6469fa2d0edd2b61cd599db4bbda93289abefbfe5 - nokogiri (1.19.0-arm-linux-musl) sha256=23ed90922f1a38aed555d3de4d058e90850c731c5b756d191b3dc8055948e73c - nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 - nokogiri (1.19.0-x86_64-darwin) sha256=1dad56220b603a8edb9750cd95798bffa2b8dd9dd9aa47f664009ee5b43e3067 - nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c - nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + nokogiri (1.19.1-aarch64-linux-gnu) sha256=cfdb0eafd9a554a88f12ebcc688d2b9005f9fce42b00b970e3dc199587b27f32 + nokogiri (1.19.1-aarch64-linux-musl) sha256=1e2150ab43c3b373aba76cd1190af7b9e92103564063e48c474f7600923620b5 + nokogiri (1.19.1-arm-linux-gnu) sha256=0a39ed59abe3bf279fab9dd4c6db6fe8af01af0608f6e1f08b8ffa4e5d407fa3 + nokogiri (1.19.1-arm-linux-musl) sha256=3a18e559ee499b064aac6562d98daab3d39ba6cbb4074a1542781b2f556db47d + nokogiri (1.19.1-arm64-darwin) sha256=dfe2d337e6700eac47290407c289d56bcf85805d128c1b5a6434ddb79731cb9e + nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf + nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a + nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 - prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 - public_suffix (7.0.0) sha256=f7090b5beb0e56f9f10d79eed4d5fbe551b3b425da65877e075dad47a6a1b095 + parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91 + protocol-http (0.60.0) sha256=ca1354947676d663b6f23c49654aee464288774e7867c4a6e406fecce9691cec + protocol-http1 (0.37.0) sha256=5bdd739e28792b341134596f6f5ab21a9d4b395f67bae69e153743eb0e69d123 + protocol-http2 (0.24.0) sha256=65327a019b7e36d2774e94050bf57a43bb60212775d2fcf02ae1d2ed4f01ef28 + protocol-rack (0.21.1) sha256=366ff16efbf4c2f8d2e3fad4e992effa2357610f70effbccfa2767d26fedc577 + protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1 + protocol-websocket (0.20.2) sha256=c41d93c35fba5dae85375c597f76975f3dbd75d8c5b2f21b33dab4dc22a5a511 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 - puppeteer-ruby (0.45.6) sha256=cb86f7b4f6f8658a709ae1a305e820bdb009548e6beff6675489926f9ceb5995 + puppeteer-ruby (0.51.0) sha256=8a7637963f8cd5b88416dd8c669a3ec2fe40a42cda2449539d75525a4da2f233 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3 rack-cache (1.17.0) sha256=49592f3ef2173b0f5524df98bb801fb411e839869e7ce84ac428dc492bf0eb90 + rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rack-timeout (0.7.0) sha256=757337e9793cca999bb73a61fe2a7d4280aa9eefbaf787ce3b98d860749c87d9 - rack-unreloader (2.1.0) sha256=18879cf2ced8ca21a01836bca706f65cce6ebe3f7d9d8a5157ce68ca62c7263a + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 - reverse_markdown (3.0.1) sha256=2cac1f448ac5c9d6d858fafb84db2a0c0fa244f6a5684ea6d14ca448895de283 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - roda (3.100.0) sha256=35d36f43c68d2bd1974dc77ade8c873558265a191018e1ada88ca662f1fa8e62 + roda (3.102.0) sha256=b2156fff6d2b1b52bfac39e4ccde0d820a26594f069c3d9e99cc0853f7ee7dcc rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 - rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c - rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-openapi (0.25.0) sha256=76e055d3ee421a2a0c5d45986ae958f045f149def995b55ee9d2c68318f38305 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rss (0.3.2) sha256=3bd0446d32d832cda00ba07f4b179401f903b52ea1fdaac0f1f08de61a501efa - rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 - rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d - rubocop-rspec (3.8.0) sha256=28440dccb3f223a9938ca1f946bd3438275b8c6c156dab909e2cb8bc424cab33 + rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2 rubocop-thread_safety (0.7.3) sha256=067cdd52fbf5deffc18995437e45b5194236eaff4f71de3375a1f6052e48f431 + ruby-lsp (0.26.8) sha256=fa607c342736c6ea791c945ae05025bc306ef9dd72c640b0b660478c5832f968 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984 - sentry-ruby (6.2.0) sha256=d7b8a358a3a0d0536bd194b19cc282c85f295b6d242731e9b4a934ccdb71ac17 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sentry-ruby (6.4.1) sha256=dac04976f791ad6ecd4fd30440c29d9b73aee08f790eeca73b439b5d67370f38 simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 ssrf_filter (1.3.0) sha256=66882d7de7d09c019098d6d7372412950ae184ebbc7c51478002058307aba6f2 - stackprof (0.2.27) sha256=aff6d28656c852e74cf632cc2046f849033dc1dedffe7cb8c030d61b5745e80c - thor (1.4.0) sha256=8763e822ccb0f1d7bee88cde131b19a65606657b847cc7b7b4b82e772bcd8a3d - tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 + stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6 webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 - websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 - websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f - zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b - zlib (3.2.2) sha256=908e61263f99c1371b5422581e2d6663bd37c6b04ae13b5f8cb10b0d09379f40 + zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd + zlib (3.2.3) sha256=5bd316698b32f31a64ab910a8b6c282442ca1626a81bbd6a1674e8522e319c20 BUNDLED WITH 2.6.6 diff --git a/Makefile b/Makefile index 1dd8783e..245f8dd6 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # frozen_string_literal: true -.PHONY: help test lint fix setup dev clean +.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e # Default target help: ## Show this help message @@ -17,23 +17,120 @@ setup: ## Full development setup echo "Created .env file"; \ fi @mkdir -p tmp/rack-cache-body tmp/rack-cache-meta + @echo "Setting up frontend..." + @cd frontend && npm install @echo "Setup complete!" -dev: ## Start development server - @echo "Starting development server..." - @echo "Server will be available at: http://localhost:3000" - @echo "Press Ctrl+C to stop" +dev: ## Start development server with live reload + @echo "Starting html2rss-web development environment..." @bin/dev -test: ## Run tests +dev-ruby: ## Start Ruby server only + @bin/dev-ruby + +dev-frontend: ## Start frontend dev server only + @cd frontend && npm run dev + +test: ## Run all tests (Ruby + Frontend) + bundle exec rspec + @cd frontend && npm run test:ci + +test-ruby: ## Run Ruby tests only bundle exec rspec -lint: ## Run linter +test-frontend: ## Run frontend tests only + @cd frontend && npm run test:ci + +test-frontend-unit: ## Run frontend unit tests only + @cd frontend && npm run test:unit + +test-frontend-contract: ## Run frontend contract tests only + @cd frontend && npm run test:contract + +test-frontend-e2e: ## Run frontend Playwright smoke tests + @cd frontend && npm run test:e2e + +check-frontend: ## Run frontend typecheck, format, and test checks + $(MAKE) lint-js + $(MAKE) test-frontend + + +lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issues found + @echo "All linting complete!" + +lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found + @echo "Running RuboCop linting..." bundle exec rubocop + @echo "Running Zeitwerk eager-load check..." + bundle exec rake zeitwerk:verify + @echo "Running YARD public-method docs check..." + bundle exec rake yard:verify_public_docs + @echo "Ruby linting complete!" + +lint-js: ## Run JavaScript/Frontend linter (Prettier) - errors when issues found + @echo "Running TypeScript typecheck..." + @cd frontend && npm run typecheck + @echo "Running Prettier format check..." + @cd frontend && npm run format:check + @echo "JavaScript linting complete!" + +lintfix: lintfix-ruby lintfix-js ## Auto-fix all linting issues (Ruby + Frontend) + @echo "All lintfix complete!" + +lintfix-ruby: ## Auto-fix Ruby linting issues + @echo "Running RuboCop auto-correct..." + -bundle exec rubocop --auto-correct + @echo "Ruby lintfix complete!" -fix: ## Auto-fix linting issues - bundle exec rubocop -a +lintfix-js: ## Auto-fix JavaScript/Frontend linting issues + @echo "Running Prettier formatting..." + @cd frontend && npm run format + @echo "JavaScript lintfix complete!" + +quick-check: ## Fast local checks (Ruby lint/docs + frontend format/typecheck) + @echo "Running quick checks..." + $(MAKE) lint-ruby + $(MAKE) lint-js + @echo "Quick checks complete!" + +ready: ## Pre-commit gate (quick checks + RSpec) + @echo "Running pre-commit checks..." + $(MAKE) quick-check + bundle exec rspec + @echo "Pre-commit checks complete!" + +yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/ + bundle exec rake yard:verify_public_docs + +openapi: ## Regenerate docs/api/v1/openapi.yaml from request specs + bundle exec rake openapi:generate + +openapi-verify: ## Regenerate OpenAPI and fail if docs/api/v1/openapi.yaml or frontend client is stale + bundle exec rake openapi:verify + $(MAKE) openapi-client-verify + +openapi-client: ## Generate frontend OpenAPI client/types from docs/api/v1/openapi.yaml + @cd frontend && npm run openapi:generate + +openapi-client-verify: ## Generate frontend OpenAPI client and fail if generated files are stale + @cd frontend && npm run openapi:verify + +openapi-lint: openapi-lint-redocly openapi-lint-spectral ## Lint docs/api/v1/openapi.yaml with Redocly and Spectral + +openapi-lint-redocly: ## Lint OpenAPI using Redocly recommended rules + npx --yes @redocly/cli lint --config .redocly.yaml docs/api/v1/openapi.yaml + +openapi-lint-spectral: ## Lint OpenAPI using Spectral OAS rules + npx --yes @stoplight/spectral-cli lint --ruleset .spectral.yaml docs/api/v1/openapi.yaml + +openai-lint-spectral: openapi-lint-spectral ## Alias for openapi-lint-spectral clean: ## Clean temporary files @rm -rf tmp/rack-cache-* coverage/ + @cd frontend && rm -rf dist/ node_modules/ @echo "Clean complete!" + +frontend-setup: ## Setup frontend dependencies + @echo "Setting up frontend dependencies..." + @cd frontend && npm install + @echo "Frontend setup complete!" diff --git a/Procfile b/Procfile deleted file mode 100644 index c65b900f..00000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: bundle exec puma -C config/puma.rb -p 3000 diff --git a/README.md b/README.md index 84c65509..fdd1215c 100644 --- a/README.md +++ b/README.md @@ -2,81 +2,118 @@ # html2rss-web -This web application scrapes websites to build and deliver RSS 2.0 feeds. +html2rss-web converts arbitrary websites into RSS 2.0 feeds with a slim Ruby backend and a Preact frontend. -## 🌐 Community & Resources +## Links -| Resource | Description | Link | -| ------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | -| **πŸ“š Documentation & Feed Directory** | Complete guides, tutorials, and browse 100+ pre-built feeds | [html2rss.github.io](https://html2rss.github.io) | -| **πŸ’¬ Community Discussions** | Get help, share ideas, and connect with other users | [GitHub Discussions](https://github.com/orgs/html2rss/discussions) | -| **πŸ“‹ Project Board** | Track development progress and upcoming features | [View Project Board](https://github.com/orgs/html2rss/projects) | -| **πŸ’– Support Development** | Help fund ongoing development and maintenance | [Sponsor on GitHub](https://github.com/sponsors/gildesmarais) | +- Docs & feed directory: https://html2rss.github.io +- Discussions: https://github.com/orgs/html2rss/discussions +- Sponsor: https://github.com/sponsors/gildesmarais -**Quick Start Options:** +## Highlights -- **New to RSS?** β†’ Start with the [web application guide](https://html2rss.github.io/web-application) -- **Need a specific feed?** β†’ Browse the [feed directory](https://html2rss.github.io/feed-directory) -- **Want to deploy?** β†’ Check out [deployment guides](https://html2rss.github.io/web-application/how-to/deployment) -- **Want to contribute?** β†’ See our [contributing guide](https://html2rss.github.io/get-involved/contributing) +- Responsive Preact interface for demo, sign-in, conversion, and result flows. +- Automatic source discovery with token-scoped permissions. +- Signed public feed URLs that work in standard RSS readers. +- Built-in SSRF defences, input validation, and HMAC-protected tokens. -**Features:** +## Architecture -- Provides stable URLs for feeds generated by automatic sourcing. -- [Create your custom feeds](https://html2rss.github.io/web-application/tutorials/building-feeds)! -- Comes with plenty of [included configs](https://html2rss.github.io/web-application/how-to/use-included-configs). -- Handles request caching. -- Sets caching-related HTTP headers. +- **Backend:** Ruby + Roda, backed by the `html2rss` gem for extraction. +- **Frontend:** Preact app built with Vite into `public/frontend`. +- **Distribution:** Docker Compose by default; other deployments require manual wiring. +- [v2 Migration Guide](docs/migrations/v2.md) -The functionality of scraping websites and building the RSS feeds is provided by the Ruby gem [`html2rss`](https://github.com/html2rss/html2rss). +## REST API Snapshot -## Documentation +```bash +# List available strategies +curl -H "Authorization: Bearer " \ + "https://your-domain.com/api/v1/strategies" -For full documentation, please see the [html2rss-web documentation](https://html2rss.github.io/web-application/). +# Create a feed and capture the signed public URL +curl -X POST "https://your-domain.com/api/v1/feeds" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com","name":"Example Feed"}' +``` -## Development +## Deploy (Docker Compose) -### Quick Start with GitHub Codespaces +1. Generate a key: `openssl rand -hex 32`. +2. Set `HTML2RSS_SECRET_KEY` in `docker-compose.yml`. +3. Start: `docker-compose up`. -The easiest way to get started is using GitHub Codespaces: +UI + API run on `http://localhost:4000`. The app exits if the secret key is missing. -1. Fork this repository -2. Click "Code" β†’ "Codespaces" β†’ "Create codespace on [your-username]/html2rss-web" -3. Wait for the codespace to build (it will automatically run `bundle install`) -4. The development server will be available at the forwarded port (usually 3000) +## Development (Dev Container) -### Local Development +Use the repository's [Dev Container](.devcontainer/README.md) for all local development and tests. +Running the app directly on the host is not supported. -1. **Clone and setup:** - ```bash - git clone https://github.com/html2rss/html2rss-web.git - cd html2rss-web - make setup - ``` +Quick start inside the Dev Container: -2. **Start development server:** - ```bash - make dev - ``` +``` +make setup +make dev +make test +make ready +make yard-verify-public-docs +bundle exec rubocop -F +bundle exec rspec +make openapi +``` -The application will be available at `http://localhost:3000`. +Dev URLs: Ruby app at `http://localhost:4000`, frontend dev server at `http://localhost:4001`. -### Development Commands +Backend code under the `Html2rss::Web` namespace now lives under `app/web/**`, so Zeitwerk can mirror constant paths directly instead of relying on directory-specific namespace wiring. +`make ready` also runs `rake zeitwerk:verify`, which eager-loads the app and fails on loader drift early. +For contributors and AI agents changing backend structure, follow the placement rules in [docs/ai-agent-app-web.md](docs/ai-agent-app-web.md). -| Command | Description | -| ------------ | --------------------------- | -| `make help` | Show all available commands | -| `make setup` | Full development setup | -| `make dev` | Start development server | -| `make test` | Run tests | -| `make lint` | Run linter | -| `make fix` | Auto-fix linting issues | -| `make clean` | Clean temporary files | +## Make Targets -## Contributing +| Command | Purpose | +| -------------------- | ------------------------------------------------------- | +| `make help` | List available shortcuts. | +| `make setup` | Install Ruby and Node dependencies. | +| `make dev` | Run Ruby (port 4000) and frontend (port 4001) dev servers. | +| `make dev-ruby` | Start only the Ruby server. | +| `make dev-frontend` | Start only the frontend dev server (port 4001). | +| `make test` | Run Ruby and frontend test suites. | +| `make test-ruby` | Run Ruby specs. | +| `make test-frontend` | Run frontend unit and contract tests. | +| `make lint` | Run all linters. | +| `make lintfix` | Auto-fix lint warnings where possible. | +| `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. | +| `make openapi` | Regenerate `docs/api/v1/openapi.yaml` from request specs. | +| `make openapi-verify`| Regenerate + fail if OpenAPI file is stale. | +| `make clean` | Remove build artefacts. | + +## OpenAPI Contract + +The OpenAPI file is generated from Ruby request specs only. + +- Regenerate: `make openapi` +- Verify drift (CI behavior): `make openapi-verify` -Contributions are welcome! Please see the [contributing guide](https://html2rss.github.io/get-involved/contributing) for more information. +## Frontend npm Scripts (inside Dev Container) -## Sponsoring +| Command | Purpose | +| ----------------------- | --------------------------------------------- | +| `npm run dev` | Vite dev server with hot reload (port 4001). | +| `npm run build` | Build static assets into `public/frontend`. | +| `npm run test:run` | Unit tests (Vitest). | +| `npm run test:contract` | Contract tests with MSW. | + +## Testing Strategy + +| Layer | Tooling | Focus | +| ----------------- | ------------------------ | ---------------------------------------------------- | +| Ruby API | RSpec + Rack::Test | Feed creation, retrieval, auth paths. | +| Frontend unit | Vitest + Testing Library | Component rendering and hooks with mocked fetch. | +| Frontend contract | Vitest + MSW | End-to-end fetch flows against mocked API responses. | +| Docker smoke | RSpec (`:docker`) | Net::HTTP probes against the containerised service. | + +## Contributing -If you find this project useful, please consider [sponsoring the project](https://github.com/sponsors/gildesmarais). +See the [html2rss project guidelines](https://html2rss.github.io/get-involved/contributing). diff --git a/Rakefile b/Rakefile index c26bc9ce..75efc2d4 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'json' +require 'fileutils' +require 'open3' ## # Helper methods used during :test run @@ -27,21 +29,32 @@ module Output end end +def test_container_exists?(container_name) + inspection, status = Open3.capture2e('docker', 'inspect', container_name) + return false unless status.success? + return false if inspection.strip.empty? + + JSON.parse(inspection).any? +rescue JSON::ParserError + false +end + task default: %w[test] desc 'Build and run docker image/container, and send requests to it' task :test do current_dir = ENV.fetch('GITHUB_WORKSPACE', __dir__) + smoke_auto_source_enabled = ENV.fetch('SMOKE_AUTO_SOURCE_ENABLED', 'false') Output.describe 'Building and running' sh 'docker build -t gilcreator/html2rss-web -f Dockerfile .' sh ['docker run', '-d', - '-p 3000:3000', + '-p 4000:4000', '--env PUMA_LOG_CONFIG=1', - '--env HEALTH_CHECK_USERNAME=username', - '--env HEALTH_CHECK_PASSWORD=password', + '--env HEALTH_CHECK_TOKEN=CHANGE_ME_HEALTH_CHECK_TOKEN', + "--env AUTO_SOURCE_ENABLED=#{smoke_auto_source_enabled}", "--mount type=bind,source=#{current_dir}/config,target=/app/config", '--name html2rss-web-test', 'gilcreator/html2rss-web'].join(' ') @@ -51,22 +64,17 @@ task :test do Output.describe 'Listing docker containers matching html2rss-web-test filter' sh 'docker ps -a --filter name=html2rss-web-test' - Output.describe 'Generating feed from a html2rss-configs config' - sh 'curl -f "http://127.0.0.1:3000/github.com/releases.rss?username=html2rss&repository=html2rss-web" || exit 1' - - Output.describe 'Generating example feed from feeds.yml' - sh 'curl -f http://127.0.0.1:3000/example.rss || exit 1' - - Output.describe 'Authenticated request to GET /health_check.txt' - sh 'docker exec html2rss-web-test curl -f http://username:password@127.0.0.1:3000/health_check.txt || exit 1' - - # skipped as html2rss is used in development version - # Output.describe 'Print output of `html2rss help`' - # sh 'docker exec html2rss-web-test html2rss help' + Output.describe 'Running RSpec smoke suite against container' + smoke_env = { + 'SMOKE_BASE_URL' => 'http://127.0.0.1:4000', + 'SMOKE_HEALTH_TOKEN' => 'CHANGE_ME_HEALTH_CHECK_TOKEN', + 'SMOKE_API_TOKEN' => 'CHANGE_ME_ADMIN_TOKEN', + 'SMOKE_AUTO_SOURCE_ENABLED' => smoke_auto_source_enabled, + 'RUN_DOCKER_SPECS' => 'true' + } + sh smoke_env, 'bundle exec rspec --tag docker' ensure - test_container_exists = JSON.parse(`docker inspect html2rss-web-test`).any? - - if test_container_exists + if test_container_exists?('html2rss-web-test') Output.describe 'Cleaning up test container' sh 'docker logs --tail all html2rss-web-test' @@ -76,3 +84,78 @@ ensure exit 1 if $ERROR_INFO end + +namespace :openapi do + desc 'Generate OpenAPI YAML from request specs' + task :generate do + FileUtils.mkdir_p('docs/api/v1') + FileUtils.rm_f('docs/api/v1/openapi.yaml') + sh({ 'OPENAPI' => '1' }, 'bundle exec rspec spec/html2rss/web/api/v1_spec.rb --order defined') + end + + desc 'Verify generated OpenAPI YAML is up to date' + task verify: :generate do + sh 'git diff --exit-code -- docs/api/v1/openapi.yaml' + end +end + +namespace :yard do + desc 'Fail when public methods in app/ are missing essential YARD docs' + task :verify_public_docs do + require 'yard' + + files = Dir.glob(File.join(__dir__, 'app/**/*.rb')) + YARD::Registry.clear + YARD::Registry.load(files, true) + + violations = [] + + YARD::Registry.all(:method).each do |method_object| + next unless method_object.visibility == :public + next unless method_object.file&.include?('/app/') + + location = "#{method_object.path} (#{method_object.file}:#{method_object.line})" + normalize_param_name = lambda do |name| + name.to_s.sub(/\A[*&]/, '').sub(/:$/, '') + end + + param_tags = method_object.tags(:param) + params = method_object.parameters.map(&:first).map { |name| normalize_param_name.call(name) } + params.reject! { |name| name == 'block' } + + param_tag_names = param_tags.map { |tag| normalize_param_name.call(tag.name) } + missing_params = params - param_tag_names + violations << "#{location} missing @param for: #{missing_params.join(', ')}" unless missing_params.empty? + + param_tags.each do |tag| + violations << "#{location} @param #{tag.name} missing type" if tag.types.nil? || tag.types.empty? + end + + return_tag = method_object.tag(:return) + if return_tag.nil? + violations << "#{location} missing @return" + elsif return_tag.types.nil? || return_tag.types.empty? + violations << "#{location} @return missing type" + end + end + + if violations.any? + puts 'YARD public method documentation check failed:' + violations.sort.each { |violation| puts " - #{violation}" } + abort "\nFound #{violations.count} YARD documentation violation(s)." + end + + puts 'YARD public method documentation check passed.' + end +end + +namespace :zeitwerk do + desc 'Fail when Zeitwerk cannot eager load the app tree cleanly' + task :verify do + ENV['RACK_ENV'] ||= 'test' + require_relative 'app' + + Html2rss::Web::Boot.eager_load! + puts 'Zeitwerk eager load check passed.' + end +end diff --git a/app.rb b/app.rb index 9471c646..e053a56d 100644 --- a/app.rb +++ b/app.rb @@ -2,144 +2,107 @@ require 'roda' require 'rack/cache' -require_relative 'roda/roda_plugins/basic_auth' +require 'json' +require 'base64' require 'html2rss' -require_relative 'app/ssrf_filter_strategy' +require_relative 'app/web/boot' + +Html2rss::Web::Boot.setup!(reloadable: ENV['RACK_ENV'] == 'development') +Html2rss::Web::Boot::Setup.call! module Html2rss module Web ## - # This app uses html2rss and serves the feeds via HTTP. - # - # It is built with [Roda](https://roda.jeremyevans.net/). + # Roda app serving RSS feeds via html2rss class App < Roda - CONTENT_TYPE_RSS = 'application/xml' - - Html2rss::RequestService.register_strategy(:ssrf_filter, SsrfFilterStrategy) - Html2rss::RequestService.default_strategy_name = :ssrf_filter - Html2rss::RequestService.unregister_strategy(:faraday) - - def self.development? = ENV['RACK_ENV'] == 'development' - - opts[:check_dynamic_arity] = false - opts[:check_arity] = :warn - - use Rack::Cache, - metastore: 'file:./tmp/rack-cache-meta', - entitystore: 'file:./tmp/rack-cache-body', - verbose: development? + FALLBACK_HTML = <<~HTML + + + + html2rss-web + + + + +

html2rss-web

+

Convert websites to RSS feeds

+

API available at /api/v1

+ + + HTML + def self.development? = EnvironmentValidator.development? + + def development? = self.class.development? + opts.merge!(check_dynamic_arity: false, check_arity: :warn) + use RequestContextMiddleware + use Rack::Cache, metastore: 'file:./tmp/rack-cache-meta', entitystore: 'file:./tmp/rack-cache-body', + verbose: development? plugin :content_security_policy do |csp| csp.default_src :none - csp.style_src :self + csp.style_src :self, "'unsafe-inline'" csp.script_src :self csp.connect_src :self csp.img_src :self - csp.font_src :self, 'data:' + csp.font_src :self csp.form_action :self csp.base_uri :none - csp.frame_ancestors :self + if development? + csp.frame_ancestors 'http://localhost:*', 'https://localhost:*', + 'http://127.0.0.1:*', 'https://127.0.0.1:*' + else + csp.frame_ancestors :none + end csp.frame_src :self + csp.object_src :none + csp.media_src :none + csp.manifest_src :none + csp.worker_src :none + csp.child_src :none csp.block_all_mixed_content + csp.upgrade_insecure_requests end - plugin :default_headers, - 'Content-Type' => 'text/html', - 'X-Content-Type-Options' => 'nosniff', - 'X-XSS-Protection' => '1; mode=block' - + plugin :default_headers, { + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Permitted-Cross-Domain-Policies' => 'none', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()', + 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload', + 'Cross-Origin-Embedder-Policy' => 'require-corp', + 'Cross-Origin-Opener-Policy' => 'same-origin', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'X-DNS-Prefetch-Control' => 'off', + 'X-Download-Options' => 'noopen' + } + + plugin :json_parser + plugin :public + plugin :head + plugin :not_allowed plugin :exception_page plugin :error_handler do |error| next exception_page(error) if development? - handle_error(error) + ErrorResponder.respond(request: request, response: response, error: error) end - plugin :hash_branch_view_subdir - plugin :public - plugin :content_for - plugin :render, escape: true, layout: 'layout' - plugin :typecast_params - plugin :basic_auth - - Dir['routes/**/*.rb'].each do |f| - if development? - Unreloader.require f - else - require_relative f - end - end - - @show_backtrace = !ENV['CI'].to_s.empty? || development? - route do |r| r.public - r.hash_branches('') - - r.root { view 'index' } - - r.get 'health_check.txt' do - handle_health_check - end - r.on String, String do |folder_name, config_name_with_ext| - response['Content-Type'] = CONTENT_TYPE_RSS - - name = "#{folder_name}/#{File.basename(config_name_with_ext, '.*')}" - config = Html2rss::Configs.find_by_name(name) - - if (params = request.params).any? - config = config.dup - config[:params] ||= {} - config[:params].merge!(params) - end - - unless config[:strategy] - config = config.dup if config.frozen? - config[:strategy] ||= Html2rss::RequestService.default_strategy_name - end - - # Merge global stylesheets into the config - config = LocalConfig.merge_global_stylesheets(config) - - feed = Html2rss.feed(config) - - HttpCache.expires(response, feed.channel.ttl.to_i * 60, cache_control: 'public') - - feed.to_s - end - - r.on String do |config_name_with_ext| - response['Content-Type'] = CONTENT_TYPE_RSS - - config = LocalConfig.find(File.basename(config_name_with_ext, '.*')) - - if (params = request.params).any? - config = config.dup - config[:params] ||= {} - config[:params].merge!(params) - end - - unless config[:strategy] - config = config.dup if config.frozen? - config[:strategy] ||= Html2rss::RequestService.default_strategy_name - end - - feed = Html2rss.feed(config) - - HttpCache.expires(response, feed.channel.ttl.to_i * 60, cache_control: 'public') - - feed.to_s - end + Routes::ApiV1.call(r) || + Routes::FeedPages.call(r, index_renderer: ->(router_ctx) { render_index_page(router_ctx) }) end - Dir['helpers/*.rb'].each do |f| - if development? - Unreloader.require f - else - require_relative f - end + private + + def render_index_page(router) + index_path = 'public/frontend/index.html' + router.response['Content-Type'] = 'text/html' + File.exist?(index_path) ? File.read(index_path) : FALLBACK_HTML end end end diff --git a/app/health_check.rb b/app/health_check.rb deleted file mode 100644 index 4e9c5409..00000000 --- a/app/health_check.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'parallel' -require_relative 'local_config' -require 'securerandom' -require 'singleton' - -module Html2rss - module Web - ## - # Checks if the local configs are generatable. - module HealthCheck - ## - # Contains logic to obtain username and password to be used with HealthCheck endpoint. - class Auth - include Singleton - - def self.username = instance.username - def self.password = instance.password - - def username - @username ||= fetch_credential('HEALTH_CHECK_USERNAME') - end - - def password - @password ||= fetch_credential('HEALTH_CHECK_PASSWORD') - end - - private - - def fetch_credential(env_var) - ENV.delete(env_var) do - SecureRandom.base64(32).tap do |string| - warn "ENV var. #{env_var} missing! Using generated value instead: #{string}" - end - end - end - end - - module_function - - ## - # @return [String] "success" when all checks passed. - def run - broken_feeds = errors - broken_feeds.any? ? broken_feeds.join("\n") : 'success' - end - - ## - # @return [Array] - def errors - [].tap do |errors| - Parallel.each(LocalConfig.feed_names) do |feed_name| - Html2rss.feed_from_yaml_config(LocalConfig::CONFIG_FILE, feed_name.to_s) - rescue StandardError => error - errors << "[#{feed_name}] #{error.class}: #{error.message}" - end - end - end - - def format_error(feed_name, error) - "[#{feed_name}] #{error.class}: #{error.message}" - end - - private_class_method :format_error - end - end -end diff --git a/app/http_cache.rb b/app/http_cache.rb deleted file mode 100644 index 8b86fadb..00000000 --- a/app/http_cache.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'time' - -module Html2rss - module Web - ## - # Collection of methods which set HTTP Caching related headers in the response. - module HttpCache - module_function - - ## - # Sets Expires and Cache-Control headers to cache for `seconds`. - # @param response [Hash] - # @param seconds [Integer] - # @param cache_control [String, nil] - def expires(response, seconds, cache_control: nil) - expires_now(response) and return if seconds <= 0 - - response['Expires'] = (Time.now + seconds).httpdate - - cache_value = "max-age=#{seconds}" - cache_value += ",#{cache_control}" if cache_control - response['Cache-Control'] = cache_value - end - - ## - # Sets Expires and Cache-Control headers to invalidate existing cache and - # prevent caching. - # @param response [Hash] - def expires_now(response) - response['Expires'] = '0' - response['Cache-Control'] = 'private,max-age=0,no-cache,no-store,must-revalidate' - end - end - end -end diff --git a/app/local_config.rb b/app/local_config.rb deleted file mode 100644 index 74347d62..00000000 --- a/app/local_config.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require 'yaml' - -module Html2rss - module Web - ## - # Provides helper methods to deal with the local config file at `CONFIG_FILE`. - module LocalConfig - ## - # raised when the local config wasn't found - class NotFound < RuntimeError; end - - CONFIG_FILE = 'config/feeds.yml' - - module_function - - ## - # @param name [String, Symbol, #to_sym] - # @return [Hash] - def find(name) - feed_config = feeds.fetch(name.to_sym) { raise NotFound, "Did not find local feed config at '#{name}'" } - merge_global_stylesheets(feed_config) - end - - ## - # Merges global stylesheets into a feed configuration if the feed doesn't already have stylesheets. - # - # @param config [Hash] The feed configuration to merge stylesheets into - # @return [Hash] The configuration with merged stylesheets (duplicated if needed) - def merge_global_stylesheets(config) - global_config = global - return config unless global_config[:stylesheets] && !config.key?(:stylesheets) - - config = config.dup - config[:stylesheets] = global_config[:stylesheets] - config - end - - ## - # @return [Hash] - def feeds - yaml.fetch(:feeds, {}) - end - - ## - # @return [Hash] - def global - yaml.reject { |key| key == :feeds } - end - - ## - # @return [Array] names of locally available feeds - def feed_names - feeds.keys - end - - ## - # @return [Hash] - def yaml - YAML.safe_load_file(CONFIG_FILE, symbolize_names: true).freeze - rescue Errno::ENOENT => error - raise NotFound, "Configuration file not found: #{error.message}" - end - end - end -end diff --git a/app/web/api/v1/contract.rb b/app/web/api/v1/contract.rb new file mode 100644 index 00000000..bc67beb8 --- /dev/null +++ b/app/web/api/v1/contract.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Api + module V1 + module Contract + CODES = { + unauthorized: Html2rss::Web::UnauthorizedError::CODE, + forbidden: Html2rss::Web::ForbiddenError::CODE, + internal_server_error: Html2rss::Web::InternalServerError::CODE + }.freeze + + MESSAGES = { + auto_source_disabled: 'Auto source feature is disabled', + health_check_failed: 'Health check failed' + }.freeze + end + end + end + end +end diff --git a/app/web/api/v1/create_feed.rb b/app/web/api/v1/create_feed.rb new file mode 100644 index 00000000..5e1fe797 --- /dev/null +++ b/app/web/api/v1/create_feed.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'time' +require 'json' + +module Html2rss + module Web + module Api + module V1 + ## + # Creates stable feed records from authenticated API requests. + module CreateFeed # rubocop:disable Metrics/ModuleLength + FEED_ATTRIBUTE_KEYS = + %i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze + class << self # rubocop:disable Metrics/ClassLength + # Creates a feed and returns a normalized API success payload. + # + # @param request [Rack::Request] HTTP request with auth context. + # @return [Hash{Symbol=>Object}] API response payload. + def call(request) + params, feed_data = build_feed_from_request(request) + emit_create_success(params) + Response.success(response: request.response, + status: 201, + data: { feed: feed_attributes(feed_data) }, + meta: { created: true }) + rescue StandardError => error + emit_create_failure(error) + raise + end + + private + + # @return [void] + def ensure_auto_source_enabled! + raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? + end + + # @param request [Rack::Request] + # @return [Hash] + def require_account(request) + account = Auth.authenticate(request) + raise Html2rss::Web::UnauthorizedError, 'Authentication required' unless account + + account + end + + # @param params [Hash] + # @param account [Hash] + # @return [Html2rss::Web::Api::V1::FeedMetadata::CreateParams] + def build_create_params(params, account) + url = validated_url(params['url'], account) + FeedMetadata::CreateParams.new( + url: url, + name: FeedMetadata.site_title_for(url), + strategy: normalize_strategy(params['strategy']) + ) + end + + # @param raw_url [String, nil] + # @param account [Hash] + # @return [String] + def validated_url(raw_url, account) + url = raw_url.to_s.strip + raise Html2rss::Web::BadRequestError, 'URL parameter is required' if url.empty? + raise Html2rss::Web::BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url) + unless UrlValidator.url_allowed?(account, url) + raise Html2rss::Web::ForbiddenError, 'URL not allowed for this account' + end + + url + end + + # @param raw_strategy [String, nil] + # @return [String] + def normalize_strategy(raw_strategy) + strategy = raw_strategy.to_s.strip + strategy = default_strategy if strategy.empty? + + raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy) + + strategy + end + + # @return [Array] supported strategy identifiers. + def supported_strategies + Html2rss::RequestService.strategy_names.map(&:to_s) + end + + # @param strategy [String] + # @return [Boolean] + def supported_strategy?(strategy) + supported_strategies.include?(strategy) + end + + # @return [String] default strategy identifier. + def default_strategy + Html2rss::RequestService.default_strategy_name.to_s + end + + # @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata] + # @return [Hash{Symbol=>Object}] + def feed_attributes(feed_data) + timestamp = Time.now.iso8601 + typed_feed = feed_metadata(feed_data) + typed_feed_attributes(typed_feed, timestamp).slice(*FEED_ATTRIBUTE_KEYS) + end + + # @param request [Rack::Request] + # @return [Hash] + def request_params(request) + return request.params unless json_request?(request) + + raw_body = request.body.read + request.body.rewind + return request.params if raw_body.strip.empty? + + parsed = JSON.parse(raw_body) + raise Html2rss::Web::BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash) + + request.params.merge(parsed) + rescue JSON::ParserError + raise Html2rss::Web::BadRequestError, 'Invalid JSON payload' + end + + # @param request [Rack::Request] + # @return [Boolean] + def json_request?(request) + content_type = request.env['CONTENT_TYPE'].to_s + content_type.include?('application/json') + end + + # @param request [Rack::Request] + # @return [Array<(Html2rss::Web::Api::V1::FeedMetadata::CreateParams, Object)>] + def build_feed_from_request(request) + account = require_account(request) + ensure_auto_source_enabled! + params = build_create_params(request_params(request), account) + + feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy) + raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data + + [params, feed_data] + end + + # @param params [Html2rss::Web::Api::V1::FeedMetadata::CreateParams] + # @return [void] + def emit_create_success(params) + Observability.emit( + event_name: 'feed.create', + outcome: 'success', + details: { strategy: params.strategy, url: params.url }, + level: :info + ) + end + + # @param error [StandardError] + # @return [void] + def emit_create_failure(error) + Observability.emit( + event_name: 'feed.create', + outcome: 'failure', + details: { error_class: error.class.name, error_message: error.message }, + level: :warn + ) + end + + # @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata] + # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata] + def feed_metadata(feed_data) + return feed_data if feed_data.is_a?(FeedMetadata::Metadata) + + FeedMetadata::Metadata.new(**feed_data) + end + + # @param typed_feed [Html2rss::Web::Api::V1::FeedMetadata::Metadata] + # @param timestamp [String] + # @return [Hash{Symbol=>Object}] + def typed_feed_attributes(typed_feed, timestamp) + typed_feed.to_h.merge(created_at: timestamp, updated_at: timestamp) + end + end + end + end + end + end +end diff --git a/app/web/api/v1/feed_metadata.rb b/app/web/api/v1/feed_metadata.rb new file mode 100644 index 00000000..dde42b33 --- /dev/null +++ b/app/web/api/v1/feed_metadata.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'html2rss/url' + +module Html2rss + module Web + module Api + module V1 + ## + # Immutable contracts for feed creation and API serialization. + module FeedMetadata + class << self + # @param url [String] + # @return [String, nil] + def site_title_for(url) + Html2rss::Url.for_channel(url).channel_titleized + rescue StandardError + nil + end + + # @param attributes [Hash{Symbol=>Object}] + # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata] + def build(attributes) + Metadata.new(**metadata_attributes(attributes)) + end + + private + + # @param attributes [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def metadata_attributes(attributes) + { + id: stable_id(attributes[:username], attributes[:url], attributes[:identity_token]), + name: attributes[:name], + url: attributes[:url], + username: attributes[:username], + strategy: attributes[:strategy], + feed_token: attributes[:feed_token], + public_url: public_url(attributes[:feed_token]), + json_public_url: json_public_url(attributes[:feed_token]) + } + end + + # @param username [String] + # @param url [String] + # @param token [String] + # @return [String] + def stable_id(username, url, token) + Digest::SHA256.hexdigest("#{username}:#{url}:#{token}")[0..15] + end + + # @param feed_token [String] + # @return [String] + def public_url(feed_token) + "/api/v1/feeds/#{feed_token}" + end + + # @param feed_token [String] + # @return [String] + def json_public_url(feed_token) + "#{public_url(feed_token)}.json" + end + end + + ## + # Feed create parameters contract. + CreateParams = Data.define(:url, :name, :strategy) do + # @return [Hash{Symbol=>Object}] + def to_h + { url: url, name: name, strategy: strategy } + end + end + + ## + # Feed metadata contract used between creation services and API responses. + Metadata = Data.define(:id, :name, :url, :username, :strategy, :feed_token, :public_url, :json_public_url) do + # @return [Hash{Symbol=>Object}] + def to_h + { + id: id, + name: name, + url: url, + username: username, + strategy: strategy, + feed_token: feed_token, + public_url: public_url, + json_public_url: json_public_url + } + end + end + end + end + end + end +end diff --git a/app/web/api/v1/health.rb b/app/web/api/v1/health.rb new file mode 100644 index 00000000..ff7a325b --- /dev/null +++ b/app/web/api/v1/health.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'time' + +module Html2rss + module Web + module Api + module V1 + ## + # Health endpoints for API v1. + # + # Keeps checks intentionally shallow (auth + config readability) so + # probes stay fast and deterministic. + module Health + class << self + # @param request [Rack::Request] + # @return [Hash{Symbol=>Object}] authenticated health payload. + def show(request) + authorize_health_check!(request) + verify_configuration! + + health_response + end + + # @param _request [Rack::Request] + # @return [Hash{Symbol=>Object}] readiness payload. + def ready(_request) + verify_configuration! + health_response + end + + # @param _request [Rack::Request] + # @return [Hash{Symbol=>Object}] liveness payload. + def live(_request) + Response.success(data: { health: { status: 'alive', timestamp: Time.now.iso8601 } }) + end + + private + + # @return [Hash{Symbol=>Object}] + def health_response + Response.success(data: { health: health_payload }) + end + + # @return [Hash{Symbol=>Object}] + # @option return [String] :status health status text. + # @option return [String] :timestamp ISO8601 timestamp. + # @option return [String] :environment rack environment. + # @option return [Float] :uptime process uptime seconds. + # @option return [Hash] :checks reserved health checks map. + def health_payload + { + status: 'healthy', + timestamp: Time.now.iso8601, + environment: ENV.fetch('RACK_ENV', 'development'), + uptime: Process.clock_gettime(Process::CLOCK_MONOTONIC), + checks: {} + } + end + + # @param request [Rack::Request] + # @return [void] + def authorize_health_check!(request) + return if env_health_check_token?(request) + + account = Auth.authenticate(request) + return if account && account[:username] == 'health-check' + + raise Html2rss::Web::UnauthorizedError, 'Health check authentication required' + end + + # @param request [Rack::Request] + # @return [Boolean] + def env_health_check_token?(request) + configured_token = ENV.fetch('HEALTH_CHECK_TOKEN', '').to_s + provided_token = bearer_token(request) + return false if configured_token.empty? || provided_token.nil? + return false unless configured_token.bytesize == provided_token.bytesize + + Rack::Utils.secure_compare(provided_token, configured_token) + end + + # @param request [Rack::Request] + # @return [String, nil] + def bearer_token(request) + auth_header = request.env['HTTP_AUTHORIZATION'] + return unless auth_header&.start_with?('Bearer ') + + token = auth_header.delete_prefix('Bearer ') + return if token.empty? + + token + end + + # @return [void] + def verify_configuration! + LocalConfig.yaml + rescue StandardError + raise Html2rss::Web::InternalServerError, Contract::MESSAGES[:health_check_failed] + end + end + end + end + end + end +end diff --git a/app/web/api/v1/response.rb b/app/web/api/v1/response.rb new file mode 100644 index 00000000..ed459e3f --- /dev/null +++ b/app/web/api/v1/response.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Api + module V1 + ## + # Shared response builders for API v1. + # + # A single helper keeps success payload shape stable across endpoints. + module Response + class << self + # Builds a success payload and optionally mutates Rack response. + # + # @param data [Hash{Symbol=>Object}] endpoint-specific payload. + # @param meta [Hash{Symbol=>Object}, nil] optional metadata. + # @param response [Hash, nil] mutable Rack response. + # @param status [Integer] HTTP status to set when response is present. + # @return [Hash{Symbol=>Object}] normalized API success body. + # @option return [Boolean] :success always true for this helper. + # @option return [Hash] :data endpoint payload. + # @option return [Hash] :meta optional metadata block. + def success(data:, meta: nil, response: nil, status: 200) + response['Content-Type'] = 'application/json' if response + response.status = status if response + + payload = { success: true, data: data } + payload[:meta] = meta if meta + payload + end + end + end + end + end + end +end diff --git a/app/web/api/v1/root_metadata.rb b/app/web/api/v1/root_metadata.rb new file mode 100644 index 00000000..b1c862b5 --- /dev/null +++ b/app/web/api/v1/root_metadata.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Api + module V1 + ## + # Builds the public metadata payload for the API root endpoint. + module RootMetadata + class << self + # @param router [Roda::RodaRequest] + # @return [Hash{Symbol=>Object}] + def build(router) + { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: "#{router.base_url}/api/v1/openapi.yaml" + }, + instance: instance_payload(router) + } + end + + private + + # @param _router [Roda::RodaRequest] + # @return [Hash{Symbol=>Object}] + def instance_payload(_router) + { + feed_creation: { + enabled: AutoSource.enabled?, + access_token_required: AutoSource.enabled? + } + } + end + end + end + end + end + end +end diff --git a/app/web/api/v1/strategies.rb b/app/web/api/v1/strategies.rb new file mode 100644 index 00000000..12582b08 --- /dev/null +++ b/app/web/api/v1/strategies.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Api + module V1 + ## + # Strategy metadata endpoints for API v1. + # + # Exposes only lightweight strategy metadata so clients can render + # choices without coupling to backend strategy internals. + module Strategies + class << self + # @param _request [Rack::Request] + # @return [Hash{Symbol=>Object}] response with strategy list. + # @option return [Hash] :data strategies payload. + # @option return [Array] :strategies available strategy metadata. + # @option return [Hash] :meta list metadata. + # @option return [Integer] :total number of strategies. + def index(_request) + strategies = Html2rss::RequestService.strategy_names.map do |name| + { + id: name.to_s, + name: name.to_s, + display_name: display_name_for(name) + } + end + + Response.success(data: { strategies: strategies }, meta: { total: strategies.count }) + end + + private + + def display_name_for(name) + case name.to_s + when 'ssrf_filter' then 'Standard (recommended)' + when 'browserless' then 'JavaScript pages' + else name.to_s.split('_').map(&:capitalize).join(' ') + end + end + end + end + end + end + end +end diff --git a/app/web/boot.rb b/app/web/boot.rb new file mode 100644 index 00000000..93cd1871 --- /dev/null +++ b/app/web/boot.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'zeitwerk' + +module Html2rss + module Web + ## + # Boot helpers for code loading and runtime setup. + module Boot + class << self + # @param reloadable [Boolean] + # @return [Zeitwerk::Loader] + def setup!(reloadable: false) + return loader if setup? + + loader.enable_reloading if reloadable + loader.setup + @setup = true # rubocop:disable ThreadSafety/ClassInstanceVariable + loader + end + + # @return [Zeitwerk::Loader] + def loader + @loader ||= build_loader # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @return [Boolean] + def setup? + # Loader setup happens once during process boot. + # rubocop:disable ThreadSafety/ClassInstanceVariable + @setup == true + # rubocop:enable ThreadSafety/ClassInstanceVariable + end + + # @return [void] + def eager_load! + loader.eager_load + end + + # @return [void] + def reload! + loader.reload + end + + private + + # @return [Zeitwerk::Loader] + def build_loader + Zeitwerk::Loader.new.tap do |new_loader| + configure_loader(new_loader) + end + end + + # @param new_loader [Zeitwerk::Loader] + # @return [void] + def configure_loader(new_loader) + new_loader.push_dir(app_root, namespace: Html2rss) + collapsed_web_dirs.each { |path| new_loader.collapse(path) } + new_loader.inflector.inflect('api_v1' => 'ApiV1') + end + + # @return [Array] + def collapsed_web_dirs + %w[config domain errors http rendering request security telemetry].map do |dir| + File.join(app_root, 'web', dir) + end + end + + ## + # Returns the application directory that maps to the Html2rss root + # namespace. + # + # @return [String] + def app_root + File.expand_path('..', __dir__) + end + end + end + end +end diff --git a/app/web/boot/development_reloader.rb b/app/web/boot/development_reloader.rb new file mode 100644 index 00000000..82e28a43 --- /dev/null +++ b/app/web/boot/development_reloader.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Boot + ## + # Development-only rack wrapper that reloads Zeitwerk-managed code when + # application files change. + class DevelopmentReloader + WATCH_GLOBS = [ + 'app/**/*.rb', + 'app.rb', + 'config/**/*.rb', + 'config/**/*.yml', + 'config.ru' + ].freeze + + # @param loader [Zeitwerk::Loader] + # @param app_provider [#call] + def initialize(loader:, app_provider:) + @loader = loader + @app_provider = app_provider + @latest_mtime = current_mtime + @reload_mutex = Mutex.new + end + + # @param env [Hash] + # @return [Array<(Integer, Hash, #each)>] + def call(env) + @reload_mutex.synchronize do + reload_if_needed + @app_provider.call.call(env) + end + end + + private + + # @return [void] + def reload_if_needed + mtime = current_mtime + return unless mtime && (!@latest_mtime || mtime > @latest_mtime) + + @loader.reload + reset_runtime_caches! + @latest_mtime = mtime + end + + # @return [Time, nil] + def current_mtime + watched_files.filter_map do |path| + next unless File.file?(path) + + File.mtime(path) + end.max + end + + # @return [Array] + def watched_files + WATCH_GLOBS.flat_map { |pattern| Dir[File.expand_path("../../#{pattern}", __dir__)] } + end + + # @return [void] + def reset_runtime_caches! + Html2rss::Web::LocalConfig.reload!(reason: 'code_reload') if defined?(Html2rss::Web::LocalConfig) + Html2rss::Web::AccountManager.reload!(reason: 'code_reload') if defined?(Html2rss::Web::AccountManager) + end + end + end + end +end diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb new file mode 100644 index 00000000..2c22f364 --- /dev/null +++ b/app/web/boot/setup.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Boot + ## + # Applies boot-time runtime configuration outside the Roda class body. + module Setup + class << self + # Validates environment configuration and wires the request service. + # + # @return [void] + def call! + validate_environment! + configure_request_service! + end + + private + + # @return [void] + def validate_environment! + EnvironmentValidator.validate_environment! + EnvironmentValidator.validate_production_security! + Flags.validate! + end + + # @return [void] + def configure_request_service! + Html2rss::RequestService.register_strategy(:ssrf_filter, SsrfFilterStrategy) + Html2rss::RequestService.default_strategy_name = :ssrf_filter + Html2rss::RequestService.unregister_strategy(:faraday) + end + end + end + end + end +end diff --git a/app/web/config/config_snapshot.rb b/app/web/config/config_snapshot.rb new file mode 100644 index 00000000..0a8fc03c --- /dev/null +++ b/app/web/config/config_snapshot.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Typed immutable snapshot built from feeds YAML. + # + # This keeps parsing/validation in one place while letting runtime callers + # progressively migrate away from dynamic hash contracts. + module ConfigSnapshot + ## + # Immutable stylesheet entry model. + StylesheetEntry = Data.define(:href, :media, :type) + ## + # Immutable auth account model. + AuthAccount = Data.define(:username, :token, :allowed_urls) + ## + # Immutable feed config boundary model. + FeedConfig = Data.define(:name, :raw) + ## + # Immutable root snapshot model. + Snapshot = Data.define(:global, :feeds, :accounts) + + class << self + # @param yaml_hash [Hash{Symbol=>Object}] + # @return [Snapshot] + def load(yaml_hash) + raise ArgumentError, 'Configuration root must be a hash' unless yaml_hash.is_a?(Hash) + + feeds_hash = normalize_feeds(yaml_hash.fetch(:feeds, {})) + global_hash = yaml_hash.reject { |key| key == :feeds } + accounts = normalize_accounts(global_hash.dig(:auth, :accounts)) + normalized_global = normalized_global_hash(global_hash, accounts) + + Snapshot.new( + global: normalized_global.freeze, + feeds: feeds_hash.freeze, + accounts: accounts.freeze + ) + end + + private + + # @param raw_feeds [Hash, Object] + # @return [Hash{Symbol=>FeedConfig}] + def normalize_feeds(raw_feeds) + return {} unless raw_feeds.is_a?(Hash) + + raw_feeds.each_with_object({}) do |(name, config), memo| + memo[name.to_sym] = FeedConfig.new(name: name.to_sym, raw: deep_dup(config).freeze) + end + end + + # @param raw_accounts [Array, Object] + # @return [Array] + def normalize_accounts(raw_accounts) + Array(raw_accounts).map do |account| + account_hash = account.to_h.transform_keys(&:to_sym) + AuthAccount.new( + username: account_hash.fetch(:username).to_s, + token: account_hash.fetch(:token).to_s, + allowed_urls: Array(account_hash[:allowed_urls]).map(&:to_s).freeze + ) + end + end + + # @param global_hash [Hash{Symbol=>Object}] + # @param accounts [Array] + # @return [Hash{Symbol=>Object}] + def normalized_global_hash(global_hash, accounts) + normalized = deep_dup(global_hash) + return normalized unless normalized.key?(:auth) + + normalized[:auth] = normalized_auth_hash(normalized[:auth], accounts) + normalized + end + + # @param auth_hash [Hash, Object] + # @param accounts [Array] + # @return [Hash{Symbol=>Object}] + def normalized_auth_hash(auth_hash, accounts) + auth = auth_hash.to_h + auth[:accounts] = accounts.map do |account| + { username: account.username, token: account.token, allowed_urls: account.allowed_urls.dup } + end + auth + end + + # @param value [Object] + # @return [Object] + def deep_dup(value) + case value + when Hash + deep_dup_hash(value) + when Array + deep_dup_array(value) + when String + value.dup + else + value + end + end + + # @param value [Hash] + # @return [Hash] + def deep_dup_hash(value) + value.each_with_object({}) do |(key, val), memo| + memo[key.is_a?(String) ? key.dup : key] = deep_dup(val) + end + end + + # @param value [Array] + # @return [Array] + def deep_dup_array(value) + value.map { |element| deep_dup(element) } + end + end + end + end +end diff --git a/app/web/config/environment_validator.rb b/app/web/config/environment_validator.rb new file mode 100644 index 00000000..36f0b18e --- /dev/null +++ b/app/web/config/environment_validator.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Environment validation for html2rss-web + # Handles validation of environment variables and configuration + module EnvironmentValidator + class << self + ## + # Validate required environment variables on startup + # @return [void] + def validate_environment! + return if ENV['HTML2RSS_SECRET_KEY'] + + if non_production? + set_development_key + else + show_production_error + end + end + + ## + # Validate production security configuration + # @return [void] + def validate_production_security! + return if non_production? + + validate_secret_key! + validate_account_configuration! + end + + # @return [Boolean] + def development? = ENV['RACK_ENV'] == 'development' + # @return [Boolean] + def test? = ENV['RACK_ENV'] == 'test' + + # @return [Boolean] + def non_production? + development? || test? + end + + # @return [Boolean] + def auto_source_enabled? + Flags.auto_source_enabled? + end + + private + + def set_development_key + ENV['HTML2RSS_SECRET_KEY'] = 'development-default-key-not-for-production' + puts '⚠️ WARNING: Using default secret key for development/testing only!' + puts ' Set HTML2RSS_SECRET_KEY environment variable for production use.' + end + + def show_production_error + puts production_error_message + exit 1 + end + + def production_error_message + <<~ERROR + ❌ ERROR: HTML2RSS_SECRET_KEY environment variable is not set! + + This application is designed to be used via Docker Compose only. + Please read the project's README.md for setup instructions. + + To generate a secure secret key and start the application: + 1. Generate a secret key: openssl rand -hex 32 + 2. Edit docker-compose.yml and replace 'your-generated-secret-key-here' with your key + 3. Start with: docker-compose up + + For more information, see: https://github.com/html2rss/html2rss-web#configuration + ERROR + end + + def validate_secret_key! + secret = ENV.fetch('HTML2RSS_SECRET_KEY', nil) + return unless secret == 'your-generated-secret-key-here' || secret.length < 32 + + SecurityLogger.log_config_validation_failure('secret_key', 'Invalid or weak secret key') + puts '❌ CRITICAL: Invalid secret key for production deployment!' + puts ' Secret key must be at least 32 characters and not the default placeholder.' + puts ' Generate a secure key: openssl rand -hex 32' + exit 1 + end + + def validate_account_configuration! + accounts = AccountManager.accounts + weak_tokens = accounts.select { |acc| acc[:token].length < 16 } + return unless weak_tokens.any? + + weak_usernames = weak_tokens.map { |acc| acc[:username] }.join(', ') + SecurityLogger.log_config_validation_failure('account_tokens', "Weak tokens for users: #{weak_usernames}") + puts '❌ CRITICAL: Weak authentication tokens detected in production!' + puts ' All tokens must be at least 16 characters long.' + puts " Weak tokens found for users: #{weak_usernames}" + exit 1 + end + end + end + end +end diff --git a/app/web/config/flags.rb b/app/web/config/flags.rb new file mode 100644 index 00000000..97f7cbac --- /dev/null +++ b/app/web/config/flags.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Typed feature flag registry and runtime access. + module Flags + # @!attribute [r] name + # @return [Symbol] + # @!attribute [r] env_key + # @return [String] + # @!attribute [r] type + # @return [Symbol] + # @!attribute [r] default + # @return [Object, Proc] + # @!attribute [r] validator + # @return [Proc, nil] + Definition = Data.define(:name, :env_key, :type, :default, :validator) + DEFINITIONS = { + auto_source_enabled: Definition.new( + name: :auto_source_enabled, + env_key: 'AUTO_SOURCE_ENABLED', + type: :boolean, + default: -> { development_or_test? }, + validator: nil + ), + async_feed_refresh_enabled: Definition.new( + name: :async_feed_refresh_enabled, + env_key: 'ASYNC_FEED_REFRESH_ENABLED', + type: :boolean, + default: false, + validator: nil + ), + async_feed_refresh_stale_factor: Definition.new( + name: :async_feed_refresh_stale_factor, + env_key: 'ASYNC_FEED_REFRESH_STALE_FACTOR', + type: :integer, + default: 3, + validator: ->(value) { value >= 1 } + ) + }.freeze + MANAGED_ENV_PREFIXES = %w[AUTO_SOURCE_ ASYNC_FEED_REFRESH_].freeze + + class << self + # @return [Boolean] + def auto_source_enabled? + fetch(:auto_source_enabled) + end + + # @return [Boolean] + def async_feed_refresh_enabled? + fetch(:async_feed_refresh_enabled) + end + + # @return [Integer] + def async_feed_refresh_stale_factor + fetch(:async_feed_refresh_stale_factor) + end + + # Validates all known flags and managed env key prefixes. + # + # @return [void] + def validate! + validate_unknown_feature_keys! + DEFINITIONS.each_key { |name| fetch(name) } + nil + end + + private + + # @param name [Symbol] + # @return [Object] + def fetch(name) + definition = DEFINITIONS.fetch(name) { raise ArgumentError, "Unknown flag '#{name}'" } + parse_definition(definition) + end + + # @param definition [Definition] + # @return [Object] + def parse_definition(definition) + raw = ENV.fetch(definition.env_key, nil) + value = raw.nil? ? resolve_default(definition.default) : parse_value(definition, raw) + validate_value!(definition, value) + value + end + + # @param default_value [Object, Proc] + # @return [Object] + def resolve_default(default_value) + default_value.respond_to?(:call) ? default_value.call : default_value + end + + # @param definition [Definition] + # @param raw [String] + # @return [Object] + def parse_value(definition, raw) + return parse_boolean(definition, raw) if definition.type == :boolean + return parse_integer(definition, raw) if definition.type == :integer + + raise ArgumentError, "Unknown flag type '#{definition.type}' for '#{definition.name}'" + end + + # @param definition [Definition] + # @param raw [String] + # @return [Boolean] + def parse_boolean(definition, raw) + normalized = raw.to_s.strip.downcase + return true if normalized == 'true' + return false if normalized == 'false' + + raise ArgumentError, "Malformed flag '#{definition.env_key}': expected true/false, got '#{raw}'" + end + + # @param definition [Definition] + # @param raw [String] + # @return [Integer] + def parse_integer(definition, raw) + Integer(raw, 10) + rescue ArgumentError + raise ArgumentError, "Malformed flag '#{definition.env_key}': expected integer, got '#{raw}'" + end + + # @param definition [Definition] + # @param value [Object] + # @return [void] + def validate_value!(definition, value) + return unless definition.validator + return if definition.validator.call(value) + + raise ArgumentError, "Malformed flag '#{definition.env_key}': value '#{value}' failed constraints" + end + + # @return [void] + def validate_unknown_feature_keys! + known = DEFINITIONS.values.map(&:env_key) + unknown = ENV.keys.select do |key| + MANAGED_ENV_PREFIXES.any? { |prefix| key.start_with?(prefix) } && !known.include?(key) + end + return if unknown.empty? + + raise ArgumentError, "Unknown feature flags: #{unknown.sort.join(', ')}" + end + + # @return [Boolean] + def development_or_test? + env = ENV.fetch('RACK_ENV', 'development') + %w[development test].include?(env) + end + end + end + end +end diff --git a/app/web/config/local_config.rb b/app/web/config/local_config.rb new file mode 100644 index 00000000..4b937d6c --- /dev/null +++ b/app/web/config/local_config.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'yaml' + +module Html2rss + module Web + ## + # Loads and normalizes feed configuration from disk. + # + # Keeping lookup/defaulting here gives the rest of the app one predictable + # config shape instead of repeating file parsing and fallback logic. + module LocalConfig + ## + # raised when the local config wasn't found + class NotFound < RuntimeError; end + ## + # raised when the local config shape is invalid + class InvalidConfig < RuntimeError; end + FEED_EXTENSION_PATTERN = /\.(json|rss|xml)\z/ + + # Path to local feed configuration file. + CONFIG_FILE = 'config/feeds.yml' + + class << self + ## + # @param name [String, Symbol, #to_sym] + # @return [Hash] + def find(name) + normalized_name = normalize_name(name) + config = snapshot.feeds.fetch(normalized_name.to_sym) do + raise NotFound, "Did not find local feed config at '#{normalized_name}'" + end + config_hash = deep_dup(config.raw) + + apply_global_defaults(config_hash) + end + + ## + # @return [Hash] + def feeds + snapshot.feeds.transform_values { |feed| deep_dup(feed.raw) } + end + + ## + # @return [Hash] + def global + deep_dup(snapshot.global) + end + + ## + # @return [Hash] + def yaml + YAML.safe_load_file(CONFIG_FILE, symbolize_names: true).freeze + rescue Errno::ENOENT => error + raise NotFound, "Configuration file not found: #{error.message}" + end + + ## + # @return [Html2rss::Web::ConfigSnapshot::Snapshot] + def snapshot + return @snapshot if @snapshot # rubocop:disable ThreadSafety/ClassInstanceVariable + + @snapshot = ConfigSnapshot.load(yaml) # rubocop:disable ThreadSafety/ClassInstanceVariable + rescue KeyError, TypeError, ArgumentError => error + raise InvalidConfig, "Invalid local config: #{error.message}" + end + + ## + # @param reason [String] + # @return [nil] + def reload!(reason: 'manual') + @snapshot = nil # rubocop:disable ThreadSafety/ClassInstanceVariable + SecurityLogger.log_cache_lifecycle('local_config', 'reload', reason: reason) + nil + end + + private + + # Applies global defaults only when feed-level keys are absent. + # + # @param config [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def apply_global_defaults(config) + global_config = global + + config[:stylesheets] ||= deep_dup(global_config[:stylesheets]) if global_config[:stylesheets] + config[:headers] ||= deep_dup(global_config[:headers]) if global_config[:headers] + + config + end + + # @param name [String, Symbol, #to_s] + # @return [String] basename without extension for feed lookup. + def normalize_name(name) + File.basename(name.to_s).sub(FEED_EXTENSION_PATTERN, '') + end + + # Deep-duplicates nested config structures to avoid mutating shared data. + # + # @param value [Object] + # @return [Object] + def deep_dup(value) + case value + when Hash + value.transform_values { |val| deep_dup(val) } + when Array + value.map { |element| deep_dup(element) } + else + value + end + end + end + end + end +end diff --git a/app/web/domain/auto_source.rb b/app/web/domain/auto_source.rb new file mode 100644 index 00000000..4b1d09c7 --- /dev/null +++ b/app/web/domain/auto_source.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Stateless helpers for auto-source feed creation and rendering. + # + # Responsibilities stay small: validate access, create stable identifiers, + # and delegate actual scraping/rendering to feed services. + module AutoSource + class << self + # @return [Boolean] + def enabled? + EnvironmentValidator.auto_source_enabled? + end + + # Builds stable feed metadata for an authenticated account. + # + # @param name [String, nil] + # @param url [String] + # @param token_data [Hash{Symbol=>Object}] authenticated account data. + # @param strategy [String] + # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata, nil] + def create_stable_feed(name, url, token_data, strategy = 'ssrf_filter') + return nil unless token_data && FeedAccess.url_allowed_for_username?(token_data[:username], url) + + feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy) + return nil unless feed_token + + Api::V1::FeedMetadata.build(metadata_attributes(name, url, token_data, strategy, feed_token)) + end + + private + + # @param name [String, nil] + # @param url [String] + # @param token_data [Hash{Symbol=>Object}] + # @param strategy [String] + # @param feed_token [String] + # @return [Hash{Symbol=>Object}] + def metadata_attributes(name, url, token_data, strategy, feed_token) + { + name: name, + url: url, + username: token_data[:username], + strategy: strategy, + feed_token: feed_token, + identity_token: token_data[:token] + } + end + end + end + end +end diff --git a/app/web/domain/cache_ttl.rb b/app/web/domain/cache_ttl.rb new file mode 100644 index 00000000..9ee793d5 --- /dev/null +++ b/app/web/domain/cache_ttl.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Utility for normalizing cache TTL values. + module CacheTtl + DEFAULT_SECONDS = 3600 + + class << self + # Converts feed-provided minutes to seconds with a safe default fallback. + # + # @param value [Object] TTL in minutes-like form. + # @param default [Integer] seconds used when value is missing/invalid. + # @return [Integer] positive cache TTL in seconds. + def seconds_from_minutes(value, default: DEFAULT_SECONDS) + minutes = value.to_i + return default unless minutes.positive? + + minutes * 60 + end + end + end + end +end diff --git a/app/web/errors/bad_request_error.rb b/app/web/errors/bad_request_error.rb new file mode 100644 index 00000000..96108458 --- /dev/null +++ b/app/web/errors/bad_request_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 400 error used for invalid client input. + class BadRequestError < HttpError + DEFAULT_MESSAGE = 'Bad Request' + STATUS = 400 + CODE = 'BAD_REQUEST' + end + end +end diff --git a/app/web/errors/error_responder.rb b/app/web/errors/error_responder.rb new file mode 100644 index 00000000..4deefe59 --- /dev/null +++ b/app/web/errors/error_responder.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Centralized error rendering for API and XML endpoints. + # + # Keeping this mapping in one place ensures consistent status codes and + # content types without duplicating rescue behavior in routes. + module ErrorResponder + API_ROOT_PATH = '/api/v1' + INTERNAL_ERROR_CODE = Api::V1::Contract::CODES[:internal_server_error] + + class << self + # @param request [Rack::Request] + # @param response [Rack::Response] + # @param error [StandardError] + # @return [String] serialized JSON or XML error body. + def respond(request:, response:, error:) + error_code = resolve_error_code(error) + response.status = resolve_status(error) + emit_error_event(error, error_code, response.status) + write_internal_error_log(request, error) + + client_message = client_message_for(error) + + return render_feed_error(request, response, client_message) if RequestTarget.feed?(request) + return render_api_error(response, client_message, error_code) if api_request?(request) + + render_xml_error(response, client_message) + end + + private + + # @param request [Rack::Request] + # @return [Boolean] + def api_request?(request) + RequestTarget.api?(request) || api_path?(request) + end + + # @param request [Rack::Request] + # @return [Boolean] + def api_path?(request) + path = request.path.to_s + path == API_ROOT_PATH || path.start_with?("#{API_ROOT_PATH}/") + end + + # @param response [Rack::Response] + # @param message [String] + # @param code [String] + # @return [String] JSON error payload. + def render_api_error(response, message, code) + response['Content-Type'] = 'application/json' + JSON.generate({ success: false, error: { message: message, code: code } }) + end + + # @param response [Rack::Response] + # @param message [String] + # @return [String] negotiated feed error payload. + def render_feed_error(request, response, message) + format = FeedResponseFormat.for_request(request) + response['Content-Type'] = FeedResponseFormat.content_type(format) + return JsonFeedBuilder.build_error_feed(message: message) if format == FeedResponseFormat::JSON_FEED + + XmlBuilder.build_error_feed(message: message) + end + + # @param response [Rack::Response] + # @param message [String] + # @return [String] XML error feed. + def render_xml_error(response, message) + response['Content-Type'] = 'application/xml' + XmlBuilder.build_error_feed(message: message) + end + + # @param request [Rack::Request] + # @param error [StandardError] + # @return [String] + def error_log_line(request, error) + request_id_header = request.respond_to?(:get_header) ? request.get_header('HTTP_X_REQUEST_ID') : nil + context = request.env['html2rss.request_context'] + request_id = request_id_header || context&.request_id + return error.message unless request_id + + "[request_id=#{request_id}] #{error.message}" + end + + # @param error [StandardError] + # @return [String] + def resolve_error_code(error) + error.respond_to?(:code) ? error.code : INTERNAL_ERROR_CODE + end + + # @param error [StandardError] + # @return [Integer] + def resolve_status(error) + error.respond_to?(:status) ? error.status : 500 + end + + # @param error [StandardError] + # @return [String] + def client_message_for(error) + error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE + end + + # @param request [Rack::Request] + # @param error [StandardError] + # @return [void] + def write_internal_error_log(request, error) + return if error.is_a?(Html2rss::Web::HttpError) + + request.env['rack.errors']&.puts(error_log_line(request, error)) + end + + # @param error [StandardError] + # @param error_code [String] + # @param status [Integer] + # @return [void] + def emit_error_event(error, error_code, status) + Observability.emit( + event_name: 'request.error', + outcome: 'failure', + details: { error_class: error.class.name, error_code: error_code, status: status }, + level: :error + ) + end + end + end + end +end diff --git a/app/web/errors/forbidden_error.rb b/app/web/errors/forbidden_error.rb new file mode 100644 index 00000000..9a65712f --- /dev/null +++ b/app/web/errors/forbidden_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 403 error used when access is denied. + class ForbiddenError < HttpError + DEFAULT_MESSAGE = 'Forbidden' + STATUS = 403 + CODE = 'FORBIDDEN' + end + end +end diff --git a/app/web/errors/http_error.rb b/app/web/errors/http_error.rb new file mode 100644 index 00000000..cad61a20 --- /dev/null +++ b/app/web/errors/http_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Base error type mapped to an HTTP status and API error code. + class HttpError < StandardError + DEFAULT_MESSAGE = 'Internal Server Error' + STATUS = 500 + CODE = 'INTERNAL_SERVER_ERROR' + + # @param message [String] + # @return [void] + def initialize(message = self.class::DEFAULT_MESSAGE) + super + end + + # @return [Integer] + def status + self.class::STATUS + end + + # @return [String] + def code + self.class::CODE + end + end + end +end diff --git a/app/web/errors/internal_server_error.rb b/app/web/errors/internal_server_error.rb new file mode 100644 index 00000000..969a3e82 --- /dev/null +++ b/app/web/errors/internal_server_error.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 500 error used for unexpected internal failures. + class InternalServerError < HttpError + end + end +end diff --git a/app/web/errors/method_not_allowed_error.rb b/app/web/errors/method_not_allowed_error.rb new file mode 100644 index 00000000..0ab5e2b3 --- /dev/null +++ b/app/web/errors/method_not_allowed_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 405 error used when the route does not support the request verb. + class MethodNotAllowedError < HttpError + DEFAULT_MESSAGE = 'Method Not Allowed' + STATUS = 405 + CODE = 'METHOD_NOT_ALLOWED' + end + end +end diff --git a/app/web/errors/not_found_error.rb b/app/web/errors/not_found_error.rb new file mode 100644 index 00000000..a2d08939 --- /dev/null +++ b/app/web/errors/not_found_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 404 error used when a resource cannot be found. + class NotFoundError < HttpError + DEFAULT_MESSAGE = 'Not Found' + STATUS = 404 + CODE = 'NOT_FOUND' + end + end +end diff --git a/app/web/errors/unauthorized_error.rb b/app/web/errors/unauthorized_error.rb new file mode 100644 index 00000000..4435299a --- /dev/null +++ b/app/web/errors/unauthorized_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 401 error used when authentication is required. + class UnauthorizedError < HttpError + DEFAULT_MESSAGE = 'Authentication required' + STATUS = 401 + CODE = 'UNAUTHORIZED' + end + end +end diff --git a/app/web/feeds/cache.rb b/app/web/feeds/cache.rb new file mode 100644 index 00000000..3f8f8a16 --- /dev/null +++ b/app/web/feeds/cache.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'digest' +require 'time' + +module Html2rss + module Web + module Feeds + ## + # Small synchronous cache for canonical feed results. + module Cache + Entry = Data.define(:result, :expires_at) + + class << self + # @param key [String] + # @param ttl_seconds [Integer] + # @param cacheable [Boolean, Proc] + # @yieldreturn [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [Html2rss::Web::Feeds::Contracts::RenderResult] + def fetch(key, ttl_seconds:, cacheable: true) + lock.synchronize do + entry = read_entry(key) + return entry.result if fresh?(entry) + + result = yield + return result unless cacheable_result?(cacheable, result) + + write_entry(key, ttl_seconds, result) + result + end + end + + # @param reason [String] + # @return [nil] + def clear!(reason: 'manual') + lock.synchronize do + @entries = {} + SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason) + end + nil + end + + private + + # @param key [String] + # @return [Entry, nil] + def read_entry(key) + entries[key] + end + + # @param entry [Entry, nil] + # @return [Boolean] + def fresh?(entry) + entry && Time.now.utc < entry.expires_at + end + + # @param key [String] + # @param ttl_seconds [Integer] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [void] + def write_entry(key, ttl_seconds, result) + entries[key] = Entry.new(result: result, expires_at: Time.now.utc + normalize_ttl(ttl_seconds)) + SecurityLogger.log_cache_lifecycle('feeds_cache', 'write', key_hash: key_hash(key)) + end + + # @param cacheable [Boolean, Proc] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [Boolean] + def cacheable_result?(cacheable, result) + return cacheable.call(result) if cacheable.respond_to?(:call) + + cacheable + end + + # @return [Hash{String=>Entry}] + def entries + @entries ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @return [Mutex] + def lock + @lock ||= Mutex.new # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @param ttl_seconds [Integer] + # @return [Integer] + def normalize_ttl(ttl_seconds) + ttl_seconds.to_i.positive? ? ttl_seconds.to_i : CacheTtl::DEFAULT_SECONDS + end + + # @param key [String] + # @return [String] + def key_hash(key) + Digest::SHA256.hexdigest(key)[0..11] + end + end + end + end + end +end diff --git a/app/web/feeds/contracts.rb b/app/web/feeds/contracts.rb new file mode 100644 index 00000000..29aa24cd --- /dev/null +++ b/app/web/feeds/contracts.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Immutable contracts used across feed request resolution, generation, and rendering. + module Contracts + ## + # Request-edge contract for feed rendering. + Request = Data.define(:target_kind, :representation, :feed_name, :token, :params) + + ## + # Normalized source inputs for shared feed generation. + ResolvedSource = Data.define(:source_kind, :cache_identity, :generator_input, :ttl_seconds) + + ## + # Normalized feed payload consumed by renderers and HTTP responders. + RenderPayload = Data.define(:feed, :site_title, :url, :strategy) + + ## + # Shared feed-serving result wrapper. + RenderResult = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key, :error_message) + end + end + end +end diff --git a/app/web/feeds/json_renderer.rb b/app/web/feeds/json_renderer.rb new file mode 100644 index 00000000..e1fe0c82 --- /dev/null +++ b/app/web/feeds/json_renderer.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'json' +require 'time' + +module Html2rss + module Web + module Feeds + ## + # Renders JSON Feed output from shared feed results. + module JsonRenderer + VERSION = 'https://jsonfeed.org/version/1.1' + + class << self + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def call(result) + case result.status + when :ok + JSON.generate(payload_for(result.payload.feed)) + when :empty + empty_feed(result) + else + error_feed(result) + end + end + + private + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def empty_feed(result) + JsonFeedBuilder.build_empty_feed_warning( + url: result.payload.url, + strategy: result.payload.strategy, + site_title: result.payload.site_title + ) + end + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def error_feed(result) + JsonFeedBuilder.build_error_feed(message: result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE) + end + + # @param feed [RSS::Rss] + # @return [Hash{Symbol=>Object}] + def payload_for(feed) + { + version: VERSION, + title: feed.channel.title, + home_page_url: feed.channel.link, + description: feed.channel.description, + items: feed.items.map { |item| item_payload(item) } + }.compact + end + + # @param item [Object] + # @return [Hash{Symbol=>Object}] + def item_payload(item) + { + id: item.respond_to?(:guid) && item.guid ? item.guid.content : (item.link || item.title), + url: item.link, + title: item.title, + content_text: item.description, + date_published: published_at(item) + }.compact + end + + # @param item [Object] + # @return [String, nil] + def published_at(item) + value = item.respond_to?(:pubDate) ? item.pubDate : nil + return value.iso8601 if value.respond_to?(:iso8601) + + Time.parse(value.to_s).utc.iso8601 if value + rescue ArgumentError + nil + end + end + end + end + end +end diff --git a/app/web/feeds/request.rb b/app/web/feeds/request.rb new file mode 100644 index 00000000..9519fe68 --- /dev/null +++ b/app/web/feeds/request.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'cgi' +module Html2rss + module Web + module Feeds + ## + # Builds normalized feed requests from route input. + module Request + class << self + # @param request [Rack::Request] + # @param target_kind [Symbol] + # @param identifier [String] + # @return [Html2rss::Web::Feeds::Contracts::Request] + def call(request:, target_kind:, identifier:) + build_request( + request: request, + target_kind: target_kind, + identifier: normalize_identifier(target_kind, FeedResponseFormat.strip_known_extension(identifier)) + ) + end + + private + + # @param request [Rack::Request] + # @param target_kind [Symbol] + # @param identifier [String] + # @return [Html2rss::Web::Feeds::Contracts::Request] + def build_request(request:, target_kind:, identifier:) + Contracts::Request.new( + target_kind: target_kind, + representation: FeedResponseFormat.for_request(request), + feed_name: target_kind == :static ? identifier : nil, + token: target_kind == :token ? identifier : nil, + params: request.params.to_h + ) + end + + # @param target_kind [Symbol] + # @param identifier [String] + # @return [String] + def normalize_identifier(target_kind, identifier) + return identifier unless target_kind == :token + + CGI.unescape(identifier) + end + end + end + end + end +end diff --git a/app/web/feeds/responder.rb b/app/web/feeds/responder.rb new file mode 100644 index 00000000..d7c1522f --- /dev/null +++ b/app/web/feeds/responder.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Resolves, renders, and writes feed responses for both token and legacy routes. + module Responder + class << self + # @param request [Rack::Request] + # @param target_kind [Symbol] + # @param identifier [String] + # @return [String] serialized feed body. + def call(request:, target_kind:, identifier:) + feed_request = Request.call(request:, target_kind:, identifier:) + resolved_source = SourceResolver.call(feed_request) + result = Service.call(resolved_source) + normalized_identifier = feed_request.feed_name || identifier + body = write_response(response: request.response, representation: feed_request.representation, result:) + + emit_result(target_kind:, identifier: normalized_identifier, resolved_source:, result:) + body + rescue StandardError => error + emit_failure(target_kind:, identifier:, error:) + raise + end + + private + + # @param response [Rack::Response] + # @param representation [Symbol] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def write_response(response:, representation:, result:) + response.status = result.status == :error ? 500 : 200 + response['Content-Type'] = FeedResponseFormat.content_type(representation) + apply_cache_headers(response, result) + ::Html2rss::Web::HttpCache.vary(response, 'Accept') + render_result(result, representation) + end + + # @param response [Rack::Response] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [void] + def apply_cache_headers(response, result) + return ::Html2rss::Web::HttpCache.expires_now(response) if result.status == :error + + ::Html2rss::Web::HttpCache.expires(response, result.ttl_seconds, cache_control: 'public') + end + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param representation [Symbol] + # @return [String] + def render_result(result, representation) + return JsonRenderer.call(result) if representation == FeedResponseFormat::JSON_FEED + + RssRenderer.call(result) + end + + # @param target_kind [Symbol] + # @param identifier [String] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [void] + def emit_result(target_kind:, identifier:, resolved_source:, result:) + return emit_success(target_kind:, identifier:, resolved_source:) unless result.status == :error + + emit_failure( + target_kind:, + identifier:, + error: Html2rss::Web::InternalServerError.new( + result.error_message || result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE + ) + ) + end + + # @param target_kind [Symbol] + # @param identifier [String] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @return [void] + def emit_success(target_kind:, identifier:, resolved_source:) + details = { + strategy: resolved_source.generator_input[:strategy], + url: resolved_source.generator_input.dig(:channel, :url) + } + details[:feed_name] = identifier if target_kind == :static + + Observability.emit(event_name: 'feed.render', outcome: 'success', details:, level: :info) + end + + # @param target_kind [Symbol] + # @param identifier [String] + # @param error [StandardError] + # @return [void] + def emit_failure(target_kind:, identifier:, error:) + details = { error_class: error.class.name, error_message: error.message } + details[:feed_name] = identifier if target_kind == :static + + Observability.emit(event_name: 'feed.render', outcome: 'failure', details:, level: :warn) + end + end + end + end + end +end diff --git a/app/web/feeds/rss_renderer.rb b/app/web/feeds/rss_renderer.rb new file mode 100644 index 00000000..6313269d --- /dev/null +++ b/app/web/feeds/rss_renderer.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Renders RSS bodies from shared feed results. + module RssRenderer + class << self + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def call(result) + case result.status + when :ok + result.payload.feed.to_s + when :empty + empty_feed(result) + else + error_feed(result) + end + end + + private + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def empty_feed(result) + XmlBuilder.build_empty_feed_warning( + url: result.payload.url, + strategy: result.payload.strategy, + site_title: result.payload.site_title + ) + end + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [String] + def error_feed(result) + XmlBuilder.build_error_feed(message: result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE) + end + end + end + end + end +end diff --git a/app/web/feeds/service.rb b/app/web/feeds/service.rb new file mode 100644 index 00000000..f7bee826 --- /dev/null +++ b/app/web/feeds/service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Shared synchronous feed service around the html2rss gem. + module Service + class << self + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @return [Html2rss::Web::Feeds::Contracts::RenderResult] + def call(resolved_source) + cache_key = "feed_result:#{resolved_source.cache_identity}" + + Cache.fetch( + cache_key, + ttl_seconds: resolved_source.ttl_seconds, + cacheable: ->(result) { result.status != :error } + ) do + build_result(resolved_source, cache_key) + end + end + + private + + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Contracts::RenderResult] + def build_result(resolved_source, cache_key) + feed = Html2rss.feed(resolved_source.generator_input) + success_result(feed, resolved_source, cache_key) + rescue StandardError => error + error_result(error, resolved_source, cache_key) + end + + # @param feed [Object] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Contracts::RenderResult] + def success_result(feed, resolved_source, cache_key) + Contracts::RenderResult.new( + status: result_status(feed), + payload: payload_for(feed, resolved_source), + message: nil, + ttl_seconds: resolved_source.ttl_seconds, + cache_key: cache_key, + error_message: nil + ) + end + + # @param feed [Object] + # @return [Boolean] + def feed_has_items?(feed) + feed.respond_to?(:items) && !feed.items.empty? + end + + # @param feed [Object] + # @return [Symbol] + def result_status(feed) + feed_has_items?(feed) ? :ok : :empty + end + + # @param feed [Object] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @return [Html2rss::Web::Feeds::Contracts::RenderPayload] + def payload_for(feed, resolved_source) + Contracts::RenderPayload.new( + feed: feed, + site_title: site_title_for(feed, resolved_source.generator_input.dig(:channel, :url)), + url: resolved_source.generator_input.dig(:channel, :url), + strategy: resolved_source.generator_input[:strategy].to_s + ) + end + + # @param feed [Object] + # @param url [String, nil] + # @return [String] + def site_title_for(feed, url) + title = feed.respond_to?(:channel) ? feed.channel&.title.to_s.strip : '' + return title unless title.empty? + + url.to_s + end + + # @param error [StandardError] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Contracts::RenderResult] + def error_result(error, resolved_source, cache_key) + Contracts::RenderResult.new( + status: :error, + payload: nil, + message: Html2rss::Web::HttpError::DEFAULT_MESSAGE, + ttl_seconds: resolved_source.ttl_seconds, + cache_key: cache_key, + error_message: error.message + ) + end + end + end + end + end +end diff --git a/app/web/feeds/source_resolver.rb b/app/web/feeds/source_resolver.rb new file mode 100644 index 00000000..0bb33a6e --- /dev/null +++ b/app/web/feeds/source_resolver.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'digest' + +module Html2rss + module Web + module Feeds + ## + # Resolves static and token-backed requests into shared generator inputs. + module SourceResolver + class << self + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] + def call(feed_request) + case feed_request.target_kind + when :static + resolve_static(feed_request) + when :token + resolve_token(feed_request) + else + raise Html2rss::Web::BadRequestError, "Unsupported feed target: #{feed_request.target_kind}" + end + end + + private + + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] + def resolve_static(feed_request) + config = LocalConfig.find(feed_request.feed_name) + generator_input = static_generator_input(config, feed_request.params) + + Contracts::ResolvedSource.new( + source_kind: :static, + cache_identity: static_cache_identity(feed_request.feed_name, feed_request.params), + generator_input: generator_input, + ttl_seconds: CacheTtl.seconds_from_minutes(generator_input.dig(:channel, :ttl)) + ) + end + + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] + def resolve_token(feed_request) + ensure_auto_source_enabled! + feed_token = FeedAccess.authorize_feed_token!(feed_request.token) + strategy = resolved_strategy(feed_token) + generator_input = token_generator_input(feed_token.url, strategy) + + Contracts::ResolvedSource.new( + source_kind: :token, + cache_identity: token_cache_identity(feed_request.token), + generator_input: generator_input, + ttl_seconds: CacheTtl.seconds_from_minutes(generator_input.dig(:channel, :ttl), default: 300) + ) + end + + # @param feed_name [String] + # @param params [Hash{Object=>Object}] + # @return [String] + def static_cache_identity(feed_name, params) + normalized_params = params.to_h.sort_by { |key, _| key.to_s } + digest = Digest::SHA256.hexdigest(Marshal.dump(normalized_params)) + "static:#{feed_name}:#{digest}" + end + + # @param config [Hash{Symbol=>Object}] + # @param params [Hash{Object=>Object}] + # @return [Hash{Symbol=>Object}] + def static_generator_input(config, params) + generator_input = config.dup + generator_input[:params] = merged_static_params(config, params) + generator_input[:strategy] ||= Html2rss::RequestService.default_strategy_name + generator_input + end + + # @param config [Hash{Symbol=>Object}] + # @param params [Hash{Object=>Object}] + # @return [Hash{Object=>Object}] + def merged_static_params(config, params) + return (config[:params] || {}).dup if params.empty? + + (config[:params] || {}).merge(params) + end + + # @param token [String] + # @return [String] + def token_cache_identity(token) + "token:#{Digest::SHA256.hexdigest(token.to_s)}" + end + + # @return [void] + def ensure_auto_source_enabled! + return if AutoSource.enabled? + + raise Html2rss::Web::ForbiddenError, Api::V1::Contract::MESSAGES[:auto_source_disabled] + end + + # @param feed_token [Html2rss::Web::FeedToken] + # @return [String] + def resolved_strategy(feed_token) + strategy = feed_token.strategy.to_s.strip + strategy = Html2rss::RequestService.default_strategy_name.to_s if strategy.empty? + supported = Html2rss::RequestService.strategy_names.map(&:to_s) + raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported.include?(strategy) + + strategy + end + + # @param url [String] + # @param strategy [String] + # @return [Hash{Symbol=>Object}] + def token_generator_input(url, strategy) + LocalConfig.global + .slice(:stylesheets, :headers) + .merge( + strategy: strategy.to_sym, + channel: { url: url }, + auto_source: {} + ) + end + end + end + end + end +end diff --git a/app/web/http/http_cache.rb b/app/web/http/http_cache.rb new file mode 100644 index 00000000..a1a4f3b3 --- /dev/null +++ b/app/web/http/http_cache.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'time' + +module Html2rss + module Web + ## + # Collection of methods which set HTTP Caching related headers in the response. + module HttpCache + class << self + ## + # Sets Expires and Cache-Control headers to cache for `seconds`. + # @param response [Hash] + # @param seconds [Integer] + # @param cache_control [String, nil] + # @return [void] + def expires(response, seconds, cache_control: nil) + expires_now(response) and return if seconds <= 0 + + response['Expires'] = (Time.now + seconds).httpdate + + cache_value = "max-age=#{seconds}" + cache_value += ",#{cache_control}" if cache_control + response['Cache-Control'] = cache_value + end + + ## + # Sets Expires and Cache-Control headers to invalidate existing cache and + # prevent caching. + # @param response [Hash] + # @return [void] + def expires_now(response) + response['Expires'] = '0' + response['Cache-Control'] = 'private,max-age=0,no-cache,no-store,must-revalidate' + end + + # @param response [Hash] + # @param fields [Array] + # @return [void] + def vary(response, *fields) + existing = response['Vary'].to_s.split(',').map(&:strip).reject(&:empty?) + response['Vary'] = (existing + fields).uniq.join(', ') + end + end + end + end +end diff --git a/app/web/rendering/feed_accept_header.rb b/app/web/rendering/feed_accept_header.rb new file mode 100644 index 00000000..595d6c09 --- /dev/null +++ b/app/web/rendering/feed_accept_header.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Parses Accept headers for feed representation negotiation. + module FeedAcceptHeader + MediaRange = Data.define(:type, :subtype, :quality, :position) do + # @return [Integer] + def specificity + return 0 if type == '*' && subtype == '*' + return 1 if subtype == '*' + + 2 + end + + # @param candidate [String] + # @return [Boolean] + def matches?(candidate) + candidate_type, candidate_subtype = candidate.downcase.split('/', 2) + return true if type == '*' && subtype == '*' + return candidate_type == type if subtype == '*' + + candidate_type == type && candidate_subtype == subtype + end + end + + class << self + # @param accept_header [String, nil] + # @param json_media_types [Array] + # @param rss_media_types [Array] + # @return [Symbol, nil] + def preferred_format(accept_header, json_media_types:, rss_media_types:) + media_ranges = parse(accept_header) + return nil if media_ranges.empty? + + json_score = best_score(media_ranges, json_media_types) + rss_score = best_score(media_ranges, rss_media_types) + + return nil unless json_score + return FeedResponseFormat::JSON_FEED if rss_score.nil? + + (json_score <=> rss_score)&.positive? ? FeedResponseFormat::JSON_FEED : nil + end + + private + + # @param accept_header [String, nil] + # @return [Array] + def parse(accept_header) + accept_header.to_s.split(',').filter_map.with_index do |raw_range, position| + build_media_range(raw_range, position) + end + end + + # @param raw_range [String] + # @param position [Integer] + # @return [MediaRange, nil] + def build_media_range(raw_range, position) + media_type, *parameter_parts = raw_range.strip.downcase.split(';') + type, subtype = media_type.to_s.split('/', 2) + return if type.to_s.empty? || subtype.to_s.empty? + + MediaRange.new( + type:, + subtype:, + quality: extract_quality(parameter_parts), + position: + ) + end + + # @param parameter_parts [Array] + # @return [Float] + def extract_quality(parameter_parts) + raw_value = parameter_parts + .map(&:strip) + .find { |part| part.start_with?('q=') } + &.split('=', 2) + &.last + quality = raw_value ? Float(raw_value) : 1.0 + quality.clamp(0.0, 1.0) + rescue ArgumentError + 1.0 + end + + # @param media_ranges [Array] + # @param candidates [Array] + # @return [Array(Float, Integer, Integer), nil] + def best_score(media_ranges, candidates) + media_ranges + .filter { |range| range.quality.positive? && candidates.any? { |candidate| range.matches?(candidate) } } + .map { |range| [range.quality, range.specificity, -range.position] } + .max + end + end + end + end +end diff --git a/app/web/rendering/feed_notice_text.rb b/app/web/rendering/feed_notice_text.rb new file mode 100644 index 00000000..ec92ac3c --- /dev/null +++ b/app/web/rendering/feed_notice_text.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Shared copy helpers for rendered feed warnings and fallback documents. + module FeedNoticeText + EMPTY_FEED_DESCRIPTION_TEMPLATE = <<~DESC + Unable to extract content from %s using the %s strategy. + The site may rely on JavaScript, block automated requests, or expose a structure that needs a different parser. + DESC + + EMPTY_FEED_ITEM_TEMPLATE = <<~DESC + No entries were extracted from %s. + Possible causes: + - JavaScript-heavy site (try the browserless strategy) + - Anti-bot protection + - Complex or changing markup + - Site blocking automated requests + + Try another strategy or reach out to the site owner. + DESC + + class << self + # @param site_title [String, nil] + # @return [String] + def empty_feed_title(site_title) + site_title ? "#{site_title} - Content Extraction Issue" : 'Content Extraction Issue' + end + + # @param url [String] + # @param strategy [String] + # @return [String] + def empty_feed_description(url:, strategy:) + format(EMPTY_FEED_DESCRIPTION_TEMPLATE, url: url, strategy: strategy) + end + + # @param url [String] + # @return [String] + def empty_feed_item(url:) + format(EMPTY_FEED_ITEM_TEMPLATE, url: url) + end + end + end + end +end diff --git a/app/web/rendering/feed_response_format.rb b/app/web/rendering/feed_response_format.rb new file mode 100644 index 00000000..f74898a6 --- /dev/null +++ b/app/web/rendering/feed_response_format.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Resolves feed response formats from request paths and Accept headers. + module FeedResponseFormat + JSON_FEED = :json_feed + RSS = :rss + + JSON_CONTENT_TYPE = 'application/feed+json' + RSS_CONTENT_TYPE = 'application/xml' + + PATH_FORMATS = { + '.json' => JSON_FEED, + '.rss' => RSS, + '.xml' => RSS + }.freeze + + JSON_MEDIA_TYPES = [ + 'application/feed+json', + 'application/json' + ].freeze + + RSS_MEDIA_TYPES = [ + 'application/rss+xml', + 'application/xml', + 'text/xml' + ].freeze + + class << self + # @param request [Rack::Request] + # @return [Symbol] negotiated feed format. + def for_request(request) + from_path(request_path(request)) || from_accept(accept_header(request)) || RSS + end + + # @param path [String] + # @return [Symbol, nil] format implied by known extension. + def from_path(path) + PATH_FORMATS.each do |suffix, format| + return format if path.end_with?(suffix) + end + + nil + end + + # @param value [String] + # @return [String] input without a known feed extension suffix. + def strip_known_extension(value) + string = value.to_s + + PATH_FORMATS.each_key do |suffix| + return string.delete_suffix(suffix) if string.end_with?(suffix) + end + + string + end + + # @param format [Symbol] + # @return [String] HTTP content type for the negotiated format. + def content_type(format) + format == JSON_FEED ? JSON_CONTENT_TYPE : RSS_CONTENT_TYPE + end + + private + + # @param request [Rack::Request] + # @return [String] + def request_path(request) + path = request.respond_to?(:env) ? request.env['PATH_INFO'] : nil + return path.to_s unless request_path_fallback?(request, path) + + request.path_info.to_s + end + + # @param request [Rack::Request] + # @return [String, nil] + def accept_header(request) + return request.get_header('HTTP_ACCEPT') unless request.respond_to?(:env) + + request.env['HTTP_ACCEPT'] || request.get_header('HTTP_ACCEPT') + end + + # @param request [Rack::Request] + # @param path [String, nil] + # @return [Boolean] + def request_path_fallback?(request, path) + path.to_s.empty? && request.respond_to?(:path_info) + end + + # @param accept_header [String, nil] + # @return [Symbol, nil] + def from_accept(accept_header) + FeedAcceptHeader.preferred_format( + accept_header, + json_media_types: JSON_MEDIA_TYPES, + rss_media_types: RSS_MEDIA_TYPES + ) + end + end + end + end +end diff --git a/app/web/rendering/json_feed_builder.rb b/app/web/rendering/json_feed_builder.rb new file mode 100644 index 00000000..0c1e3187 --- /dev/null +++ b/app/web/rendering/json_feed_builder.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'json' +require 'time' +module Html2rss + module Web + ## + # Central JSON Feed rendering helpers. + module JsonFeedBuilder + VERSION_URL = 'https://jsonfeed.org/version/1.1' + + class << self + # @param message [String] + # @param title [String] + # @return [String] single-item JSON Feed error document. + def build_error_feed(message:, title: 'Error') + build_single_item_feed( + title:, + description: "Failed to generate feed: #{message}", + item: { + title:, + content_text: message + } + ) + end + + # @param url [String] + # @param strategy [String] + # @param site_title [String, nil] + # @return [String] JSON Feed warning document when extraction yields no content. + def build_empty_feed_warning(url:, strategy:, site_title: nil) + build_single_item_feed( + title: FeedNoticeText.empty_feed_title(site_title), + description: FeedNoticeText.empty_feed_description(url: url, strategy: strategy), + home_page_url: url, + item: empty_feed_item(url) + ) + end + + private + + # @param title [String] + # @param description [String] + # @param item [Hash{Symbol=>Object}] + # @param home_page_url [String, nil] + # @return [String] + def build_single_item_feed(title:, description:, item:, home_page_url: nil) + payload = { + version: VERSION_URL, + title: title, + home_page_url: home_page_url, + description: description, + items: [build_single_item(item)] + }.compact + + JSON.generate(payload) + end + + # @param item [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def build_single_item(item) + timestamp = Time.now.utc.iso8601 + + { + id: item[:url] || "#{item[:title]}-#{timestamp}", + url: item[:url], + title: item[:title], + content_text: item[:content_text], + content_html: item[:content_html], + date_published: timestamp + }.compact + end + + # @param url [String] + # @return [Hash{Symbol=>String}] + def empty_feed_item(url) + { + title: 'Content Extraction Failed', + content_text: FeedNoticeText.empty_feed_item(url: url), + url: url + } + end + end + end + end +end diff --git a/app/web/rendering/xml_builder.rb b/app/web/rendering/xml_builder.rb new file mode 100644 index 00000000..399a4d39 --- /dev/null +++ b/app/web/rendering/xml_builder.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'nokogiri' +require 'time' +module Html2rss + module Web + ## + # Central RSS/XML rendering helpers. + # + # XML shaping is centralized so endpoints/services can return consistent feed + # output without duplicating channel/item boilerplate. + module XmlBuilder + class << self + # @param title [String] + # @param description [String] + # @param link [String, nil] + # @param items [ArrayObject}>] + # @param timestamp [Time, nil] + # @return [String] serialized RSS XML document. + def build_rss_feed(title:, description:, link: nil, items: [], timestamp: nil) + current_time = timestamp || Time.now + formatted_now = format_pub_date(current_time) + + Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| + xml.rss(version: '2.0') do + xml.channel do + build_channel(xml, title:, description:, link:, now: formatted_now) + build_items(xml, items, default_pub_date: formatted_now) + end + end + end.doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + end + + # @param message [String] + # @param title [String] + # @return [String] single-item RSS error document. + def build_error_feed(message:, title: 'Error') + build_single_item_feed( + title:, + description: "Failed to generate feed: #{message}", + item: { + title:, + description: message + } + ) + end + + # @param url [String] + # @param strategy [String] + # @param site_title [String, nil] + # @return [String] RSS warning document when extraction yields no content. + def build_empty_feed_warning(url:, strategy:, site_title: nil) + build_single_item_feed( + title: FeedNoticeText.empty_feed_title(site_title), + description: FeedNoticeText.empty_feed_description(url: url, strategy: strategy), + item: { title: 'Content Extraction Failed', description: FeedNoticeText.empty_feed_item(url: url), + link: url }, + link: url + ) + end + + private + + # @param title [String] + # @param description [String] + # @param item [Hash{Symbol=>Object}] + # @param link [String, nil] + # @return [String] + def build_single_item_feed(title:, description:, item:, link: nil) + timestamp = Time.now + build_rss_feed( + title:, + description:, + link:, + items: [feed_item(item, timestamp:)], + timestamp: + ) + end + + # @param item [Hash{Symbol=>Object}] + # @param timestamp [Time] + # @return [Hash{Symbol=>Object}] normalized item with required RSS fields. + def feed_item(item, timestamp:) + feed_item = { + title: item[:title], + description: item[:description], + pubDate: timestamp + } + feed_item[:link] = item[:link] if item[:link] + feed_item + end + + # @param xml [Nokogiri::XML::Builder] + # @param title [String] + # @param description [String] + # @param link [String, nil] + # @param now [String] + # @return [void] + def build_channel(xml, title:, description:, link:, now:) + xml.title(title.to_s) + xml.description(description.to_s) + xml.link(link.to_s) if link + xml.lastBuildDate(now) + xml.pubDate(now) + end + + # @param xml [Nokogiri::XML::Builder] + # @param items [ArrayObject}>] + # @param default_pub_date [String] + # @return [void] + def build_items(xml, items, default_pub_date:) + items.each do |item| + xml.item do + append_text_node(xml, :title, item[:title]) + append_text_node(xml, :description, item[:description]) + append_text_node(xml, :link, item[:link]) + xml.pubDate(format_pub_date(item[:pubDate] || default_pub_date)) + end + end + end + + # @param xml [Nokogiri::XML::Builder] + # @param node_name [Symbol] + # @param value [Object] + # @return [void] + def append_text_node(xml, node_name, value) + xml.public_send(node_name, value.to_s) if value + end + + # @param pub_date [Time, String] + # @return [String] RFC2822 date string for RSS output. + def format_pub_date(pub_date) + pub_date.is_a?(Time) ? pub_date.rfc2822 : pub_date.to_s + end + end + end + end +end diff --git a/app/web/request/request_context.rb b/app/web/request/request_context.rb new file mode 100644 index 00000000..59e884c5 --- /dev/null +++ b/app/web/request/request_context.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Request-scoped context accessors for observability correlation. + module RequestContext + ## + # Immutable request context model. + Context = Data.define(:request_id, :path, :http_method, :route_group, :actor, :strategy, :started_at) + + class << self + # @param context [Context] + # @return [Context] + def set!(context) + Thread.current[:request_context] = context + end + + # @return [Context, nil] + def current + Thread.current[:request_context] + end + + # @return [Hash{Symbol=>Object}] + def current_h + context = current + return {} unless context + + context_hash(context).compact + end + + # @return [nil] + def clear! + Thread.current[:request_context] = nil + nil + end + + # @param context [Context] + # @return [Hash{Symbol=>Object}] + def context_hash(context) + { + request_id: context.request_id, + path: context.path, + method: context.http_method, + route_group: context.route_group, + actor: context.actor, + strategy: context.strategy, + started_at: context.started_at + } + end + end + end + end +end diff --git a/app/web/request/request_context_middleware.rb b/app/web/request/request_context_middleware.rb new file mode 100644 index 00000000..ad49ad75 --- /dev/null +++ b/app/web/request/request_context_middleware.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'rack/request' +require 'securerandom' +require 'time' + +module Html2rss + module Web + ## + # Rack middleware that initializes request correlation context. + class RequestContextMiddleware + ROUTE_PREFIX_TO_GROUP = { + '/api/v1' => 'api_v1' + }.freeze + + # @param app [#call] + def initialize(app) + @app = app + end + + # @param env [Hash] + # @return [Array<(Integer, Hash, #each)>] + def call(env) + request = Rack::Request.new(env) + context = build_context(request) + env['html2rss.request_context'] = context + RequestContext.set!(context) + call_app_with_request_id(env, context.request_id) + ensure + RequestContext.clear! + end + + private + + # @param request [Rack::Request] + # @return [String] + def request_id_for(request) + incoming = request.get_header('HTTP_X_REQUEST_ID').to_s.strip + return incoming unless incoming.empty? + + SecureRandom.hex(8) + end + + # @param path [String] + # @return [String] + def route_group_for(path) + ROUTE_PREFIX_TO_GROUP.each do |prefix, group| + return group if path == prefix || path.start_with?("#{prefix}/") + end + + 'static' + end + + # @param request [Rack::Request] + # @return [Html2rss::Web::RequestContext::Context] + def build_context(request) + path = request.path_info.to_s + RequestContext::Context.new( + request_id: request_id_for(request), + path: path, + http_method: request.request_method.to_s.upcase, + route_group: route_group_for(path), + actor: nil, + strategy: request.params['strategy'], + started_at: Time.now.utc.iso8601 + ) + end + + # @param env [Hash] + # @param request_id [String] + # @return [Array<(Integer, Hash, #each)>] + def call_app_with_request_id(env, request_id) + status, headers, body = @app.call(env) + headers['X-Request-Id'] ||= request_id + [status, headers, body] + end + end + end +end diff --git a/app/web/request/request_target.rb b/app/web/request/request_target.rb new file mode 100644 index 00000000..9c7df270 --- /dev/null +++ b/app/web/request/request_target.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Request-scoped response target metadata used by routing and error handling. + module RequestTarget + ENV_KEY = 'html2rss.request_target' + + API = :api + FEED = :feed + + class << self + # @param request [#env] + # @param target [Symbol] + # @return [Symbol] assigned target. + def mark!(request, target) + request.env[ENV_KEY] = target + end + + # @param request [#env] + # @return [Symbol, nil] request target selected by the router. + def current(request) + request.env[ENV_KEY] + end + + # @param request [#env] + # @return [Boolean] + def api?(request) + current(request) == API + end + + # @param request [#env] + # @return [Boolean] + def feed?(request) + current(request) == FEED + end + end + end + end +end diff --git a/app/web/routes/api_v1.rb b/app/web/routes/api_v1.rb new file mode 100644 index 00000000..df8f8f63 --- /dev/null +++ b/app/web/routes/api_v1.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + ## + # Wires all API v1 routes in one place. + # + # Keeping route assembly centralized keeps HTTP entry behavior easy to + # reason about and avoids duplicated content-type/render logic. + module ApiV1 + class << self + # Mounts `/api/v1` routes on the provided router. + # + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + router.on 'api', 'v1' do + RequestTarget.mark!(router, RequestTarget::API) + router.response['Content-Type'] = 'application/json' + + HealthRoutes.call(router) + FeedRoutes.call(router) + MetadataRoutes.call(router) + end + end + end + end + end + end +end diff --git a/app/web/routes/api_v1/feed_routes.rb b/app/web/routes/api_v1/feed_routes.rb new file mode 100644 index 00000000..fdfd7c93 --- /dev/null +++ b/app/web/routes/api_v1/feed_routes.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts feed-related API routes under `/api/v1/feeds`. + module FeedRoutes + class << self + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + router.on 'feeds' do + router.get String do |token| + RequestTarget.mark!(router, RequestTarget::FEED) + Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + end + + router.post do + JSON.generate(Api::V1::CreateFeed.call(router)) + end + end + end + end + end + end + end + end +end diff --git a/app/web/routes/api_v1/health_routes.rb b/app/web/routes/api_v1/health_routes.rb new file mode 100644 index 00000000..dc1d1929 --- /dev/null +++ b/app/web/routes/api_v1/health_routes.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts health and readiness endpoints under `/api/v1/health`. + module HealthRoutes + class << self + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + router.on 'health' do + mount_readiness(router) + mount_liveness(router) + + router.get do + JSON.generate(Api::V1::Health.show(router)) + end + end + end + + private + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_readiness(router) + router.on 'ready' do + router.get do + JSON.generate(Api::V1::Health.ready(router)) + end + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_liveness(router) + router.on 'live' do + router.get do + JSON.generate(Api::V1::Health.live(router)) + end + end + end + end + end + end + end + end +end diff --git a/app/web/routes/api_v1/metadata_routes.rb b/app/web/routes/api_v1/metadata_routes.rb new file mode 100644 index 00000000..0e87aac0 --- /dev/null +++ b/app/web/routes/api_v1/metadata_routes.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts OpenAPI, root metadata, and strategy listing endpoints. + module MetadataRoutes + class << self + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + mount_openapi_spec(router) + mount_strategies(router) + mount_root(router) + end + + private + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_openapi_spec(router) + router.on 'openapi.yaml' do + router.get do + router.response['Content-Type'] = 'application/yaml' + openapi_spec_contents + end + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_strategies(router) + router.on 'strategies' do + router.get do + JSON.generate(Api::V1::Strategies.index(router)) + end + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_root(router) + router.root do + router.get do + render_root_metadata(router) + end + end + + router.is do + router.get do + render_root_metadata(router) + end + end + end + + # @return [String] + def openapi_spec_path + File.expand_path('../../../../docs/api/v1/openapi.yaml', __dir__) + end + + # @param router [Roda::RodaRequest] + # @return [String] + def render_root_metadata(router) + JSON.generate(Api::V1::Response.success(data: Api::V1::RootMetadata.build(router))) + end + + # @return [String] + def openapi_spec_contents + return File.read(openapi_spec_path) if File.exist?(openapi_spec_path) + + <<~YAML + openapi: 3.0.3 + info: + title: html2rss-web API + version: 1.0.0 + paths: {} + YAML + end + end + end + end + end + end +end diff --git a/app/web/routes/feed_pages.rb b/app/web/routes/feed_pages.rb new file mode 100644 index 00000000..535329f6 --- /dev/null +++ b/app/web/routes/feed_pages.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + ## + # Mounts the root page and legacy feed paths. + module FeedPages + class << self + # @param router [Roda::RodaRequest] + # @param index_renderer [#call] + # @return [void] + def call(router, index_renderer:) + router.root do + index_renderer.call(router) + end + + router.get do + feed_name = requested_feed_name(router) + next if feed_name.empty? + next if feed_name.include?('.') && !feed_name.end_with?('.json', '.xml', '.rss') + + RequestTarget.mark!(router, RequestTarget::FEED) + Feeds::Responder.call(request: router, target_kind: :static, identifier: feed_name) + end + end + + private + + # @param router [Roda::RodaRequest] + # @return [String] + def requested_feed_name(router) + router.path_info.to_s.delete_prefix('/') + end + end + end + end + end +end diff --git a/app/web/security/account_manager.rb b/app/web/security/account_manager.rb new file mode 100644 index 00000000..45d5a37b --- /dev/null +++ b/app/web/security/account_manager.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Thread-safe account snapshot cache. + # + # Keeps config reads cheap by materializing one immutable snapshot and + # exposing narrow lookup helpers for auth and authorization flows. + module AccountManager + class << self + # Forces account snapshot refresh on next access. + # Used by tests and can be used by runtime reload hooks. + # + # @param reason [String] + # @return [nil] + def reload!(reason: 'manual') + @snapshot = nil # rubocop:disable ThreadSafety/ClassInstanceVariable + SecurityLogger.log_cache_lifecycle('account_manager', 'reload', reason: reason) + nil + end + + # @param token [String] + # @return [Hash{Symbol=>Object}, nil] + def get_account(token) + return nil unless token + + snapshot[:token_index][token] + end + + # @return [ArrayObject}>] + def accounts + snapshot[:accounts] + end + + # @param username [String] + # @return [Hash{Symbol=>Object}, nil] + def get_account_by_username(username) + return nil unless username + + snapshot[:username_index][username] + end + + private + + # Lazily initializes and memoizes an immutable account snapshot. + # + # @return [Hash{Symbol=>Object}] + # @option return [ArrayObject}>] :accounts frozen account list. + # @option return [Hash{String=>Hash{Symbol=>Object}}] :token_index token lookup table. + # @option return [Hash{String=>Hash{Symbol=>Object}}] :username_index username lookup table. + def snapshot + return @snapshot if @snapshot # rubocop:disable ThreadSafety/ClassInstanceVariable + + mutex.synchronize do + @snapshot ||= build_snapshot + end + end + + # @return [Mutex] synchronization primitive for snapshot rebuilds. + def mutex + @mutex ||= Mutex.new # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # Builds the immutable account snapshot from local configuration. + # + # @return [Hash{Symbol=>Object}] + # @option return [ArrayObject}>] :accounts frozen account list. + # @option return [Hash{String=>Hash{Symbol=>Object}}] :token_index token lookup table. + # @option return [Hash{String=>Hash{Symbol=>Object}}] :username_index username lookup table. + def build_snapshot + raw_accounts = LocalConfig.global.dig(:auth, :accounts) + accounts = normalized_accounts(raw_accounts) + token_index = index_accounts(accounts, :token) + username_index = index_accounts(accounts, :username) + + SecurityLogger.log_cache_lifecycle('account_manager', 'build', accounts_count: accounts.length) + { accounts: accounts, token_index: token_index, username_index: username_index }.freeze + end + + # @param raw_accounts [Array, nil] + # @return [ArrayObject}>] + def normalized_accounts(raw_accounts) + Array(raw_accounts).map { |account| account.transform_keys(&:to_sym).freeze }.freeze + end + + # @param accounts [ArrayObject}>] + # @param key [Symbol] + # @return [Hash{Object=>Hash{Symbol=>Object}}] + def index_accounts(accounts, key) + accounts.to_h { |account| [account[key], account] }.freeze + end + end + end + end +end diff --git a/app/web/security/auth.rb b/app/web/security/auth.rb new file mode 100644 index 00000000..9f16ef75 --- /dev/null +++ b/app/web/security/auth.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'openssl' +module Html2rss + ## + # Web application modules for html2rss + module Web + ## + # Authentication and feed-token validation helpers. + # + # This module keeps auth decisions in one place so route handlers can stay + # thin and rely on one consistent success/failure contract. + module Auth + class << self + # @param request [Rack::Request] + # @return [Hash{Symbol=>Object}, nil] account attributes when authenticated. + def authenticate(request) + token = extract_token(request) + return audit_auth(request, nil, 'missing_token') unless token + + account = AccountManager.get_account(token) + audit_auth(request, account, 'invalid_token') + end + + # @param username [String] + # @param url [String] + # @param strategy [String] + # @param expires_in [Integer] seconds (default: 10 years) + # @return [String, nil] signed feed token when generation succeeds. + def generate_feed_token(username, url, strategy:, expires_in: FeedToken::DEFAULT_EXPIRY) + token = FeedToken.create_with_validation( + username: username, + url: url, + strategy: strategy, + expires_in: expires_in, + secret_key: secret_key + ) + token&.encode + end + + # @param token [String] + # @return [Html2rss::Web::FeedToken, nil] + def validate_and_decode_feed_token(token) + decoded = FeedToken.decode(token) + return unless decoded + + with_validated_token(token, decoded.url) { |validated| validated } + end + + private + + # @param request [Rack::Request] + # @param reason [String] + # @return [nil] always nil to preserve authenticate return contract. + def log_auth_failure(request, reason) + SecurityLogger.log_auth_failure(request.ip, request.user_agent, reason) + nil + end + + # @param account [Hash{Symbol=>Object}] + # @param request [Rack::Request] + # @return [Hash{Symbol=>Object}] unchanged account payload. + def log_auth_success(account, request) + assign_request_context_actor(account[:username]) + SecurityLogger.log_auth_success(account[:username], request.ip) + account + end + + # @param request [Rack::Request] + # @return [String, nil] + def extract_token(request) + auth_header = request.env['HTTP_AUTHORIZATION'] + return unless auth_header&.start_with?('Bearer ') + + token = auth_header.delete_prefix('Bearer ') + return nil if token.empty? || token.length > 1024 + + token + end + + # Keeps success/failure logging in one branch so authenticate remains + # easy to scan. + # + # @param request [Rack::Request] + # @param account [Hash{Symbol=>Object}, nil] + # @param failure_reason [String] + # @return [Hash{Symbol=>Object}, nil] + def audit_auth(request, account, failure_reason) + if account + Observability.emit(event_name: 'auth.authenticate', outcome: 'success', + details: { username: account[:username] }, level: :info) + return log_auth_success(account, request) + end + + Observability.emit(event_name: 'auth.authenticate', outcome: 'failure', + details: { reason: failure_reason }, level: :warn) + log_auth_failure(request, failure_reason) + end + + # Validates token integrity, records token-usage telemetry, and yields + # only when token checks pass. + # + # @param feed_token [String, nil] + # @param url [String, nil] + # @yieldparam token [Html2rss::Web::FeedToken] + # @return [Object, nil] block result when valid, otherwise nil. + def with_validated_token(feed_token, url) + return nil unless feed_token && url + + token = FeedToken.validate_and_decode(feed_token, url, secret_key) + assign_request_context_strategy(token&.strategy) + SecurityLogger.log_token_usage(feed_token, url, !token.nil?) + return nil unless token + + yield token + end + + # @return [String] + def secret_key + ENV.fetch('HTML2RSS_SECRET_KEY') + end + + # @param username [String, nil] + # @return [void] + def assign_request_context_actor(username) + context = RequestContext.current + return unless context && username + + RequestContext.set!(context.with(actor: username)) + end + + # @param strategy [String, nil] + # @return [void] + def assign_request_context_strategy(strategy) + context = RequestContext.current + return unless context && strategy + + RequestContext.set!(context.with(strategy: strategy)) + end + end + end + end +end diff --git a/app/web/security/feed_access.rb b/app/web/security/feed_access.rb new file mode 100644 index 00000000..a77dce62 --- /dev/null +++ b/app/web/security/feed_access.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Centralizes account, token, and URL access checks for token-backed feed flows. + module FeedAccess + class << self + # @param username [String, nil] + # @return [Hash{Symbol=>Object}, nil] + def account_for_username(username) + AccountManager.get_account_by_username(username) + end + + # @param token [String] + # @return [Html2rss::Web::FeedToken] + def authorize_feed_token!(token) + feed_token = Auth.validate_and_decode_feed_token(token) + raise Html2rss::Web::UnauthorizedError, 'Invalid token' unless feed_token + + account = account_for_username(feed_token.username) + raise Html2rss::Web::UnauthorizedError, 'Account not found' unless account + raise Html2rss::Web::ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, feed_token.url) + + feed_token + end + + # @param username [String, nil] + # @param url [String] + # @return [Boolean] + def url_allowed_for_username?(username, url) + account = account_for_username(username) + return false unless account + + UrlValidator.url_allowed?(account, url) + end + end + end + end +end diff --git a/app/web/security/feed_token.rb b/app/web/security/feed_token.rb new file mode 100644 index 00000000..926e8f9d --- /dev/null +++ b/app/web/security/feed_token.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'base64' +require 'json' +require 'openssl' +require 'zlib' +module Html2rss + module Web # rubocop:disable Metrics/ModuleLength + ## + # Immutable feed token value object with encode/decode and validation helpers. + # + # It keeps signing, validation, and payload shaping in one place so auth + # callers can treat tokens as a single boundary contract. + FeedToken = Data.define(:username, :url, :expires_at, :signature, :strategy) do + # @param username [String] + # @param url [String] + # @param secret_key [String] + # @param strategy [String] + # @param expires_in [Integer] + # @return [Html2rss::Web::FeedToken, nil] signed token object when inputs are valid. + def self.create_with_validation(username:, url:, secret_key:, strategy:, expires_in: FeedToken::DEFAULT_EXPIRY) + return unless valid_inputs?(username, url, secret_key, strategy) + + expires_at = Time.now.to_i + expires_in.to_i + payload = build_payload(username, url, expires_at, strategy) + signature = generate_signature(secret_key, payload) + + new(username: username, url: url, expires_at: expires_at, signature: signature, strategy: strategy) + end + + # @param encoded_token [String, nil] + # @return [Html2rss::Web::FeedToken, nil] decoded token when payload is valid. + def self.decode(encoded_token) # rubocop:disable Metrics/MethodLength + return unless encoded_token + + token_data = parse_token_data(encoded_token) + return unless valid_token_data?(token_data) + + payload = token_data[:p] + new( + username: payload[:u], + url: payload[:l], + expires_at: payload[:e], + signature: token_data[:s], + strategy: payload[:t] + ) + rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError + nil + end + + # @param encoded_token [String, nil] + # @param expected_url [String, nil] + # @param secret_key [String] + # @return [Html2rss::Web::FeedToken, nil] validated token bound to expected URL. + def self.validate_and_decode(encoded_token, expected_url, secret_key) + token = decode(encoded_token) + return unless token + return unless token.valid_signature?(secret_key) + return unless token.valid_for_url?(expected_url) + return if token.expired? + + token + end + + # @return [String] compressed + URL-safe token representation. + def encode + compressed = Zlib::Deflate.deflate(build_token_data.to_json) + Base64.urlsafe_encode64(compressed) + end + + # @return [Boolean] true when token expiration is in the past. + def expired? + Time.now.to_i > expires_at + end + + # @param candidate_url [String] + # @return [Boolean] true when candidate URL matches token URL exactly. + def valid_for_url?(candidate_url) + url == candidate_url + end + + # @param secret_key [String] + # @return [Boolean] true when signature matches payload under given key. + def valid_signature?(secret_key) + return false unless self.class.valid_secret_key?(secret_key) + + expected_signature = self.class.generate_signature(secret_key, payload_for_signature) + secure_compare(signature, expected_signature) + end + + private + + # @return [Hash{Symbol=>Object}] canonical payload used for signature checks. + def payload_for_signature + payload = { username: username, url: url, expires_at: expires_at } + payload[:strategy] = strategy if strategy + payload + end + + # @return [Hash{Symbol=>Object}] compact token envelope. + def build_token_data + payload = { u: username, l: url, e: expires_at } + payload[:t] = strategy if strategy + { p: payload, s: signature } + end + + # Constant-time compare prevents timing leaks on signature mismatch. + # + # @param first [String, nil] + # @param second [String, nil] + # @return [Boolean] + def secure_compare(first, second) # rubocop:disable Naming/PredicateMethod + return false unless first && second && first.bytesize == second.bytesize + + first.each_byte.zip(second.each_byte).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero? + end + + class << self + # @param username [String] + # @param url [String] + # @param expires_at [Integer] + # @param strategy [String] + # @return [Hash{Symbol=>Object}] signature payload. + def build_payload(username, url, expires_at, strategy) + { username: username, url: url, expires_at: expires_at, strategy: strategy } + end + + # @param secret_key [String] + # @param payload [Hash, String] + # @return [String] HMAC digest. + def generate_signature(secret_key, payload) + data = payload.is_a?(String) ? payload : JSON.generate(payload) + OpenSSL::HMAC.hexdigest(FeedToken::HMAC_ALGORITHM, secret_key, data) + end + + # @param encoded_token [String] + # @return [Hash{Symbol=>Object}] parsed token envelope. + def parse_token_data(encoded_token) + decoded = Base64.urlsafe_decode64(encoded_token) + inflated = Zlib::Inflate.inflate(decoded) + JSON.parse(inflated, symbolize_names: true) + end + + # @param token_data [Object] + # @return [Boolean] true when structure contains required payload/signature keys. + def valid_token_data?(token_data) + return false unless token_data.is_a?(Hash) + + payload = token_data[:p] + signature = token_data[:s] + payload.is_a?(Hash) && signature.is_a?(String) && !signature.empty? && + FeedToken::COMPRESSED_PAYLOAD_KEYS.all? { |key| payload[key] } + end + + # @param username [Object] + # @param url [Object] + # @param secret_key [Object] + # @param strategy [Object] + # @return [Boolean] + def valid_inputs?(username, url, secret_key, strategy) + valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key) && + valid_strategy?(strategy) + end + + # @param username [Object] + # @return [Boolean] + def valid_username?(username) + username.is_a?(String) && !username.empty? && username.length <= 100 && username.match?(/\A[a-zA-Z0-9_-]+\z/) + end + + # @param secret_key [Object] + # @return [Boolean] + def valid_secret_key?(secret_key) + secret_key.is_a?(String) && !secret_key.empty? + end + + # @param strategy [Object] + # @return [Boolean] + def valid_strategy?(strategy) + strategy.is_a?(String) && !strategy.empty? && strategy.length <= 50 && strategy.match?(/\A[a-z0-9_]+\z/) + end + end + end + + FeedToken::DEFAULT_EXPIRY = 315_360_000 # 10 years in seconds + FeedToken::HMAC_ALGORITHM = 'SHA256' + FeedToken::REQUIRED_TOKEN_KEYS = %i[p s].freeze + FeedToken::COMPRESSED_PAYLOAD_KEYS = %i[u l e].freeze + end +end diff --git a/app/web/security/security_logger.rb b/app/web/security/security_logger.rb new file mode 100644 index 00000000..1d15d495 --- /dev/null +++ b/app/web/security/security_logger.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'logger' +require 'json' +require 'digest' +require 'time' +module Html2rss + module Web + ## + # Security event logging for html2rss-web + # Provides structured logging for security events to stdout + module SecurityLogger + class << self + # Initialize logger to stdout with structured JSON output + # @return [Logger] + def logger + Thread.current[:security_logger] ||= create_logger + end + + # Reset logger (for testing) + # @return [void] + def reset_logger! + Thread.current[:security_logger] = nil + end + + ## + # Log authentication failure + # @param ip [String] client IP address + # @param user_agent [String] client user agent + # @param reason [String] failure reason + # @return [void] + def log_auth_failure(ip, user_agent, reason) + log_event('auth_failure', { + ip: ip, + user_agent: user_agent, + reason: reason + }, severity: :warn) + end + + ## + # Log authentication success + # @param username [String] authenticated username + # @param ip [String] client IP address + # @return [void] + def log_auth_success(username, ip) + log_event('auth_success', { + username: username, + ip: ip + }, severity: :info) + end + + ## + # Log rate limit exceeded + # @param ip [String] client IP address + # @param endpoint [String] endpoint that was rate limited + # @param limit [Integer] rate limit that was exceeded + # @return [void] + def log_rate_limit_exceeded(ip, endpoint, limit) + log_event('rate_limit_exceeded', { + ip: ip, + endpoint: endpoint, + limit: limit + }, severity: :warn) + end + + ## + # Log token usage + # @param feed_token [String] feed token (hashed for privacy) + # @param url [String] URL being accessed + # @param success [Boolean] whether the token was valid + # @return [void] + def log_token_usage(feed_token, url, success) + severity = success ? :info : :warn + + log_event('token_usage', { + success: success, + url: url, + token_hash: Digest::SHA256.hexdigest(feed_token)[0..7] + }, severity: severity) + end + + ## + # Log suspicious activity + # @param ip [String] client IP address + # @param activity [String] description of suspicious activity + # @param details [Hash] additional details + # @return [void] + def log_suspicious_activity(ip, activity, details = {}) + log_event('suspicious_activity', { + ip: ip, + activity: activity, + **details + }, severity: :warn) + end + + ## + # Log blocked request + # @param ip [String] client IP address + # @param reason [String] reason for blocking + # @param endpoint [String] endpoint that was blocked + # @return [void] + def log_blocked_request(ip, reason, endpoint) + log_event('blocked_request', { + ip: ip, + reason: reason, + endpoint: endpoint + }, severity: :warn) + end + + ## + # Log configuration validation failure + # @param component [String] component that failed validation + # @param details [String] validation failure details + # @return [void] + def log_config_validation_failure(component, details) + log_event('config_validation_failure', { + component: component, + details: details + }, severity: :error) + end + + # Log lifecycle events for in-memory config/cache snapshots + # @param component [String] component name + # @param event [String] lifecycle event name + # @param details [Hash] optional extra context + # @return [void] + def log_cache_lifecycle(component, event, details = {}) + log_event('cache_lifecycle', { + component: component, + event: event, + **details + }, severity: :info) + end + + private + + def create_logger + Logger.new($stdout).tap do |log| + log.formatter = proc do |severity, datetime, _progname, msg| + "#{{ + timestamp: datetime.iso8601, + level: severity, + service: 'html2rss-web', + **JSON.parse(msg, symbolize_names: true) + }.to_json}\n" + end + end + end + + ## + # Log a security event + # @param event_type [String] type of security event + # @param data [Hash] event data + def log_event(event_type, data, severity: :warn) + context_data = RequestContext.current_h + payload = { + security_event: event_type, + **context_data, + **data + }.to_json + + logger.public_send(severity, payload) + rescue StandardError => error + handle_logging_error(error, event_type, data) + end + + ## + # Handle logging errors with fallback mechanisms + # @param error [StandardError] the error that occurred + # @param event_type [String] type of security event + # @param data [Hash] event data + def handle_logging_error(error, event_type, data) + Kernel.warn("Security logging error: #{error.message}") + Kernel.warn("Security event: #{event_type} - #{data}") + end + end + end + end +end diff --git a/app/ssrf_filter_strategy.rb b/app/web/security/ssrf_filter_strategy.rb similarity index 82% rename from app/ssrf_filter_strategy.rb rename to app/web/security/ssrf_filter_strategy.rb index a6bb9758..2ad3f76f 100644 --- a/app/ssrf_filter_strategy.rb +++ b/app/web/security/ssrf_filter_strategy.rb @@ -2,13 +2,14 @@ require 'ssrf_filter' require 'html2rss' -require_relative '../app/local_config' - module Html2rss module Web ## # Strategy to fetch a URL using the SSRF filter. class SsrfFilterStrategy < Html2rss::RequestService::Strategy + # Executes a URL fetch through `ssrf_filter` and adapts response shape. + # + # @return [Html2rss::RequestService::Response] def execute headers = LocalConfig.global.fetch(:headers, {}).merge( ctx.headers.transform_keys(&:to_sym) diff --git a/app/web/security/url_validator.rb b/app/web/security/url_validator.rb new file mode 100644 index 00000000..797d26d2 --- /dev/null +++ b/app/web/security/url_validator.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'html2rss/url' + +module Html2rss + module Web + ## + # URL validation and pattern matching utilities built on Html2rss::Url + module UrlValidator + MAX_URL_LENGTH = 2048 + + class << self + # @param url [String] + # @return [Boolean] + def valid_url?(url) + !normalize_url(url).nil? + end + + # @param account [Hash] + # @param url [String] + # @return [Boolean] + def url_allowed?(account, url) + return false unless account && url + + allowed_urls = Array(account[:allowed_urls]) + return false unless (normalized_url = normalize_url(url)) + + return false if allowed_urls.empty? + + allowed_urls.any? do |pattern| + wildcard?(pattern) ? match_wildcard?(pattern, normalized_url) : match_exact?(pattern, normalized_url) + end + end + + private + + def match_exact?(pattern, normalized_url) + return true if pattern == normalized_url + + normalized_pattern = normalize_url(pattern) + normalized_pattern ? normalized_pattern == normalized_url : false + end + + def match_wildcard?(pattern, normalized_url) + return true if pattern == '*' + + File.fnmatch?(pattern, normalized_url, File::FNM_CASEFOLD) + end + + def wildcard?(pattern) + pattern.include?('*') + end + + def normalize_url(url) + return nil unless url.is_a?(String) && !url.empty? && url.length <= MAX_URL_LENGTH + + Html2rss::Url.for_channel(url).to_s + rescue StandardError + nil + end + end + end + end +end diff --git a/app/web/telemetry/observability.rb b/app/web/telemetry/observability.rb new file mode 100644 index 00000000..496a0c9b --- /dev/null +++ b/app/web/telemetry/observability.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'json' +require 'logger' +require 'time' + +module Html2rss + module Web + ## + # Structured observability event emitter for request-critical paths. + module Observability + SCHEMA_VERSION = '1.0' + + class << self + # @param event_name [String] + # @param outcome [String] expected values: success|failure. + # @param details [Hash{Symbol=>Object}] + # @param level [Symbol] + # @return [void] + def emit(event_name:, outcome:, details: {}, level: :info) + logger.public_send(level, build_payload(event_name, outcome, details).to_json) + rescue StandardError => error + handle_emit_error(error, event_name, outcome) + end + + private + + # @return [Logger] + def logger + Thread.current[:observability_logger] ||= Logger.new($stdout).tap do |log| + log.formatter = proc do |severity, datetime, _progname, msg| + "#{{ + timestamp: datetime.iso8601, + level: severity, + service: 'html2rss-web', + **JSON.parse(msg, symbolize_names: true) + }.to_json}\n" + end + end + end + + # @param error [StandardError] + # @param event_name [String] + # @param outcome [String] + # @return [void] + def handle_emit_error(error, event_name, outcome) + Kernel.warn("Observability emit error: #{error.message}") + Kernel.warn("event_name=#{event_name} outcome=#{outcome}") + end + + # @param event_name [String] + # @param outcome [String] + # @param details [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def build_payload(event_name, outcome, details) + context = RequestContext.current_h + base_payload(event_name, outcome, context).merge(details: details) + end + + # @param event_name [String] + # @param outcome [String] + # @param context [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def base_payload(event_name, outcome, context) + { + event_name: event_name, schema_version: SCHEMA_VERSION, request_id: context[:request_id], + route_group: context[:route_group], actor: context[:actor], outcome: outcome, **context_fields(context) + } + end + + # @param context [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def context_fields(context) + context.slice(:path, :method, :strategy, :started_at) + end + end + end + end +end diff --git a/bin/dev b/bin/dev index 4d11845b..e599caf2 100755 --- a/bin/dev +++ b/bin/dev @@ -1,20 +1,94 @@ #!/usr/bin/env bash # frozen_string_literal: true -# Development server startup script set -e -# Load environment variables if .env file exists +# Load .env if exists if [ -f .env ]; then export $(cat .env | grep -v '^#' | xargs) fi -# Set default environment export RACK_ENV=${RACK_ENV:-development} -echo "Starting html2rss-web in development mode..." -echo "Environment: $RACK_ENV" -echo "Port: ${PORT:-3000}" +# BusyBox lsof in this image reports internal FDs and exits 0, so prefer netstat. +port_in_use() { + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]$1$" +} -# Start the development server -bundle exec rackup -p ${PORT:-3000} -o 0.0.0.0 +# Cleanup function for graceful shutdown +cleanup() { + echo "" + echo "πŸ›‘ Shutting down development servers..." + + # Kill Ruby server + if [ ! -z "$RUBY_PID" ]; then + kill $RUBY_PID 2>/dev/null || true + wait $RUBY_PID 2>/dev/null || true + fi + + # Kill frontend dev server and its children + if [ ! -z "$FRONTEND_PID" ]; then + # Kill the npm process and its children + pkill -P $FRONTEND_PID 2>/dev/null || true + kill $FRONTEND_PID 2>/dev/null || true + wait $FRONTEND_PID 2>/dev/null || true + fi + + # Clean up any remaining processes on our ports + pkill -f "puma.*html2rss-web" 2>/dev/null || true + pkill -f "vite.*4001" 2>/dev/null || true + + echo "βœ… Development servers stopped" + exit 0 +} + +trap cleanup SIGINT SIGTERM + +# Prevent silent failures from port conflicts +if port_in_use 4000; then + echo "❌ Port 4000 is already in use. Run: \`pkill -f 'puma.*html2rss-web'\`" + exit 1 +fi + +# Start Ruby server +bundle exec puma -p 4000 -C config/puma.rb & +RUBY_PID=$! + +# Verify Ruby server started successfully (allow slower boots in containers) +RUBY_STARTUP_TIMEOUT=${RUBY_STARTUP_TIMEOUT:-30} +ruby_started=false +for _ in $(seq 1 "$RUBY_STARTUP_TIMEOUT"); do + if ! kill -0 $RUBY_PID 2>/dev/null; then + echo "❌ Ruby server failed to start" + exit 1 + fi + + if port_in_use 4000; then + ruby_started=true + break + fi + + sleep 1 +done + +if [ "$ruby_started" != "true" ]; then + echo "❌ Ruby server did not start listening on port 4000 within ${RUBY_STARTUP_TIMEOUT}s" + kill $RUBY_PID 2>/dev/null || true + exit 1 +fi + +# Start frontend dev server +cd frontend +npm run dev & +FRONTEND_PID=$! + +# Verify frontend server started +sleep 3 +if ! kill -0 $FRONTEND_PID 2>/dev/null; then + echo "❌ Frontend dev server failed to start" + kill $RUBY_PID 2>/dev/null || true + exit 1 +fi + +echo "βœ… Development environment ready at http://localhost:4001" +wait $RUBY_PID $FRONTEND_PID diff --git a/bin/dev-ruby b/bin/dev-ruby new file mode 100755 index 00000000..f1b0cb78 --- /dev/null +++ b/bin/dev-ruby @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# frozen_string_literal: true + +set -e + +# Load .env if exists +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +export RACK_ENV=${RACK_ENV:-development} + +# Prevent silent failures from port conflicts +if lsof -Pi :${PORT:-4000} -sTCP:LISTEN -t >/dev/null 2>&1; then + echo "❌ Port ${PORT:-4000} is already in use. Run: pkill -f 'puma.*html2rss-web'" + exit 1 +fi + +echo "Starting Ruby server (code reloading enabled)" +bundle exec puma -p ${PORT:-4000} -C config/puma.rb diff --git a/bin/dev-with-frontend b/bin/dev-with-frontend new file mode 100755 index 00000000..52712386 --- /dev/null +++ b/bin/dev-with-frontend @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# frozen_string_literal: true + +# Development server startup script with frontend hot reload +set -e + +# Load environment variables if .env file exists +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +# Set default environment +export RACK_ENV=${RACK_ENV:-development} + +echo "Starting html2rss-web development environment..." +echo "Environment: $RACK_ENV" +echo "Ruby server: http://localhost:4000" +echo "Astro dev server: http://localhost:4001 (with live reload)" +echo "Main development URL: http://localhost:4001" +echo "" + +# Function to cleanup background processes +cleanup() { + echo "" + echo "Shutting down servers..." + kill $RUBY_PID 2>/dev/null || true + kill $ASTRO_PID 2>/dev/null || true + kill $WATCHER_PID 2>/dev/null || true + wait $RUBY_PID 2>/dev/null || true + wait $ASTRO_PID 2>/dev/null || true + wait $WATCHER_PID 2>/dev/null || true + echo "Servers stopped." + exit 0 +} + +# Set up signal handlers +trap cleanup SIGINT SIGTERM + +# Start Ruby server in background +echo "Starting Ruby server..." +bundle exec puma -p ${PORT:-4000} -C config/puma.rb & +RUBY_PID=$! + +# Wait a moment for Ruby server to start +sleep 3 + +# Start Astro dev server with API proxy +echo "Starting Astro dev server with API proxy..." +cd frontend + +# Start Astro dev server (it will proxy API calls to Ruby server) +npm run dev & +ASTRO_PID=$! + +# Wait a moment for Astro server to start +sleep 3 + +# Wait for both processes +wait $RUBY_PID $ASTRO_PID diff --git a/bin/setup b/bin/setup index b8ade628..71769aa3 100755 --- a/bin/setup +++ b/bin/setup @@ -31,6 +31,7 @@ echo "Running tests to verify setup..." bundle exec rspec echo "Setup complete! You can now run:" -echo " bin/dev # Start development server" +echo " bin/dev # Start development server (Ruby + Astro)" +echo " bin/dev-ruby # Start Ruby server only" echo " bundle exec rspec # Run tests" echo " bundle exec rubocop # Run linter" diff --git a/config.ru b/config.ru index 9f084cd0..726d1fd1 100644 --- a/config.ru +++ b/config.ru @@ -3,6 +3,7 @@ require 'rubygems' require 'bundler/setup' require 'rack-timeout' +require_relative 'app/web/boot/development_reloader' if ENV.key?('SENTRY_DSN') Bundler.require(:sentry) @@ -11,17 +12,7 @@ if ENV.key?('SENTRY_DSN') Sentry.init do |config| config.dsn = ENV.fetch('SENTRY_DSN') - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for tracing. - # We recommend adjusting this value in production. config.traces_sample_rate = 1.0 - # or - # config.traces_sampler = lambda do |_context| - # true - # end - # Set profiles_sample_rate to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. config.profiles_sample_rate = 1.0 end @@ -29,28 +20,19 @@ if ENV.key?('SENTRY_DSN') end dev = ENV.fetch('RACK_ENV', nil) == 'development' -requires = Dir['app/**/*.rb'] if dev - require 'logger' - logger = Logger.new($stdout) - - require 'rack/unreloader' - Unreloader = Rack::Unreloader.new(subclasses: %w[Roda Html2rss], - logger:, - reload: dev) do - Html2rss::Web::App - end - Unreloader.require('app.rb') { 'Html2rss::Web::App' } - - requires.each { |f| Unreloader.require(f) } + require_relative 'app' - run Unreloader + run Html2rss::Web::Boot::DevelopmentReloader.new( + loader: Html2rss::Web::Boot.loader, + app_provider: -> { Html2rss::Web::App.app } + ) else use Rack::Timeout require_relative 'app' - requires.each { |f| require_relative f } + Html2rss::Web::Boot.eager_load! run(Html2rss::Web::App.freeze.app) end diff --git a/config/feeds.yml b/config/feeds.yml index 503e9631..af8ec0d2 100644 --- a/config/feeds.yml +++ b/config/feeds.yml @@ -1,9 +1,27 @@ +auth: + accounts: + - username: "admin" + token: "CHANGE_ME_ADMIN_TOKEN" + allowed_urls: + - "*" # Full access + - username: "demo" + token: "CHANGE_ME_DEMO_TOKEN" + allowed_urls: + - "https://www.chip.de/testberichte" + - "https://news.ycombinator.com" + - "https://github.com/trending" + - username: "health-check" + token: "CHANGE_ME_HEALTH_CHECK_TOKEN" + allowed_urls: [] # Health check doesn't need URL access + stylesheets: - href: "/rss.xsl" media: "all" type: "text/xsl" + headers: "User-Agent": "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" + feeds: # your custom feeds go here: example: diff --git a/config/puma.rb b/config/puma.rb index 31e7c553..43ffdf75 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,10 +1,19 @@ # frozen_string_literal: true -workers Integer(ENV.fetch('WEB_CONCURRENCY', 2)) -threads_count = Integer(ENV.fetch('WEB_MAX_THREADS', 5)) -threads threads_count, threads_count +# Single worker in dev enables code reloading (cluster mode prevents reloading) +if ENV['RACK_ENV'] == 'development' + workers 0 + threads_count = Integer(ENV.fetch('WEB_MAX_THREADS', 5)) + threads threads_count, threads_count + plugin :tmp_restart + log_requests true +else + workers Integer(ENV.fetch('WEB_CONCURRENCY', 2)) + threads_count = Integer(ENV.fetch('WEB_MAX_THREADS', 5)) + threads threads_count, threads_count + preload_app! + log_requests false +end -preload_app! - -port ENV.fetch('PORT', 3000) +port ENV.fetch('PORT', 4000) environment ENV.fetch('RACK_ENV', 'development') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..96366feb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# Docker Compose configuration for html2rss-web +# This is the default configuration - customize as needed + +services: + html2rss-web: + image: gilcreator/html2rss-web + restart: unless-stopped + ports: + - "127.0.0.1:4000:4000" + volumes: + - type: bind + source: ./config/feeds.yml + target: /app/config/feeds.yml + read_only: true + environment: + RACK_ENV: production + PORT: 4000 + HTML2RSS_SECRET_KEY: your-generated-secret-key-here + HEALTH_CHECK_TOKEN: CHANGE_ME_HEALTH_CHECK_TOKEN + BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002 + BROWSERLESS_IO_API_TOKEN: 6R0W53R135510 + + watchtower: + image: containrrr/watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - "~/.docker/config.json:/config.json" + command: --cleanup --interval 7200 + + browserless: + image: "ghcr.io/browserless/chromium" + restart: unless-stopped + ports: + - "127.0.0.1:4002:4002" + environment: + PORT: 4002 + CONCURRENT: 10 + TOKEN: 6R0W53R135510 diff --git a/docs/ARCHITECTURE_DELIVERY_PLAN.md b/docs/ARCHITECTURE_DELIVERY_PLAN.md new file mode 100644 index 00000000..ea01b054 --- /dev/null +++ b/docs/ARCHITECTURE_DELIVERY_PLAN.md @@ -0,0 +1,186 @@ +# Architecture Delivery Plan + +## Goal +Deliver a production-strong, low-risk architecture evolution for `html2rss-web` with phased rollout, explicit contracts, and CI-enforced safety. + +This plan is based on direct code scan plus Roda documentation review (Context7: `/jeremyevans/roda`). + +## Feasibility Verdict +Feasible to deliver autonomously in phased slices. + +Why: +- Existing module boundaries already separate routing, feed orchestration, auth, config, and API response shaping. +- Safety rails already exist (`make ready`, strict YARD gate, OpenAPI generation/lint paths). +- Most proposed changes can be introduced behind compatibility adapters without API or XML contract breaks. + +Primary constraint: +- Async feed refresh introduces the largest operational risk and should be introduced last, behind a feature toggle. + +## Validated Baseline (Code-Backed) + +1. Assumption: boundary contracts are hash-heavy. +Status: confirmed. +Evidence: +- `LocalConfig.find`/`global` return mutable-shape hashes. +- `Auth.authenticate`, `AccountManager` lookups, `AutoSource.create_stable_feed`, `CreateFeed.call`, `ShowFeed.call` pass hash payloads across boundaries. + +2. Assumption: feed generation is mostly synchronous in request path. +Status: confirmed. +Evidence: +- `App#handle_feed_generation` calls `Feeds.generate_feed` inline. +- `ShowFeed#render_generated_feed` calls `AutoSource.generate_feed_object` and processes output inline. + +3. Assumption: error handling is centralized but context-poor. +Status: partially confirmed. +Evidence: +- Centralized `plugin :error_handler` delegates to `ErrorResponder`. +- `SecurityLogger` is structured JSON but has no mandatory request correlation contract (no request context model). + +4. Assumption: configuration is centralized but not typed. +Status: confirmed. +Evidence: +- `LocalConfig` loads YAML with symbolized hashes; callers use `dig` and hash semantics. +- `AccountManager` snapshots are immutable but still hash-based. + +5. Assumption: OpenAPI exists but frontend generated client is not enforced. +Status: confirmed. +Evidence: +- `docs/api/v1/openapi.yaml` present. +- `Makefile` includes `openapi`, `openapi-verify`, and lint targets. +- Frontend has no generated OpenAPI client dependency/config yet. + +## Target Outcomes +- Explicit immutable boundary models (`Data`) for high-churn app contracts. +- Request-context observability with durable correlation keys. +- Typed validated runtime configuration snapshots. +- Contract-first backend/frontend sync via generated client. +- Optional async/stale-while-revalidate feed pipeline with rollback control. + +## Roda-Aligned Delivery Notes +- Keep route tree composition through existing `Routes::ApiV1` and `Routes::Static` seams. +- Introduce request context at Rack/request edge (`request.env`) and pass via narrow adapters. +- Keep `plugin :error_handler` as single top-level rescue path, enriching payload/log context rather than splitting rescue logic across routes. +- Prefer additive plugins/middleware and feature toggles over route rewrites. + +## Phase Plan + +## Phase 0: ADR + Contracts Baseline (1-2 days) +Deliverables: +- ADR-001 boundary `Data` model policy. +- ADR-002 request context + log contract. +- ADR-003 typed config schema + failure modes. +- ADR-004 OpenAPI generated client policy. +- ADR-005 async refresh architecture guardrails. + +Exit criteria: +- ADRs accepted with rollback notes and migration order. + +## Phase 1: Typed Config Snapshot (2-4 days) +Deliverables: +- Config schema and typed `Data` models for global/auth/feed config nodes. +- Validation at boot/reload with explicit error diagnostics. +- Compatibility adapter so existing hash consumers continue to work. + +Acceptance: +- Invalid config fails fast outside development. +- Existing behavior unchanged for valid config. + +Risks: +- Hidden optional keys in feeds config. +Mitigation: +- Add schema for optional keys with defaults and targeted migration warnings. + +## Phase 2: Request Context + Observability Contract (2-4 days) +Deliverables: +- RequestContext model (request_id, path, method, actor, route_group, strategy, duration_ms). +- Context initializer and propagation helpers. +- `ErrorResponder` and `SecurityLogger` context-aware logging. + +Acceptance: +- Logs for failures/auth/token usage include request correlation keys. +- No API response shape regression. + +Risks: +- Leaking sensitive token data. +Mitigation: +- Keep hashing/fingerprinting policy; never log raw token. + +## Phase 3: Boundary `Data` Models at API Edges (4-7 days) +Deliverables: +- Replace hash contracts first in: + - Auth/account context. + - API feed create params. + - API feed response payload internals. + - Feed metadata boundary between `AutoSource` and API layer. +- Add bidirectional adapters (`Hash` <-> `Data`) while migrating. + +Acceptance: +- Public HTTP contracts unchanged. +- No cyclomatic complexity increase in touched methods. + +Risks: +- Broad refactor blast radius. +Mitigation: +- Strangler approach by edge-first modules, one boundary at a time. + +## Phase 4: OpenAPI-Generated Frontend Client (2-3 days) +Deliverables: +- Add `@hey-api/openapi-ts` setup in frontend. +- Generate typed client from `docs/api/v1/openapi.yaml`. +- Replace handwritten API types/usages for covered endpoints. +- CI drift check for generated client. + +Acceptance: +- Frontend compiles against generated types. +- Spec/client drift fails CI. + +Risks: +- Divergence between generated models and existing UI assumptions. +Mitigation: +- Introduce thin compatibility wrapper to phase migration. + +## Phase 5: Pluggable Async Refresh (5-10 days) +Deliverables: +- Pipeline contract (`Fetch -> Extract -> Normalize -> Render -> Cache`). +- Cache-first read path with stale-while-revalidate option. +- Async refresh path with bounded retries and visibility. +- Feature flag to revert to sync path immediately. + +Acceptance: +- Stable response behavior under upstream slowness/failures. +- Observable refresh outcomes and queue depth. + +Risks: +- Operational complexity. +Mitigation: +- Keep minimal worker model first; add scaling patterns only with measured need. + +## Autonomous Delivery Execution Plan +Delivery can be performed autonomously with pre-commit gating on each slice: + +1. Implement one phase at a time behind flags/adapters. +2. Run inside Dev Container only. +3. Run `make ready` before each commit. +4. Keep commits single-concern and reversible. +5. Include short migration notes in commit body when contracts move. + +## Commit Packaging Plan (commit-intent) +Expected commit sequence for implementation (example titles): + +1. `docs(architecture): ratify phased delivery plan with validated assumptions` +2. `refactor(config): introduce typed config snapshot with compatibility adapter` +3. `feat(observability): add request context contract and correlated logging` +4. `refactor(api): migrate feed/auth boundaries to Data-backed contracts` +5. `feat(frontend-api): generate OpenAPI client and enforce drift checks` +6. `feat(feed-runtime): add async refresh pipeline behind feature flag` + +Outlier rule: +- If a cross-cutting commit is unavoidable, mark it explicitly as an outlier with rollback boundary in the commit body. + +## Definition of Done per Phase +- `make ready` passes in Dev Container. +- No regressions in API shape or feed XML semantics unless explicitly planned. +- Rollback path documented and tested for the phase. + +## Immediate Next Step +Start Phase 0 ADR set and lock naming/ownership of boundary `Data` types before code changes. diff --git a/docs/adr/0001-boundary-data-contracts.md b/docs/adr/0001-boundary-data-contracts.md new file mode 100644 index 00000000..e8944d7b --- /dev/null +++ b/docs/adr/0001-boundary-data-contracts.md @@ -0,0 +1,22 @@ +# ADR-0001: Boundary Contracts Use Ruby Data Models + +## Status +Accepted + +## Context +Boundary methods currently exchange loosely shaped hashes, increasing ambiguity and making refactors risky. + +## Decision +Introduce immutable `Data` models at high-churn boundaries (auth/account identity, feed create params, feed metadata, request context). + +## Consequences +- Positive: clearer contracts, safer refactors, reduced defensive nil/hash checks. +- Negative: requires adapter code while legacy hash consumers are migrated. + +## Rollout +- Add `Data` models + hash adapters. +- Migrate edge modules first. +- Keep hash compatibility methods during transition. + +## Rollback +Route boundary code back through hash adapters while retaining models for future retries. diff --git a/docs/adr/0002-request-context-observability.md b/docs/adr/0002-request-context-observability.md new file mode 100644 index 00000000..82ca74cb --- /dev/null +++ b/docs/adr/0002-request-context-observability.md @@ -0,0 +1,31 @@ +# ADR-0002: Request Context and Correlated Observability + +## Status +Accepted + +## Context +Structured security logs exist, but cross-event request correlation is inconsistent. + +## Decision +Add request context middleware to create a per-request context and expose it to logging/error paths. + +Minimum keys: +- `request_id` +- `path` +- `method` +- `route_group` +- `actor` +- `strategy` +- `started_at` + +## Consequences +- Positive: faster production debugging and safer incident triage. +- Negative: small overhead in request lifecycle and context propagation. + +## Rollout +- Initialize context in middleware. +- Enrich security/error logs with context keys. +- Keep response payloads stable. + +## Rollback +Disable middleware usage and fall back to existing logging behavior. diff --git a/docs/adr/0003-typed-config-snapshot.md b/docs/adr/0003-typed-config-snapshot.md new file mode 100644 index 00000000..36c24ab5 --- /dev/null +++ b/docs/adr/0003-typed-config-snapshot.md @@ -0,0 +1,22 @@ +# ADR-0003: Typed Validated Config Snapshot + +## Status +Accepted + +## Context +Config loading is centralized but callers consume dynamic hashes with uneven validation. + +## Decision +Materialize an immutable typed config snapshot from `config/feeds.yml` with explicit validation and defaults. + +## Consequences +- Positive: deterministic runtime behavior, clearer boot-time failures. +- Negative: schema maintenance overhead. + +## Rollout +- Build snapshot models and validators. +- Keep hash compatibility for existing callers. +- Migrate consumers to typed readers incrementally. + +## Rollback +Use legacy hash accessors while retaining snapshot parsing behind compatibility methods. diff --git a/docs/adr/0004-openapi-generated-frontend-client.md b/docs/adr/0004-openapi-generated-frontend-client.md new file mode 100644 index 00000000..9cf4c573 --- /dev/null +++ b/docs/adr/0004-openapi-generated-frontend-client.md @@ -0,0 +1,22 @@ +# ADR-0004: OpenAPI-Generated Frontend Client + +## Status +Accepted + +## Context +OpenAPI spec is maintained, but frontend API typing is partially handwritten. + +## Decision +Use `@hey-api/openapi-ts` to generate client/types from `docs/api/v1/openapi.yaml` and enforce drift checks. + +## Consequences +- Positive: tighter backend/frontend contract fidelity. +- Negative: generated artifacts add maintenance in CI and local workflows. + +## Rollout +- Add generator config and npm script. +- Generate client into frontend source tree. +- Add verify target to fail on stale generated output. + +## Rollback +Temporarily use existing fetch hooks while keeping generator config in place. diff --git a/docs/adr/0005-async-feed-refresh-pipeline.md b/docs/adr/0005-async-feed-refresh-pipeline.md new file mode 100644 index 00000000..2883bc15 --- /dev/null +++ b/docs/adr/0005-async-feed-refresh-pipeline.md @@ -0,0 +1,22 @@ +# ADR-0005: Async Feed Refresh with Cache-First Read Path + +## Status +Accepted + +## Context +Feed generation is synchronous in the request path, coupling latency to upstream source behavior. + +## Decision +Introduce a cache-first feed runtime with optional asynchronous refresh behind a feature flag. + +## Consequences +- Positive: improved resilience and latency under upstream instability. +- Negative: operational complexity (queue/worker lifecycle, visibility). + +## Rollout +- Start with in-process queue + worker and bounded retries. +- Serve fresh cache when present, optionally stale-while-revalidate. +- Keep sync fallback path available by flag. + +## Rollback +Disable async refresh flag and route all reads through synchronous generation. diff --git a/docs/adr/0006-app-context.md b/docs/adr/0006-app-context.md new file mode 100644 index 00000000..5bdab59f --- /dev/null +++ b/docs/adr/0006-app-context.md @@ -0,0 +1,27 @@ +# ADR-0006: AppContext as Dependency Wiring Root + +## Status +Accepted (2026-03-01) + +## Context +Application dependencies are currently referenced through module constants and implicit globals from route entrypoints. This makes startup contracts and dependency ownership harder to reason about. + +## Decision +Introduce `Html2rss::Web::AppContext` as the single dependency wiring root used by `App`. + +`AppContext` owns boot-time dependency references for: +- config (`LocalConfig`, `EnvironmentValidator`) +- auth (`Auth`) +- flags (`Flags`) +- logging/observability (`SecurityLogger`, `Observability`) +- API handlers (`Api::V1::*`) +- route assemblers (`Routes::*`) + +Route composition must receive dependencies from `AppContext` rather than looking them up ad hoc in `App`. + +## Consequences +- Positive: explicit boot graph, simpler dependency audits, easier future test seams. +- Negative: more keyword wiring in route entry modules. + +## Rollback +Remove `AppContext`, restore direct module constant references from `App` route assembly. diff --git a/docs/ai-agent-app-web.md b/docs/ai-agent-app-web.md new file mode 100644 index 00000000..f6bc502b --- /dev/null +++ b/docs/ai-agent-app-web.md @@ -0,0 +1,60 @@ +# `app/web` Rules For AI Agents + +This file is intentionally prescriptive. If you are an AI coding agent changing Ruby backend code, follow these rules before adding files or moving code. + +## Namespace Contract + +- `app/` is the Zeitwerk root for `Html2rss`. +- `app/web/**` maps to the `Html2rss::Web` namespace. +- Do not add `require_relative` calls between files under `app/web/**` unless the file is a non-Zeitwerk boot entrypoint. +- Path, filename, and constant name must match. If a constant is `Html2rss::Web::SecurityLogger`, the file belongs at `app/web/security/security_logger.rb`. + +## Directory Placement + +Use the narrowest concern folder that fits the object. + +- `app/web/api/`: API contract and endpoint implementation objects. +- `app/web/boot/`: process boot, loader setup, dev reload, runtime setup. +- `app/web/config/`: environment flags, local config loading, config snapshots. +- `app/web/domain/`: backend domain helpers that do not belong to API, request, rendering, or security. +- `app/web/errors/`: error classes and error response serialization. +- `app/web/feeds/`: feed fetching, rendering orchestration, cache use, feed service contracts. +- `app/web/http/`: low-level HTTP response/cache helpers. +- `app/web/rendering/`: content negotiation and feed output builders. +- `app/web/request/`: request-scoped context and middleware. +- `app/web/routes/`: Roda route composition only. +- `app/web/security/`: auth, token handling, account access, SSRF request strategy, security logging. +- `app/web/telemetry/`: observability event emission only. + +## Placement Heuristics + +- Put code in `routes/` only if it mounts or composes Roda request branches. +- Put code in `api/` only if it is specific to `/api/v1` contracts or endpoint behavior. +- Put code in `feeds/` if it is part of fetching, resolving, rendering, or caching feeds. +- Put code in `domain/` only as a last resort. If a better concern folder exists, use it. +- Do not create generic buckets such as `services`, `utils`, `helpers`, or `concerns`. + +## Consolidation Rules + +- Prefer concern folders over a flat `app/web/` root. +- Do not merge unrelated objects just to reduce file count. +- Consolidate only when one file is clearly a thin wrapper around another concept and the merged object still has a single responsibility. +- If a file defines multiple top-level constants, stop and check whether Zeitwerk naming or the public API would become less clear. + +## Boot And Runtime Rules + +- `app.rb` should declare the Roda app and its Rack/Roda plugins. +- Process-level boot side effects belong in `app/web/boot/**`. +- Register external runtime integrations, validate environment, and configure shared services in boot objects, not inline in the Roda class body. + +## Route Rules + +- Keep route composition centralized in `app/web/routes/**`. +- Split route modules by endpoint concern when a route file grows, but preserve matching order. +- Root metadata routes must use exact matching (`r.is`) so they do not swallow subpaths. + +## Change Checklist + +- Update or add specs for the behavior you moved. +- Run `docker compose -f .devcontainer/docker-compose.yml exec -T app make ready`. +- Smoke the app at `http://127.0.0.1:4001/` when request or UI behavior changed. diff --git a/docs/api/CONTRACT_POLICY.md b/docs/api/CONTRACT_POLICY.md new file mode 100644 index 00000000..c365f688 --- /dev/null +++ b/docs/api/CONTRACT_POLICY.md @@ -0,0 +1,27 @@ +# API Contract Policy (Code-First) + +## Source of Truth +- Implementation + request specs are the source of truth. +- `docs/api/v1/openapi.yaml` is generated output and must reflect code behavior. + +## Required Gates +- `make openapi-verify` must fail on OpenAPI drift. +- `make openapi-lint` must fail on OpenAPI quality violations. +- Frontend generated client drift must fail CI via `npm run openapi:verify` in `frontend/`. + +## Frontend Client Policy +- Generated client/types under `frontend/src/api/generated` are machine-generated only. +- Manual edits in generated files are not allowed. +- Frontend API calls should use generated SDK primitives. + +## CI Contract +- CI must run: + - backend spec drift verification + - OpenAPI linting + - frontend generated-client drift verification + +## Change Process +1. Modify backend behavior/spec-driving tests. +2. Regenerate OpenAPI. +3. Regenerate frontend client. +4. Verify zero drift and passing lint. diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml new file mode 100644 index 00000000..f2e83139 --- /dev/null +++ b/docs/api/v1/openapi.yaml @@ -0,0 +1,532 @@ +--- +openapi: 3.0.3 +info: + title: html2rss-web API + version: 1.0.0 + description: RESTful API for converting websites to RSS feeds. + contact: + name: html2rss-web Support + url: https://github.com/html2rss/html2rss-web + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: +- url: https://api.html2rss.dev/api/v1 + description: Production server +- url: http://127.0.0.1:4000/api/v1 + description: Development server +paths: + "/": + get: + description: API metadata + operationId: getApiMetadata + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + api: + properties: + description: + type: string + name: + type: string + openapi_url: + type: string + required: + - name + - description + - openapi_url + type: object + instance: + properties: + feed_creation: + properties: + access_token_required: + type: boolean + enabled: + type: boolean + required: + - enabled + - access_token_required + type: object + required: + - feed_creation + type: object + required: + - api + - instance + type: object + success: + type: boolean + required: + - success + - data + type: object + description: returns instance feed-creation capability + security: [] + summary: API metadata + tags: + - Root + "/feeds": + post: + description: Create a feed + operationId: createFeed + parameters: + - in: header + name: Authorization + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + strategy: + type: string + url: + type: string + required: + - url + - strategy + type: object + responses: + '201': + content: + application/json: + schema: + properties: + data: + properties: + feed: + properties: + created_at: + type: string + feed_token: + type: string + id: + type: string + name: + type: string + public_url: + type: string + strategy: + type: string + updated_at: + type: string + url: + type: string + required: + - id + - name + - url + - strategy + - feed_token + - public_url + - created_at + - updated_at + type: object + required: + - feed + type: object + meta: + properties: + created: + type: boolean + required: + - created + type: object + success: + type: boolean + required: + - success + - data + - meta + type: object + description: creates a feed when request is valid + '401': + content: + application/json: + schema: + properties: + error: + properties: + code: + type: string + message: + type: string + required: + - message + - code + type: object + success: + type: boolean + required: + - success + - error + type: object + description: returns 401 with UNAUTHORIZED error payload + '403': + content: + application/json: + schema: + properties: + error: + properties: + code: + type: string + message: + type: string + required: + - message + - code + type: object + success: + type: boolean + required: + - success + - error + type: object + description: returns forbidden for authenticated requests when auto source + is disabled + security: + - BearerAuth: [] + summary: Create a feed + tags: + - Feeds + "/feeds/{token}": + get: + description: Render feed by token + operationId: renderFeedByToken + parameters: + - in: path + name: token + required: true + schema: + type: string + responses: + '200': + content: + application/feed+json: + schema: + type: string + application/xml: + schema: + type: string + description: renders feed for a valid token + '401': + content: + application/feed+json: + example: + title: Error + version: https://jsonfeed.org/version/1.1 + schema: + type: string + application/xml: + example: |- + + ErrorInternal Server Error + schema: + type: string + description: returns JSON Feed-shaped errors when requested by json extension + '403': + content: + application/feed+json: + example: + title: Error + version: https://jsonfeed.org/version/1.1 + schema: + type: string + application/xml: + example: |- + + ErrorInternal Server Error + schema: + type: string + description: returns JSON Feed-shaped forbidden errors when requested through + Accept + '500': + content: + application/feed+json: + example: + title: Error + version: https://jsonfeed.org/version/1.1 + schema: + type: string + application/xml: + example: |- + + ErrorInternal Server Error + schema: + type: string + description: returns non-cacheable json feed errors when service generation + fails + security: [] + summary: Render feed by token + tags: + - Feeds + "/health": + get: + description: Authenticated health check + operationId: getHealthStatus + parameters: + - in: header + name: Authorization + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + health: + properties: + checks: + properties: {} + type: object + environment: + type: string + status: + type: string + timestamp: + type: string + uptime: + format: float + type: number + required: + - status + - timestamp + - environment + - uptime + - checks + type: object + required: + - health + type: object + success: + type: boolean + required: + - success + - data + type: object + description: returns health status when token is valid + '401': + content: + application/json: + schema: + properties: + error: + properties: + code: + type: string + message: + type: string + required: + - message + - code + type: object + success: + type: boolean + required: + - success + - error + type: object + description: returns 401 with UNAUTHORIZED error payload + '500': + content: + application/json: + schema: + properties: + error: + properties: + code: + type: string + message: + type: string + required: + - message + - code + type: object + success: + type: boolean + required: + - success + - error + type: object + description: returns error when configuration fails + security: + - BearerAuth: [] + summary: Authenticated health check + tags: + - Health + "/health/live": + get: + description: Liveness probe + operationId: getLivenessProbe + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + health: + properties: + status: + type: string + timestamp: + type: string + required: + - status + - timestamp + type: object + required: + - health + type: object + success: + type: boolean + required: + - success + - data + type: object + description: returns liveness status without authentication + security: [] + summary: Liveness probe + tags: + - Health + "/health/ready": + get: + description: Readiness probe + operationId: getReadinessProbe + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + health: + properties: + checks: + properties: {} + type: object + environment: + type: string + status: + type: string + timestamp: + type: string + uptime: + format: float + type: number + required: + - status + - timestamp + - environment + - uptime + - checks + type: object + required: + - health + type: object + success: + type: boolean + required: + - success + - data + type: object + description: returns readiness status without authentication + security: [] + summary: Readiness probe + tags: + - Health + "/openapi.yaml": + get: + description: OpenAPI specification + operationId: getOpenApiSpec + responses: + '200': + content: + application/yaml: + schema: + type: string + description: serves the OpenAPI document as YAML + security: [] + summary: OpenAPI specification + tags: + - Root + "/strategies": + get: + description: List extraction strategies + operationId: listStrategies + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + strategies: + items: + properties: + display_name: + type: string + id: + type: string + name: + type: string + required: + - id + - name + - display_name + type: object + type: array + required: + - strategies + type: object + meta: + properties: + total: + type: integer + required: + - total + type: object + success: + type: boolean + required: + - success + - data + - meta + type: object + description: returns available strategies + security: [] + summary: List extraction strategies + tags: + - Strategies +components: + securitySchemes: + BearerAuth: + description: Bearer token authentication for API access. + type: http + scheme: bearer + bearerFormat: JWT +tags: +- name: Root + description: API metadata and service-level information. +- name: Health + description: Health and readiness endpoints. +- name: Strategies + description: Feed extraction strategy discovery. +- name: Feeds + description: Feed creation and feed rendering operations. diff --git a/docs/flags/FLAG_MODEL.md b/docs/flags/FLAG_MODEL.md new file mode 100644 index 00000000..69f1647f --- /dev/null +++ b/docs/flags/FLAG_MODEL.md @@ -0,0 +1,33 @@ +# Feature Flag Model + +## Purpose +Define one typed source of truth for runtime feature flags, including parsing, defaults, ownership, and startup validation rules. + +## Registry + +| Name | Env key | Type | Default | Owner | +|---|---|---|---|---| +| `auto_source_enabled` | `AUTO_SOURCE_ENABLED` | `boolean` | `development/test: true`, `other: false` | platform | +| `async_feed_refresh_enabled` | `ASYNC_FEED_REFRESH_ENABLED` | `boolean` | `false` | platform | +| `async_feed_refresh_stale_factor` | `ASYNC_FEED_REFRESH_STALE_FACTOR` | `integer` (`>= 1`) | `3` | platform | + +## Parsing Rules +- Boolean values accepted: `true`, `false` (case-insensitive). +- Integer values must parse as base-10 integers and satisfy declared constraints. +- Missing values resolve to registry defaults. + +## Validation Rules +- Boot must fail fast (`raise`) on malformed flag values. +- Boot must fail fast (`raise`) on unknown feature-style env keys matching managed prefixes: + - `AUTO_SOURCE_` + - `ASYNC_FEED_REFRESH_` + +## Lifecycle +- Add: registry entry + docs update + tests. +- Change: update registry + migration note in PR description. +- Deprecate: remove env reads from callers first, then remove registry entry. + +## Pre-Work Inventory (Current Direct ENV Feature Reads) +- `app/environment_validator.rb` (`AUTO_SOURCE_ENABLED`) +- `app.rb` (`ASYNC_FEED_REFRESH_ENABLED`) [migrated in this revamp] +- `app/feed_runtime.rb` (`ASYNC_FEED_REFRESH_STALE_FACTOR`) diff --git a/docs/migrations/v2.md b/docs/migrations/v2.md new file mode 100644 index 00000000..e6c8414c --- /dev/null +++ b/docs/migrations/v2.md @@ -0,0 +1,242 @@ +# html2rss-web v2 Migration Guide (master -> feat/revamp-frontend) + +This is a full migration guide for the v2 rewrite compared to `master`. +The branch is a large architecture change, not an incremental patch. + +## Scope and Impact + +Compared to `master`, v2 includes: + +- Full backend API restructuring under `app/api/v1/*` and `app/routes/*` +- New Astro frontend app under `frontend/` (components, hooks, tests, docs) +- Security/auth/token/error handling rework +- Dev container standardization and expanded Make workflows +- CI/CD workflow overhaul and smoke-test improvements + +Change size from `master`: + +- ~134 files changed +- ~16k+ lines added, ~3.6k lines removed + +Treat this migration as a **major version upgrade**. + +## Audience-Specific Summary + +### Operators / Deployment + +You must review: + +- Port and probe behavior +- Required environment variables and feature flags +- Docker/CI smoke behavior +- Security defaults and startup validation + +### API Integrators + +You must review: + +- Health route and auth expectations +- Feed endpoint auth/forbidden semantics +- Error payload behavior for unexpected failures + +### Contributors + +You must review: + +- New devcontainer-first workflow +- New backend/frontend test split +- New repository layout and module boundaries + +## Breaking / Behavior Changes + +## 1) Runtime Port and Container Defaults + +- v2 standardizes app runtime on `4000` by default. +- If your infra assumed `3000`, update ingress/proxy or set `PORT` explicitly. + +Affected surfaces: + +- `config/puma.rb` +- `Dockerfile` +- local smoke defaults in `Rakefile` and `spec/smoke/docker_spec.rb` +- docs/OpenAPI examples + +## 2) Health Endpoint Model Changed + +v2 health routes: + +- `GET /api/v1/health` (authenticated) +- `GET /api/v1/health/ready` (unauthenticated readiness) +- `GET /api/v1/health/live` (unauthenticated liveness) + +If your orchestrator cannot set auth headers, use `ready/live` probes. + +## 3) Error Payload Hardening + +- Unexpected internal failures now return a generic client message. +- Internal details are not exposed in API responses. + +Client impact: + +- Depend on `error.code`, not raw exception text. + +## 4) Feed Endpoint Auth and Policy Semantics + +Semantics are now explicit and consistent: + +- `401 UNAUTHORIZED` for missing/invalid credentials +- `403 FORBIDDEN` only for authenticated policy violations + - example: auto source disabled + - example: URL not allowed for account + +## 5) Auto Source Feature Flag Semantics + +Policy is environment-sensitive: + +- Development: enabled unless `AUTO_SOURCE_ENABLED=false` +- Non-development: enabled only when `AUTO_SOURCE_ENABLED=true` + +CI/smoke behavior: + +- `SMOKE_AUTO_SOURCE_ENABLED` must be set to match intended runtime mode. + +## 6) Legacy Server-Rendered UI/Helpers Removed + +Removed legacy ERB/helpers/routes stack in favor of API + Astro frontend: + +- removed `views/*` legacy pages +- removed old `helpers/*` and legacy `routes/auto_source.rb` +- frontend assets now built from `frontend/` + +If you customized old server-rendered pages, port those customizations into Astro. + +## Architecture Changes + +## Backend + +New module boundaries: + +- API contract and handlers: `app/api/v1/*` +- Route composition: `app/routes/api_v1.rb`, `app/routes/static.rb` +- Error policy: `app/error_responder.rb` +- Env/feature policy: `app/environment_validator.rb` +- TTL policy: `app/cache_ttl.rb` +- Feed command split: `app/api/v1/feeds/create_feed.rb`, `show_feed.rb` + +Security/auth stack: + +- token/account management via `app/auth.rb`, `app/feed_token.rb`, `app/account_manager.rb` +- URL allow-list validation in `app/url_validator.rb` +- structured security logging in `app/security_logger.rb` + +## Frontend + +New Astro app and docs site under `frontend/`: + +- UI: `frontend/src/components/*` +- hooks: `frontend/src/hooks/*` +- docs content: `frontend/src/content/docs/*` +- tests: Vitest unit + contract suites + +## Tooling and Workflow Changes + +## Development Workflow + +Dev container is now first-class: + +- `.devcontainer/*` added/updated +- Make targets expanded (`make setup`, `make dev`, `make test`, `make ready`, etc.) + +## CI/CD + +- Legacy workflow replaced by `.github/workflows/ci.yml` +- Distinct Ruby and frontend jobs +- Docker smoke coverage now runs in matrix mode for auto-source enabled/disabled + +## Configuration and Environment + +## Required/Important Variables + +- `HTML2RSS_SECRET_KEY` (required in production) +- `PORT` (defaults to `4000` in v2) +- `AUTO_SOURCE_ENABLED` (intentional per-environment behavior) +- `HEALTH_CHECK_TOKEN` for authenticated health checks + +## Production Validation + +v2 startup performs stronger production checks: + +- secret key presence/strength checks +- account token strength validation + +Invalid production config now fails fast at boot. + +## API Contract and Docs + +Primary API doc: + +- `docs/api/v1/openapi.yaml` + +v2 adds contract-focused specs and smoke tests, but because this rewrite is broad, re-check any +custom client assumptions against actual runtime behavior before rollout. + +## Testing Model Changes + +v2 test layers: + +- Ruby API request/integration specs +- Frontend unit/contract tests +- Docker smoke tests (now mode-aware with `SMOKE_AUTO_SOURCE_ENABLED`) + +Pre-commit gate: + +- `make ready` (RuboCop + Ruby RSpec) + +## Migration Plan (Recommended) + +1. **Inventory integrations** + - health probes + - API clients + - proxy/routing assumptions + +2. **Prepare environment** + - set `HTML2RSS_SECRET_KEY` + - set `AUTO_SOURCE_ENABLED` intentionally + - verify `PORT` assumptions + +3. **Update probe config** + - move readiness/liveness to `/api/v1/health/ready` and `/api/v1/health/live` + - keep `/api/v1/health` for authenticated deep checks + +4. **Update client error handling** + - key off `error.code` + - avoid depending on internal exception strings + +5. **Run smoke in both feature modes** + - `SMOKE_AUTO_SOURCE_ENABLED=false` + - `SMOKE_AUTO_SOURCE_ENABLED=true` + +6. **Canary rollout** + - monitor auth failures, forbidden rates, feed creation success + - verify probe behavior and cache headers + +## Rollback Boundary + +Because v2 is a broad rewrite, rollback should be version-level (image/tag rollback), not +partial cherry-picks. + +Keep previous deployment artifacts available until: + +- probes are stable +- feed creation/retrieval metrics are healthy +- no client-contract regressions are observed + +## Post-Migration Verification Checklist + +- [ ] App reachable on expected port (`4000` unless overridden) +- [ ] Readiness/liveness probes green +- [ ] Authenticated health endpoint works with token +- [ ] Feed creation success path works when auto source enabled +- [ ] Correct forbidden behavior when auto source disabled +- [ ] API clients handle `error.code` contract correctly +- [ ] Frontend build and API integration flows verified diff --git a/docs/observability/EVENT_SCHEMA.md b/docs/observability/EVENT_SCHEMA.md new file mode 100644 index 00000000..c909c120 --- /dev/null +++ b/docs/observability/EVENT_SCHEMA.md @@ -0,0 +1,38 @@ +# Event Schema Contract + +## Scope +Critical path events: +- auth +- feed create +- feed render +- errors + +## Required Fields +- `event_name` (string) +- `schema_version` (string, current: `1.0`) +- `request_id` (string, nullable only outside request lifecycle) +- `route_group` (string) +- `actor` (string, nullable) +- `outcome` (`success` | `failure`) + +## Optional Fields +- `details` (object) +- `path`, `method`, `strategy`, `started_at` from request context when available + +## Categories and Log Pairing + +| Category | Event examples | Log level | +|---|---|---| +| auth | `auth.authenticate` | `info` (success), `warn` (failure) | +| feed create | `feed.create` | `info` (success), `warn` (failure) | +| feed render | `feed.render` | `info` (success), `warn` (failure) | +| errors | `request.error` | `error` | + +## Emission Rules +- Emit exactly one canonical event per successful critical-path action. +- Emit one failure event at the boundary where failure is determined. +- Preserve existing security logs; schema events are additive. + +## Non-Goals +- CI-enforced event-count coverage metrics. +- Backfilling historic logs. diff --git a/docs/plans/revamp-frontend.md b/docs/plans/revamp-frontend.md new file mode 100644 index 00000000..6f5978c0 --- /dev/null +++ b/docs/plans/revamp-frontend.md @@ -0,0 +1,179 @@ +# Revamp: Frontend & Governance β€” Delivery Plan + +**Branch:** `feat/revamp-frontend` +**Release type:** Major β€” breaking changes; no backward compatibility. +**Regression gate:** Manual e2e by author. +**Primary quality goal:** Elegant, simple, readable Ruby. Subtract accidental complexity; add only what earns its weight. + +--- + +## Scope + +Focus areas 2, 4, 5, 6: + +- **2** β€” AppContext and typed application state wiring +- **4** β€” Observability contract (event schema) +- **5** β€” OpenAPI as source of truth (code-first) +- **6** β€” Centralized feature flag model + +--- + +## Principles + +- **Code-first OpenAPI.** Implement, then generate the spec. The spec reflects what the code does β€” not a design artifact written ahead of it. +- **Design-first docs.** Each governance document is written before its phase begins. Docs gate implementation. +- **No backward compatibility.** No adapters, shims, or migration layers. This is a major release. +- **Hard-fail on misconfiguration.** Unknown or malformed flags raise at startup. No graceful degradation. +- **Simplicity over structure.** Every added layer must justify itself. Prefer flat, direct, readable code. When in doubt, do less. + +--- + +## Assumptions Check (Code-Backed, 2026-03-01) + +1. Assumption: `AppContext` already exists or has a migration scaffold. +Status: **invalid**. +Evidence: +- No `AppContext` implementation found in `app/` or `frontend/`. +- Boot still wires dependencies directly in [`app.rb`](/Users/gil/versioned/html2rss/html2rss-web/app.rb). + +2. Assumption: typed application state wiring is already centralized. +Status: **partially confirmed**. +Evidence: +- Frontend uses typed interfaces and hook-local state in [`frontend/src/components/App.tsx`](/Users/gil/versioned/html2rss/html2rss-web/frontend/src/components/App.tsx) and [`frontend/src/hooks/useAuth.ts`](/Users/gil/versioned/html2rss/html2rss-web/frontend/src/hooks/useAuth.ts). +- No shared frontend AppContext/store abstraction exists yet. + +3. Assumption: governance docs referenced by this plan already exist. +Status: **invalid**. +Evidence: +- Missing: `docs/adr/0006-app-context.md`, `docs/flags/FLAG_MODEL.md`, `docs/observability/EVENT_SCHEMA.md`, `docs/api/CONTRACT_POLICY.md`. + +4. Assumption: feature-flag reads are centralized and validated. +Status: **invalid**. +Evidence: +- Direct `ENV` feature/config reads exist in [`app/environment_validator.rb`](/Users/gil/versioned/html2rss/html2rss-web/app/environment_validator.rb), [`app.rb`](/Users/gil/versioned/html2rss/html2rss-web/app.rb), [`app/feed_runtime.rb`](/Users/gil/versioned/html2rss/html2rss-web/app/feed_runtime.rb), [`app/api/v1/health.rb`](/Users/gil/versioned/html2rss/html2rss-web/app/api/v1/health.rb), and [`app/auth.rb`](/Users/gil/versioned/html2rss/html2rss-web/app/auth.rb). + +5. Assumption: observability schema fields in this plan are already emitted. +Status: **partially confirmed**. +Evidence: +- Request context provides `request_id`, `route_group`, `actor` in [`app/request_context.rb`](/Users/gil/versioned/html2rss/html2rss-web/app/request_context.rb). +- Structured logs currently emit `security_event` and context fields in [`app/security_logger.rb`](/Users/gil/versioned/html2rss/html2rss-web/app/security_logger.rb), but not the proposed contract fields `event_name`, `schema_version`, `outcome`. + +6. Assumption: OpenAPI and frontend client drift checks are fully enforced in CI. +Status: **partially confirmed**. +Evidence: +- `Makefile` contains `openapi`, `openapi-verify`, `openapi-client-verify`, and `openapi-lint` targets. +- CI currently enforces backend spec drift (`bundle exec rake openapi:verify`) in [`.github/workflows/ci.yml`](/Users/gil/versioned/html2rss/html2rss-web/.github/workflows/ci.yml), but does not explicitly run `make openapi-lint` or `npm run openapi:verify` in frontend CI. + +--- + +## Phases + +### Phase A β€” AppContext + +**Governance doc first:** `docs/adr/0006-app-context.md` + +Before implementation, define: +- AppContext structure and responsibility boundary. +- Dependency graph: config, auth, flags, logger, metrics, runtime. +- Boot order and initialization contract. + +**Implementation** + +- Introduce AppContext as the single wiring point for application dependencies. +- Remove module-level globals; pass dependencies explicitly. +- No adapters or compatibility shims β€” direct wiring only. + +**Rollback:** Revert phase commits on branch. No production state to unwind. + +**Done when:** AppContext boots cleanly; all dependencies resolve at startup; no global state leaks. + +--- + +### Phase B β€” Feature Flags + +**Governance doc first:** `docs/flags/FLAG_MODEL.md` + +Before implementation, define: +- Typed registry schema: name, type, default, owner, lifecycle (add / change / deprecate). +- ENV mapping policy. +- Pre-work inventory: record current direct `ENV` reads and map each to Flags registry ownership. + +**Implementation** + +- Introduce Flags registry as the sole source of truth for all feature flags. +- All flag reads route through the registry β€” zero direct `ENV[]` checks for feature decisions. +- Startup validation: `raise` on unknown or malformed flag. No warn-and-continue. + +**Rollback:** Revert phase commits on branch. + +**Done when:** No direct `ENV[]` feature checks exist outside the Flags module; app raises on unknown flags at boot. + +--- + +### Phase C β€” Observability + +**Governance doc first:** `docs/observability/EVENT_SCHEMA.md` + +Before implementation, define: +- Required event fields: `event_name`, `schema_version`, `request_id`, `route_group`, `actor`, `outcome`. +- Event categories and log/metric pairing matrix. +- Coverage is a contract by convention β€” not enforced by an automated CI metric. + +**Implementation** + +- Add structured event emission at critical paths: auth, feed create, feed render, errors. +- All covered paths must emit structured log output with the required schema fields. + +**Rollback:** Revert phase commits on branch. + +**Done when:** All documented critical paths emit structured events with required fields; schema doc and implementation agree. + +--- + +### Phase D β€” OpenAPI + +**Governance doc first:** `docs/api/CONTRACT_POLICY.md` + +Before implementation, define: +- Code-first generation policy: spec is generated from implementation. +- Drift enforcement: CI fails if the committed spec diverges from the freshly generated one. +- Frontend client generation policy and drift check. + +**Implementation** + +- Implement API endpoints (shapes are up for grabs β€” no contract is frozen from any prior version). +- Generate OpenAPI spec from implementation. +- Use existing `make openapi-verify` and `make openapi-lint` targets as the contract gate. +- Wire/enforce drift + lint checks in CI pipeline (including frontend generated-client drift verification). +- Regenerate frontend client from spec; wire into the frontend build. + +**Rollback:** Revert phase commits on branch. + +**Done when:** `make openapi-verify` and `make openapi-lint` pass; CI fails on spec/client drift; frontend client is generated, not hand-written. + +--- + +## Acceptance Criteria + +- `make ready` passes inside the Dev Container. +- `make openapi-verify` and `make openapi-lint` pass. +- CI fails on OpenAPI spec / frontend client drift. +- No direct `ENV[]` feature checks outside the Flags module. +- App raises at startup on unknown or malformed flags. +- All critical paths (auth, feed create, feed render, errors) emit structured events with required schema fields. +- No backward-compat adapters, shims, or migration layers anywhere in the codebase. + +--- + +## Commit Slices + +| # | Commit message | +|---|----------------| +| 1 | `docs(arch): AppContext design ADR and flag model spec` | +| 2 | `refactor(core): introduce AppContext and explicit dependency wiring` | +| 3 | `refactor(flags): centralize flag registry with hard-crash startup validation` | +| 4 | `docs(observability): event schema contract` | +| 5 | `feat(observability): structured event emission at critical paths` | +| 6 | `feat(api): implement endpoints and generate OpenAPI spec` | +| 7 | `feat(api-contract): add openapi-verify, openapi-lint, and CI drift enforcement` | +| 8 | `docs(architecture): contract policy and governance index` | diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..009064d1 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,8 @@ +# Frontend package files +package-lock.json +yarn.lock + +# Generated and transient frontend output +.astro/ +test-results/ +src/api/generated/ diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts new file mode 100644 index 00000000..a741535d --- /dev/null +++ b/frontend/e2e/smoke.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +test.describe('frontend smoke', () => { + test('loads demo onboarding and auth toggle', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Convert website to RSS' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Run demo' })).toBeVisible(); + + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('button', { name: 'Back to demo' })).toBeVisible(); + await expect(page.getByLabel('Username')).toBeVisible(); + await expect(page.getByLabel('Token')).toBeVisible(); + + await page.getByRole('button', { name: 'Back to demo' }).click(); + await expect(page.getByRole('button', { name: 'Run demo' })).toBeVisible(); + }); +}); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..5b424d0c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + + + html2rss + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..1dfc02dc --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5740 @@ +{ + "name": "html2rss-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "html2rss-frontend", + "dependencies": { + "@hey-api/client-fetch": "^0.13.1", + "preact": "^10.27.2", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.93.1", + "@playwright/test": "^1.58.2", + "@preact/preset-vite": "^2.10.2", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/preact": "^3.2.4", + "jsdom": "^27.0.0", + "msw": "^2.11.3", + "prettier": "^3.x.x", + "typescript": "^5.9.3", + "vite": "^6.3.6", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.4.tgz", + "integrity": "sha512-cKjSKvWGmAziQWbCouOsFwb14mp1betm8Y7Fn+yglDMUUu3r9DCbJ9iJbeFDenLMqFbIMC0pQP8K+B8LAxX3OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.1.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", + "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.5.tgz", + "integrity": "sha512-kI2MX9pmImjxWT8nxDZY+MuN6r1jJGe7WxizEbsAEPB/zxfW5wYLIiPG1v3UKgEOOP8EsDkp0ZL99oRFAdPM8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/cookie/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "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-syntax-patches-for-csstree": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.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.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hey-api/client-fetch": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.13.1.tgz", + "integrity": "sha512-29jBRYNdxVGlx5oewFgOrkulZckpIpBIRHth3uHFn1PrL2ucMy52FvWOY3U3dVx2go1Z3kUmMi6lr07iOpUqqA==", + "deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "@hey-api/openapi-ts": "< 2" + } + }, + "node_modules/@hey-api/codegen-core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.0.tgz", + "integrity": "sha512-HglL4B4QwpzocE+c8qDU6XK8zMf8W8Pcv0RpFDYxHuYALWLTnpDUuEsglC7NQ4vC1maoXsBpMbmwpco0N4QviA==", + "license": "MIT", + "dependencies": { + "@hey-api/types": "0.1.3", + "ansi-colors": "4.1.3", + "c12": "3.3.3", + "color-support": "1.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.1.tgz", + "integrity": "sha512-7atnpUkT8TyUPHYPLk91j/GyaqMuwTEHanLOe50Dlx0EEvNuQqFD52Yjg8x4KU0UFL1mWlyhE+sUE/wAtQ1N2A==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "7.1.3", + "@types/json-schema": "7.0.15", + "js-yaml": "4.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.93.1", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.93.1.tgz", + "integrity": "sha512-oQJPHiVkJKesZFpoW3jfQhrSQ7xdgzai7895ENl6ZDjCaIK6bOUTly7bsu+7+0ONsGH9jbtGbkoUzC+MtY+RKg==", + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "0.7.0", + "@hey-api/json-schema-ref-parser": "1.3.1", + "@hey-api/shared": "0.2.1", + "@hey-api/types": "0.1.3", + "ansi-colors": "4.1.3", + "color-support": "1.1.3", + "commander": "14.0.3" + }, + "bin": { + "openapi-ts": "bin/run.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/shared": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.2.1.tgz", + "integrity": "sha512-uWI9047e9OVe3Ss+6vPMnRiixjRcjcBbdgpeq4IQymet3+wsn0+N/4RLDHBz1h57SemaxayPRUA0JOOsuC1qyA==", + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "0.7.0", + "@hey-api/json-schema-ref-parser": "1.3.1", + "@hey-api/types": "0.1.3", + "ansi-colors": "4.1.3", + "cross-spawn": "7.0.6", + "open": "11.0.0", + "semver": "7.7.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/types": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.3.tgz", + "integrity": "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", + "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.18.tgz", + "integrity": "sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", + "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/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/@inquirer/core/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/@inquirer/core/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/@inquirer/core/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/@inquirer/core/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/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "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/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.6.tgz", + "integrity": "sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.2.tgz", + "integrity": "sha512-K9wHlJOtkE+cGqlyQ5v9kL3Ge0Ql4LlIZjkUTL+1zf3nNdF88F9UZN6VTV8jdzBX9Fl7WSzeNMSDG7qECPmSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.15", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@prefresh/vite": "^2.4.1", + "@rollup/pluginutils": "^4.1.1", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.3.4", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.3" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x" + } + }, + "node_modules/@preact/preset-vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@preact/preset-vite/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@preact/preset-vite/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.2.tgz", + "integrity": "sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.7.tgz", + "integrity": "sha512-AsyeitiPwG7UkT0mqgKzIDuydmYSKtBlzXEb5ymzskvxewcmVGRjQkcHDy6PCNBT7soAyHpQ0mPgXX4IeyOlUg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.10.tgz", + "integrity": "sha512-lt+ODASOtXRWaPplp7/DlrgAaInnQYNvcpCglQBMx2OeJPyZ4IqPRaxsK77w96mWshjYwkqTsRSHoAM7aAn0ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/dom/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/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/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/@testing-library/dom/node_modules/chalk/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/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", + "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/preact": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.2.4.tgz", + "integrity": "sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^8.11.1" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "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/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "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.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", + "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^17.2.3", + "exsolve": "^1.0.8", + "giget": "^2.0.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.0.0", + "pkg-types": "^2.3.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "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": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "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/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/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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/cliui/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/cliui/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/cliui/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/cliui/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/cliui/node_modules/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/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/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", + "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "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-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "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/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true, + "license": "ISC" + }, + "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-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "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/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/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "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/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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-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/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "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/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "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-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "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/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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.5.4", + "cssstyle": "^5.3.0", + "data-urls": "^6.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.3.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0", + "ws": "^8.18.2", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.3.tgz", + "integrity": "sha512-878imp8jxIpfzuzxYfX0qqTq1IFQz/1/RBHs/PyirSjzi+xKM/RRfIpIqHSCWjH0GxidrjhgiiXC+DWXNDvT9w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.39.1", + "@open-draft/deferred-promise": "^2.2.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^4.26.1", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "license": "MIT" + }, + "node_modules/nypm/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "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-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "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/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/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/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/preact": { + "version": "10.29.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", + "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "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/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "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/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/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/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/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.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.15.tgz", + "integrity": "sha512-heYRCiGLhtI+U/D0V8YM3QRwPfsLJiP+HX+YwiHZTnWzjIKC+ZCxQRYlzvOoTEc6KIP62B1VeAN63diGCng2hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.15.tgz", + "integrity": "sha512-YBkp2VfS9VTRMPNL2PA6PMESmxV1JEVoAr5iBlZnB5JG3KUrWzNCB3yNNkRa2FZkqClaBgfNYCp8PgpYmpjkZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.12.tgz", + "integrity": "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "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/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/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/yargs/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/yargs/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/yargs/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/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..677b51bd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "html2rss-frontend", + "type": "module", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "dev": "vite --port 4001 --host", + "build": "vite build", + "preview": "vite preview --port 4001 --host", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "openapi:generate": "openapi-ts -i ../docs/api/v1/openapi.yaml -o src/api/generated -c @hey-api/client-fetch", + "openapi:verify": "npm run openapi:generate && git diff --exit-code -- src/api/generated", + "test": "vitest", + "test:run": "vitest run", + "test:unit": "vitest run --reporter=verbose --config vitest.unit.config.ts", + "test:contract": "vitest run --reporter=verbose --config vitest.contract.config.ts", + "test:ci": "npm run test:unit && npm run test:contract", + "test:e2e": "playwright test" + }, + "dependencies": { + "@hey-api/client-fetch": "^0.13.1", + "preact": "^10.27.2", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.93.1", + "@preact/preset-vite": "^2.10.2", + "@playwright/test": "^1.58.2", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/preact": "^3.2.4", + "jsdom": "^27.0.0", + "msw": "^2.11.3", + "prettier": "^3.x.x", + "typescript": "^5.9.3", + "vite": "^6.3.6", + "vitest": "^3.2.4" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..760d6489 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4001', + headless: true, + trace: 'on-first-retry', + launchOptions: chromiumExecutablePath + ? { + executablePath: chromiumExecutablePath, + args: ['--no-sandbox'], + } + : undefined, + }, + webServer: { + command: 'npm run dev -- --host 0.0.0.0 --port 4001', + url: 'http://127.0.0.1:4001', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 00000000..476923e8 --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,21 @@ +/** @type {import("prettier").Config} */ +export default { + printWidth: 110, + singleQuote: false, + trailingComma: 'all', + overrides: [ + { + files: '*.{html,md,mdx}', + options: { + proseWrap: 'preserve', + }, + }, + { + files: '*.{js,ts,jsx,tsx}', + options: { + singleQuote: true, + trailingComma: 'es5', + }, + }, + ], +}; diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx new file mode 100644 index 00000000..d694fd11 --- /dev/null +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import { http, HttpResponse } from 'msw'; +import { server, buildFeedResponse } from './mocks/server'; +import { App } from '../components/App'; + +describe('App contract', () => { + const token = 'contract-token'; + + const authenticate = () => { + window.sessionStorage.setItem('html2rss_access_token', token); + }; + + it('shows feed result when API responds with success', async () => { + authenticate(); + + server.use( + http.post('/api/v1/feeds', async ({ request }) => { + const body = (await request.json()) as { url: string; strategy: string }; + + expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' }); + expect(request.headers.get('authorization')).toBe(`Bearer ${token}`); + + return HttpResponse.json( + buildFeedResponse({ + url: body.url, + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + }) + ); + }), + http.get('/api/v1/feeds/generated-token', ({ request }) => { + expect(request.headers.get('accept')).toBe('application/feed+json'); + + return HttpResponse.json( + { + items: [{ title: 'Contract Item' }], + }, + { + headers: { 'content-type': 'application/feed+json' }, + } + ); + }) + ); + + render(); + + await screen.findByLabelText('Page URL'); + + const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement; + fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await waitFor(() => { + expect(screen.getByText('Example Feed')).toBeInTheDocument(); + expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute( + 'href', + 'http://localhost:3000/api/v1/feeds/generated-token.json' + ); + expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); + expect(screen.getByText('Feed preview')).toBeInTheDocument(); + expect(screen.getByText('Contract Item')).toBeInTheDocument(); + }); + }); + + it('loads instance metadata from /api/v1 without trailing slash', async () => { + let slashlessMetadataRequests = 0; + let trailingSlashMetadataRequests = 0; + + server.use( + http.get('/api/v1', () => { + slashlessMetadataRequests += 1; + + return HttpResponse.json({ + success: true, + data: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/api/v1/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, + }, + }, + }, + }); + }), + http.get('/api/v1/', () => { + trailingSlashMetadataRequests += 1; + + return HttpResponse.text('', { status: 404 }); + }) + ); + + render(); + + await screen.findByLabelText('Page URL'); + + expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument(); + expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument(); + expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1); + expect(trailingSlashMetadataRequests).toBe(0); + }); + + it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => { + server.use( + http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })), + http.get('/api/v1/', () => HttpResponse.text('', { status: 404 })) + ); + + render(); + + await screen.findByText('Instance metadata unavailable'); + + expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument(); + }); + + it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => { + authenticate(); + + server.use( + http.post('/api/v1/feeds', async () => + HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 }) + ) + ); + + render(); + + await screen.findByLabelText('Page URL'); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await screen.findByText('Access token was rejected. Paste a valid token to continue.'); + + expect(screen.getByText('Add access token')).toBeInTheDocument(); + expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument(); + expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx new file mode 100644 index 00000000..dbe84e44 --- /dev/null +++ b/frontend/src/__tests__/App.test.tsx @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import { h } from 'preact'; +import { App } from '../components/App'; + +vi.mock('../hooks/useAccessToken', () => ({ + useAccessToken: vi.fn(), +})); + +vi.mock('../hooks/useFeedConversion', () => ({ + useFeedConversion: vi.fn(), +})); + +vi.mock('../hooks/useApiMetadata', () => ({ + useApiMetadata: vi.fn(), +})); + +vi.mock('../hooks/useStrategies', () => ({ + useStrategies: vi.fn(), +})); + +import { useAccessToken } from '../hooks/useAccessToken'; +import { useApiMetadata } from '../hooks/useApiMetadata'; +import { useFeedConversion } from '../hooks/useFeedConversion'; +import { useStrategies } from '../hooks/useStrategies'; + +const mockUseAccessToken = useAccessToken as any; +const mockUseApiMetadata = useApiMetadata as any; +const mockUseFeedConversion = useFeedConversion as any; +const mockUseStrategies = useStrategies as any; + +describe('App', () => { + const mockSaveToken = vi.fn(); + const mockClearToken = vi.fn(); + const mockConvertFeed = vi.fn(); + const mockClearConversionError = vi.fn(); + const mockClearResult = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + mockUseAccessToken.mockReturnValue({ + token: null, + hasToken: false, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: null, + }); + + mockUseApiMetadata.mockReturnValue({ + metadata: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/api/v1/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, + }, + }, + }, + isLoading: false, + error: null, + }); + + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: null, + error: null, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + }); + + mockUseStrategies.mockReturnValue({ + strategies: [ + { id: 'ssrf_filter', name: 'ssrf_filter', display_name: 'Standard (recommended)' }, + { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages' }, + ], + isLoading: false, + error: null, + }); + }); + + it('renders the radical-simple create flow', () => { + render(); + + expect(screen.getByLabelText('html2rss')).toBeInTheDocument(); + expect(screen.getByLabelText('Page URL')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); + }); + + it('autofocuses the source url field', async () => { + render(); + + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByLabelText('Page URL')); + }); + }); + + it('shows inline token prompt when submitting without a token', async () => { + render(); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + expect(screen.getByText('Add access token')).toBeInTheDocument(); + expect(screen.getByLabelText('Page URL')).toBeDisabled(); + expect(screen.getByRole('combobox')).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Set up your own instance with Docker.' })).toBeInTheDocument(); + expect(screen.getByText('This instance needs an access token.')).toBeInTheDocument(); + expect(screen.queryByText('Paste an access token to keep going.')).not.toBeInTheDocument(); + await waitFor(() => { + expect(document.activeElement).toBe(document.getElementById('access-token')); + }); + expect(mockConvertFeed).not.toHaveBeenCalled(); + }); + + it('renders the result panel when a feed is available', async () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: { + id: 'feed-123', + name: 'Example Feed', + url: 'https://example.com/articles', + strategy: 'ssrf_filter', + feed_token: 'example-token', + public_url: '/api/v1/feeds/example-token', + json_public_url: '/api/v1/feeds/example-token.json', + }, + error: null, + convertFeed: mockConvertFeed, + clearError: mockClearConversionError, + clearResult: mockClearResult, + }); + + render(); + + expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument(); + expect(screen.getByText('Example Feed')).toBeInTheDocument(); + }); + + it('surfaces conversion errors to the user', () => { + mockUseFeedConversion.mockReturnValue({ + isConverting: false, + result: null, + error: 'Access denied', + convertFeed: mockConvertFeed, + clearResult: mockClearResult, + }); + + render(); + + expect(screen.getByText('Feed generation failed')).toBeInTheDocument(); + expect(screen.getByText('Access denied')).toBeInTheDocument(); + }); + + it('clears stored token from instance info', () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: null, + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear saved token' })); + + expect(mockClearToken).toHaveBeenCalled(); + }); + + it('saves access token and resumes feed creation from the inline prompt', async () => { + render(); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + const accessTokenInput = document.getElementById('access-token') as HTMLInputElement; + fireEvent.input(accessTokenInput, { target: { value: 'token-123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save and continue' })); + + await waitFor(() => { + expect(mockSaveToken).toHaveBeenCalledWith('token-123'); + expect(mockConvertFeed).toHaveBeenCalledWith( + 'https://example.com/articles', + 'ssrf_filter', + 'token-123' + ); + }); + }); + + it('reopens the token prompt when a saved token is rejected', async () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: null, + }); + mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); + + render(); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await waitFor(() => { + expect(screen.getByText('Add access token')).toBeInTheDocument(); + expect( + screen.getByText('Access token was rejected. Paste a valid token to continue.') + ).toBeInTheDocument(); + expect(mockClearToken).toHaveBeenCalled(); + expect(mockClearConversionError).toHaveBeenCalled(); + }); + }); + + it('clears stale conversion error when backing out of token recovery', async () => { + mockUseAccessToken.mockReturnValue({ + token: 'saved-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: null, + }); + mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); + + render(); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await screen.findByText('Access token was rejected. Paste a valid token to continue.'); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument(); + expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument(); + }); + + it('submits the token prompt with Enter', async () => { + render(); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + const accessTokenInput = document.getElementById('access-token') as HTMLInputElement; + fireEvent.input(accessTokenInput, { target: { value: 'token-123' } }); + fireEvent.keyDown(accessTokenInput, { key: 'Enter' }); + + await waitFor(() => { + expect(mockSaveToken).toHaveBeenCalledWith('token-123'); + }); + }); + + it('builds a bookmarklet that returns to the current frontend entry', () => { + window.history.replaceState({}, '', 'http://localhost:3000/frontend/index.html'); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + const bookmarklet = screen.getByRole('link', { name: 'Bookmarklet' }); + expect(bookmarklet.getAttribute('href')).toContain('/frontend/index.html?url='); + expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent'); + }); +}); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx new file mode 100644 index 00000000..230a81d7 --- /dev/null +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import { h } from 'preact'; +import { ResultDisplay } from '../components/ResultDisplay'; + +describe('ResultDisplay', () => { + const mockOnCreateAnother = vi.fn(); + const mockResult = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'ssrf_filter', + feed_token: 'test-feed-token', + public_url: 'https://example.com/feed.xml', + json_public_url: 'https://example.com/feed.json', + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { title: 'Item One' }, + { content_text: '56 points by canpan 1 hour ago | hide | 18 comments' }, + { content_text: '2. Item Two ( example.com )' }, + ], + }), + } as Response); + }); + + it('renders the simplified result actions and preview', async () => { + render(); + + expect(screen.getByText('Test Feed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute( + 'href', + 'https://example.com/feed.json' + ); + await waitFor(() => { + expect(screen.getByText('Item One')).toBeInTheDocument(); + expect(screen.getByText(/points by canpan/i)).toBeInTheDocument(); + expect(screen.getByText('Item Two')).toBeInTheDocument(); + }); + expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.json', { + headers: { Accept: 'application/feed+json' }, + }); + }); + + it('surfaces preview fetch failures as a result-state message', async () => { + vi.mocked(window.fetch).mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument(); + }); + }); + + it('calls onCreateAnother when the reset button is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Create another feed' })); + + expect(mockOnCreateAnother).toHaveBeenCalled(); + }); + + it('copies feed URL to clipboard when copy button is clicked', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy feed URL' })); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://example.com/feed.xml'); + }); + }); +}); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts new file mode 100644 index 00000000..23bda60e --- /dev/null +++ b/frontend/src/__tests__/mocks/server.ts @@ -0,0 +1,77 @@ +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; + +export const server = setupServer( + http.get('/api/v1', () => { + return HttpResponse.json({ + success: true, + data: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/api/v1/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, + }, + }, + }, + }); + }), + http.get('/api/v1/strategies', () => { + return HttpResponse.json({ + success: true, + data: { + strategies: [ + { + id: 'ssrf_filter', + name: 'ssrf_filter', + display_name: 'Standard (recommended)', + }, + { + id: 'browserless', + name: 'browserless', + display_name: 'JavaScript pages', + }, + ], + }, + meta: { total: 2 }, + }); + }) +); + +export interface FeedResponseOverrides { + id?: string; + name?: string; + url?: string; + strategy?: string; + feed_token?: string; + public_url?: string; + json_public_url?: string; + created_at?: string; + updated_at?: string; +} + +export function buildFeedResponse(overrides: FeedResponseOverrides = {}) { + const timestamp = overrides.created_at ?? new Date('2024-01-01T00:00:00Z').toISOString(); + + return { + success: true, + data: { + feed: { + id: overrides.id ?? 'feed-123', + name: overrides.name ?? 'Example Feed', + url: overrides.url ?? 'https://example.com/articles', + strategy: overrides.strategy ?? 'ssrf_filter', + feed_token: overrides.feed_token ?? 'example-token', + public_url: overrides.public_url ?? '/api/v1/feeds/example-token', + json_public_url: overrides.json_public_url ?? '/api/v1/feeds/example-token.json', + created_at: timestamp, + updated_at: overrides.updated_at ?? timestamp, + }, + }, + meta: { created: true }, + }; +} diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts new file mode 100644 index 00000000..3594f61a --- /dev/null +++ b/frontend/src/__tests__/setup.ts @@ -0,0 +1,88 @@ +import '@testing-library/jest-dom'; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/preact'; +import { server } from './mocks/server'; + +// Mock window and document for tests +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Persistent storage stubs with in-memory backing store +const createStorageMock = () => { + const store = new Map(); + + return { + store, + api: { + get length() { + return store.size; + }, + getItem: vi.fn((key: string) => (store.has(key) ? store.get(key)! : null)), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + clear: vi.fn(() => { + store.clear(); + }), + key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null), + }, + }; +}; + +const local = createStorageMock(); +const session = createStorageMock(); + +Object.defineProperty(window, 'localStorage', { + value: local.api, +}); + +Object.defineProperty(window, 'sessionStorage', { + value: session.api, +}); + +beforeEach(() => { + local.store.clear(); + session.store.clear(); + local.api.getItem.mockClear(); + local.api.setItem.mockClear(); + local.api.removeItem.mockClear(); + local.api.clear.mockClear(); + local.api.key.mockClear(); + session.api.getItem.mockClear(); + session.api.setItem.mockClear(); + session.api.removeItem.mockClear(); + session.api.clear.mockClear(); + session.api.key.mockClear(); +}); + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(() => Promise.resolve()), + }, +}); + +// Ensure scrollIntoView exists for components relying on it +Element.prototype.scrollIntoView = vi.fn(); + +// Wire up MSW in node environment +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); +afterAll(() => server.close()); diff --git a/frontend/src/__tests__/useAuth.test.ts b/frontend/src/__tests__/useAuth.test.ts new file mode 100644 index 00000000..bb5fd1ba --- /dev/null +++ b/frontend/src/__tests__/useAuth.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/preact'; +import { useAuth } from '../hooks/useAuth'; + +type MockedStorage = Storage & { + getItem: ReturnType; + setItem: ReturnType; + removeItem: ReturnType; + clear: ReturnType; +}; + +const createStorageMock = (): MockedStorage => { + return { + length: 0, + clear: vi.fn(), + getItem: vi.fn(), + key: vi.fn(), + removeItem: vi.fn(), + setItem: vi.fn(), + } as unknown as MockedStorage; +}; + +let sessionStorageMock: MockedStorage; + +describe('useAuth', () => { + beforeEach(() => { + sessionStorageMock = createStorageMock(); + Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, + configurable: true, + writable: true, + }); + vi.clearAllMocks(); + }); + + it('should initialize with unauthenticated state', () => { + sessionStorageMock.getItem.mockReturnValue(null); + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.username).toBeNull(); + expect(result.current.token).toBeNull(); + }); + + it('should load auth state from sessionStorage on mount', () => { + sessionStorageMock.getItem + .mockReturnValueOnce('testuser') // username + .mockReturnValueOnce('testtoken'); // token + + const { result } = renderHook(() => useAuth()); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.username).toBe('testuser'); + expect(result.current.token).toBe('testtoken'); + expect(sessionStorageMock.getItem).toHaveBeenCalledWith('html2rss_username'); + expect(sessionStorageMock.getItem).toHaveBeenCalledWith('html2rss_token'); + }); + + it('should login and store credentials', async () => { + sessionStorageMock.getItem.mockReturnValue(null); + + const { result } = renderHook(() => useAuth()); + + await act(async () => { + result.current.login('newuser', 'newtoken'); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.username).toBe('newuser'); + expect(result.current.token).toBe('newtoken'); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith('html2rss_username', 'newuser'); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith('html2rss_token', 'newtoken'); + }); + + it('should logout and clear credentials', () => { + sessionStorageMock.getItem.mockReturnValueOnce('testuser').mockReturnValueOnce('testtoken'); + + const { result } = renderHook(() => useAuth()); + + act(() => { + result.current.logout(); + }); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.username).toBeNull(); + expect(result.current.token).toBeNull(); + expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('html2rss_username'); + expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('html2rss_token'); + }); +}); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts new file mode 100644 index 00000000..cbdcc39b --- /dev/null +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/preact'; +import { http, HttpResponse } from 'msw'; +import { server, buildFeedResponse } from './mocks/server'; +import { useFeedConversion } from '../hooks/useFeedConversion'; + +describe('useFeedConversion contract', () => { + it('sends feed creation request with bearer token', async () => { + let receivedAuthorization: string | null = null; + + server.use( + http.post('/api/v1/feeds', async ({ request }) => { + const body = (await request.json()) as { url: string; strategy: string }; + receivedAuthorization = request.headers.get('authorization'); + + expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' }); + + return HttpResponse.json( + buildFeedResponse({ + url: body.url, + feed_token: 'generated-token', + public_url: '/api/v1/feeds/generated-token', + json_public_url: '/api/v1/feeds/generated-token.json', + }) + ); + }) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'test-token-123'); + }); + + expect(receivedAuthorization).toBe('Bearer test-token-123'); + expect(result.current.error).toBeNull(); + expect(result.current.result?.feed_token).toBe('generated-token'); + expect(result.current.result?.public_url).toBe('/api/v1/feeds/generated-token'); + expect(result.current.result?.json_public_url).toBe('/api/v1/feeds/generated-token.json'); + }); + + it('propagates API validation errors', async () => { + server.use( + http.post('/api/v1/feeds', async () => + HttpResponse.json( + { success: false, error: { message: 'URL parameter is required' } }, + { status: 400 } + ) + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + ).rejects.toThrow('URL parameter is required'); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('URL parameter is required'); + }); + + it('normalizes malformed successful responses', async () => { + server.use( + http.post('/api/v1/feeds', async () => + HttpResponse.text('not-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + ).rejects.toThrow('Invalid response format from feed creation API'); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('Invalid response format from feed creation API'); + }); +}); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts new file mode 100644 index 00000000..4d08a542 --- /dev/null +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } from 'vitest'; +import { renderHook, act } from '@testing-library/preact'; +import { useFeedConversion } from '../hooks/useFeedConversion'; + +describe('useFeedConversion', () => { + let fetchMock: SpyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.spyOn(global, 'fetch'); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useFeedConversion()); + + expect(result.current.isConverting).toBe(false); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('should handle successful conversion', async () => { + const mockResult = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'ssrf_filter', + feed_token: 'test-token', + public_url: 'https://example.com/feed.xml', + json_public_url: 'https://example.com/feed.json', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { feed: mockResult }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken'); + }); + + expect(result.current.isConverting).toBe(false); + expect(result.current.result).toEqual(mockResult); + expect(result.current.error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should handle conversion error', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + error: { message: 'Bad Request' }, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken') + ).rejects.toThrow('Bad Request'); + }); + + expect(result.current.isConverting).toBe(false); + expect(result.current.result).toBeNull(); + expect(result.current.error).toContain('Bad Request'); + }); + + it('should handle network errors gracefully', async () => { + fetchMock.mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com', 'ssrf_filter', 'testtoken') + ).rejects.toThrow('Network error'); + }); + + expect(result.current.isConverting).toBe(false); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('Network error'); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 00000000..3e8bd3f3 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,19 @@ +import { createClient, createConfig } from './generated/client'; + +const resolveBaseUrl = (): string => { + if (typeof window === 'undefined') return 'http://localhost/api/v1'; + + const origin = window.location?.origin; + if (!origin || origin === 'null') return 'http://localhost/api/v1'; + + return `${origin}/api/v1`; +}; + +export const apiClient = createClient( + createConfig({ + baseUrl: resolveBaseUrl(), + }) +); + +export const bearerHeaders = (token: string | null): Record => + token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts new file mode 100644 index 00000000..57d0a54b --- /dev/null +++ b/frontend/src/api/contracts.ts @@ -0,0 +1,14 @@ +import type { CreateFeedResponses, GetApiMetadataResponses, ListStrategiesResponses } from './generated'; + +export type FeedRecord = CreateFeedResponses[201]['data']['feed']; +export type StrategyRecord = ListStrategiesResponses[200]['data']['strategies'][number]; + +export interface ApiMetadataRecord { + api: GetApiMetadataResponses[200]['data']['api']; + instance: { + feed_creation: { + enabled: boolean; + access_token_required: boolean; + }; + }; +} diff --git a/frontend/src/api/generated/client.gen.ts b/frontend/src/api/generated/client.gen.ts new file mode 100644 index 00000000..8b4fbb6a --- /dev/null +++ b/frontend/src/api/generated/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T> | Promise & T>>; + +export const client = createClient(createConfig({ baseUrl: 'https://api.html2rss.dev/api/v1' })); diff --git a/frontend/src/api/generated/client/client.gen.ts b/frontend/src/api/generated/client/client.gen.ts new file mode 100644 index 00000000..d2e55a14 --- /dev/null +++ b/frontend/src/api/generated/client/client.gen.ts @@ -0,0 +1,288 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response: Response; + + try { + response = await _fetch(request); + } catch (error) { + // Handle fetch exceptions (AbortError, network errors, etc.) + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, undefined as any, request, opts)) as unknown; + } + } + + finalError = finalError || ({} as unknown); + + if (opts.throwOnError) { + throw finalError; + } + + // Return error response + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response: undefined as any, + }; + } + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/frontend/src/api/generated/client/index.ts b/frontend/src/api/generated/client/index.ts new file mode 100644 index 00000000..b295edec --- /dev/null +++ b/frontend/src/api/generated/client/index.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/frontend/src/api/generated/client/types.gen.ts b/frontend/src/api/generated/client/types.gen.ts new file mode 100644 index 00000000..8c0df232 --- /dev/null +++ b/frontend/src/api/generated/client/types.gen.ts @@ -0,0 +1,214 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T> | Promise & T>>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/frontend/src/api/generated/client/utils.gen.ts b/frontend/src/api/generated/client/utils.gen.ts new file mode 100644 index 00000000..b4bd2435 --- /dev/null +++ b/frontend/src/api/generated/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/frontend/src/api/generated/core/auth.gen.ts b/frontend/src/api/generated/core/auth.gen.ts new file mode 100644 index 00000000..3ebf9947 --- /dev/null +++ b/frontend/src/api/generated/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/frontend/src/api/generated/core/bodySerializer.gen.ts b/frontend/src/api/generated/core/bodySerializer.gen.ts new file mode 100644 index 00000000..8ad92c9f --- /dev/null +++ b/frontend/src/api/generated/core/bodySerializer.gen.ts @@ -0,0 +1,84 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>(body: T): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/frontend/src/api/generated/core/params.gen.ts b/frontend/src/api/generated/core/params.gen.ts new file mode 100644 index 00000000..7955601a --- /dev/null +++ b/frontend/src/api/generated/core/params.gen.ts @@ -0,0 +1,169 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + (params[field.in] as Record)[name] = arg; + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[key.slice(prefix.length)] = value; + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/frontend/src/api/generated/core/pathSerializer.gen.ts b/frontend/src/api/generated/core/pathSerializer.gen.ts new file mode 100644 index 00000000..994b2848 --- /dev/null +++ b/frontend/src/api/generated/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/frontend/src/api/generated/core/queryKeySerializer.gen.ts b/frontend/src/api/generated/core/queryKeySerializer.gen.ts new file mode 100644 index 00000000..5000df60 --- /dev/null +++ b/frontend/src/api/generated/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/frontend/src/api/generated/core/serverSentEvents.gen.ts b/frontend/src/api/generated/core/serverSentEvents.gen.ts new file mode 100644 index 00000000..6aa6cf02 --- /dev/null +++ b/frontend/src/api/generated/core/serverSentEvents.gen.ts @@ -0,0 +1,243 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + // Normalize line endings: CRLF -> LF, then CR -> LF + buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/frontend/src/api/generated/core/types.gen.ts b/frontend/src/api/generated/core/types.gen.ts new file mode 100644 index 00000000..97463257 --- /dev/null +++ b/frontend/src/api/generated/core/types.gen.ts @@ -0,0 +1,104 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/frontend/src/api/generated/core/utils.gen.ts b/frontend/src/api/generated/core/utils.gen.ts new file mode 100644 index 00000000..e7ddbe35 --- /dev/null +++ b/frontend/src/api/generated/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts new file mode 100644 index 00000000..a81e16b8 --- /dev/null +++ b/frontend/src/api/generated/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getOpenApiSpec, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetOpenApiSpecData, GetOpenApiSpecResponse, GetOpenApiSpecResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts new file mode 100644 index 00000000..f9f0bb7e --- /dev/null +++ b/frontend/src/api/generated/sdk.gen.ts @@ -0,0 +1,87 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetOpenApiSpecData, GetOpenApiSpecResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * API metadata + * + * API metadata + */ +export const getApiMetadata = (options?: Options) => (options?.client ?? client).get({ url: '/', ...options }); + +/** + * Create a feed + * + * Create a feed + */ +export const createFeed = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/feeds', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Render feed by token + * + * Render feed by token + */ +export const renderFeedByToken = (options: Options) => (options.client ?? client).get({ url: '/feeds/{token}', ...options }); + +/** + * Authenticated health check + * + * Authenticated health check + */ +export const getHealthStatus = (options: Options) => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/health', + ...options +}); + +/** + * Liveness probe + * + * Liveness probe + */ +export const getLivenessProbe = (options?: Options) => (options?.client ?? client).get({ url: '/health/live', ...options }); + +/** + * Readiness probe + * + * Readiness probe + */ +export const getReadinessProbe = (options?: Options) => (options?.client ?? client).get({ url: '/health/ready', ...options }); + +/** + * OpenAPI specification + * + * OpenAPI specification + */ +export const getOpenApiSpec = (options?: Options) => (options?.client ?? client).get({ url: '/openapi.yaml', ...options }); + +/** + * List extraction strategies + * + * List extraction strategies + */ +export const listStrategies = (options?: Options) => (options?.client ?? client).get({ url: '/strategies', ...options }); diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts new file mode 100644 index 00000000..80d6d4f3 --- /dev/null +++ b/frontend/src/api/generated/types.gen.ts @@ -0,0 +1,290 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://api.html2rss.dev/api/v1' | 'http://127.0.0.1:4000/api/v1' | (string & {}); +}; + +export type GetApiMetadataData = { + body?: never; + path?: never; + query?: never; + url: '/'; +}; + +export type GetApiMetadataResponses = { + /** + * returns instance feed-creation capability + */ + 200: { + data: { + api: { + description: string; + name: string; + openapi_url: string; + }; + instance: { + feed_creation: { + access_token_required: boolean; + enabled: boolean; + }; + }; + }; + success: boolean; + }; +}; + +export type GetApiMetadataResponse = GetApiMetadataResponses[keyof GetApiMetadataResponses]; + +export type CreateFeedData = { + body?: { + strategy: string; + url: string; + }; + headers: { + Authorization: string; + }; + path?: never; + query?: never; + url: '/feeds'; +}; + +export type CreateFeedErrors = { + /** + * returns 401 with UNAUTHORIZED error payload + */ + 401: { + error: { + code: string; + message: string; + }; + success: boolean; + }; + /** + * returns forbidden for authenticated requests when auto source is disabled + */ + 403: { + error: { + code: string; + message: string; + }; + success: boolean; + }; +}; + +export type CreateFeedError = CreateFeedErrors[keyof CreateFeedErrors]; + +export type CreateFeedResponses = { + /** + * creates a feed when request is valid + */ + 201: { + data: { + feed: { + created_at: string; + feed_token: string; + id: string; + json_public_url: string; + name: string; + public_url: string; + strategy: string; + updated_at: string; + url: string; + }; + }; + meta: { + created: boolean; + }; + success: boolean; + }; +}; + +export type CreateFeedResponse = CreateFeedResponses[keyof CreateFeedResponses]; + +export type RenderFeedByTokenData = { + body?: never; + path: { + token: string; + }; + query?: never; + url: '/feeds/{token}'; +}; + +export type RenderFeedByTokenErrors = { + /** + * returns JSON Feed-shaped errors when requested by json extension + */ + 401: string; + /** + * returns JSON Feed-shaped forbidden errors when requested through Accept + */ + 403: string; + /** + * returns non-cacheable json feed errors when service generation fails + */ + 500: string; +}; + +export type RenderFeedByTokenError = RenderFeedByTokenErrors[keyof RenderFeedByTokenErrors]; + +export type RenderFeedByTokenResponses = { + /** + * renders feed for a valid token + */ + 200: string; +}; + +export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses]; + +export type GetHealthStatusData = { + body?: never; + headers: { + Authorization: string; + }; + path?: never; + query?: never; + url: '/health'; +}; + +export type GetHealthStatusErrors = { + /** + * returns 401 with UNAUTHORIZED error payload + */ + 401: { + error: { + code: string; + message: string; + }; + success: boolean; + }; + /** + * returns error when configuration fails + */ + 500: { + error: { + code: string; + message: string; + }; + success: boolean; + }; +}; + +export type GetHealthStatusError = GetHealthStatusErrors[keyof GetHealthStatusErrors]; + +export type GetHealthStatusResponses = { + /** + * returns health status when token is valid + */ + 200: { + data: { + health: { + checks: { + [key: string]: unknown; + }; + environment: string; + status: string; + timestamp: string; + uptime: number; + }; + }; + success: boolean; + }; +}; + +export type GetHealthStatusResponse = GetHealthStatusResponses[keyof GetHealthStatusResponses]; + +export type GetLivenessProbeData = { + body?: never; + path?: never; + query?: never; + url: '/health/live'; +}; + +export type GetLivenessProbeResponses = { + /** + * returns liveness status without authentication + */ + 200: { + data: { + health: { + status: string; + timestamp: string; + }; + }; + success: boolean; + }; +}; + +export type GetLivenessProbeResponse = GetLivenessProbeResponses[keyof GetLivenessProbeResponses]; + +export type GetReadinessProbeData = { + body?: never; + path?: never; + query?: never; + url: '/health/ready'; +}; + +export type GetReadinessProbeResponses = { + /** + * returns readiness status without authentication + */ + 200: { + data: { + health: { + checks: { + [key: string]: unknown; + }; + environment: string; + status: string; + timestamp: string; + uptime: number; + }; + }; + success: boolean; + }; +}; + +export type GetReadinessProbeResponse = GetReadinessProbeResponses[keyof GetReadinessProbeResponses]; + +export type GetOpenApiSpecData = { + body?: never; + path?: never; + query?: never; + url: '/openapi.yaml'; +}; + +export type GetOpenApiSpecResponses = { + /** + * serves the OpenAPI document as YAML + */ + 200: string; +}; + +export type GetOpenApiSpecResponse = GetOpenApiSpecResponses[keyof GetOpenApiSpecResponses]; + +export type ListStrategiesData = { + body?: never; + path?: never; + query?: never; + url: '/strategies'; +}; + +export type ListStrategiesResponses = { + /** + * returns available strategies + */ + 200: { + data: { + strategies: Array<{ + display_name: string; + id: string; + name: string; + }>; + }; + meta: { + total: number; + }; + success: boolean; + }; +}; + +export type ListStrategiesResponse = ListStrategiesResponses[keyof ListStrategiesResponses]; diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 00000000..2be23113 Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx new file mode 100644 index 00000000..02ccdd75 --- /dev/null +++ b/frontend/src/components/App.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from 'preact/hooks'; +import { ResultDisplay } from './ResultDisplay'; +import { CreateFeedPanel, UtilityStrip, type Strategy } from './AppPanels'; +import { useAccessToken } from '../hooks/useAccessToken'; +import { useApiMetadata } from '../hooks/useApiMetadata'; +import { useFeedConversion } from '../hooks/useFeedConversion'; +import { useStrategies } from '../hooks/useStrategies'; + +const EMPTY_FEED_ERRORS = { url: '', form: '' }; +const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; + +function BrandLockup() { + return ( +
+
+ ); +} + +export function App() { + const { + token, + hasToken, + saveToken, + clearToken, + isLoading: tokenLoading, + error: tokenStateError, + } = useAccessToken(); + const { metadata, isLoading: metadataLoading, error: metadataError } = useApiMetadata(); + const { + isConverting, + result, + error: conversionError, + convertFeed, + clearError, + clearResult, + } = useFeedConversion(); + const { strategies, isLoading: strategiesLoading, error: strategiesError } = useStrategies(); + + const [feedFormData, setFeedFormData] = useState({ url: '', strategy: 'ssrf_filter' }); + const [feedFieldErrors, setFeedFieldErrors] = useState(EMPTY_FEED_ERRORS); + const [showTokenPrompt, setShowTokenPrompt] = useState(false); + const [tokenDraft, setTokenDraft] = useState(''); + const [tokenError, setTokenError] = useState(''); + const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (feedFormData.url) return; + + const urlParam = new URLSearchParams(window.location.search).get('url'); + if (!urlParam) return; + + setFeedFormData((prev) => ({ ...prev, url: urlParam })); + }, [feedFormData.url]); + + useEffect(() => { + const nextStrategy = strategies[0]?.id; + if (!nextStrategy) return; + + const hasCurrentStrategy = strategies.some((strategy) => strategy.id === feedFormData.strategy); + if (!hasCurrentStrategy) setFeedFormData((prev) => ({ ...prev, strategy: nextStrategy })); + }, [strategies, feedFormData.strategy]); + + const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; + + const setFeedField = (key: 'url' | 'strategy', value: string) => { + setFeedFormData((prev) => ({ ...prev, [key]: value })); + setFeedFieldErrors((prev) => ({ + ...prev, + url: key === 'url' ? '' : prev.url, + form: '', + })); + clearError(); + }; + + const strategyHint = (strategy: Strategy) => { + if (strategy.id === 'ssrf_filter') return 'Start here for most pages.'; + if (strategy.id === 'browserless') return 'Use this if the page loads content with JavaScript.'; + return strategy.name; + }; + + const isAccessTokenError = (message: string) => { + const normalized = message.toLowerCase(); + return ( + normalized.includes('unauthorized') || + normalized.includes('forbidden') || + normalized.includes('access token') || + normalized.includes('authentication') + ); + }; + + const attemptFeedCreation = async (accessToken: string) => { + if (!feedFormData.url.trim()) { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: 'Source URL is required.' }); + return false; + } + + if (!feedCreation.enabled) { + setFeedFieldErrors({ + ...EMPTY_FEED_ERRORS, + form: 'Custom feed generation is disabled for this instance.', + }); + return false; + } + + if (feedCreation.access_token_required && !accessToken) { + clearError(); + setShowTokenPrompt(true); + setTokenError(''); + return false; + } + + try { + await convertFeed(feedFormData.url, feedFormData.strategy, accessToken); + setShowTokenPrompt(false); + setTokenError(''); + return true; + } catch (submitError) { + const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.'; + + if (feedCreation.access_token_required && isAccessTokenError(message)) { + clearToken(); + clearError(); + setTokenDraft(''); + setShowTokenPrompt(true); + setTokenError('Access token was rejected. Paste a valid token to continue.'); + setFeedFieldErrors(EMPTY_FEED_ERRORS); + return false; + } + + if (message.toLowerCase().includes('url')) { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: message }); + } else { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: message }); + } + return false; + } + }; + + const handleFeedSubmit = async (event: Event) => { + event.preventDefault(); + setFeedFieldErrors(EMPTY_FEED_ERRORS); + await attemptFeedCreation(token ?? ''); + }; + + const handleSaveToken = async () => { + try { + const normalizedToken = tokenDraft.trim(); + await saveToken(normalizedToken); + setTokenError(''); + const created = await attemptFeedCreation(normalizedToken); + if (created) setTokenDraft(''); + } catch (error) { + setTokenError(error instanceof Error ? error.message : 'Unable to save access token.'); + } + }; + + const handleCreateAnother = () => { + clearResult(); + setFocusCreateComposerKey((current) => current + 1); + }; + + if (metadataLoading || tokenLoading) { + return ( +
+ +
+ +
+ ); + } + + return ( +
+
+ +
+ + {(metadataError || tokenStateError) && ( + + )} + + {result ? ( + + ) : ( + <> + { + setTokenDraft(value); + setTokenError(''); + clearError(); + }} + onSaveToken={handleSaveToken} + onCancelTokenPrompt={() => { + setShowTokenPrompt(false); + setTokenError(''); + clearError(); + }} + strategyHint={strategyHint} + /> +
+ ); +} diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx new file mode 100644 index 00000000..f7204a6e --- /dev/null +++ b/frontend/src/components/AppPanels.tsx @@ -0,0 +1,253 @@ +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; +import { Bookmarklet } from './Bookmarklet'; +import { DominantField } from './DominantField'; + +export interface Strategy { + id: string; + name: string; + display_name: string; +} + +export interface FeedFormData { + url: string; + strategy: string; +} + +export interface FeedFieldErrors { + url: string; + form: string; +} + +interface CreateFeedPanelProps { + focusComposerKey: number; + feedFormData: FeedFormData; + feedFieldErrors: FeedFieldErrors; + conversionError: string | null; + isConverting: boolean; + strategies: Strategy[]; + strategiesLoading: boolean; + strategiesError: string | null; + feedCreationEnabled: boolean; + accessTokenRequired: boolean; + hasAccessToken: boolean; + tokenDraft: string; + tokenError: string; + showTokenPrompt: boolean; + onFeedSubmit: (event: Event) => void; + onFeedFieldChange: (key: 'url' | 'strategy', value: string) => void; + onTokenDraftChange: (value: string) => void; + onSaveToken: () => void; + onCancelTokenPrompt: () => void; + strategyHint: (strategy: Strategy) => string; +} + +export function CreateFeedPanel({ + focusComposerKey, + feedFormData, + feedFieldErrors, + conversionError, + isConverting, + strategies, + strategiesLoading, + strategiesError, + feedCreationEnabled, + accessTokenRequired, + hasAccessToken, + tokenDraft, + tokenError, + showTokenPrompt, + onFeedSubmit, + onFeedFieldChange, + onTokenDraftChange, + onSaveToken, + onCancelTokenPrompt, + strategyHint, +}: CreateFeedPanelProps) { + const selectedStrategy = strategies.find((strategy) => strategy.id === feedFormData.strategy); + const urlInputRef = useRef(null); + const tokenInputRef = useRef(null); + const strategyOptionLabel = (strategy: Strategy) => { + if (strategy.id === 'ssrf_filter') return 'Standard rendering'; + if (strategy.id === 'browserless') return 'JavaScript pages'; + return strategy.display_name; + }; + + useLayoutEffect(() => { + if (!urlInputRef.current || typeof window === 'undefined') return; + + const focusHandle = window.requestAnimationFrame(() => { + const input = urlInputRef.current; + if (!input) return; + + input.focus(); + input.select(); + }); + + return () => window.cancelAnimationFrame(focusHandle); + }, [focusComposerKey]); + + useLayoutEffect(() => { + if (!showTokenPrompt || !tokenInputRef.current || typeof window === 'undefined') return; + + const focusHandle = window.requestAnimationFrame(() => { + tokenInputRef.current?.focus(); + }); + + return () => window.cancelAnimationFrame(focusHandle); + }, [showTokenPrompt]); + + return ( +
+
+ '} + disabled={isConverting || !feedCreationEnabled || showTokenPrompt} + error={feedFieldErrors.url} + onInput={(event) => onFeedFieldChange('url', (event.target as HTMLInputElement).value)} + /> + + + {strategiesError &&

{strategiesError}

} + {!strategiesError && selectedStrategy?.id === 'browserless' && ( +

{strategyHint(selectedStrategy)}

+ )} + + {!feedCreationEnabled && ( +

Custom feed generation is disabled for this instance.

+ )} +
+ + {showTokenPrompt && ( +
+
+

Add access token

+

This instance needs an access token.

+
+ + + Set up your own instance with Docker. + +
+ +
+
+ +
+
+ )} + + {conversionError && ( + + )} + + {feedFieldErrors.form && ( + + )} +
+ ); +} + +interface UtilityStripProps { + hidden?: boolean; + hasAccessToken: boolean; + onClearToken: () => void; +} + +export function UtilityStrip({ hidden = false, hasAccessToken, onClearToken }: UtilityStripProps) { + const [isOpen, setIsOpen] = useState(false); + + if (hidden) return null; + + return ( +
+ + {isOpen && ( +
+ + + Source code + + {hasAccessToken && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Bookmarklet.tsx b/frontend/src/components/Bookmarklet.tsx new file mode 100644 index 00000000..6802f5e8 --- /dev/null +++ b/frontend/src/components/Bookmarklet.tsx @@ -0,0 +1,20 @@ +export function Bookmarklet() { + const bookmarkletHref = (() => { + if (typeof window === 'undefined') return '#'; + + const appUrl = new URL(window.location.href); + appUrl.search = ''; + appUrl.hash = ''; + + const targetPath = appUrl.pathname.endsWith('/frontend/index.html') ? appUrl.pathname : '/'; + const targetPrefix = `${appUrl.origin}${targetPath}?url=`; + + return `javascript:window.location.href=${JSON.stringify(targetPrefix)}+encodeURIComponent(window.location.href);`; + })(); + + return ( + + Bookmarklet + + ); +} diff --git a/frontend/src/components/DominantField.tsx b/frontend/src/components/DominantField.tsx new file mode 100644 index 00000000..75c902f3 --- /dev/null +++ b/frontend/src/components/DominantField.tsx @@ -0,0 +1,71 @@ +import type { JSX, Ref } from 'preact'; + +interface DominantFieldProps { + id: string; + label: string; + value: string; + placeholder?: string; + type?: string; + readOnly?: boolean; + autoFocus?: boolean; + disabled?: boolean; + actionLabel: string; + actionText: string; + actionVariant?: 'default' | 'soft'; + onAction?: () => void; + onInput?: JSX.GenericEventHandler; + inputRef?: Ref; + error?: string; +} + +export function DominantField({ + id, + label, + value, + placeholder, + type = 'text', + readOnly = false, + autoFocus = false, + disabled = false, + actionLabel, + actionText, + actionVariant = 'default', + onAction, + onInput, + inputRef, + error, +}: DominantFieldProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx new file mode 100644 index 00000000..571de6a6 --- /dev/null +++ b/frontend/src/components/ResultDisplay.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import type { FeedRecord } from '../api/contracts'; +import { DominantField } from './DominantField'; + +interface JsonFeedItem { + title?: string; + content_text?: string; +} + +interface JsonFeedResponse { + items?: JsonFeedItem[]; +} + +interface ResultDisplayProps { + result: FeedRecord; + onCreateAnother: () => void; +} + +export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) { + const [copyNotice, setCopyNotice] = useState(''); + const [previewItems, setPreviewItems] = useState([]); + const [previewError, setPreviewError] = useState(''); + const copyResetRef = useRef(undefined); + + const fullUrl = result.public_url.startsWith('http') + ? result.public_url + : `${window.location.origin}${result.public_url}`; + const jsonFeedUrl = result.json_public_url.startsWith('http') + ? result.json_public_url + : `${window.location.origin}${result.json_public_url}`; + + useEffect(() => { + return () => { + if (copyResetRef.current) window.clearTimeout(copyResetRef.current); + }; + }, []); + + useEffect(() => { + let isCancelled = false; + + const loadPreview = async () => { + try { + const response = await window.fetch(fullUrl, { + headers: { Accept: 'application/feed+json' }, + }); + if (!response.ok) throw new Error('Preview request failed'); + const payload = (await response.json()) as JsonFeedResponse; + const itemTitles = + payload.items + ?.map((item) => normalizePreviewText(item.title || item.content_text)) + .filter((title): title is string => Boolean(title)) + .slice(0, 3) || []; + + if (!isCancelled) { + setPreviewItems(itemTitles); + setPreviewError(''); + } + } catch { + if (!isCancelled) { + setPreviewItems([]); + setPreviewError('Preview unavailable right now.'); + } + } + }; + + void loadPreview(); + + return () => { + isCancelled = true; + }; + }, [fullUrl]); + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopyNotice('Feed URL copied to clipboard.'); + if (copyResetRef.current) window.clearTimeout(copyResetRef.current); + copyResetRef.current = window.setTimeout(() => setCopyNotice(''), 2500); + } catch { + setCopyNotice('Clipboard copy failed. Copy the feed URL manually.'); + } + }; + + return ( +
+
+

{result.name}

+
+ + void copyToClipboard(fullUrl)} + /> + +
+ + Open feed + + + JSON Feed + + +
+ + {previewItems.length > 0 && ( +
+

Feed preview

+
    + {previewItems.map((item) => ( +
  • {item}
  • + ))} +
+
+ )} + + {previewError && ( +
+

Feed preview

+

{previewError}

+
+ )} + + {copyNotice && ( +
+

{copyNotice}

+
+ )} +
+ ); +} + +function normalizePreviewText(value?: string): string | null { + if (!value) return null; + + const normalized = decodeHtmlEntities(value) + .replace(/\s+/g, ' ') + .replace(/^\d+\.\s+/, '') + .replace(/\s+\([^)]*\)\s*$/, '') + .trim(); + + return normalized || null; +} + +function decodeHtmlEntities(value: string): string { + if (typeof document === 'undefined') return value; + + const textarea = document.createElement('textarea'); + textarea.innerHTML = value; + return textarea.value; +} diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/hooks/useAccessToken.ts b/frontend/src/hooks/useAccessToken.ts new file mode 100644 index 00000000..01f022cc --- /dev/null +++ b/frontend/src/hooks/useAccessToken.ts @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'preact/hooks'; + +const ACCESS_TOKEN_KEY = 'html2rss_access_token'; + +interface AccessTokenState { + token: string | null; + isLoading: boolean; + error: string | null; +} + +const memoryStorage = (() => { + const store = new Map(); + + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + } as Storage; +})(); + +const resolveStorage = (): Storage => { + if (typeof window === 'undefined') return memoryStorage; + + try { + return window.sessionStorage ?? memoryStorage; + } catch { + return memoryStorage; + } +}; + +export function useAccessToken() { + const [state, setState] = useState({ + token: null, + isLoading: true, + error: null, + }); + + useEffect(() => { + const storage = resolveStorage(); + + try { + const token = storage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ''; + + setState({ + token: token || null, + isLoading: false, + error: null, + }); + } catch { + setState({ + token: null, + isLoading: false, + error: 'Failed to load access token state', + }); + } + }, []); + + const saveToken = async (token: string) => { + const normalized = token.trim(); + if (!normalized) throw new Error('Access token is required'); + + const storage = resolveStorage(); + storage.setItem(ACCESS_TOKEN_KEY, normalized); + + setState({ + token: normalized, + isLoading: false, + error: null, + }); + }; + + const clearToken = () => { + const storage = resolveStorage(); + storage.removeItem(ACCESS_TOKEN_KEY); + + setState({ + token: null, + isLoading: false, + error: null, + }); + }; + + return { + token: state.token, + hasToken: Boolean(state.token), + isLoading: state.isLoading, + error: state.error, + saveToken, + clearToken, + }; +} diff --git a/frontend/src/hooks/useApiMetadata.ts b/frontend/src/hooks/useApiMetadata.ts new file mode 100644 index 00000000..6feccba3 --- /dev/null +++ b/frontend/src/hooks/useApiMetadata.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'preact/hooks'; +import type { ApiMetadataRecord } from '../api/contracts'; + +interface ApiMetadataState { + metadata: ApiMetadataRecord | null; + isLoading: boolean; + error: string | null; +} + +interface ApiMetadataPayload { + success?: boolean; + data?: unknown; +} + +export function useApiMetadata() { + const [state, setState] = useState({ + metadata: null, + isLoading: true, + error: null, + }); + + useEffect(() => { + const controller = new AbortController(); + + const load = async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await fetch('/api/v1', { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + const payload = await parseMetadataPayload(response); + const metadata = payload.data as ApiMetadataRecord | undefined; + + if (!response.ok || !payload.success || !metadata?.instance) { + throw new Error('Invalid response format from API metadata'); + } + + setState({ + metadata, + isLoading: false, + error: null, + }); + } catch (error) { + if (controller.signal.aborted) return; + + setState({ + metadata: null, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load API metadata', + }); + } + }; + + load(); + return () => controller.abort(); + }, []); + + return state; +} + +async function parseMetadataPayload(response: Response): Promise { + const body = await response.text(); + if (!body.trim()) return {}; + + try { + return JSON.parse(body) as ApiMetadataPayload; + } catch { + return {}; + } +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..a3e1ddfc --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,140 @@ +import { useState, useEffect } from 'preact/hooks'; + +const USERNAME_KEY = 'html2rss_username'; +const TOKEN_KEY = 'html2rss_token'; + +interface AuthState { + isAuthenticated: boolean; + username: string | null; + token: string | null; + isLoading: boolean; + error: string | null; +} + +interface MemoryStorage extends Storage {} + +const memoryStorage: MemoryStorage = (() => { + const store = new Map(); + + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + } as Storage; +})(); + +const resolveStorage = (): Storage => { + if (typeof window === 'undefined') { + return memoryStorage; + } + + try { + return window.sessionStorage ?? memoryStorage; + } catch (error) { + return memoryStorage; + } +}; + +export function useAuth() { + const [authState, setAuthState] = useState({ + isAuthenticated: false, + username: null, + token: null, + isLoading: true, + error: null, + }); + + useEffect(() => { + const storage = resolveStorage(); + + try { + const username = storage.getItem(USERNAME_KEY); + const token = storage.getItem(TOKEN_KEY); + + if (username && token && username.trim() && token.trim()) { + setAuthState({ + isAuthenticated: true, + username: username.trim(), + token: token.trim(), + isLoading: false, + error: null, + }); + } else { + setAuthState((prev) => ({ ...prev, isLoading: false })); + } + } catch (error) { + setAuthState((prev) => ({ + ...prev, + isLoading: false, + error: 'Failed to load authentication state', + })); + } + }, []); + + const login = async (username: string, token: string) => { + if (!username?.trim()) { + throw new Error('Username is required'); + } + if (!token?.trim()) { + throw new Error('Token is required'); + } + + const storage = resolveStorage(); + + try { + storage.setItem(USERNAME_KEY, username.trim()); + storage.setItem(TOKEN_KEY, token.trim()); + + setAuthState({ + isAuthenticated: true, + username: username.trim(), + token: token.trim(), + isLoading: false, + error: null, + }); + } catch (error) { + throw new Error('Failed to save authentication data'); + } + }; + + const logout = () => { + const storage = resolveStorage(); + + try { + storage.removeItem(USERNAME_KEY); + storage.removeItem(TOKEN_KEY); + + setAuthState({ + isAuthenticated: false, + username: null, + token: null, + isLoading: false, + error: null, + }); + } catch (error) { + setAuthState((prev) => ({ + ...prev, + error: 'Failed to clear authentication data', + })); + } + }; + + return { + isAuthenticated: authState.isAuthenticated, + username: authState.username, + token: authState.token, + isLoading: authState.isLoading, + error: authState.error, + login, + logout, + }; +} diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts new file mode 100644 index 00000000..134c2220 --- /dev/null +++ b/frontend/src/hooks/useFeedConversion.ts @@ -0,0 +1,104 @@ +import { useState } from 'preact/hooks'; +import { createFeed } from '../api/generated'; +import { apiClient } from '../api/client'; +import type { FeedRecord } from '../api/contracts'; + +interface ConversionState { + isConverting: boolean; + result: FeedRecord | null; + error: string | null; +} + +export function useFeedConversion() { + const [state, setState] = useState({ + isConverting: false, + result: null, + error: null, + }); + + const convertFeed = async (url: string, strategy: string, token: string) => { + if (!url?.trim()) throw new Error('URL is required'); + if (!strategy?.trim()) throw new Error('Strategy is required'); + + try { + new URL(url.trim()); + } catch { + throw new Error('Invalid URL format'); + } + + setState((prev) => ({ ...prev, isConverting: true, error: null })); + + try { + const response = await createFeed({ + client: apiClient, + headers: { + Authorization: `Bearer ${token}`, + }, + body: { + url: url.trim(), + strategy: strategy.trim(), + }, + throwOnError: true, + }); + + if (!response.data?.success || !response.data.data?.feed) { + throw new Error('Invalid response format'); + } + + const result = response.data.data.feed; + setState((prev) => ({ ...prev, isConverting: false, result, error: null })); + return result; + } catch (error) { + const message = toErrorMessage(error); + setState((prev) => ({ + ...prev, + isConverting: false, + error: message, + result: null, + })); + throw new Error(message); + } + }; + + const clearResult = () => { + window.document.body.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + setState({ + isConverting: false, + result: null, + error: null, + }); + }; + + const clearError = () => { + setState((prev) => ({ ...prev, error: null })); + }; + + return { + isConverting: state.isConverting, + result: state.result, + error: state.error, + convertFeed, + clearError, + clearResult, + }; +} + +const toErrorMessage = (error: unknown): string => { + if (error instanceof SyntaxError) return 'Invalid response format from feed creation API'; + if (error instanceof Error) return error.message; + if (typeof error === 'string' && error.trim()) return error; + + const message = extractMessage(error); + return message ?? 'An unexpected error occurred'; +}; + +const extractMessage = (error: unknown): string | null => { + if (!error || typeof error !== 'object') return null; + + const candidate = + (error as { error?: { message?: unknown }; message?: unknown }).error?.message ?? + (error as { message?: unknown }).message; + + return typeof candidate === 'string' && candidate.trim() ? candidate : null; +}; diff --git a/frontend/src/hooks/useStrategies.ts b/frontend/src/hooks/useStrategies.ts new file mode 100644 index 00000000..28dda7c7 --- /dev/null +++ b/frontend/src/hooks/useStrategies.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'preact/hooks'; +import { listStrategies } from '../api/generated'; +import { apiClient } from '../api/client'; +import type { StrategyRecord } from '../api/contracts'; + +interface StrategiesState { + strategies: StrategyRecord[]; + isLoading: boolean; + error: string | null; +} + +export function useStrategies() { + const [state, setState] = useState({ + strategies: [], + isLoading: true, + error: null, + }); + + const fetchStrategies = async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await listStrategies({ + client: apiClient, + }); + + if (response.error || !response.data?.success || !response.data.data?.strategies) { + throw new Error('Invalid response format from strategies API'); + } + + setState({ + strategies: response.data.data.strategies, + isLoading: false, + error: null, + }); + } catch (error) { + setState({ + strategies: [], + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch strategies', + }); + } + }; + + useEffect(() => { + fetchStrategies(); + }, []); + + return { + strategies: state.strategies, + isLoading: state.isLoading, + error: state.error, + refetch: fetchStrategies, + }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..e6c6a74a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { render } from 'preact'; +import { App } from './components/App'; +import './styles/main.css'; + +function Root() { + return ( +
+
+ +
+
+ ); +} + +render(, document.getElementById('app')!); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 00000000..229fbebe --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,767 @@ +:root { + color-scheme: dark; + --font-family-ui: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; + --font-family-display: "Fraunces", "Iowan Old Style", serif; + --font-family-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-size-00: 0.8125rem; + --font-size-0: 0.9375rem; + --font-size-1: 1rem; + --font-size-2: 1.125rem; + --line-height-tight: 1.1; + --line-height-base: 1.5; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 3rem; + --border-width: 1px; + --radius-sm: 0.35rem; + --radius-md: 0.7rem; + --bg-page: #050505; + --bg-page-muted: #090909; + --bg-input: #111111; + --bg-input-strong: #151515; + --bg-success: rgba(110, 231, 183, 0.08); + --bg-danger: rgba(248, 113, 113, 0.1); + --border-muted: rgba(255, 255, 255, 0.12); + --border-strong: rgba(255, 255, 255, 0.24); + --text-strong: #f3f3ef; + --text-body: rgba(243, 243, 239, 0.9); + --text-muted: rgba(243, 243, 239, 0.58); + --text-faint: rgba(243, 243, 239, 0.28); + --text-inverse: #050505; + --accent: #f3f3ef; + --accent-strong: #ffffff; + --danger: #fca5a5; + --success: #9ae6b4; + --focus-ring: 0 0 0 3px rgba(255, 255, 255, 0.16); + --page-max-width: 56rem; + --content-max-width: 52rem; + --transition-fast: 140ms ease; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 32%), + linear-gradient(180deg, #080808 0%, #040404 100%); +} + +body { + margin: 0; + min-width: 20rem; + color: var(--text-body); + font-family: var(--font-family-ui); + font-size: var(--font-size-0); + line-height: var(--line-height-base); + text-rendering: optimizeLegibility; + background: transparent; +} + +body, +button, +input, +select, +textarea { + font: inherit; +} + +a { + color: var(--accent); + text-decoration: none; +} + +button, +input, +select, +textarea { + margin: 0; +} + +#app { + min-height: 100vh; +} + +.page-shell { + min-height: 100vh; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; +} + +.page-main { + width: min(100%, var(--page-max-width)); + margin: 0 auto; + padding: clamp(0.85rem, 3vh, 2rem) var(--space-4) var(--space-5); +} + +.workspace-shell, +.form-shell, +.field-stack, +.token-gate, +.workspace-hero, +.utility-strip, +.utility-strip__items, +.result-copy, +.result-actions, +.status-card, +.app-footer__inner { + display: grid; +} + +.workspace-shell { + width: min(100%, var(--content-max-width)); + margin: 0 auto; + gap: var(--space-5); +} + +.workspace-shell--centered, +.workspace-hero, +.utility-strip, +.result-copy, +.app-footer__inner { + justify-items: center; +} + +.workspace-hero { + gap: 0.35rem; + text-align: center; +} + +.workspace-shell--loading { + max-width: 32rem; + padding-top: var(--space-7); +} + +.brand-lockup { + display: inline-grid; + justify-items: center; + gap: 0.3rem; +} + +.brand-lockup__mark { + width: 1.7rem; + height: 1.7rem; + padding: 0.3rem; + display: grid; + gap: 0.18rem; + border: var(--border-width) solid var(--border-muted); + border-radius: 0.45rem; + background: transparent; +} + +.brand-lockup__mark span { + display: block; + background: var(--accent); +} + +.brand-lockup__mark span:nth-child(1) { + width: 100%; + height: 0.22rem; +} + +.brand-lockup__mark span:nth-child(2) { + width: 70%; + height: 0.22rem; +} + +.brand-lockup__mark span:nth-child(3) { + width: 46%; + height: 0.22rem; +} + +.brand-lockup__wordmark { + color: var(--text-strong); + font-family: var(--font-family-display); + font-size: 0.96rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.form-shell--minimal, +.result-shell { + width: min(100%, 46rem); + margin: 0 auto; +} + +.form-shell--minimal, +.result-shell, +.field-stack, +.token-gate, +.result-copy { + gap: var(--space-4); +} + +.field-stack--inactive { + opacity: 0.34; +} + +.result-shell { + gap: var(--space-3); +} + +.dominant-field { + position: relative; + width: 100%; + display: grid; + align-items: center; +} + +.field-block { + display: grid; + gap: var(--space-2); +} + +.field-block--primary { + gap: var(--space-2); +} + +.field-block--hero, +.field-block--subtle, +.field-block--token, +.result-url { + justify-items: center; +} + +.field-block--select { + justify-items: center; + gap: 0; +} + +.field-label { + color: var(--text-muted); + font-size: var(--font-size-00); + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; +} + +.field-label--ghost { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.field-error { + min-height: 1em; + color: var(--danger); + font-size: var(--font-size-00); + text-align: center; +} + +.field-help, +.result-meta { + margin: 0; + color: var(--text-muted); + text-align: center; +} + +.field-help--alert { + color: var(--danger); +} + +.input { + width: 100%; + padding: 0.95rem 1.1rem; + border: var(--border-width) solid var(--border-muted); + border-radius: 999px; + background: var(--bg-input); + color: var(--text-strong); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + background-color var(--transition-fast), + transform var(--transition-fast); +} + +.input::placeholder { + color: var(--text-faint); +} + +.input:focus-visible, +.btn:focus-visible, +.utility-link:focus-visible, +.utility-button:focus-visible, +a:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: var(--border-strong); +} + +.input--mono { + font-family: var(--font-family-mono); +} + +.input--hero { + min-height: 4.5rem; + padding-inline: 1.35rem 4.9rem; + font-size: clamp(1.05rem, 2.3vw, 1.5rem); + letter-spacing: -0.03em; +} + +.input--subtle, +.input--select { + width: auto; + min-width: min(100%, 18rem); + background: transparent; + border-color: transparent; + color: var(--text-muted); + text-align: center; + font-size: 0.98rem; + font-weight: 500; + padding: 0; +} + +.input:disabled, +.input--select:disabled { + cursor: not-allowed; +} + +.dominant-field__action { + position: absolute; + right: 0.7rem; + top: 0.7rem; + width: 3rem; + height: 3rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: var(--text-strong); + cursor: pointer; + transition: + transform var(--transition-fast), + background-color var(--transition-fast), + color var(--transition-fast), + opacity var(--transition-fast); +} + +.dominant-field__action--text { + width: auto; + min-width: 4.4rem; + padding: 0 0.9rem; + font-size: var(--font-size-00); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.dominant-field__action--soft { + background: rgba(255, 255, 255, 0.045); + color: var(--text-muted); +} + +.dominant-field__action:hover:not(:disabled) { + transform: translateX(0.04rem); + background: rgba(255, 255, 255, 0.11); +} + +.dominant-field__action--soft:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + color: var(--text-strong); +} + +.dominant-field__action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.dominant-field__action:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.btn { + min-height: 3rem; + padding: 0 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: var(--border-width) solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-strong); + text-decoration: none; + cursor: pointer; + font-weight: 600; + transition: + transform var(--transition-fast), + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + opacity var(--transition-fast); +} + +.btn:hover:not(:disabled) { + transform: translateY(-0.04rem); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn--primary { + background: var(--accent); + color: var(--text-inverse); +} + +.btn--primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.btn--ghost { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); +} + +.btn--ghost:hover:not(:disabled) { + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.08); +} + +.btn--quiet, +.btn--linkish { + min-height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-muted); +} + +.btn--quiet:hover:not(:disabled), +.btn--linkish:hover:not(:disabled) { + background: transparent; + color: var(--text-strong); +} + +.notice { + display: grid; + gap: var(--space-2); + width: min(100%, 38rem); + margin: 0 auto; + padding: var(--space-3) var(--space-4); + border: var(--border-width) solid var(--border-muted); + border-radius: var(--radius-md); + background: var(--bg-page-muted); +} + +.notice p { + margin: 0; +} + +.notice__title { + color: var(--text-strong); +} + +.notice--error { + border-color: rgba(248, 113, 113, 0.22); + background: var(--bg-danger); +} + +.notice--success { + border-color: rgba(110, 231, 183, 0.22); + background: var(--bg-success); +} + +.token-gate { + width: min(100%, 38rem); + margin: 0 auto; + padding-top: var(--space-2); + gap: var(--space-3); +} + +.token-gate__copy { + display: grid; + gap: 0; + justify-items: start; + text-align: left; +} + +.token-gate__copy h2 { + margin: 0; + color: var(--text-strong); + font-family: var(--font-family-display); + font-size: clamp(1.08rem, 2.6vw, 1.32rem); + font-weight: 600; + line-height: var(--line-height-tight); +} + +.token-gate__hint { + margin: 0.35rem 0 0; + color: var(--text-muted); + font-size: var(--font-size-0); + line-height: 1.45; +} + +.token-gate__actions, +.result-actions { + display: grid; + gap: var(--space-3); + justify-items: center; +} + +.token-gate__actions { + justify-items: end; + margin-top: calc(var(--space-1) * -1); +} + +.token-gate__actions .btn { + min-height: 2.55rem; + padding-inline: 0.95rem; + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.025); + color: var(--text-body); + font-size: 0.98rem; + font-weight: 550; +} + +.token-gate__back { + margin-top: calc(var(--space-3) * -0.4); + justify-self: start; +} + +.token-gate__nudge { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-0); + line-height: 1.5; +} + +.token-gate__nudge-link { + text-decoration: none; + color: rgba(255, 255, 255, 0.72); +} + +.token-gate__nudge-link:hover, +.token-gate__nudge-link:focus-visible { + color: var(--text-body); + text-decoration: underline; + text-underline-offset: 0.16em; +} + +.field-block--token { + justify-items: stretch; + gap: 0.5rem; +} + +.field-block--token .input { + min-height: 3.5rem; + padding-inline: 1.1rem; + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); +} + +.field-block--token .input::placeholder { + color: rgba(255, 255, 255, 0.28); +} + +.field-block--token .field-error { + text-align: left; +} + +.result-copy { + width: min(100%, 40rem); + margin: 0 auto; + justify-items: start; + text-align: left; +} + +.result-meta { + max-width: 32rem; + font-size: clamp(0.98rem, 1.5vw, 1.16rem); + line-height: 1.18; + color: var(--text-strong); +} + +.result-preview { + width: min(100%, 40rem); + margin: 0 auto; + display: grid; + gap: var(--space-3); + justify-items: start; + padding-top: var(--space-3); + border-top: var(--border-width) solid rgba(255, 255, 255, 0.08); +} + +.result-preview__label { + margin: 0; + color: rgba(255, 255, 255, 0.72); + font-size: var(--font-size-00); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.result-preview__list { + margin: 0; + padding: 0; + width: 100%; + list-style: none; + display: grid; + gap: 0.6rem; +} + +.result-preview__list li { + color: var(--text-body); + max-width: 34rem; + font-family: var(--font-family-display); + font-size: clamp(1rem, 1.65vw, 1.28rem); + line-height: 1.2; + text-align: left; +} + +.result-actions--quiet { + width: min(100%, 40rem); + margin: 0 auto; + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-items: start; + justify-content: flex-start; + gap: var(--space-4); + margin-top: calc(var(--space-2) * -1); +} + +.btn--linkish { + color: rgba(255, 255, 255, 0.78); + font-weight: 560; +} + +.status-card { + gap: var(--space-3); + justify-items: center; + text-align: center; + padding: var(--space-5); + border: var(--border-width) solid var(--border-muted); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.02); +} + +.status-card p { + margin: var(--space-1) 0 0; + color: var(--text-muted); +} + +.status-card__spinner { + width: 1rem; + height: 1rem; + border: var(--border-width) solid rgba(255, 255, 255, 0.16); + border-top-color: var(--accent); + border-radius: 999px; + animation: spin 0.9s linear infinite; +} + +.utility-strip { + gap: var(--space-2); +} + +.utility-strip__items { + gap: var(--space-2); + justify-items: center; +} + +.utility-link, +.utility-button { + border: 0; + background: transparent; + color: var(--text-muted); + padding: 0; + font-size: var(--font-size-00); + cursor: pointer; +} + +.utility-link:hover, +.utility-button:hover { + color: var(--text-strong); +} + +.utility-button--toggle { + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@media (min-width: 48rem) { + .page-main { + padding-top: clamp(1rem, 6vh, 4rem); + } + + .result-actions { + grid-template-columns: none; + } + + .utility-strip__items { + grid-auto-flow: column; + gap: var(--space-4); + } +} + +@media (max-width: 47.9375rem) { + .page-main { + padding-inline: var(--space-3); + } + + .workspace-shell { + gap: var(--space-4); + } + + .input--hero { + min-height: 4rem; + padding-right: 4.2rem; + } + + .result-copy, + .result-preview, + .result-actions--quiet { + width: 100%; + } + + .result-actions--quiet { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-3); + } + + .token-gate__actions { + justify-items: stretch; + } + + .token-gate__actions .btn { + width: 100%; + } + + .field-block--select { + gap: 0; + } + + .dominant-field__action { + top: 0.5rem; + right: 0.5rem; + width: 3rem; + height: 3rem; + } + + .dominant-field__action--text { + min-width: 4rem; + padding-inline: 0.7rem; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..89eeff6b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "jsx": "react-jsx", + "jsxImportSource": "preact", + "allowJs": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "types": ["vitest/globals", "@testing-library/jest-dom", "msw"] + }, + "include": ["src", "e2e", "playwright.config.ts", "vite.config.ts", "vitest*.ts", "vitest.config.js"] +} diff --git a/frontend/tsconfig.typecheck.json b/frontend/tsconfig.typecheck.json new file mode 100644 index 00000000..21e71b45 --- /dev/null +++ b/frontend/tsconfig.typecheck.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true + }, + "exclude": ["src/__tests__/**/*", "e2e/**/*", "playwright.config.ts", "vitest*.ts", "vitest.config.js"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..a49e5b55 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; + +export default defineConfig({ + plugins: [preact()], + server: { + host: true, + port: 4001, + proxy: { + '/api': 'http://localhost:4000', + '/rss.xsl': 'http://localhost:4000', + }, + }, + preview: { + host: true, + port: 4001, + }, + optimizeDeps: { + exclude: ['msw/node'], + }, + build: { + outDir: '../public/frontend', + emptyOutDir: true, + }, +}); diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 00000000..ea98df2a --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,16 @@ +import { defineConfig, configDefaults } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./src/__tests__/setup.ts'], + globals: true, + testTimeout: 10000, + hookTimeout: 10000, + exclude: [...configDefaults.exclude, 'tests/**', 'e2e/**'], + }, + esbuild: { + jsx: 'automatic', + jsxImportSource: 'preact', + }, +}); diff --git a/frontend/vitest.contract.config.ts b/frontend/vitest.contract.config.ts new file mode 100644 index 00000000..bae7efc7 --- /dev/null +++ b/frontend/vitest.contract.config.ts @@ -0,0 +1,8 @@ +import { mergeConfig } from 'vitest/config'; +import baseConfig from './vitest.config'; + +export default mergeConfig(baseConfig, { + test: { + include: ['src/__tests__/**/*.contract.test.*'], + }, +}); diff --git a/frontend/vitest.unit.config.ts b/frontend/vitest.unit.config.ts new file mode 100644 index 00000000..1450004b --- /dev/null +++ b/frontend/vitest.unit.config.ts @@ -0,0 +1,8 @@ +import { mergeConfig } from 'vitest/config'; +import baseConfig from './vitest.config'; + +export default mergeConfig(baseConfig, { + test: { + exclude: [...(baseConfig.test?.exclude ?? []), 'src/__tests__/**/*.contract.test.*'], + }, +}); diff --git a/helpers/auto_source.rb b/helpers/auto_source.rb deleted file mode 100644 index 19fb7981..00000000 --- a/helpers/auto_source.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require 'addressable' -require 'base64' -require 'html2rss' - -module Html2rss - module Web - ## - # Helper methods for handling auto source feature. - class AutoSource - def self.enabled? = ENV['AUTO_SOURCE_ENABLED'].to_s == 'true' - def self.username = ENV.fetch('AUTO_SOURCE_USERNAME') - def self.password = ENV.fetch('AUTO_SOURCE_PASSWORD') - - def self.allowed_origins = ENV.fetch('AUTO_SOURCE_ALLOWED_ORIGINS', '') - .split(',') - .map(&:strip) - .reject(&:empty?) - .to_set - - # @param rss [RSS::Rss] - # @param default_in_minutes [Integer] - # @return [Integer] - def self.ttl_in_seconds(rss, default_in_minutes: 60) - (rss&.channel&.ttl || default_in_minutes) * 60 - end - - # @param request [Roda::RodaRequest] - # @param response [Roda::RodaResponse] - # @param allowed_origins [Set] - def self.check_request_origin!(request, response, allowed_origins = AutoSource.allowed_origins) - if allowed_origins.empty? - response.write 'No allowed origins are configured. Please set AUTO_SOURCE_ALLOWED_ORIGINS.' - else - origin = Set[request.env['HTTP_HOST'], request.env['HTTP_X_FORWARDED_HOST']].delete(nil) - return if allowed_origins.intersect?(origin) - - response.write 'Origin is not allowed.' - end - - response.status = 403 - request.halt - end - end - end -end diff --git a/helpers/handle_error.rb b/helpers/handle_error.rb deleted file mode 100644 index b5d9b9ee..00000000 --- a/helpers/handle_error.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require 'html2rss/configs' -require_relative '../app/local_config' - -module Html2rss - module Web - class App - def handle_error(error) # rubocop:disable Metrics/MethodLength - case error - when Html2rss::Config::DynamicParams::ParamsMissing, - Roda::RodaPlugins::TypecastParams::Error - set_error_response('Parameters missing or invalid', 422) - when Html2rss::Selectors::PostProcessors::UnknownPostProcessorName - set_error_response('Invalid feed config', 422) - when LocalConfig::NotFound, - Html2rss::Configs::ConfigNotFound - set_error_response('Feed config not found', 404) - when Html2rss::Error - set_error_response('Html2rss error', 422) - else - set_error_response('Internal Server Error', 500) - end - - @show_backtrace = self.class.development? - @error = error - - set_view_subdir nil - view 'error' - end - - private - - def set_error_response(page_title, status) - @page_title = page_title - response.status = status - end - end - end -end diff --git a/helpers/handle_health_check.rb b/helpers/handle_health_check.rb deleted file mode 100644 index 347337ca..00000000 --- a/helpers/handle_health_check.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Html2rss - module Web - class App - def handle_health_check - HttpCache.expires_now(response) - - with_basic_auth(realm: HealthCheck, - username: HealthCheck::Auth.username, - password: HealthCheck::Auth.password) do - HealthCheck.run - end - end - end - end -end diff --git a/public/auto_source.css b/public/auto_source.css deleted file mode 100644 index 2f1fa5d3..00000000 --- a/public/auto_source.css +++ /dev/null @@ -1,83 +0,0 @@ -/* Auto Source Form */ -.auto_source { - display: flex; - flex-direction: column; -} - -.auto_source__wrapper { - display: flex; - flex-direction: column; - margin: 0 0 1em; - background: var(--background-alt); - padding: 0.75em 1em; - border-radius: 1em; -} -.auto_source input, -.auto_source button { - width: 100%; -} - -.auto_source > form { - display: flex; - flex-direction: column; -} - -.auto_source > form label { - display: flex; - justify-content: center; - align-items: center; -} - -.auto_source > form label > span { - flex: 1; -} - -.auto_source > form label > input { - flex: 0; -} - -.auto_source nav { - display: flex; - flex-direction: column; -} - -@media screen and (min-width: 768px) { - .auto_source nav { - justify-content: space-between; - flex-direction: row; - } -} - -.auto_source nav button { - margin-left: 0.25em; - margin-right: 0; - flex: 1; - font-size: 0.9em; - padding: 0.75em; -} - -.auto_source__bookmarklet { - background-color: var(--background); - padding: 0.25em; - border-radius: 0.25em; -} - -.auto_source button.muted { - color: var(--text-muted); - border: 1px solid var(--background-alt); -} - -.auto_source iframe { - margin-top: 1em; - border: 2px groove transparent; - border-radius: 0.5em; - display: none; /* Hide by default */ - transition: border-color 0.2s; -} - -.auto_source iframe[src] { - display: block; - min-height: 50em; - max-height: 80vh; - border-color: var(--highlight); -} diff --git a/public/auto_source.js b/public/auto_source.js deleted file mode 100644 index dfff3f75..00000000 --- a/public/auto_source.js +++ /dev/null @@ -1,315 +0,0 @@ -const autoSource = (function () { - const BASE_PATH = "auto_source"; - - class Bookmarklet { - constructor() { - const $bookmarklet = document.querySelector("a#bookmarklet"); - - if (!$bookmarklet) { - console.error("Bookmarklet element not found in the DOM."); - return; - } - - $bookmarklet.href = this.generateBookmarkletHref(); - } - - generateBookmarkletHref() { - const h2rUrl = new URL(window.location.origin); - h2rUrl.pathname = `${BASE_PATH}/`; - h2rUrl.search = "?url="; - h2rUrl.hash = ""; - - return `javascript:window.location.href='${h2rUrl.toString()}'+window.location.href;`; - } - } - - class FormHandler { - constructor() { - // Initialize DOM elements - this.form = document.querySelector("form"); - this.urlInput = document.querySelector("#url"); - this.iframe = document.querySelector("iframe"); - this.rssUrlInput = document.querySelector("#rss_url"); - - if (!this.form || !this.urlInput || !this.iframe || !this.rssUrlInput) { - console.error("One or more necessary form elements were not found in the DOM."); - return; - } - - // Bind event handlers - this.initEventListeners(); - this.setInitialUrl(); - } - - /** - * Initializes event listeners for form elements. - */ - initEventListeners() { - // Event listener for URL input change - this.urlInput.addEventListener("change", () => this.clearRssUrl()); - this.urlInput.addEventListener("blur", (event) => this.handleFormSubmit(event)); - - // Event listener for form submit - this.form.addEventListener("submit", (event) => this.handleFormSubmit(event)); - - const $radios = this.form?.querySelectorAll('input[type="radio"]'); - Array.from($radios).forEach(($radio) => { - $radio.addEventListener("change", (event) => this.handleFormSubmit(event)); - }); - - // Event listener for RSS URL input focus - this.rssUrlInput.addEventListener("focus", () => { - const strippedIframeSrc = this.iframe.src.trim(); - if (this.rssUrlInput.value.trim() !== strippedIframeSrc) { - this.updateIframeSrc(this.rssUrlInput.value.trim()); - } - }); - } - - /** - * Sets the initial URL from query parameter if it exists. - */ - setInitialUrl() { - const params = new URLSearchParams(window.location.search); - const initialUrl = params.get("url"); - if (initialUrl) { - this.urlInput.value = initialUrl; - this.form.dispatchEvent(new Event("submit")); - } - } - - /** - * Clears the RSS URL input value. - */ - clearRssUrl() { - this.rssUrlInput.value = ""; - } - - /** - * Handles form submission. - * @param {Event} event - The form submit event. - */ - handleFormSubmit(event) { - event.preventDefault(); - - this.rssUrlInput.value = ""; - this.iframe.src = ""; - - const url = this.urlInput.value; - - if (this.isValidUrl(url)) { - const encodedUrl = this.encodeUrl(url); - const params = {}; - const strategy = this.form?.querySelector('input[name="strategy"]:checked')?.value; - if (strategy) { - params["strategy"] = strategy; - } - - const autoSourceUrl = this.generateAutoSourceUrl(encodedUrl, params); - - this.rssUrlInput.value = autoSourceUrl; - this.rssUrlInput.select(); - - const targetSearch = `?url=${url}&strategy=${strategy}`; - if (window.location.search !== targetSearch) { - window.history.pushState({}, "", targetSearch); - } - } - } - - /** - * Checks if the URL is valid and starts with "http". - * @param {string} url - The URL to validate. - * @returns {boolean} True if the URL is valid, false otherwise. - */ - isValidUrl(url) { - try { - new URL(url); - return url.trim() !== "" && url.startsWith("http"); - } catch (_) { - return false; - } - } - - /** - * Encodes the URL using base64 encoding. - * @param {string} url - The URL to encode. - * @returns {string} The base64 encoded URL. - */ - encodeUrl(url) { - return btoa(url).replace(/=/g, ""); - } - - /** - * Generates an auto-source URL. - * @param {string} encodedUrl - The base64 encoded URL. - * @returns {string} The generated auto-source URL. - */ - generateAutoSourceUrl(encodedUrl, params = {}) { - const baseUrl = new URL(window.location.origin); - - const url = new URL(`${baseUrl}${BASE_PATH}/${encodedUrl}`); - url.search = new URLSearchParams(params).toString(); - return url.toString(); - } - - /** - * Updates the iframe source. - * @param {string} rssUrlValue - The RSS URL value. - */ - updateIframeSrc(rssUrlValue) { - this.iframe.src = rssUrlValue === "" ? "about://blank" : `${rssUrlValue}`; - } - } - - class ButtonHandler { - constructor() { - // Cache necessary DOM elements - const copyButton = document.querySelector("#copy"); - const gotoButton = document.querySelector("#goto"); - const openInFeedButton = document.querySelector("#openInFeed"); - const rssUrlField = document.querySelector("#rss_url"); - const resetCredentialsButton = document.querySelector("#resetCredentials"); - - if (!copyButton || !gotoButton || !openInFeedButton || !rssUrlField || !resetCredentialsButton) { - console.error("One or more necessary button elements were not found in the DOM."); - return; - } - - // Assign elements to instance variables - this.copyButton = copyButton; - this.gotoButton = gotoButton; - this.openInFeedButton = openInFeedButton; - this.rssUrlField = rssUrlField; - this.resetCredentialsButton = resetCredentialsButton; - - // Initialize event listeners - this.initEventListeners(); - } - - /** - * Initializes event listeners for buttons. - */ - initEventListeners() { - // Bind event handlers to the context of the class instance - this.copyButton.addEventListener("click", this.copyText.bind(this)); - this.gotoButton.addEventListener("click", this.openLink.bind(this)); - this.openInFeedButton.addEventListener("click", this.subscribeToFeed.bind(this)); - this.resetCredentialsButton.addEventListener("click", this.resetCredentials.bind(this)); - } - - /** - * Copies the text from the text field to the clipboard. - */ - async copyText() { - try { - const textToCopy = this.rssUrlWithAuth; - await navigator.clipboard.writeText(textToCopy); - } catch (error) { - console.error("Failed to copy text to clipboard:", error); - } - } - - /** - * Opens the link specified in the text field. - */ - openLink() { - const linkToOpen = this.rssUrlWithAuth; - - if (typeof linkToOpen === "string" && linkToOpen.trim() !== "") { - window.open(linkToOpen, "_blank", "noopener,noreferrer"); - } - } - - /** - * Subscribes to the feed specified in the text field. - */ - async subscribeToFeed() { - window.open(this.rssUrlWithAuth); - } - - get rssUrlWithAuth() { - const feedUrl = this.rssUrlField.value; - const storedUser = LocalStorageFacade.getOrAsk("username"); - const storedPassword = LocalStorageFacade.getOrAsk("password"); - - const url = new URL(feedUrl); - url.username = storedUser; - url.password = storedPassword; - - return `feed:${url.toString()}`; - } - - resetCredentials() { - ["username", "password"].forEach((key) => { - LocalStorageFacade.remove(key); - }); - - alert("Credentials have been reset. Click 'Subscribe' to re-enter credentials."); - } - } - - class LocalStorageFacade { - static get prefix() { - return "html2rss-web/auto_source/"; - } - - static get(key) { - key = LocalStorageFacade.encode(`${LocalStorageFacade.prefix}${key}`); - - return LocalStorageFacade.decode(localStorage.getItem(key)); - } - - static set(key, value) { - key = LocalStorageFacade.encode(`${LocalStorageFacade.prefix}${key}`); - - return localStorage.setItem(key, LocalStorageFacade.encode(value)); - } - - static remove(key) { - key = LocalStorageFacade.encode(`${LocalStorageFacade.prefix}${key}`); - - return localStorage.removeItem(key); - } - - static getOrAsk(key) { - let value = LocalStorageFacade.get(key); - - while (typeof value !== "string" || value === "") { - value = window.prompt(`Please enter your ${key}:`); - - if (!value || value.trim() === "") { - alert(`Blank ${key} submitted. Try again!`); - } else { - LocalStorageFacade.set(key, value); - } - } - - return value; - } - - static encode(value) { - return btoa(value.trim()).replace(/=/g, ""); - } - - static decode(value) { - if (typeof value !== "string") { - return null; - } - - return atob(value); - } - } - - function init() { - new Bookmarklet(); - new FormHandler(); - new ButtonHandler(); - } - - return { init: init }; -})(); - -document.readyState === "complete" - ? autoSource.init() - : document.addEventListener("DOMContentLoaded", autoSource.init()); diff --git a/public/rss.js b/public/rss.js deleted file mode 100644 index 96f4c0eb..00000000 --- a/public/rss.js +++ /dev/null @@ -1,5 +0,0 @@ -document.addEventListener("DOMContentLoaded", (_) => { - const $url = document.getElementById("url"); - $url.value = window.location.href; - $url.addEventListener("click", ({ target }) => target.select()); -}); diff --git a/public/rss.xsl b/public/rss.xsl index 1d5258e9..d66e8007 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -1,73 +1,59 @@ - - - - - - - - - <xsl:value-of select="rss/channel/title"/> (Feed) - - - - - -
-

- -

-
- - -
-

- - -

+ + + + + + + + <xsl:value-of select="rss/channel/title" /> (Feed) + + + + +

+ +

-

Feed content preview

-
    - -
  1. -

    - - - - -

    -
    - + +
    +

    + + + + + + + + + + + + + + + Item + + Item + +

    + + +
    + +
    +
    -
  2. -
    -
-
-
-

- This feed was generated by - - - . -

-
- -<% end %> - -
-

Auto Source

- -
- - - <%- default_strategy_name = Html2rss::RequestService.default_strategy_name %> -
- Strategy - <% Html2rss::RequestService.strategy_names.each do |strategy| %> - - <% end %> -
-
- -
- - - -
- - -
- - - - diff --git a/views/error.erb b/views/error.erb deleted file mode 100644 index 6a25526b..00000000 --- a/views/error.erb +++ /dev/null @@ -1,23 +0,0 @@ -

<%= response.status %> - <%= @page_title %>

- -
-<%= @error.class %>: <%= @error.message %>
-
-<% if @show_backtrace %>
-<%= @error.backtrace.join("\n") %>
-<% end %>
-
- -
-

Need help?

-

- Browse the - - html2rss project website - - or start a - - discussion on Github. - -

-
diff --git a/views/index.erb b/views/index.erb deleted file mode 100644 index a2e8c34c..00000000 --- a/views/index.erb +++ /dev/null @@ -1,15 +0,0 @@ -

- This is a - html2rss-web - instance -

- -

- Check out the - example.rss. -

- -

- Find out more about the html2rss ecosystem - at the project website. -

diff --git a/views/layout.erb b/views/layout.erb deleted file mode 100644 index f5d2a77c..00000000 --- a/views/layout.erb +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - html2rss-web<% if @page_title %> - <%= @page_title %><% end %> - - - <%== content_for :css %> - - - - - <%== yield %> - <%== content_for :scripts %> - -