From 850e7424e87e4a757ee55ac4c91aa156505d269a Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 29 Mar 2026 13:39:56 +0200 Subject: [PATCH 01/22] Align result and feed UI --- app/web/feeds/responder.rb | 41 ++- frontend/src/__tests__/App.contract.test.tsx | 2 +- frontend/src/__tests__/ResultDisplay.test.tsx | 6 +- frontend/src/components/ResultDisplay.tsx | 33 ++- frontend/src/styles/main.css | 86 +----- public/feed-reader-link.js | 6 + public/rss.xsl | 254 +++++++++++++++--- public/shared-ui.css | 157 +++++++++++ spec/html2rss/web/app_spec.rb | 6 +- spec/html2rss/web/feeds/responder_spec.rb | 32 ++- spec/public/rss_xsl_spec.rb | 101 +++++++ 11 files changed, 567 insertions(+), 157 deletions(-) create mode 100644 public/feed-reader-link.js create mode 100644 spec/public/rss_xsl_spec.rb diff --git a/app/web/feeds/responder.rb b/app/web/feeds/responder.rb index d7c1522f..329fdece 100644 --- a/app/web/feeds/responder.rb +++ b/app/web/feeds/responder.rb @@ -12,13 +12,9 @@ class << self # @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 + feed_request, resolved_source, result = resolve_request(request:, target_kind:, identifier:) body = write_response(response: request.response, representation: feed_request.representation, result:) - - emit_result(target_kind:, identifier: normalized_identifier, resolved_source:, result:) + emit_response_result(target_kind:, identifier:, feed_request:, resolved_source:, result:) body rescue StandardError => error emit_failure(target_kind:, identifier:, error:) @@ -27,6 +23,39 @@ def call(request:, target_kind:, identifier:) private + # @param request [Rack::Request] + # @param target_kind [Symbol] + # @param identifier [String] + # @return [Array<(Html2rss::Web::Feeds::Contracts::Request, Html2rss::Web::Feeds::Contracts::ResolvedSource, Html2rss::Web::Feeds::Contracts::RenderResult)>] + def resolve_request(request:, target_kind:, identifier:) + feed_request = Request.call(request:, target_kind:, identifier:) + resolved_source = SourceResolver.call(feed_request) + result = Service.call(resolved_source) + [feed_request, resolved_source, result] + end + + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param identifier [String] + # @return [String] + def normalized_identifier(feed_request, identifier) + feed_request.feed_name || identifier + end + + # @param target_kind [Symbol] + # @param identifier [String] + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [void] + def emit_response_result(target_kind:, identifier:, feed_request:, resolved_source:, result:) + emit_result( + target_kind:, + identifier: normalized_identifier(feed_request, identifier), + resolved_source:, + result: + ) + end + # @param response [Rack::Response] # @param representation [Symbol] # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 8a3f7e5c..f90e89fd 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -64,7 +64,7 @@ describe('App contract', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index 818ee0f0..9646f558 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -48,10 +48,10 @@ describe('ResultDisplay', () => { it('renders the success state actions and richer preview cards', async () => { render(); - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Test Feed')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Subscribe in reader' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open in feed reader' })).toHaveAttribute( 'href', 'feed:https://example.com/feed.xml' ); @@ -96,7 +96,7 @@ describe('ResultDisplay', () => { ); await waitFor(() => { - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); expect(screen.getByText('Loading preview…')).toBeInTheDocument(); }); diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index 81a6dd36..f700821c 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -40,13 +40,28 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) { return (
-

Feed created

-

Your feed is ready

-

{feed.name}

-

Subscribe to this URL in your RSS reader.

+
+ +
+

Feed ready

+

{feed.name}

