diff --git a/.gitignore b/.gitignore index 164fb7b7..f2b04a43 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/ @@ -54,3 +53,4 @@ .yardoc frontend/.astro +.pnpm-store diff --git a/Gemfile b/Gemfile index a956d93e..41fda845 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,6 @@ gem 'parallel' gem 'rack-cache' gem 'rack-timeout' gem 'roda' -gem 'ssrf_filter' gem 'zeitwerk' gem 'puma', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 5fc465bf..ef6c1213 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -339,7 +339,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - ssrf_filter (1.4.0) stackprof (0.2.28) stringio (3.2.0) thor (1.5.0) @@ -394,7 +393,6 @@ DEPENDENCIES ruby-lsp sentry-ruby simplecov - ssrf_filter stackprof vcr webmock @@ -526,7 +524,6 @@ CHECKSUMS simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 - ssrf_filter (1.4.0) sha256=64634f7955836808244d9ce65800af682803000b8eb15a5d573df25ab3c5422b stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 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/docs/README.md b/docs/README.md index 318d3afb..ba66490a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,20 +41,23 @@ 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 npm Scripts +### Frontend pnpm Scripts | Command | Purpose | | ----------------------- | -------------------------------------------- | -| `npm run dev` | Vite dev server with hot reload (port 4001). | -| `npm run build` | Build static assets into `frontend/dist/`. | -| `npm run test:run` | Unit tests (Vitest). | -| `npm run test:contract` | Contract tests with MSW. | +| `pnpm run dev` | Vite dev server with hot reload (port 4001). | +| `pnpm run build` | Build static assets into `frontend/dist/`. | +| `pnpm run lint` | Run ESLint across the frontend workspace. | +| `pnpm run test:run` | Unit tests (Vitest). | +| `pnpm run test:contract`| Contract tests with MSW. | --- 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/public/feed-reader-link.js b/public/feed-reader-link.js new file mode 100644 index 00000000..276ace26 --- /dev/null +++ b/public/feed-reader-link.js @@ -0,0 +1,10 @@ +document.addEventListener('DOMContentLoaded', function () { + var readerLink = document.querySelector('[data-feed-reader-link]'); + if (!readerLink) return; + var currentHref = readerLink.getAttribute('href'); + if (currentHref && currentHref !== '#') return; + + var pageHref = window.location.href; + var feedHref = pageHref.indexOf('feed:') === 0 ? pageHref : 'feed:' + pageHref; + readerLink.setAttribute('href', feedHref); +}); diff --git a/public/rss.xsl b/public/rss.xsl index cc74adbe..02d8cb25 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -1,5 +1,5 @@ - + @@ -10,6 +10,7 @@ <xsl:value-of select="rss/channel/title" /> (Feed) + @@ -168,25 +261,68 @@ -
-

- - - -

- - -

- - - - - - - - -

-
+
+
+
+ +

+ + + +

+
+ + +

+ + + + + + + + +

+
+ + +

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

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

@@ -282,7 +422,7 @@

- +

@@ -291,10 +431,31 @@

- -

- Open original -

+ + @@ -342,19 +503,49 @@ - + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + < + + + + + diff --git a/public/shared-ui.css b/public/shared-ui.css index 9bb4b48a..a17b0206 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -1,9 +1,10 @@ /* Shared design-system primitives for both the app UI and RSS/XSL surfaces. See docs/design-system.md before changing this file. */ :root { color-scheme: dark; + --font-family-ui: system-ui, -apple-system, "Segoe UI", sans-serif; --font-family-display: system-ui, -apple-system, "Segoe UI", sans-serif; - --font-family-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-family-mono: "SFMono-Regular", consolas, "Liberation Mono", monospace; --font-size-00: 0.8125rem; --font-size-0: 0.9375rem; --font-size-1: 1rem; @@ -18,10 +19,12 @@ --space-6: 2rem; --space-7: 3rem; --border-width: 1px; + --radius-pill: 999px; --border-default: var(--border-width) solid var(--border-subtle); - --radius-sm: 0.35rem; - --radius-md: 0.7rem; - --radius-lg: 0.95rem; + --radius-md: var(--space-3); + --radius-lg: 1rem; + --motion-nudge: 0.04rem; + --layout-field-max-width: 38rem; --eyebrow-letter-spacing: 0.08em; --brand-lockup-gap: 0.3rem; --brand-lockup-mark-size: 1.7rem; @@ -32,37 +35,49 @@ --brand-lockup-line-2-width: 70%; --brand-lockup-line-3-width: 46%; --brand-lockup-wordmark-size: 0.96rem; - --bg-page: #050505; - --bg-page-muted: #090909; - --bg-input: #111111; - --bg-input-strong: #151515; - --bg-success: rgba(110, 231, 183, 0.08); - --bg-danger: rgba(248, 113, 113, 0.1); - --surface-base: rgba(255, 255, 255, 0.02); - --surface-elevated: rgba(255, 255, 255, 0.04); - --border-muted: rgba(255, 255, 255, 0.12); - --border-subtle: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.24); + --color-rgb-white: 255 255 255; + --color-rgb-black: 0 0 0; + --color-rgb-text: 243 243 239; + --color-rgb-success: 110 231 183; + --color-rgb-danger: 248 113 113; + --color-rgb-reader: 255 147 0; + --bg-success: rgb(var(--color-rgb-success) / 8%); + --bg-danger: rgb(var(--color-rgb-danger) / 10%); + --border-success: rgb(var(--color-rgb-success) / 20%); + --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: rgba(243, 243, 239, 0.9); - --text-muted: rgba(243, 243, 239, 0.58); - --text-faint: rgba(243, 243, 239, 0.28); - --eyebrow-color: rgba(255, 255, 255, 0.72); + --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; --accent: #f3f3ef; - --accent-strong: #ffffff; + --accent-strong: #fff; --danger: #fca5a5; - --success: #9ae6b4; - --focus-ring: 0 0 0 3px rgba(255, 255, 255, 0.16); - --shadow-elevated: 0 24px 60px rgba(0, 0, 0, 0.32); - --page-max-width: 60rem; - --rail-shell: 58rem; - --rail-reading: 44rem; - --rail-copy: 34rem; + --focus-ring: 0 0 0 3px rgb(var(--color-rgb-white) / 16%); + --shadow-elevated: 0 24px 60px rgb(var(--color-rgb-black) / 32%); + --layout-page-max-width: 60rem; + --layout-rail-shell: 58rem; + --layout-rail-reading: 44rem; + --layout-rail-copy: 34rem; --section-gap-tight: var(--space-4); --section-gap: var(--space-5); - --section-gap-loose: var(--space-6); - --content-max-width: var(--rail-shell); --transition-fast: 140ms ease; } @@ -79,7 +94,7 @@ body { html { background: - radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 32%), + radial-gradient(circle at top, var(--overlay-top-light), transparent 32%), linear-gradient(180deg, #080808 0%, #040404 100%); } @@ -92,7 +107,7 @@ body { line-height: var(--line-height-base); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; + text-rendering: optimizelegibility; background: transparent; } @@ -123,17 +138,17 @@ textarea { } .layout-shell { - width: min(100%, var(--rail-shell)); + width: min(100%, var(--layout-rail-shell)); margin: 0 auto; } .layout-rail-reading { - width: min(100%, var(--rail-reading)); + width: min(100%, var(--layout-rail-reading)); margin: 0 auto; } .layout-rail-copy { - max-width: var(--rail-copy); + max-width: var(--layout-rail-copy); } .layout-stack { @@ -149,7 +164,7 @@ textarea { border: var(--border-default); border-radius: var(--radius-lg); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015)), + linear-gradient(180deg, var(--overlay-top-light), transparent), var(--surface-base); } @@ -165,6 +180,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, var(--overlay-hero-accent), transparent 46%), + linear-gradient(180deg, var(--overlay-top-light), 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 var(--border-muted); + border-radius: 1.15rem; + background: + linear-gradient(180deg, var(--overlay-icon-top), var(--surface-base)), + var(--overlay-icon-base); + box-shadow: inset 0 1px 0 var(--border-subtle); +} + +.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 +262,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: var(--radius-pill); + 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:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: var(--border-strong); +} + +.btn:hover:not(:disabled) { + transform: translateY(calc(var(--motion-nudge) * -1)); + text-decoration: none; +} + +.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: var(--border-subtle); +} + +.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 +379,15 @@ textarea { font-weight: 600; letter-spacing: 0.01em; } + +@media (width < 48rem) { + .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