+
+
+
+ {subscribeUrl && ( + + Open in feed reader + + )} + + Open feed + +
{result.retry && (

{`Retried automatically with ${result.retry.to} after ${result.retry.from} could not finish the page.`} @@ -67,14 +82,6 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) { />

- {subscribeUrl && ( - - Subscribe in reader - - )} - - Open feed - Open JSON Feed diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 40721669..1e268b87 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -263,71 +263,6 @@ a:focus-visible { 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: var(--border-subtle); - background: var(--surface-elevated); -} - -.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); @@ -504,18 +439,9 @@ a:focus-visible { text-align: left; } -.result-hero { - justify-items: start; - text-align: left; -} - -.result-title { - margin: 0; - color: var(--text-strong); - font-family: var(--font-family-display); - font-size: clamp(1.9rem, 4.2vw, 2.85rem); - line-height: 0.98; - letter-spacing: -0.03em; +.result-hero__reader { + border-color: rgba(255, 147, 0, 0.24); + background: rgba(255, 147, 0, 0.12); } .result-meta { @@ -526,12 +452,6 @@ a:focus-visible { text-align: left; } -.result-lede { - margin: 0; - color: var(--text-muted); - font-size: var(--font-size-1); -} - .result-preview { justify-items: start; padding-top: var(--section-gap); diff --git a/public/feed-reader-link.js b/public/feed-reader-link.js new file mode 100644 index 00000000..f24b7bcf --- /dev/null +++ b/public/feed-reader-link.js @@ -0,0 +1,6 @@ +document.addEventListener('DOMContentLoaded', function () { + var readerLink = document.querySelector('[data-feed-reader-link]'); + if (!readerLink) return; + + readerLink.setAttribute('href', 'feed:' + window.location.href); +}); diff --git a/public/rss.xsl b/public/rss.xsl index cc74adbe..4bf52fd9 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -10,6 +10,7 @@ <xsl:value-of select="rss/channel/title" /> (Feed) + @@ -168,25 +261,57 @@
-
-

- - - -

- - -

- - - - - - - - -

-
+
+
+
+ +

+ + + +

+
+ + +

+ + + + + + + + +

+
+ + +

+ + + Updated + + + + Published + + + + Latest item + + + +

+
+ +
@@ -273,6 +398,10 @@ Untitled item + + + +

@@ -282,7 +411,7 @@

- +

@@ -291,10 +420,31 @@

- -

- Open original -

+ + @@ -342,19 +492,49 @@ - + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + < + + + + + diff --git a/public/shared-ui.css b/public/shared-ui.css index 9bb4b48a..97fec73c 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -165,6 +165,79 @@ textarea { border-radius: var(--radius-md); } +.ui-hero { + justify-items: start; + text-align: left; + position: relative; + overflow: hidden; + box-shadow: var(--shadow-elevated); +} + +.ui-hero::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(135deg, rgba(255, 147, 0, 0.12), transparent 46%), + linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 32%); +} + +.ui-hero > * { + position: relative; + z-index: 1; +} + +.ui-hero__masthead { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +.ui-hero__icon-wrap { + width: clamp(3.6rem, 8vw, 4.6rem); + height: clamp(3.6rem, 8vw, 4.6rem); + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 1.15rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02)), + rgba(255, 255, 255, 0.03); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.ui-hero__icon { + width: 62%; + height: 62%; + display: block; +} + +.ui-hero__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: center; +} + +.ui-display-title { + margin: 0; + color: var(--text-strong); + font-family: var(--font-family-display); + font-size: clamp(1.9rem, 4.2vw, 2.85rem); + line-height: 0.98; + letter-spacing: -0.03em; +} + +.ui-lede { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-1); +} + .ui-eyebrow { margin: 0; color: var(--eyebrow-color); @@ -174,6 +247,78 @@ textarea { font-weight: 600; } +.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); + text-decoration: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: var(--border-strong); +} + +.btn--primary { + background: var(--accent); + color: var(--text-inverse); +} + +.btn--primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.btn--ghost { + border-color: var(--border-subtle); + background: var(--surface-elevated); +} + +.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); +} + .brand-lockup { display: inline-grid; justify-items: center; @@ -219,3 +364,15 @@ textarea { font-weight: 600; letter-spacing: 0.01em; } + +@media (max-width: 47.9375rem) { + .ui-hero__masthead { + grid-template-columns: 1fr; + } + + .ui-hero__actions { + display: grid; + grid-template-columns: 1fr; + width: 100%; + } +} diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index d387fa86..f34ae32e 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'climate_control' +require 'securerandom' require_relative '../../../app' @@ -97,9 +98,8 @@ def app = described_class end it 'serves static feed routes with caching headers' do - stub_static_feed - - get '/legacy' + stub_static_feed(rss_body: '') + get "/legacy-#{SecureRandom.hex(4)}" expect(last_response.status).to eq(200) expect(last_response.headers['Content-Type']).to eq('application/xml') diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index b449723a..d1898199 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -49,17 +49,8 @@ it 'resolves the source through the real request and source resolver path', :aggregate_failures do write_response - expect(Html2rss::Web::Feeds::Service).to have_received(:call).with( - have_attributes( - source_kind: :static, - cache_identity: a_string_starting_with('static:example:'), - generator_input: include(strategy: :faraday, channel: { url: 'https://example.com', ttl: 10 }), - ttl_seconds: 600 - ) - ) - expect(response['Cache-Control']).to include('max-age=600') - expect(response['Cache-Control']).to include('public') - expect(response['Vary']).to eq('Accept') + expect_resolved_static_source + expect_cache_headers end it 'emits success after writing the response' do @@ -157,4 +148,23 @@ def request_for(path:, accept:) def response_tuple(body) [response.status, response['Content-Type'], body] end + + # @return [void] + def expect_resolved_static_source + expect(Html2rss::Web::Feeds::Service).to have_received(:call).with( + have_attributes( + source_kind: :static, + cache_identity: a_string_starting_with('static:example:'), + generator_input: include(strategy: :faraday, channel: { url: 'https://example.com', ttl: 10 }), + ttl_seconds: 600 + ) + ) + end + + # @return [void] + def expect_cache_headers + expect(response['Cache-Control']).to include('max-age=600') + expect(response['Cache-Control']).to include('public') + expect(response['Vary']).to eq('Accept') + end end diff --git a/spec/public/rss_xsl_spec.rb b/spec/public/rss_xsl_spec.rb new file mode 100644 index 00000000..1e9545e2 --- /dev/null +++ b/spec/public/rss_xsl_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'nokogiri' +require_relative '../../app' + +# rubocop:disable RSpec/MultipleExpectations +RSpec.describe 'public/rss.xsl' do + subject(:rendered_html) do + Nokogiri::XSLT(File.read(File.expand_path('../../public/rss.xsl', __dir__))).transform(Nokogiri::XML(feed_xml)).to_s + end + + let(:feed_xml) do + <<~XML + + + + The Example Feed + Example feed description with enough detail to exercise the hero copy. + https://example.com/articles + html2rss V. 1.0.0 + Mon, 01 Jan 2024 00:00:00 GMT + + First article + First article excerpt.

]]>
+ https://example.com/articles/1 + Mon, 01 Jan 2024 10:00:00 GMT + Policy + editor@example.com + +
+ + Second article + Math 1 < 2 > 0

]]>
+ https://example.com/articles/2 + Tue, 02 Jan 2024 10:00:00 GMT +
+ + Math 1 < 2 > 0 + Math 1 < 2 > 0 + https://example.com/articles/3 + Wed, 03 Jan 2024 10:00:00 GMT + +
+
+ XML + end + + it 'uses the feed icon in the hero and as the favicon' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('link[rel="icon"]')['href']).to eq('/feed.svg') + expect(doc.at_css('.feed-hero__icon')['src']).to eq('/feed.svg') + end + + it 'renders the feed-reader hero action with client-side wiring' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('[data-feed-reader-link]')).not_to be_nil + expect(doc.at_css('[data-feed-reader-link]').text.strip).to eq('Open in feed reader') + expect(doc.at_css('[data-feed-reader-link]')['href']).to eq('#') + expect(doc.at_css('script')['src']).to eq('/feed-reader-link.js') + end + + it 'uses the shared ui stylesheet' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('link[rel="stylesheet"]')['href']).to eq('/shared-ui.css') + end + + it 'preserves plain-text angle brackets while stripping actual html tags' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.css('.feed-card__title').last.text.strip).to eq('Math 1 < 2 > 0') + expect(doc.css('.feed-card__excerpt')[1].text.strip).to eq('Math 1 < 2 > 0') + end + + it 'surfaces last build time in the hero instead of decorative quality pills' do + doc = Nokogiri::HTML(rendered_html) + hero_stamp = doc.at_css('.feed-hero__stamp') + + expect(hero_stamp.text.gsub(/\s+/, ' ').strip).to eq('Updated Mon, 01 Jan 2024 00:00:00 GMT') + expect(doc.css('.feed-quality__pill')).to be_empty + end + + it 'uses the shared brand lockup in the feed header' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('.brand-lockup')).not_to be_nil + expect(doc.at_css('.brand-lockup__wordmark').text.strip).to eq('html2rss') + end + + it 'shows muted quality indicators instead of item metadata values' do + doc = Nokogiri::HTML(rendered_html) + + first_card_signals = doc.css('.feed-card').first.css('.feed-signal').map { |node| node.text.strip } + + expect(first_card_signals).to include('Summary', 'Image', 'Tags', 'Byline') + end +end +# rubocop:enable RSpec/MultipleExpectations From 94940abdf8849c6bfc85653a6b53d84db3ebd9af Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Tue, 31 Mar 2026 09:37:31 +0200 Subject: [PATCH 02/22] chore: install eslint and style-migrate --- .devcontainer/devcontainer.json | 7 +- .gitignore | 1 - Makefile | 6 +- docs/README.md | 1 + frontend/.gitignore | 3 + frontend/eslint.config.js | 38 + frontend/package-lock.json | 1975 +++++++++++++++-- frontend/package.json | 8 + frontend/src/__tests__/App.contract.test.tsx | 4 +- frontend/src/__tests__/App.test.tsx | 77 +- frontend/src/__tests__/ResultDisplay.test.tsx | 7 +- frontend/src/__tests__/setup.ts | 14 +- frontend/src/__tests__/useAccessToken.test.ts | 28 +- frontend/src/__tests__/useAuth.test.ts | 16 +- .../useFeedConversion.contract.test.ts | 14 +- .../src/__tests__/useFeedConversion.test.ts | 46 +- frontend/src/api/client.ts | 6 +- frontend/src/api/contracts.ts | 4 +- frontend/src/components/App.tsx | 126 +- frontend/src/components/AppPanels.tsx | 59 +- frontend/src/components/Bookmarklet.tsx | 4 +- frontend/src/components/DominantField.tsx | 4 +- frontend/src/components/ResultDisplay.tsx | 18 +- frontend/src/{env.d.ts => environment.d.ts} | 0 frontend/src/hooks/useAccessToken.ts | 31 +- frontend/src/hooks/useApiMetadata.ts | 10 +- frontend/src/hooks/useAuth.ts | 46 +- frontend/src/hooks/useFeedConversion.ts | 99 +- frontend/src/hooks/useStrategies.ts | 6 +- frontend/src/main.tsx | 2 +- frontend/src/utils/url.ts | 4 +- frontend/vitest.config.js | 4 +- 32 files changed, 2133 insertions(+), 535 deletions(-) create mode 100644 frontend/.gitignore create mode 100644 frontend/eslint.config.js rename frontend/src/{env.d.ts => environment.d.ts} (100%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b3795e01..1be83e9c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,11 +8,16 @@ "vscode": { "extensions": [ "rebornix.ruby", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint" ], "settings": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "prettier.configPath": "./frontend/prettier.config.js", "ruby.format": "rubocop", "ruby.lint": { "rubocop": true }, diff --git a/.gitignore b/.gitignore index 164fb7b7..bc566c73 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,6 @@ # Ignore rack cache /tmp/rack-cache-* - # Ignore frontend build output and tooling caches /frontend/dist/ /frontend/node_modules/ diff --git a/Makefile b/Makefile index fe7295f6..94fb7cdb 100644 --- a/Makefile +++ b/Makefile @@ -67,9 +67,11 @@ lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found bundle exec rake yard:verify_public_docs @echo "Ruby linting complete!" -lint-js: ## Run JavaScript/Frontend linter (Prettier) - errors when issues found +lint-js: ## Run JavaScript/Frontend linting (TypeScript + ESLint + Prettier) - errors when issues found @echo "Running TypeScript typecheck..." @cd frontend && npm run typecheck + @echo "Running ESLint..." + @cd frontend && npm run lint @echo "Running Prettier format check..." @cd frontend && npm run format:check @echo "JavaScript linting complete!" @@ -83,6 +85,8 @@ lintfix-ruby: ## Auto-fix Ruby linting issues @echo "Ruby lintfix complete!" lintfix-js: ## Auto-fix JavaScript/Frontend linting issues + @echo "Running ESLint auto-fix..." + @cd frontend && npm run lint:fix @echo "Running Prettier formatting..." @cd frontend && npm run format @echo "JavaScript lintfix complete!" diff --git a/docs/README.md b/docs/README.md index 318d3afb..e9521a8d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ Running the app directly on the host is not supported. | ----------------------- | -------------------------------------------- | | `npm run dev` | Vite dev server with hot reload (port 4001). | | `npm run build` | Build static assets into `frontend/dist/`. | +| `npm run lint` | Run ESLint across the frontend workspace. | | `npm run test:run` | Unit tests (Vitest). | | `npm run test:contract` | Contract tests with MSW. | diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..ea4e0394 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist/ +.astro diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..932c2b50 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import eslintPluginUnicorn from 'eslint-plugin-unicorn'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['.astro/**', 'dist/**', 'node_modules/**', 'src/api/generated/**', 'test-results/**'], + }, + { + files: ['src/**/*.{js,jsx,ts,tsx}', 'e2e/**/*.ts', './*.{js,ts}'], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + eslintPluginUnicorn.configs.recommended, + ], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + ...globals.vitest, + }, + }, + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'unicorn/filename-case': 'off', + 'unicorn/better-regex': 'warn', + }, + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index de917e97..f1469874 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,16 +10,22 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@eslint/js": "^9.36.0", "@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", "baseline-browser-mapping": "^2.10.9", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-unicorn": "^57.0.0", + "globals": "^16.4.0", "jsdom": "^27.0.0", "msw": "^2.11.3", "prettier": "^3.x.x", "typescript": "^5.9.3", + "typescript-eslint": "^8.44.1", "vite": "^6.3.6", "vitest": "^3.2.4" }, @@ -1050,6 +1056,163 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@hey-api/codegen-core": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.0.tgz", @@ -1153,6 +1316,58 @@ "typescript": ">=5.5.3" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", @@ -1954,39 +2169,6 @@ "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", @@ -2097,6 +2279,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -2104,134 +2293,456 @@ "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==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "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" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "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==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "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==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "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==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^4.0.3" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "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==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">= 14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@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/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-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==", "dev": true, "license": "MIT", @@ -2322,6 +2833,13 @@ "@babel/core": "^7.12.10" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -2352,10 +2870,21 @@ "dev": true, "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { - "version": "4.26.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", - "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -2373,11 +2902,11 @@ ], "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" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2386,6 +2915,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/builtin-modules": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz", + "integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2521,10 +3063,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/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==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "dev": true, "funding": [ { @@ -2559,6 +3111,39 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/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/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -2569,6 +3154,22 @@ "node": ">= 16" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -2579,6 +3180,29 @@ "consola": "^3.2.3" } }, + "node_modules/clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/clean-regexp/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -2723,6 +3347,13 @@ "node": ">=20" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -2747,6 +3378,20 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2947,6 +3592,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -3148,9 +3800,9 @@ } }, "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==", + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", "dev": true, "license": "ISC" }, @@ -3205,79 +3857,322 @@ "stop-iteration-iterator": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-unicorn": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-57.0.0.tgz", + "integrity": "sha512-zUYYa6zfNdTeG9BISWDlcLmz16c+2Ck2o5ZDHh0UzXJz3DEP7xjmlVDTzbyV0W+XksgZ0q37WEWzN2D2Ze+g9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "@eslint-community/eslint-utils": "^4.4.1", + "ci-info": "^4.1.0", + "clean-regexp": "^1.0.0", + "core-js-compat": "^3.40.0", + "esquery": "^1.6.0", + "globals": "^15.15.0", + "indent-string": "^5.0.0", + "is-builtin-module": "^4.0.0", + "jsesc": "^3.1.0", + "pluralize": "^8.0.0", + "read-package-up": "^11.0.0", + "regexp-tree": "^0.1.27", + "regjsparser": "^0.12.0", + "semver": "^7.7.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=18.18" + }, + "funding": { + "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" + }, + "peerDependencies": { + "eslint": ">=9.20.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/es-module-lexer": { + "node_modules/esquery": { "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==", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "es-errors": "^1.3.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10" } }, - "node_modules/esbuild": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", - "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" }, "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": ">=4.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=6" + "node": ">=4.0" } }, "node_modules/estree-walker": { @@ -3290,6 +4185,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -3307,6 +4212,27 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3325,6 +4251,70 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3453,6 +4443,32 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3571,6 +4587,19 @@ "dev": true, "license": "MIT" }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3625,6 +4654,43 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3635,6 +4701,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3718,6 +4797,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-builtin-module": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-4.0.0.tgz", + "integrity": "sha512-rWP3AMAalQSesXO8gleROyL2iKU73SX5Er66losQn9rWOWL4Gef0a/xOEOVqjWGMuR2vHG3FJ8UUmT700O8oFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-modules": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -3764,6 +4859,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3774,6 +4879,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -4106,30 +5224,98 @@ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/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/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=6" + "node": ">= 0.8.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "p-locate": "^5.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, @@ -4140,6 +5326,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -4187,6 +5380,19 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4269,6 +5475,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -4288,12 +5501,27 @@ } }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -4431,6 +5659,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -4438,6 +5684,69 @@ "dev": true, "license": "MIT" }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4451,6 +5760,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4571,6 +5890,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "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", @@ -4633,6 +5962,16 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -4670,6 +6009,44 @@ "destr": "^2.0.3" } }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -4684,6 +6061,16 @@ "node": ">=8" } }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -4705,6 +6092,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4725,6 +6138,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rettime": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", @@ -5031,6 +6454,42 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stack-trace": { "version": "1.0.0-pre2", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", @@ -5099,6 +6558,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-literal": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", @@ -5226,12 +6698,38 @@ "node": ">=16" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/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-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -5259,6 +6757,43 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/until-async": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", @@ -5270,9 +6805,9 @@ } }, "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==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -5300,6 +6835,27 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -5619,6 +7175,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -5766,6 +7332,19 @@ "node": ">=8" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index d3051a95..d431d5cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,8 @@ "dev": "vite --port 4001 --host", "build": "vite build", "preview": "vite preview --port 4001 --host", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", @@ -25,15 +27,21 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@eslint/js": "^9.36.0", "@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", "baseline-browser-mapping": "^2.10.9", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-unicorn": "^57.0.0", + "globals": "^16.4.0", "jsdom": "^27.0.0", "msw": "^2.11.3", "prettier": "^3.x.x", + "typescript-eslint": "^8.44.1", "typescript": "^5.9.3", "vite": "^6.3.6", "vitest": "^3.2.4" diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index f90e89fd..05f51b6e 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -8,7 +8,7 @@ describe('App contract', () => { const token = 'contract-token'; const authenticate = () => { - window.localStorage.setItem('html2rss_access_token', token); + globalThis.localStorage.setItem('html2rss_access_token', token); }; it('shows feed result when API responds with success', async () => { @@ -161,6 +161,6 @@ describe('App contract', () => { expect(screen.getByText('Add access token')).toBeInTheDocument(); expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument(); - expect(window.localStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeUndefined(); }); }); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 3383146a..c9af1bee 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -1,6 +1,5 @@ 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', () => ({ @@ -38,15 +37,15 @@ describe('App', () => { beforeEach(() => { vi.clearAllMocks(); - window.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/'); mockUseAccessToken.mockReturnValue({ - token: null, + token: undefined, hasToken: false, saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockUseApiMetadata.mockReturnValue({ @@ -65,13 +64,13 @@ describe('App', () => { }, }, isLoading: false, - error: null, + error: undefined, }); mockUseFeedConversion.mockReturnValue({ isConverting: false, - result: null, - error: null, + result: undefined, + error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, @@ -83,7 +82,7 @@ describe('App', () => { { id: 'browserless', name: 'browserless', display_name: 'JavaScript pages (recommended)' }, ], isLoading: false, - error: null, + error: undefined, }); }); @@ -127,7 +126,7 @@ describe('App', () => { mockUseStrategies.mockReturnValue({ strategies: [{ id: 'faraday', name: 'faraday', display_name: 'Default' }], isLoading: false, - error: null, + error: undefined, }); render(); @@ -144,9 +143,13 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); - window.history.replaceState({}, '', 'http://localhost:3000/?url=https%3A%2F%2Fexample.com%2Farticles'); + globalThis.history.replaceState( + {}, + '', + 'http://localhost:3000/?url=https%3A%2F%2Fexample.com%2Farticles' + ); render(); @@ -171,7 +174,7 @@ describe('App', () => { 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(document.activeElement).toBe(document.querySelector('#access-token')); }); expect(mockConvertFeed).not.toHaveBeenCalled(); }); @@ -199,7 +202,7 @@ describe('App', () => { }, }, isLoading: false, - error: null, + error: undefined, }); render(); @@ -230,9 +233,9 @@ describe('App', () => { error: 'Preview unavailable right now.', isLoading: false, }, - retry: null, + retry: undefined, }, - error: null, + error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, @@ -249,7 +252,7 @@ describe('App', () => { it('surfaces conversion errors to the user', () => { mockUseFeedConversion.mockReturnValue({ isConverting: false, - result: null, + result: undefined, error: 'Access denied', convertFeed: mockConvertFeed, clearError: mockClearConversionError, @@ -265,8 +268,8 @@ describe('App', () => { it('shows an explicit loading notice while feed creation is still resolving preview state', () => { mockUseFeedConversion.mockReturnValue({ isConverting: true, - result: null, - error: null, + result: undefined, + error: undefined, convertFeed: mockConvertFeed, clearError: mockClearConversionError, clearResult: mockClearResult, @@ -287,7 +290,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); render(); @@ -305,18 +308,18 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityItems = Array.from( + const utilityItems = [ screen .getByLabelText('Utilities') - .querySelectorAll('.utility-strip__items > a, .utility-strip__items > button') - ).map((element) => element.textContent); + .querySelectorAll('.utility-strip__items > a, .utility-strip__items > button'), + ].map((element) => element.textContent); expect(utilityItems).toEqual([ 'Try included feeds', @@ -335,7 +338,7 @@ describe('App', () => { target: { value: 'https://example.com/articles' }, }); fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - const accessTokenInput = document.getElementById('access-token') as HTMLInputElement; + const accessTokenInput = document.querySelector('#access-token') as HTMLInputElement; fireEvent.input(accessTokenInput, { target: { value: 'token-123' } }); fireEvent.click(screen.getByRole('button', { name: 'Save and continue' })); @@ -352,7 +355,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); @@ -380,7 +383,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized')); @@ -406,7 +409,7 @@ describe('App', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); - const accessTokenInput = document.getElementById('access-token') as HTMLInputElement; + const accessTokenInput = document.querySelector('#access-token') as HTMLInputElement; fireEvent.input(accessTokenInput, { target: { value: 'token-123' } }); fireEvent.keyDown(accessTokenInput, { key: 'Enter' }); @@ -416,7 +419,7 @@ describe('App', () => { }); it('builds a bookmarklet that returns to the root app entry', () => { - window.history.replaceState({}, '', 'http://localhost:3000/'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); @@ -426,7 +429,7 @@ describe('App', () => { }); it('opens token entry immediately for bookmarklet urls when no token is saved', async () => { - window.history.replaceState({}, '', 'http://localhost:3000/?url=example.com%2Farticles'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/?url=example.com%2Farticles'); render(); @@ -442,7 +445,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed .mockRejectedValueOnce( @@ -450,7 +453,7 @@ describe('App', () => { manualRetryStrategy: 'browserless', }) ) - .mockResolvedValueOnce(undefined); + .mockResolvedValueOnce(); render(); @@ -478,7 +481,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce( Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), { @@ -504,7 +507,7 @@ describe('App', () => { saveToken: mockSaveToken, clearToken: mockClearToken, isLoading: false, - error: null, + error: undefined, }); mockConvertFeed.mockRejectedValueOnce( Object.assign(new Error('URL not allowed for this account'), { @@ -529,14 +532,14 @@ describe('App', () => { }); it('shows the utility links in a user-focused order', () => { - window.history.replaceState({}, '', 'http://localhost:3000/#result'); + globalThis.history.replaceState({}, '', 'http://localhost:3000/#result'); render(); fireEvent.click(screen.getByRole('button', { name: 'More' })); - const utilityLinks = Array.from( - screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a') - ).map((link) => link.textContent); + const utilityLinks = [ + ...screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a'), + ].map((link) => link.textContent); expect(utilityLinks).toEqual([ 'Try included feeds', 'Bookmarklet', diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index 9646f558..70d8de96 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -1,6 +1,5 @@ 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', () => { @@ -35,10 +34,10 @@ describe('ResultDisplay', () => { publishedLabel: 'Jan 3, 2024', }, ], - error: null, + error: undefined, isLoading: false, }, - retry: null, + retry: undefined, }; beforeEach(() => { @@ -90,7 +89,7 @@ describe('ResultDisplay', () => { it('keeps the result state visible while preview is still loading', async () => { render( ); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 66c108f8..6e38500b 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -5,12 +5,12 @@ import { cleanup } from '@testing-library/preact'; let server: typeof import('./mocks/server').server; // Mock window and document for tests -Object.defineProperty(window, 'matchMedia', { +Object.defineProperty(globalThis, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, - onchange: null, + onchange: undefined, addListener: vi.fn(), // deprecated removeListener: vi.fn(), // deprecated addEventListener: vi.fn(), @@ -29,7 +29,7 @@ const createStorageMock = () => { get length() { return store.size; }, - getItem: vi.fn((key: string) => (store.has(key) ? store.get(key)! : null)), + getItem: vi.fn((key: string) => (store.has(key) ? store.get(key)! : undefined)), setItem: vi.fn((key: string, value: string) => { store.set(key, value); }), @@ -39,7 +39,7 @@ const createStorageMock = () => { clear: vi.fn(() => { store.clear(); }), - key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null), + key: vi.fn((index: number) => [...store.keys()][index] ?? undefined), }, }; }; @@ -47,16 +47,10 @@ const createStorageMock = () => { const local = createStorageMock(); const session = createStorageMock(); -Object.defineProperty(window, 'localStorage', { - value: local.api, -}); Object.defineProperty(globalThis, 'localStorage', { value: local.api, }); -Object.defineProperty(window, 'sessionStorage', { - value: session.api, -}); Object.defineProperty(globalThis, 'sessionStorage', { value: session.api, }); diff --git a/frontend/src/__tests__/useAccessToken.test.ts b/frontend/src/__tests__/useAccessToken.test.ts index cabf4d63..fd4c27a2 100644 --- a/frontend/src/__tests__/useAccessToken.test.ts +++ b/frontend/src/__tests__/useAccessToken.test.ts @@ -4,30 +4,30 @@ import { useAccessToken } from '../hooks/useAccessToken'; describe('useAccessToken', () => { beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); + globalThis.localStorage.clear(); + globalThis.sessionStorage.clear(); }); it('loads the persisted token from localStorage', async () => { - window.localStorage.setItem('html2rss_access_token', 'persisted-token'); + globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); const { result } = renderHook(() => useAccessToken()); expect(result.current.isLoading).toBe(false); expect(result.current.token).toBe('persisted-token'); expect(result.current.hasToken).toBe(true); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); }); it('migrates a legacy session token into localStorage', async () => { - window.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); + globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); const { result } = renderHook(() => useAccessToken()); expect(result.current.isLoading).toBe(false); expect(result.current.token).toBe('legacy-token'); - expect(window.localStorage.getItem('html2rss_access_token')).toBe('legacy-token'); - expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('legacy-token'); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeUndefined(); }); it('saves new tokens to the persistent storage path', async () => { @@ -39,13 +39,13 @@ describe('useAccessToken', () => { expect(result.current.token).toBe('new-token'); expect(result.current.hasToken).toBe(true); - expect(window.localStorage.getItem('html2rss_access_token')).toBe('new-token'); - expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('new-token'); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeUndefined(); }); it('clears both persistent and legacy token copies', async () => { - window.localStorage.setItem('html2rss_access_token', 'persisted-token'); - window.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); + globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token'); + globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token'); const { result } = renderHook(() => useAccessToken()); @@ -53,9 +53,9 @@ describe('useAccessToken', () => { result.current.clearToken(); }); - expect(result.current.token).toBeNull(); + expect(result.current.token).toBeUndefined(); expect(result.current.hasToken).toBe(false); - expect(window.localStorage.getItem('html2rss_access_token')).toBeNull(); - expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeUndefined(); + expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeUndefined(); }); }); diff --git a/frontend/src/__tests__/useAuth.test.ts b/frontend/src/__tests__/useAuth.test.ts index 5995ff27..02ea4b2d 100644 --- a/frontend/src/__tests__/useAuth.test.ts +++ b/frontend/src/__tests__/useAuth.test.ts @@ -27,12 +27,12 @@ describe('useAuth', () => { beforeEach(() => { localStorageMock = createStorageMock(); sessionStorageMock = createStorageMock(); - Object.defineProperty(window, 'localStorage', { + Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true, writable: true, }); - Object.defineProperty(window, 'sessionStorage', { + Object.defineProperty(globalThis, 'sessionStorage', { value: sessionStorageMock, configurable: true, writable: true, @@ -41,13 +41,13 @@ describe('useAuth', () => { }); it('should initialize with unauthenticated state', () => { - localStorageMock.getItem.mockReturnValue(null); + localStorageMock.getItem.mockReturnValue(); const { result } = renderHook(() => useAuth()); expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeNull(); - expect(result.current.token).toBeNull(); + expect(result.current.username).toBeUndefined(); + expect(result.current.token).toBeUndefined(); }); it('should load auth state from sessionStorage on mount', () => { @@ -65,7 +65,7 @@ describe('useAuth', () => { }); it('should login and store credentials', async () => { - localStorageMock.getItem.mockReturnValue(null); + localStorageMock.getItem.mockReturnValue(); const { result } = renderHook(() => useAuth()); @@ -90,8 +90,8 @@ describe('useAuth', () => { }); expect(result.current.isAuthenticated).toBe(false); - expect(result.current.username).toBeNull(); - expect(result.current.token).toBeNull(); + expect(result.current.username).toBeUndefined(); + expect(result.current.token).toBeUndefined(); expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_username'); expect(localStorageMock.removeItem).toHaveBeenCalledWith('html2rss_token'); }); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 33cb19dc..97788249 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -6,7 +6,7 @@ import { useFeedConversion } from '../hooks/useFeedConversion'; describe('useFeedConversion contract', () => { it('sends feed creation request with bearer token', async () => { - let receivedAuthorization: string | null = null; + let receivedAuthorization: string | undefined; server.use( http.post('/api/v1/feeds', async ({ request }) => { @@ -48,12 +48,12 @@ describe('useFeedConversion contract', () => { }); expect(receivedAuthorization).toBe('Bearer test-token-123'); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token'); expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json'); await waitFor(() => { - expect(result.current.result?.preview.error).toBeNull(); + expect(result.current.result?.preview.error).toBeUndefined(); expect(result.current.result?.preview.isLoading).toBe(false); expect(result.current.result?.preview.items).toHaveLength(1); }); @@ -77,7 +77,7 @@ describe('useFeedConversion contract', () => { ).rejects.toThrow('URL parameter is required'); }); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('URL parameter is required'); }); @@ -99,7 +99,7 @@ describe('useFeedConversion contract', () => { ).rejects.toThrow('Invalid response format from feed creation API'); }); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Invalid response format from feed creation API'); }); @@ -115,7 +115,7 @@ describe('useFeedConversion contract', () => { { status: 201 } ) ), - http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(null, { status: 502 })) + http.get('/api/v1/feeds/generated-token.json', async () => new HttpResponse(undefined, { status: 502 })) ); const { result } = renderHook(() => useFeedConversion()); @@ -124,7 +124,7 @@ describe('useFeedConversion contract', () => { await result.current.convertFeed('https://example.com/articles', 'faraday', 'token'); }); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); await waitFor(() => { expect(result.current.result?.preview.items).toEqual([]); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index a0082a77..d3afd4f5 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -7,7 +7,7 @@ describe('useFeedConversion', () => { beforeEach(() => { vi.clearAllMocks(); - fetchMock = vi.spyOn(global, 'fetch'); + fetchMock = vi.spyOn(globalThis, 'fetch'); }); afterEach(() => { @@ -18,8 +18,8 @@ describe('useFeedConversion', () => { const { result } = renderHook(() => useFeedConversion()); expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeNull(); - expect(result.current.error).toBeNull(); + expect(result.current.result).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); it('should handle successful conversion', async () => { @@ -78,10 +78,10 @@ describe('useFeedConversion', () => { feed: mockFeed, preview: { items: [], - error: null, + error: undefined, isLoading: true, }, - retry: null, + retry: undefined, }); await waitFor(() => { expect(result.current.result).toEqual({ @@ -95,13 +95,13 @@ describe('useFeedConversion', () => { url: 'https://example.com/item', }, ], - error: null, + error: undefined, isLoading: false, }, - retry: null, + retry: undefined, }); }); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -128,7 +128,7 @@ describe('useFeedConversion', () => { }); expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toContain('Bad Request'); }); @@ -144,7 +144,7 @@ describe('useFeedConversion', () => { }); expect(result.current.isConverting).toBe(false); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Network error'); }); @@ -189,10 +189,10 @@ describe('useFeedConversion', () => { feed: createdFeed, preview: { items: [], - error: null, + error: undefined, isLoading: true, }, - retry: null, + retry: undefined, }); await waitFor(() => { expect(result.current.result).toEqual({ @@ -202,10 +202,10 @@ describe('useFeedConversion', () => { error: 'Preview unavailable right now.', isLoading: false, }, - retry: null, + retry: undefined, }); }); - expect(result.current.error).toBeNull(); + expect(result.current.error).toBeUndefined(); }); it('publishes the result before preview loading finishes', async () => { @@ -221,7 +221,7 @@ describe('useFeedConversion', () => { updated_at: '2024-01-01T00:00:00Z', }; - let resolvePreviewResponse: ((value: Response) => void) | null = null; + let resolvePreviewResponse: ((value: Response) => void) | undefined; const previewResponse = new Promise((resolve) => { resolvePreviewResponse = resolve; }); @@ -251,10 +251,10 @@ describe('useFeedConversion', () => { feed: createdFeed, preview: { items: [], - error: null, + error: undefined, isLoading: true, }, - retry: null, + retry: undefined, }); expect(result.current.isConverting).toBe(false); expect(result.current.result).toEqual(conversionResult); @@ -288,7 +288,7 @@ describe('useFeedConversion', () => { url: 'https://example.com/item', }, ], - error: null, + error: undefined, isLoading: false, }); }); @@ -435,7 +435,7 @@ describe('useFeedConversion', () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Unauthorized'); }); @@ -462,7 +462,7 @@ describe('useFeedConversion', () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe('Input rejected'); }); @@ -576,7 +576,7 @@ describe('useFeedConversion', () => { 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' ); expect(thrownError?.manualRetryStrategy).toBeUndefined(); - expect(result.current.result).toBeNull(); + expect(result.current.result).toBeUndefined(); expect(result.current.error).toBe( 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed' ); @@ -606,11 +606,11 @@ describe('useFeedConversion', () => { updated_at: '2024-01-01T00:00:00Z', }; - let resolvePreviewA: ((value: Response) => void) | null = null; + let resolvePreviewA: ((value: Response) => void) | undefined; const previewAPromise = new Promise((resolve) => { resolvePreviewA = resolve; }); - let resolvePreviewB: ((value: Response) => void) | null = null; + let resolvePreviewB: ((value: Response) => void) | undefined; const previewBPromise = new Promise((resolve) => { resolvePreviewB = resolve; }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3e8bd3f3..267ebea4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,9 +1,9 @@ import { createClient, createConfig } from './generated/client'; const resolveBaseUrl = (): string => { - if (typeof window === 'undefined') return 'http://localhost/api/v1'; + if (globalThis.window === undefined) return 'http://localhost/api/v1'; - const origin = window.location?.origin; + const origin = globalThis.location?.origin; if (!origin || origin === 'null') return 'http://localhost/api/v1'; return `${origin}/api/v1`; @@ -15,5 +15,5 @@ export const apiClient = createClient( }) ); -export const bearerHeaders = (token: string | null): Record => +export const bearerHeaders = (token?: string): Record => token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index 041154d4..aafde477 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -11,7 +11,7 @@ export interface FeedPreviewItem { export interface FeedPreviewState { items: FeedPreviewItem[]; - error: string | null; + error?: string; isLoading: boolean; } @@ -24,7 +24,7 @@ export interface FeedRetryState { export interface CreatedFeedResult { feed: FeedRecord; preview: FeedPreviewState; - retry: FeedRetryState | null; + retry?: FeedRetryState; } export interface ApiMetadataRecord { diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 981da692..a1adb17b 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -12,6 +12,47 @@ const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; const preferredStrategy = (strategies: { id: string }[]) => strategies.find((strategy) => strategy.id === 'faraday')?.id ?? strategies[0]?.id; +function strategyHint(strategy: Strategy) { + if (strategy.id === 'faraday') return 'Start here for most pages.'; + if (strategy.id === 'browserless') return 'Use this if the page loads content with JavaScript.'; + return strategy.name; +} + +function isAccessTokenError(message: string) { + const normalized = message.toLowerCase(); + const mentionsAuthToken = + normalized.includes('access token') || + normalized.includes('token') || + normalized.includes('authentication') || + normalized.includes('bearer'); + + return ( + normalized.includes('unauthorized') || + normalized.includes('invalid token') || + normalized.includes('token rejected') || + normalized.includes('authentication') || + (normalized.includes('forbidden') && mentionsAuthToken) + ); +} + +function isActionableStrategySwitch(message: string, currentStrategy: string, retryStrategy: string) { + if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; + + const normalized = message.toLowerCase(); + return !( + normalized.includes('unauthorized') || + normalized.includes('forbidden') || + normalized.includes('not allowed') || + normalized.includes('disabled') || + normalized.includes('access token') || + normalized.includes('token') || + normalized.includes('authentication') || + normalized.includes('bad request') || + normalized.includes('url') || + normalized.includes('unsupported strategy') + ); +} + interface ConversionErrorWithMeta extends Error { manualRetryStrategy?: string; } @@ -56,19 +97,19 @@ export function App() { const [tokenError, setTokenError] = useState(''); const [manualRetryStrategy, setManualRetryStrategy] = useState(''); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); - const autoSubmitUrlRef = useRef(null); - const hasAutoSubmittedRef = useRef(false); + const autoSubmitUrlReference = useRef(undefined); + const hasAutoSubmittedReference = useRef(false); const selectedStrategy = feedFormData.strategy || preferredStrategy(strategies) || ''; useEffect(() => { - if (typeof window === 'undefined') return; + if (globalThis.window === undefined) return; - const urlParam = new URLSearchParams(window.location.search).get('url'); - if (!urlParam) return; - autoSubmitUrlRef.current = urlParam; + const urlParameter = new URLSearchParams(globalThis.location.search).get('url'); + if (!urlParameter) return; + autoSubmitUrlReference.current = urlParameter; if (feedFormData.url) return; - setFeedFormData((prev) => ({ ...prev, url: urlParam })); + setFeedFormData((previous) => ({ ...previous, url: urlParameter })); }, [feedFormData.url]); useEffect(() => { @@ -76,7 +117,7 @@ export function App() { if (!nextStrategy) return; const hasCurrentStrategy = strategies.some((strategy) => strategy.id === feedFormData.strategy); - if (!hasCurrentStrategy) setFeedFormData((prev) => ({ ...prev, strategy: nextStrategy })); + if (!hasCurrentStrategy) setFeedFormData((previous) => ({ ...previous, strategy: nextStrategy })); }, [strategies, feedFormData.strategy]); const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; @@ -84,57 +125,16 @@ export function App() { const submitDisabled = isConverting || strategiesLoading || !feedCreation.enabled || showTokenPrompt; const setFeedField = (key: 'url' | 'strategy', value: string) => { - setFeedFormData((prev) => ({ ...prev, [key]: value })); - setFeedFieldErrors((prev) => ({ - ...prev, - url: key === 'url' ? '' : prev.url, + setFeedFormData((previous) => ({ ...previous, [key]: value })); + setFeedFieldErrors((previous) => ({ + ...previous, + url: key === 'url' ? '' : previous.url, form: '', })); setManualRetryStrategy(''); clearError(); }; - const strategyHint = (strategy: Strategy) => { - if (strategy.id === 'faraday') 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(); - const mentionsAuthToken = - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bearer'); - - return ( - normalized.includes('unauthorized') || - normalized.includes('invalid token') || - normalized.includes('token rejected') || - normalized.includes('authentication') || - (normalized.includes('forbidden') && mentionsAuthToken) - ); - }; - - const isActionableStrategySwitch = (message: string, currentStrategy: string, retryStrategy: string) => { - if (currentStrategy !== 'faraday' || retryStrategy !== 'browserless') return false; - - const normalized = message.toLowerCase(); - return !( - normalized.includes('unauthorized') || - normalized.includes('forbidden') || - normalized.includes('not allowed') || - normalized.includes('disabled') || - normalized.includes('access token') || - normalized.includes('token') || - normalized.includes('authentication') || - normalized.includes('bad request') || - normalized.includes('url') || - normalized.includes('unsupported strategy') - ); - }; - const attemptFeedCreation = async (accessToken: string, strategyOverride?: string) => { const strategy = strategyOverride || selectedStrategy; const normalizedUrl = normalizeUserUrl(feedFormData.url); @@ -158,7 +158,7 @@ export function App() { } if (feedCreation.access_token_required && !accessToken) { - setFeedFormData((prev) => ({ ...prev, url: normalizedUrl })); + setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); clearError(); setShowTokenPrompt(true); setTokenError(''); @@ -166,7 +166,7 @@ export function App() { } try { - setFeedFormData((prev) => ({ ...prev, url: normalizedUrl })); + setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); await convertFeed(normalizedUrl, strategy, accessToken); setShowTokenPrompt(false); setTokenError(''); @@ -225,27 +225,27 @@ export function App() { const handleRetryWithStrategy = () => { if (!manualRetryStrategy) return; - setFeedFormData((prev) => ({ ...prev, strategy: manualRetryStrategy })); + setFeedFormData((previous) => ({ ...previous, strategy: manualRetryStrategy })); setFeedFieldErrors(EMPTY_FEED_ERRORS); clearError(); void attemptFeedCreation(token ?? '', manualRetryStrategy); }; useEffect(() => { - const autoSubmitUrl = autoSubmitUrlRef.current; - if (!autoSubmitUrl || hasAutoSubmittedRef.current) return; + const autoSubmitUrl = autoSubmitUrlReference.current; + if (!autoSubmitUrl || hasAutoSubmittedReference.current) return; if (strategiesLoading || metadataLoading || tokenLoading) return; if (feedFormData.url !== autoSubmitUrl || !selectedStrategy) return; if (feedCreation.access_token_required && !token) { - hasAutoSubmittedRef.current = true; - setFeedFormData((prev) => ({ ...prev, url: normalizeUserUrl(autoSubmitUrl) })); + hasAutoSubmittedReference.current = true; + setFeedFormData((previous) => ({ ...previous, url: normalizeUserUrl(autoSubmitUrl) })); setShowTokenPrompt(true); setTokenError(''); return; } - hasAutoSubmittedRef.current = true; + hasAutoSubmittedReference.current = true; setFeedFieldErrors(EMPTY_FEED_ERRORS); void attemptFeedCreation(token ?? ''); }, [ @@ -302,8 +302,6 @@ export function App() { strategiesError={strategiesError} feedCreationEnabled={feedCreation.enabled} featuredFeeds={featuredFeeds} - accessTokenRequired={feedCreation.access_token_required} - hasAccessToken={hasToken} tokenDraft={tokenDraft} tokenError={tokenError} showTokenPrompt={showTokenPrompt} @@ -327,7 +325,7 @@ export function App() {

); } + +function normalizeLocalOriginUrl(value?: string): string | undefined { + if (!value || globalThis.window === undefined) return value; + + try { + const target = new URL(value, globalThis.location.origin); + const current = new URL(globalThis.location.origin); + const isLocalHost = (host: string) => host === 'localhost' || host === '127.0.0.1'; + + if (isLocalHost(current.hostname) && isLocalHost(target.hostname) && target.port !== current.port) { + return `${current.origin}${target.pathname}${target.search}${target.hash}`; + } + + return target.toString(); + } catch { + return value; + } +} From 085f0e63d0c028dd43674ed0a0d6cb10516c46b7 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 4 Apr 2026 10:52:38 +0200 Subject: [PATCH 18/22] Align frontend journey with industrial minimal state frame --- frontend/src/components/AppPanels.tsx | 2 +- frontend/src/components/ResultDisplay.tsx | 9 ++- frontend/src/styles/main.css | 85 ++++++++++++++++++----- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 2591d6f9..2747ddaa 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -226,7 +226,7 @@ export function CreateFeedPanel({ Set up your own instance with Docker.
-
diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index b107305e..7a7c3b5e 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -22,7 +22,8 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res : `${globalThis.location.origin}${feed.json_public_url}`; const subscribeUrl = /^https?:\/\//i.test(fullUrl) ? `feed:${fullUrl}` : undefined; const isFeedReady = readinessPhase === 'feed_ready'; - const canManuallyRetryReadiness = readinessPhase === 'feed_not_ready_yet' || readinessPhase === 'preview_unavailable'; + const canManuallyRetryReadiness = + readinessPhase === 'feed_not_ready_yet' || readinessPhase === 'preview_unavailable'; const previewItems = showAllPreviewItems ? preview.items : preview.items.slice(0, 3); const hasMorePreviewItems = preview.items.length > 3; const statusTitle = { @@ -77,7 +78,7 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res
{canManuallyRetryReadiness && ( - )} @@ -174,7 +175,9 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res

Preview

Latest items from this feed

-

Feed is ready. Preview items will appear once the source publishes entries.

+

+ Feed is ready. Preview items will appear once the source publishes entries. +

)} diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index df2b82b6..87484a22 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -34,6 +34,14 @@ max-width: var(--layout-rail-shell); margin: 0 auto; gap: var(--section-gap); + + --state-frame-radius: var(--radius-lg); + --state-frame-border: rgb(var(--color-rgb-white) / 14%); + --state-frame-bg: + linear-gradient(180deg, rgb(var(--color-rgb-white) / 3%), transparent 58%), + rgb(var(--color-rgb-white) / 1.5%); + --state-frame-shadow: + inset 0 1px 0 rgb(var(--color-rgb-white) / 5%), 0 18px 36px rgb(var(--color-rgb-black) / 24%); } .workspace-shell--centered, @@ -57,6 +65,11 @@ .result-shell { width: 100%; margin: 0 auto; + border: var(--border-width) solid var(--state-frame-border); + border-radius: var(--state-frame-radius); + background: var(--state-frame-bg); + box-shadow: var(--state-frame-shadow); + padding: clamp(var(--space-4), 4vw, var(--space-5)); } .form-shell--minimal, @@ -152,6 +165,8 @@ margin: 0; color: var(--text-muted); text-align: center; + font-size: var(--font-size-0); + line-height: 1.45; } .field-help--alert { @@ -273,6 +288,10 @@ a:focus-visible { max-width: var(--layout-field-max-width); margin: 0 auto; box-shadow: var(--shadow-elevated); + border-color: var(--state-frame-border); + background: + linear-gradient(180deg, rgb(var(--color-rgb-white) / 4%), transparent 68%), + rgb(var(--color-rgb-white) / 2%); } .notice p { @@ -352,7 +371,11 @@ a:focus-visible { width: 100%; max-width: var(--layout-field-max-width); margin: 0 auto; - padding-top: var(--space-2); + padding: var(--space-4); + border: var(--border-width) solid var(--state-frame-border); + border-radius: var(--radius-md); + background: + linear-gradient(180deg, rgb(var(--color-rgb-white) / 3%), transparent), rgb(var(--color-rgb-white) / 2%); gap: var(--space-3); } @@ -367,16 +390,19 @@ a:focus-visible { margin: 0; color: var(--text-strong); font-family: var(--font-family-display); - font-size: clamp(1.08rem, 2.6vw, 1.32rem); + font-size: clamp(1.18rem, 2.3vw, 1.45rem); font-weight: 600; - line-height: var(--line-height-tight); + line-height: 1.02; + letter-spacing: -0.015em; } .token-gate__hint { margin: var(--space-1) 0 0; color: var(--text-muted); - font-size: var(--font-size-0); + font-size: var(--font-size-00); line-height: 1.45; + letter-spacing: 0.03em; + text-transform: uppercase; } .token-gate__actions, @@ -394,11 +420,17 @@ a:focus-visible { .token-gate__actions .btn { min-height: calc(var(--space-6) + var(--space-2)); padding-inline: var(--space-4); - border-color: var(--border-subtle); - background: var(--surface-base); - color: var(--text-body); + border-color: var(--accent); + background: var(--accent); + color: var(--text-inverse); font-size: var(--font-size-0); font-weight: 600; + letter-spacing: 0.02em; +} + +.token-gate__actions .btn:hover:not(:disabled) { + border-color: var(--accent-strong); + background: var(--accent-strong); } .token-gate__back { @@ -451,22 +483,24 @@ a:focus-visible { .result-meta { margin: 0; - font-size: clamp(var(--font-size-0), 1.5vw, var(--font-size-2)); - line-height: 1.18; - color: var(--text-strong); + font-size: clamp(var(--font-size-0), 1.35vw, var(--font-size-2)); + line-height: 1.25; + color: var(--text-body); text-align: left; } .result-preview { justify-items: start; - padding-top: var(--section-gap); - border-top: var(--border-default); + padding-top: var(--space-4); + border-top: var(--border-width) solid var(--state-frame-border); } .result-preview__intro { margin: 0; color: var(--text-muted); - font-size: var(--font-size-0); + font-size: var(--font-size-00); + text-transform: uppercase; + letter-spacing: 0.03em; } .result-preview__list { @@ -481,14 +515,20 @@ a:focus-visible { .preview-card { width: 100%; padding: clamp(1rem, 3vw, 1.15rem); + border-color: var(--state-frame-border); + background: + linear-gradient(180deg, rgb(var(--color-rgb-white) / 4%), transparent 80%), + rgb(var(--color-rgb-white) / 2%); + box-shadow: inset 0 1px 0 rgb(var(--color-rgb-white) / 4%); } .preview-card__title { margin: 0; color: var(--text-strong); font-family: var(--font-family-display); - font-size: clamp(1.12rem, 2vw, 1.45rem); - line-height: 1.08; + font-size: clamp(1.02rem, 1.7vw, 1.22rem); + line-height: 1.2; + letter-spacing: -0.01em; } .preview-card__date { @@ -527,13 +567,20 @@ a:focus-visible { flex-wrap: wrap; align-items: center; justify-content: flex-start; - gap: var(--space-4); + gap: var(--space-3); margin-top: calc(var(--space-1) * -1); } +.result-actions--quiet .btn--ghost { + border-color: var(--state-frame-border); +} + .btn--linkish { color: var(--eyebrow-color); font-weight: 600; + font-size: var(--font-size-00); + letter-spacing: 0.06em; + text-transform: uppercase; } .notice[data-state="loading"] p { @@ -615,6 +662,12 @@ a:focus-visible { gap: var(--space-4); } + .form-shell--minimal, + .result-shell { + padding: var(--space-4); + border-radius: var(--radius-md); + } + .input--lg { min-height: 4rem; padding-right: calc(var(--space-7) + var(--space-4)); From a6f734a59cc60e8725caa4dbee3092f4e29e731c Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 4 Apr 2026 11:04:02 +0200 Subject: [PATCH 19/22] Refine readiness retry UX cadence and flatten form shell --- frontend/src/__tests__/ResultDisplay.test.tsx | 2 +- .../src/__tests__/useFeedConversion.test.ts | 199 ++++++++++++++-- frontend/src/components/ResultDisplay.tsx | 14 +- frontend/src/hooks/useFeedConversion.ts | 219 ++++++++++++++---- frontend/src/styles/main.css | 12 +- 5 files changed, 382 insertions(+), 64 deletions(-) diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index db2181b8..c5646253 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -115,7 +115,7 @@ describe('ResultDisplay', () => { await waitFor(() => { expect(screen.getByText('Feed created')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Try readiness check again' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Checking readiness…' })).toBeDisabled(); expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument(); expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument(); }); diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index d3afd4f5..a8556aa5 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -81,6 +81,7 @@ describe('useFeedConversion', () => { error: undefined, isLoading: true, }, + readinessPhase: 'link_created', retry: undefined, }); await waitFor(() => { @@ -98,6 +99,7 @@ describe('useFeedConversion', () => { error: undefined, isLoading: false, }, + readinessPhase: 'feed_ready', retry: undefined, }); }); @@ -149,6 +151,8 @@ describe('useFeedConversion', () => { }); it('preserves the created feed when preview loading fails after feed creation', async () => { + vi.useFakeTimers(); + try { const createdFeed = { id: 'test-id', name: 'Test Feed', @@ -175,14 +179,15 @@ describe('useFeedConversion', () => { } ) ); - fetchMock.mockResolvedValueOnce(new Response('nope', { status: 502 })); + fetchMock.mockResolvedValue(new Response('nope', { status: 502 })); const { result } = renderHook(() => useFeedConversion()); let conversionResult: Awaited> | undefined; - await act(async () => { - conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - }); + await act(async () => { + conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await vi.advanceTimersByTimeAsync(260 + 620 + 1180 + 1800 + 100); + }); expect(result.current.isConverting).toBe(false); expect(conversionResult).toEqual({ @@ -192,23 +197,28 @@ describe('useFeedConversion', () => { error: undefined, isLoading: true, }, + readinessPhase: 'link_created', retry: undefined, }); - await waitFor(() => { - expect(result.current.result).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: 'Preview unavailable right now.', - isLoading: false, - }, - retry: undefined, + await waitFor(() => { + expect(result.current.result).toEqual({ + feed: createdFeed, + preview: { + items: [], + error: 'Feed is still preparing. Try again in a few seconds.', + isLoading: false, + }, + readinessPhase: 'feed_not_ready_yet', + retry: undefined, + }); }); - }); - expect(result.current.error).toBeUndefined(); + expect(result.current.error).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); - it('publishes the result before preview loading finishes', async () => { + it('publishes link_created before readiness is confirmed', async () => { const createdFeed = { id: 'test-id', name: 'Test Feed', @@ -254,6 +264,7 @@ describe('useFeedConversion', () => { error: undefined, isLoading: true, }, + readinessPhase: 'link_created', retry: undefined, }); expect(result.current.isConverting).toBe(false); @@ -291,6 +302,162 @@ describe('useFeedConversion', () => { error: undefined, isLoading: false, }); + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + }); + }); + + it('retries readiness checks after transient preview failures and eventually becomes ready', async () => { + vi.useFakeTimers(); + try { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + 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: createdFeed }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce(new Response('temporary-failure', { status: 500 })) + .mockResolvedValueOnce(new Response('still-warming-up', { status: 503 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + title: 'Recovered item', + content_text: 'Recovered preview excerpt', + url: 'https://example.com/item', + date_published: '2024-01-02T00:00:00Z', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/feed+json' }, + } + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await vi.advanceTimersByTimeAsync(260 + 620 + 50); + }); + + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_ready'); + expect(result.current.result?.preview.items[0]?.title).toBe('Recovered item'); + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + it('stops readiness retries after the configured limit and marks feed_not_ready_yet', async () => { + vi.useFakeTimers(); + try { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + 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: createdFeed }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValue(new Response('temporary-failure', { status: 500 })); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + await vi.advanceTimersByTimeAsync(260 + 620 + 1180 + 1800 + 100); + }); + + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); + expect(result.current.result?.preview.error).toBe( + 'Feed is still preparing. Try again in a few seconds.' + ); + }); + expect(fetchMock).toHaveBeenCalledTimes(6); + } finally { + vi.useRealTimers(); + } + }); + + it('marks preview_unavailable for non-retryable preview responses', async () => { + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + 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: createdFeed }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + .mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); + }); + + await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('preview_unavailable'); + expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); }); }); diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index 7a7c3b5e..07417edd 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -24,6 +24,8 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res const isFeedReady = readinessPhase === 'feed_ready'; const canManuallyRetryReadiness = readinessPhase === 'feed_not_ready_yet' || readinessPhase === 'preview_unavailable'; + const isReadinessCheckInProgress = readinessPhase === 'link_created' && preview.isLoading; + const showReadinessAction = canManuallyRetryReadiness || isReadinessCheckInProgress; const previewItems = showAllPreviewItems ? preview.items : preview.items.slice(0, 3); const hasMorePreviewItems = preview.items.length > 3; const statusTitle = { @@ -77,9 +79,15 @@ export function ResultDisplay({ result, onCreateAnother, onRetryReadiness }: Res
- {canManuallyRetryReadiness && ( - )}
diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index d7dee632..fe7c4438 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -1,7 +1,12 @@ import { useRef, useState } from 'preact/hooks'; import { createFeed } from '../api/generated'; import { apiClient } from '../api/client'; -import type { CreatedFeedResult, FeedPreviewItem, FeedRecord } from '../api/contracts'; +import type { + CreatedFeedResult, + FeedPreviewItem, + FeedReadinessPhase, + FeedRecord, +} from '../api/contracts'; import { normalizeUserUrl } from '../utils/url'; interface JsonFeedItem { @@ -28,7 +33,9 @@ interface ConversionError extends Error { } const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; +const FEED_NOT_READY_MESSAGE = 'Feed is still preparing. Try again in a few seconds.'; const NON_RETRYABLE_ERROR_CODES = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN']); +const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; export function useFeedConversion() { const requestIdReference = useRef(0); @@ -95,6 +102,34 @@ export function useFeedConversion() { setState((previous) => ({ ...previous, error: undefined })); }; + const retryReadinessCheck = () => { + const currentResult = state.result; + if (!currentResult) return; + + const requestId = requestIdReference.current + 1; + requestIdReference.current = requestId; + + const resetResult: CreatedFeedResult = { + ...currentResult, + readinessPhase: 'link_created', + preview: buildLoadingPreviewState(), + }; + + setState((previous) => ({ + ...previous, + isConverting: false, + error: undefined, + result: resetResult, + })); + void hydratePreview( + currentResult.feed, + requestId, + currentResult.retry, + setState, + requestIdReference + ); + }; + return { isConverting: state.isConverting, result: state.result, @@ -102,28 +137,86 @@ export function useFeedConversion() { convertFeed, clearError, clearResult, + retryReadinessCheck, }; } -async function loadPreview(feed: FeedRecord): Promise { - const response = await globalThis.fetch(feed.json_public_url, { - headers: { Accept: 'application/feed+json' }, - }); +interface PreviewLoadResult { + preview: CreatedFeedResult['preview']; + readinessPhase: FeedReadinessPhase; + shouldRetry: boolean; +} - if (!response.ok) throw new Error(PREVIEW_UNAVAILABLE_MESSAGE); +async function loadPreview(feed: FeedRecord): Promise { + let response: Response; + try { + response = await globalThis.fetch(feed.json_public_url, { + headers: { Accept: 'application/feed+json' }, + }); + } catch { + return { + preview: { + items: [], + error: FEED_NOT_READY_MESSAGE, + isLoading: false, + }, + readinessPhase: 'feed_not_ready_yet', + shouldRetry: true, + }; + } - const payload = (await response.json()) as JsonFeedResponse; - const items = - payload.items - ?.map((item) => normalizePreviewItem(item)) - .filter((item): item is FeedPreviewItem => item !== undefined) - .slice(0, 5) || []; + if (!response.ok) { + if (isTransientReadinessStatus(response.status)) { + return { + preview: { + items: [], + error: FEED_NOT_READY_MESSAGE, + isLoading: false, + }, + readinessPhase: 'feed_not_ready_yet', + shouldRetry: true, + }; + } - return { - items, - error: items.length > 0 ? undefined : PREVIEW_UNAVAILABLE_MESSAGE, - isLoading: false, - }; + return { + preview: { + items: [], + error: PREVIEW_UNAVAILABLE_MESSAGE, + isLoading: false, + }, + readinessPhase: 'preview_unavailable', + shouldRetry: false, + }; + } + + try { + const payload = (await response.json()) as JsonFeedResponse; + const items = + payload.items + ?.map((item) => normalizePreviewItem(item)) + .filter((item): item is FeedPreviewItem => item !== undefined) + .slice(0, 5) || []; + + return { + preview: { + items, + error: undefined, + isLoading: false, + }, + readinessPhase: 'feed_ready', + shouldRetry: false, + }; + } catch { + return { + preview: { + items: [], + error: PREVIEW_UNAVAILABLE_MESSAGE, + isLoading: false, + }, + readinessPhase: 'preview_unavailable', + shouldRetry: false, + }; + } } function buildLoadingPreviewState(): CreatedFeedResult['preview'] { @@ -141,32 +234,39 @@ async function hydratePreview( setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, requestIdReference: { current: number } ) { - const preview = await loadPreview(feed).catch((error: unknown) => ({ - items: [], - error: toPreviewErrorMessage(error), - isLoading: false, - })); + const delays = [0, ...PREVIEW_RETRY_DELAYS_MS]; + let lastAttempt: PreviewLoadResult | undefined; - if (requestIdReference.current !== requestId) return; + for (const [index, delayMs] of delays.entries()) { + if (delayMs > 0) await wait(delayMs); + if (requestIdReference.current !== requestId) return; - setState((previous) => { - if ( - requestIdReference.current !== requestId || - !previous.result || - previous.result.feed.feed_token !== feed.feed_token - ) { - return previous; + const attempt = await loadPreview(feed); + lastAttempt = attempt; + if (requestIdReference.current !== requestId) return; + + const exhausted = index === delays.length - 1; + if (!attempt.shouldRetry || exhausted) { + setPreviewResult(feed, attempt.preview, attempt.readinessPhase, retry, requestId, setState, requestIdReference); + return; } + } - return { - ...previous, - result: { - feed, - preview, - retry, + if (!lastAttempt) { + setPreviewResult( + feed, + { + items: [], + error: FEED_NOT_READY_MESSAGE, + isLoading: false, }, - }; - }); + 'feed_not_ready_yet', + retry, + requestId, + setState, + requestIdReference + ); + } } async function requestFeedCreation(url: string, strategy: string, token: string): Promise { @@ -256,11 +356,35 @@ const toErrorMessage = (error: unknown): string => { return 'An unexpected error occurred'; }; -const toPreviewErrorMessage = (error: unknown): string => { - if (error instanceof SyntaxError) return PREVIEW_UNAVAILABLE_MESSAGE; - if (error instanceof Error && error.message.trim()) return error.message; - return PREVIEW_UNAVAILABLE_MESSAGE; -}; +function setPreviewResult( + feed: FeedRecord, + preview: CreatedFeedResult['preview'], + readinessPhase: FeedReadinessPhase, + retry: CreatedFeedResult['retry'], + requestId: number, + setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void, + requestIdReference: { current: number } +) { + setState((previous) => { + if ( + requestIdReference.current !== requestId || + !previous.result || + previous.result.feed.feed_token !== feed.feed_token + ) { + return previous; + } + + return { + ...previous, + result: { + feed, + preview, + readinessPhase, + retry, + }, + }; + }); +} function markConversionStarted( setState: (value: ConversionState | ((previous: ConversionState) => ConversionState)) => void @@ -278,6 +402,7 @@ function publishCreatedFeed( const result: CreatedFeedResult = { feed, preview: buildLoadingPreviewState(), + readinessPhase: 'link_created', retry, }; @@ -319,6 +444,14 @@ const extractErrorDetails = ( return { message, code, status }; }; +function isTransientReadinessStatus(status: number): boolean { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +async function wait(durationMs: number): Promise { + await new Promise((resolve) => globalThis.setTimeout(resolve, durationMs)); +} + function retryableForFallback(error: unknown): boolean { const details = extractErrorDetails(error); const errorCode = details?.code?.toUpperCase(); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 87484a22..c8fbbd34 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -65,11 +65,21 @@ .result-shell { width: 100%; margin: 0 auto; + padding: clamp(var(--space-4), 4vw, var(--space-5)); +} + +.form-shell--minimal { + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.result-shell { border: var(--border-width) solid var(--state-frame-border); border-radius: var(--state-frame-radius); background: var(--state-frame-bg); box-shadow: var(--state-frame-shadow); - padding: clamp(var(--space-4), 4vw, var(--space-5)); } .form-shell--minimal, From de148cf55227f68087239316b57f630ff6617e39 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 4 Apr 2026 11:57:21 +0200 Subject: [PATCH 20/22] Fix docs auth boundaries and stabilize feed UI/test primitives --- docs/README.md | 4 +- docs/architecture.md | 16 +++- .../src/__tests__/useFeedConversion.test.ts | 94 ++++++++++--------- public/rss.xsl | 10 +- public/shared-ui.css | 5 + 5 files changed, 76 insertions(+), 53 deletions(-) diff --git a/docs/README.md b/docs/README.md index 30b2e177..ba66490a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,11 +41,13 @@ Running the app directly on the host is not supported. | ------------------------------ | ---------------------------------------------------------- | | `make setup` | Install Ruby and Node dependencies. | | `make dev` | Run Ruby (port 4000) and frontend (port 4001) dev servers. | -| `make ready` | Full pre-flight check (Lint + Test + OpenAPI + Zeitwerk). | +| `make ready` | Pre-commit gate: `make quick-check` + `bundle exec rspec`. | | `make test` | Run Ruby and frontend test suites. | | `make lint` | Run all linters. | | `make yard-verify-public-docs` | Enforce typed YARD docs for public methods in `app/`. | | `make openapi` | Regenerate `public/openapi.yaml` from request specs. | +| `make openapi-verify` | Verify generated OpenAPI and frontend client artifacts are current. | +| `make openapi-lint` | Lint OpenAPI with Redocly + Spectral. | ### Frontend pnpm Scripts diff --git a/docs/architecture.md b/docs/architecture.md index 8c5480fd..1e100c97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,15 +21,21 @@ flowchart TD Requests enter via `app.rb` and are dispatched to `app/web/routes/`. -- **API v1**: Authenticated via `app/web/security/auth.rb`. -- **Public Feeds**: Validated via HMAC tokens in `app/web/security/feed_token.rb`. +- **Static feed pages (`/`)**: Routed by `app/web/routes/feed_pages.rb` and resolved as `target_kind: :static`. + - Source: static config in `config/feeds.yml` (via `LocalConfig.find`). + - Auth boundary: no feed token required on this route. + - Failure mode: unknown feed names fail at static config lookup. +- **Token-backed feed reads (`/api/v1/feeds/:token`)**: Routed by `app/web/routes/api_v1/feed_routes.rb` and resolved as `target_kind: :token`. + - Token scope: `FeedAccess.authorize_feed_token!` validates signature/expiry and re-checks account URL access. + - Constraint: disabled when AutoSource is off (`ForbiddenError` from `SourceResolver.ensure_auto_source_enabled!`). +- **Feed creation (`POST /api/v1/feeds`)**: Authenticated via bearer token in `app/web/security/auth.rb`; this endpoint mints feed tokens for subsequent token-backed reads. ### 2. Resolution -The `Html2rss::Web::Feeds::SourceResolver` determines where the feed configuration comes from: +The `Html2rss::Web::Feeds::SourceResolver` determines where feed configuration comes from based on route target: -- **Static**: Pre-defined in `config/feeds.yml`. -- **Dynamic**: Generated on-the-fly via the `/api/v1/feeds` endpoint (AutoSource). +- **Static (`target_kind: :static`)**: Pre-defined in `config/feeds.yml`. +- **Token (`target_kind: :token`)**: Generated from validated feed token payload + AutoSource globals. ### 3. Fetching & Rendering diff --git a/frontend/src/__tests__/useFeedConversion.test.ts b/frontend/src/__tests__/useFeedConversion.test.ts index a8556aa5..7f55096d 100644 --- a/frontend/src/__tests__/useFeedConversion.test.ts +++ b/frontend/src/__tests__/useFeedConversion.test.ts @@ -2,6 +2,16 @@ import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } fro import { renderHook, act, waitFor } from '@testing-library/preact'; import { useFeedConversion } from '../hooks/useFeedConversion'; +const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; +const SHORT_SETTLE_MS = 50; +const FULL_SETTLE_MS = 100; + +const sumDelays = (delays: readonly number[]) => delays.reduce((total, delay) => total + delay, 0); + +const advanceAfterRetries = async (delays: readonly number[], settleMs: number) => { + await vi.advanceTimersByTimeAsync(sumDelays(delays) + settleMs); +}; + describe('useFeedConversion', () => { let fetchMock: SpyInstance; @@ -153,53 +163,53 @@ describe('useFeedConversion', () => { it('preserves the created feed when preview loading fails after feed creation', async () => { vi.useFakeTimers(); try { - const createdFeed = { - id: 'test-id', - name: 'Test Feed', - url: 'https://example.com', - strategy: 'faraday', - feed_token: 'test-token', - public_url: 'https://example.com/feed', - json_public_url: 'https://example.com/feed.json', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }; + const createdFeed = { + id: 'test-id', + name: 'Test Feed', + url: 'https://example.com', + strategy: 'faraday', + feed_token: 'test-token', + public_url: 'https://example.com/feed', + 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: createdFeed, - }, - }), - { - status: 201, - headers: { 'Content-Type': 'application/json' }, - } - ) - ); - fetchMock.mockResolvedValue(new Response('nope', { status: 502 })); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: { + feed: createdFeed, + }, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + fetchMock.mockResolvedValue(new Response('nope', { status: 502 })); - const { result } = renderHook(() => useFeedConversion()); - let conversionResult: Awaited> | undefined; + const { result } = renderHook(() => useFeedConversion()); + let conversionResult: Awaited> | undefined; await act(async () => { conversionResult = await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await vi.advanceTimersByTimeAsync(260 + 620 + 1180 + 1800 + 100); + await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); }); - expect(result.current.isConverting).toBe(false); - expect(conversionResult).toEqual({ - feed: createdFeed, - preview: { - items: [], - error: undefined, - isLoading: true, - }, - readinessPhase: 'link_created', - retry: undefined, - }); + expect(result.current.isConverting).toBe(false); + expect(conversionResult).toEqual({ + feed: createdFeed, + preview: { + items: [], + error: undefined, + isLoading: true, + }, + readinessPhase: 'link_created', + retry: undefined, + }); await waitFor(() => { expect(result.current.result).toEqual({ feed: createdFeed, @@ -359,7 +369,7 @@ describe('useFeedConversion', () => { await act(async () => { await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await vi.advanceTimersByTimeAsync(260 + 620 + 50); + await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS.slice(0, 2), SHORT_SETTLE_MS); }); await waitFor(() => { @@ -406,7 +416,7 @@ describe('useFeedConversion', () => { await act(async () => { await result.current.convertFeed('https://example.com', 'faraday', 'testtoken'); - await vi.advanceTimersByTimeAsync(260 + 620 + 1180 + 1800 + 100); + await advanceAfterRetries(PREVIEW_RETRY_DELAYS_MS, FULL_SETTLE_MS); }); await waitFor(() => { diff --git a/public/rss.xsl b/public/rss.xsl index 252d401e..02d8cb25 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -70,8 +70,8 @@ } .feed-hero__action--primary { - border-color: rgba(255, 147, 0, 0.24); - background: rgba(255, 147, 0, 0.12); + border-color: var(--border-reader-strong); + background: var(--surface-reader-strong); } .feed-hero__stamp { @@ -175,7 +175,7 @@ } .feed-card__actions a { - color: rgba(255, 255, 255, 0.76); + color: var(--text-soft); font-size: var(--font-size-00); font-weight: 600; letter-spacing: 0.06em; @@ -205,8 +205,8 @@ gap: 0.38rem; padding: 0.34rem 0.58rem; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--border-chip); + background: var(--surface-chip); color: var(--text-muted); font-size: 0.72rem; letter-spacing: 0.07em; diff --git a/public/shared-ui.css b/public/shared-ui.css index fc549f9a..46d21e48 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -47,18 +47,23 @@ --border-danger: rgb(var(--color-rgb-danger) / 20%); --surface-reader: rgb(var(--color-rgb-reader) / 10%); --border-reader: rgb(var(--color-rgb-reader) / 20%); + --surface-reader-strong: rgb(var(--color-rgb-reader) / 12%); + --border-reader-strong: rgb(var(--color-rgb-reader) / 24%); --overlay-top-light: rgb(var(--color-rgb-white) / 4%); --overlay-hero-accent: rgb(var(--color-rgb-reader) / 12%); --overlay-icon-top: rgb(var(--color-rgb-white) / 8%); --overlay-icon-base: rgb(var(--color-rgb-white) / 3%); --surface-base: rgb(var(--color-rgb-white) / 2%); --surface-elevated: var(--overlay-top-light); + --surface-chip: rgb(var(--color-rgb-white) / 2.5%); --border-muted: rgb(var(--color-rgb-white) / 12%); --border-subtle: rgb(var(--color-rgb-white) / 8%); --border-strong: rgb(var(--color-rgb-white) / 24%); + --border-chip: rgb(var(--color-rgb-white) / 8%); --text-strong: #f3f3ef; --text-body: rgb(var(--color-rgb-text) / 90%); --text-muted: rgb(var(--color-rgb-text) / 58%); + --text-soft: rgb(var(--color-rgb-text) / 76%); --text-faint: rgb(var(--color-rgb-text) / 28%); --eyebrow-color: rgb(var(--color-rgb-white) / 72%); --text-inverse: #050505; From f39f1b8ae9859e0277e3ad913a75dffc095d5e04 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 4 Apr 2026 12:02:31 +0200 Subject: [PATCH 21/22] git rm --cached -r skills --- .codex/skills/finish-review | 1 - .codex/skills/gh-review-resolve | 1 - .codex/skills/pr-slicer | 1 - .codex/skills/review-tests | 1 - .codex/skills/ruby-dev | 1 - 5 files changed, 5 deletions(-) delete mode 120000 .codex/skills/finish-review delete mode 120000 .codex/skills/gh-review-resolve delete mode 120000 .codex/skills/pr-slicer delete mode 120000 .codex/skills/review-tests delete mode 120000 .codex/skills/ruby-dev diff --git a/.codex/skills/finish-review b/.codex/skills/finish-review deleted file mode 120000 index 5f002b05..00000000 --- a/.codex/skills/finish-review +++ /dev/null @@ -1 +0,0 @@ -/Users/gil/.dotfiles/skills/finish-review \ No newline at end of file diff --git a/.codex/skills/gh-review-resolve b/.codex/skills/gh-review-resolve deleted file mode 120000 index 70f73b64..00000000 --- a/.codex/skills/gh-review-resolve +++ /dev/null @@ -1 +0,0 @@ -/Users/gil/.dotfiles/skills/gh-review-resolve \ No newline at end of file diff --git a/.codex/skills/pr-slicer b/.codex/skills/pr-slicer deleted file mode 120000 index 14be4ec3..00000000 --- a/.codex/skills/pr-slicer +++ /dev/null @@ -1 +0,0 @@ -/Users/gil/.dotfiles/skills/pr-slicer \ No newline at end of file diff --git a/.codex/skills/review-tests b/.codex/skills/review-tests deleted file mode 120000 index 8d067bc0..00000000 --- a/.codex/skills/review-tests +++ /dev/null @@ -1 +0,0 @@ -/Users/gil/.dotfiles/skills/review-tests \ No newline at end of file diff --git a/.codex/skills/ruby-dev b/.codex/skills/ruby-dev deleted file mode 120000 index 3a666b92..00000000 --- a/.codex/skills/ruby-dev +++ /dev/null @@ -1 +0,0 @@ -/Users/gil/.dotfiles/skills/ruby-dev \ No newline at end of file From 7f1d3f01de5d17d2bdacc4553309260f391a60a3 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 4 Apr 2026 12:47:04 +0200 Subject: [PATCH 22/22] . --- .../useFeedConversion.contract.test.ts | 9 +++++-- frontend/src/api/contracts.ts | 3 +++ frontend/src/hooks/useFeedConversion.ts | 25 ++++++++----------- frontend/src/styles/main.css | 5 +--- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 97788249..cc0a34fa 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -52,7 +52,9 @@ describe('useFeedConversion contract', () => { expect(result.current.result?.feed.feed_token).toBe('generated-token'); expect(result.current.result?.feed.public_url).toBe('/api/v1/feeds/generated-token'); expect(result.current.result?.feed.json_public_url).toBe('/api/v1/feeds/generated-token.json'); + expect(result.current.result?.readinessPhase).toBe('link_created'); await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_ready'); expect(result.current.result?.preview.error).toBeUndefined(); expect(result.current.result?.preview.isLoading).toBe(false); expect(result.current.result?.preview.items).toHaveLength(1); @@ -103,7 +105,7 @@ describe('useFeedConversion contract', () => { expect(result.current.error).toBe('Invalid response format from feed creation API'); }); - it('preserves the created feed when preview loading fails', async () => { + it('marks the feed as not-ready-yet when preview endpoint keeps returning 5xx', async () => { server.use( http.post('/api/v1/feeds', async () => HttpResponse.json( @@ -127,8 +129,11 @@ describe('useFeedConversion contract', () => { expect(result.current.error).toBeUndefined(); expect(result.current.result?.feed.feed_token).toBe('generated-token'); await waitFor(() => { + expect(result.current.result?.readinessPhase).toBe('feed_not_ready_yet'); expect(result.current.result?.preview.items).toEqual([]); - expect(result.current.result?.preview.error).toBe('Preview unavailable right now.'); + expect(result.current.result?.preview.error).toBe( + 'Feed is still preparing. Try again in a few seconds.' + ); expect(result.current.result?.preview.isLoading).toBe(false); }); }); diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index aafde477..d4fed873 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -15,6 +15,8 @@ export interface FeedPreviewState { isLoading: boolean; } +export type FeedReadinessPhase = 'link_created' | 'feed_ready' | 'feed_not_ready_yet' | 'preview_unavailable'; + export interface FeedRetryState { automatic: boolean; from: string; @@ -24,6 +26,7 @@ export interface FeedRetryState { export interface CreatedFeedResult { feed: FeedRecord; preview: FeedPreviewState; + readinessPhase: FeedReadinessPhase; retry?: FeedRetryState; } diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index fe7c4438..482003ee 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -1,12 +1,7 @@ import { useRef, useState } from 'preact/hooks'; import { createFeed } from '../api/generated'; import { apiClient } from '../api/client'; -import type { - CreatedFeedResult, - FeedPreviewItem, - FeedReadinessPhase, - FeedRecord, -} from '../api/contracts'; +import type { CreatedFeedResult, FeedPreviewItem, FeedReadinessPhase, FeedRecord } from '../api/contracts'; import { normalizeUserUrl } from '../utils/url'; interface JsonFeedItem { @@ -121,13 +116,7 @@ export function useFeedConversion() { error: undefined, result: resetResult, })); - void hydratePreview( - currentResult.feed, - requestId, - currentResult.retry, - setState, - requestIdReference - ); + void hydratePreview(currentResult.feed, requestId, currentResult.retry, setState, requestIdReference); }; return { @@ -247,7 +236,15 @@ async function hydratePreview( const exhausted = index === delays.length - 1; if (!attempt.shouldRetry || exhausted) { - setPreviewResult(feed, attempt.preview, attempt.readinessPhase, retry, requestId, setState, requestIdReference); + setPreviewResult( + feed, + attempt.preview, + attempt.readinessPhase, + retry, + requestId, + setState, + requestIdReference + ); return; } } diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index c8fbbd34..cdfeed17 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -80,6 +80,7 @@ border-radius: var(--state-frame-radius); background: var(--state-frame-bg); box-shadow: var(--state-frame-shadow); + gap: var(--section-gap); } .form-shell--minimal, @@ -105,10 +106,6 @@ margin: 0 auto; } -.result-shell { - gap: var(--section-gap); -} - .result-shell .dominant-field { --control-input-lg-min-height: 4rem; --control-input-lg-padding-inline: 1.25rem 5rem;