brand_logo 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +87 -50
  4. data/lib/brand_logo/cache.rb +48 -0
  5. data/lib/brand_logo/concurrency.rb +53 -0
  6. data/lib/brand_logo/config.rb +95 -43
  7. data/lib/brand_logo/context.rb +68 -0
  8. data/lib/brand_logo/deadline.rb +30 -0
  9. data/lib/brand_logo/dimensions.rb +79 -0
  10. data/lib/brand_logo/domain.rb +64 -0
  11. data/lib/brand_logo/errors.rb +1 -3
  12. data/lib/brand_logo/fetcher.rb +78 -81
  13. data/lib/brand_logo/http/client.rb +25 -0
  14. data/lib/brand_logo/http/real_client.rb +110 -0
  15. data/lib/brand_logo/http/response.rb +8 -0
  16. data/lib/brand_logo/http/url_guard.rb +68 -0
  17. data/lib/brand_logo/icon.rb +41 -20
  18. data/lib/brand_logo/image_format.rb +53 -0
  19. data/lib/brand_logo/image_probe.rb +29 -0
  20. data/lib/brand_logo/logging.rb +1 -7
  21. data/lib/brand_logo/lookup.rb +94 -0
  22. data/lib/brand_logo/page.rb +37 -0
  23. data/lib/brand_logo/ranker.rb +115 -0
  24. data/lib/brand_logo/sorbet.rb +8 -0
  25. data/lib/brand_logo/sorbet_shim.rb +44 -0
  26. data/lib/brand_logo/strategies/base.rb +19 -0
  27. data/lib/brand_logo/strategies/browserconfig.rb +70 -0
  28. data/lib/brand_logo/strategies/duckduckgo.rb +21 -0
  29. data/lib/brand_logo/strategies/google.rb +29 -0
  30. data/lib/brand_logo/strategies/json_ld.rb +91 -0
  31. data/lib/brand_logo/strategies/link_tag.rb +58 -0
  32. data/lib/brand_logo/strategies/manifest.rb +68 -0
  33. data/lib/brand_logo/strategies/meta_tag.rb +80 -0
  34. data/lib/brand_logo/url_resolver.rb +30 -0
  35. data/lib/brand_logo/version.rb +1 -1
  36. data/lib/brand_logo.rb +30 -28
  37. metadata +42 -30
  38. data/lib/brand_logo/html_parser.rb +0 -63
  39. data/lib/brand_logo/http_client.rb +0 -59
  40. data/lib/brand_logo/image_analyzer.rb +0 -36
  41. data/lib/brand_logo/strategies/base_strategy.rb +0 -117
  42. data/lib/brand_logo/strategies/duckduckgo_strategy.rb +0 -45
  43. data/lib/brand_logo/strategies/manifest_strategy.rb +0 -137
  44. data/lib/brand_logo/strategies/meta_tag_strategy.rb +0 -95
  45. data/lib/brand_logo/strategies/scraping/default_favicon_checker.rb +0 -33
  46. data/lib/brand_logo/strategies/scraping/dimensions_extractor.rb +0 -42
  47. data/lib/brand_logo/strategies/scraping/format_extractor.rb +0 -56
  48. data/lib/brand_logo/strategies/scraping/icon_finder.rb +0 -79
  49. data/lib/brand_logo/strategies/scraping/url_normalizer.rb +0 -50
  50. data/lib/brand_logo/strategies/scraping_strategy.rb +0 -77
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'json'
5
+
6
+ module BrandLogo
7
+ module Strategies
8
+ class Manifest
9
+ extend T::Sig
10
+ include Base
11
+
12
+ sig { override.returns(Symbol) }
13
+ def stage
14
+ :remote
15
+ end
16
+
17
+ sig { override.params(context: Context).returns(T::Array[Icon]) }
18
+ def call(context)
19
+ page = context.page
20
+ return [] unless page
21
+
22
+ manifest_url = page.resolve(page.document.at_css('link[rel~="manifest"]')&.[]('href'))
23
+ response = manifest_url && context.get(manifest_url)
24
+ return [] unless response
25
+
26
+ icons = parse(response)
27
+ icons.filter_map { |entry| build(entry, response.url) }
28
+ end
29
+
30
+ private
31
+
32
+ sig { params(response: Http::Response).returns(T::Array[T.untyped]) }
33
+ def parse(response)
34
+ data = JSON.parse(response.body)
35
+ icons = data['icons'] if data.is_a?(::Hash)
36
+ icons.is_a?(::Array) ? icons : []
37
+ rescue JSON::ParserError => e
38
+ Logging.logger.warn("Manifest: invalid JSON at #{response.url}: #{e.message}")
39
+ []
40
+ end
41
+
42
+ sig { params(entry: T.untyped, manifest_url: String).returns(T.nilable(Icon)) }
43
+ def build(entry, manifest_url)
44
+ return nil unless entry.is_a?(::Hash)
45
+
46
+ url = UrlResolver.resolve(entry['src'], manifest_url) if entry['src'].is_a?(String)
47
+ return nil unless url
48
+
49
+ Icon.new(
50
+ url: url,
51
+ source: :manifest,
52
+ kind: kind(entry['purpose']),
53
+ format: ImageFormat.from_mime(entry['type']) || ImageFormat.from_url(url),
54
+ dimensions: Dimensions.parse(entry['sizes'])
55
+ )
56
+ end
57
+
58
+ sig { params(purpose: T.untyped).returns(Symbol) }
59
+ def kind(purpose)
60
+ purposes = purpose.to_s.downcase.split
61
+ if purposes.include?('monochrome') then :monochrome
62
+ elsif purposes.include?('maskable') && !purposes.include?('any') then :maskable
63
+ else :icon
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Strategies
6
+ class MetaTag
7
+ extend T::Sig
8
+ include Base
9
+
10
+ KINDS = T.let({
11
+ 'og:image' => :social,
12
+ 'og:image:secure_url' => :social,
13
+ 'og:image:url' => :social,
14
+ 'twitter:image' => :social,
15
+ 'twitter:image:src' => :social,
16
+ 'og:logo' => :logo,
17
+ 'msapplication-tileimage' => :tile
18
+ }.freeze, T::Hash[String, Symbol])
19
+
20
+ SIDES = T.let({ 'og:image:width' => :width, 'og:image:height' => :height }.freeze, T::Hash[String, Symbol])
21
+
22
+ sig { override.returns(Symbol) }
23
+ def stage
24
+ :document
25
+ end
26
+
27
+ sig { override.params(context: Context).returns(T::Array[Icon]) }
28
+ def call(context)
29
+ page = context.page
30
+ page ? collect(page) : []
31
+ end
32
+
33
+ private
34
+
35
+ sig { params(page: Page).returns(T::Array[Icon]) }
36
+ def collect(page)
37
+ icons = T.let([], T::Array[Icon])
38
+ og_index = T.let(nil, T.nilable(Integer))
39
+ entries(page.document).each do |key, content|
40
+ if (side = SIDES[key])
41
+ icons[og_index] = with_side(T.must(icons[og_index]), side, content.to_i) if og_index
42
+ elsif (icon = build(page, key, content))
43
+ og_index = icons.size if key.start_with?('og:image')
44
+ icons << icon
45
+ end
46
+ end
47
+ icons
48
+ end
49
+
50
+ sig { params(document: Nokogiri::HTML5::Document).returns(T::Array[[String, String]]) }
51
+ def entries(document)
52
+ document.css('meta[content]').map do |meta|
53
+ [(meta['property'] || meta['name']).to_s.strip.downcase, meta['content'].to_s.strip]
54
+ end
55
+ end
56
+
57
+ sig { params(page: Page, key: String, content: String).returns(T.nilable(Icon)) }
58
+ def build(page, key, content)
59
+ kind = KINDS[key]
60
+ url = page.resolve(content) if kind
61
+ return nil unless kind && url
62
+
63
+ Icon.new(url: url, source: :meta_tag, kind: kind, format: ImageFormat.from_url(url))
64
+ end
65
+
66
+ sig { params(icon: Icon, side: Symbol, pixels: Integer).returns(Icon) }
67
+ def with_side(icon, side, pixels)
68
+ return icon unless pixels.positive?
69
+
70
+ current = icon.dimensions
71
+ dimensions = if side == :width
72
+ Dimensions.new(width: pixels, height: current.height)
73
+ else
74
+ Dimensions.new(width: current.width, height: pixels)
75
+ end
76
+ icon.with(dimensions: dimensions)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'uri'
5
+
6
+ module BrandLogo
7
+ module UrlResolver
8
+ extend T::Sig
9
+
10
+ DATA_IMAGE = %r{\Adata:image/}i
11
+
12
+ sig { params(href: T.nilable(String), base: String).returns(T.nilable(String)) }
13
+ def self.resolve(href, base)
14
+ value = href.to_s.strip
15
+ return nil if value.empty?
16
+ return value if value.match?(DATA_IMAGE)
17
+
18
+ uri = join(base, value) || join(base, URI::DEFAULT_PARSER.escape(value))
19
+ uri.to_s if uri.is_a?(URI::HTTP) && uri.host
20
+ end
21
+
22
+ sig { params(base: String, value: String).returns(T.nilable(URI::Generic)) }
23
+ def self.join(base, value)
24
+ URI.join(base, value)
25
+ rescue URI::Error
26
+ nil
27
+ end
28
+ private_class_method :join
29
+ end
30
+ end
@@ -2,5 +2,5 @@
2
2
  # typed: strict
3
3
 
4
4
  module BrandLogo
5
- VERSION = '1.0.0'
5
+ VERSION = '2.0.0'
6
6
  end
data/lib/brand_logo.rb CHANGED
@@ -1,37 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
  # typed: strict
3
3
 
4
- require 'sorbet-runtime'
4
+ require_relative 'brand_logo/sorbet'
5
5
 
6
- # Foundation
7
6
  require_relative 'brand_logo/version'
8
7
  require_relative 'brand_logo/errors'
9
- require_relative 'brand_logo/config'
10
8
  require_relative 'brand_logo/logging'
11
-
12
- # Interfaces & implementations
13
- require_relative 'brand_logo/http_client'
14
- require_relative 'brand_logo/image_analyzer'
15
- require_relative 'brand_logo/html_parser'
16
-
17
- # Domain model
9
+ require_relative 'brand_logo/concurrency'
10
+ require_relative 'brand_logo/cache'
11
+ require_relative 'brand_logo/dimensions'
12
+ require_relative 'brand_logo/image_format'
18
13
  require_relative 'brand_logo/icon'
19
-
20
- # Strategies — base must be loaded before subclasses
21
- require_relative 'brand_logo/strategies/base_strategy'
22
-
23
- # Scraping utilities (loaded before strategies that use them)
24
- require_relative 'brand_logo/strategies/scraping/format_extractor'
25
- require_relative 'brand_logo/strategies/scraping/url_normalizer'
26
- require_relative 'brand_logo/strategies/scraping/dimensions_extractor'
27
- require_relative 'brand_logo/strategies/scraping/default_favicon_checker'
28
- require_relative 'brand_logo/strategies/scraping/icon_finder'
29
-
30
- # Concrete strategies
31
- require_relative 'brand_logo/strategies/scraping_strategy'
32
- require_relative 'brand_logo/strategies/duckduckgo_strategy'
33
- require_relative 'brand_logo/strategies/meta_tag_strategy'
34
- require_relative 'brand_logo/strategies/manifest_strategy'
35
-
36
- # Entry point
14
+ require_relative 'brand_logo/config'
15
+ require_relative 'brand_logo/deadline'
16
+ require_relative 'brand_logo/domain'
17
+
18
+ require_relative 'brand_logo/http/response'
19
+ require_relative 'brand_logo/http/client'
20
+ require_relative 'brand_logo/http/url_guard'
21
+ require_relative 'brand_logo/http/real_client'
22
+
23
+ require_relative 'brand_logo/url_resolver'
24
+ require_relative 'brand_logo/image_probe'
25
+ require_relative 'brand_logo/page'
26
+ require_relative 'brand_logo/context'
27
+ require_relative 'brand_logo/ranker'
28
+
29
+ require_relative 'brand_logo/strategies/base'
30
+ require_relative 'brand_logo/strategies/link_tag'
31
+ require_relative 'brand_logo/strategies/json_ld'
32
+ require_relative 'brand_logo/strategies/meta_tag'
33
+ require_relative 'brand_logo/strategies/manifest'
34
+ require_relative 'brand_logo/strategies/browserconfig'
35
+ require_relative 'brand_logo/strategies/google'
36
+ require_relative 'brand_logo/strategies/duckduckgo'
37
+
38
+ require_relative 'brand_logo/lookup'
37
39
  require_relative 'brand_logo/fetcher'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brand_logo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdVitam
@@ -30,6 +30,9 @@ dependencies:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '5.2'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '7'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
@@ -37,6 +40,9 @@ dependencies:
37
40
  - - ">="
38
41
  - !ruby/object:Gem::Version
39
42
  version: '5.2'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '7'
40
46
  - !ruby/object:Gem::Dependency
41
47
  name: logger
42
48
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +64,9 @@ dependencies:
58
64
  - - ">="
59
65
  - !ruby/object:Gem::Version
60
66
  version: '1.18'
67
+ - - "<"
68
+ - !ruby/object:Gem::Version
69
+ version: '2'
61
70
  type: :runtime
62
71
  prerelease: false
63
72
  version_requirements: !ruby/object:Gem::Requirement
@@ -65,23 +74,13 @@ dependencies:
65
74
  - - ">="
66
75
  - !ruby/object:Gem::Version
67
76
  version: '1.18'
68
- - !ruby/object:Gem::Dependency
69
- name: sorbet-runtime
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - ">="
73
- - !ruby/object:Gem::Version
74
- version: '0.5'
75
- type: :runtime
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - ">="
77
+ - - "<"
80
78
  - !ruby/object:Gem::Version
81
- version: '0.5'
79
+ version: '2'
82
80
  description: 'Retrieves brand logos and icons from websites using a chain of strategies:
83
- HTML favicon tags, Open Graph / Twitter meta images, PWA web app manifests, and
84
- a DuckDuckGo fallback. Returns the best icon based on format and dimensions.'
81
+ HTML favicon tags, Open Graph / Twitter meta images, PWA web app manifests, JSON-LD
82
+ logos, browserconfig, then Google and DuckDuckGo fallbacks. Ranks every candidate
83
+ and returns the best one.'
85
84
  executables: []
86
85
  extensions: []
87
86
  extra_rdoc_files: []
@@ -90,24 +89,37 @@ files:
90
89
  - LICENSE.txt
91
90
  - README.md
92
91
  - lib/brand_logo.rb
92
+ - lib/brand_logo/cache.rb
93
+ - lib/brand_logo/concurrency.rb
93
94
  - lib/brand_logo/config.rb
95
+ - lib/brand_logo/context.rb
96
+ - lib/brand_logo/deadline.rb
97
+ - lib/brand_logo/dimensions.rb
98
+ - lib/brand_logo/domain.rb
94
99
  - lib/brand_logo/errors.rb
95
100
  - lib/brand_logo/fetcher.rb
96
- - lib/brand_logo/html_parser.rb
97
- - lib/brand_logo/http_client.rb
101
+ - lib/brand_logo/http/client.rb
102
+ - lib/brand_logo/http/real_client.rb
103
+ - lib/brand_logo/http/response.rb
104
+ - lib/brand_logo/http/url_guard.rb
98
105
  - lib/brand_logo/icon.rb
99
- - lib/brand_logo/image_analyzer.rb
106
+ - lib/brand_logo/image_format.rb
107
+ - lib/brand_logo/image_probe.rb
100
108
  - lib/brand_logo/logging.rb
101
- - lib/brand_logo/strategies/base_strategy.rb
102
- - lib/brand_logo/strategies/duckduckgo_strategy.rb
103
- - lib/brand_logo/strategies/manifest_strategy.rb
104
- - lib/brand_logo/strategies/meta_tag_strategy.rb
105
- - lib/brand_logo/strategies/scraping/default_favicon_checker.rb
106
- - lib/brand_logo/strategies/scraping/dimensions_extractor.rb
107
- - lib/brand_logo/strategies/scraping/format_extractor.rb
108
- - lib/brand_logo/strategies/scraping/icon_finder.rb
109
- - lib/brand_logo/strategies/scraping/url_normalizer.rb
110
- - lib/brand_logo/strategies/scraping_strategy.rb
109
+ - lib/brand_logo/lookup.rb
110
+ - lib/brand_logo/page.rb
111
+ - lib/brand_logo/ranker.rb
112
+ - lib/brand_logo/sorbet.rb
113
+ - lib/brand_logo/sorbet_shim.rb
114
+ - lib/brand_logo/strategies/base.rb
115
+ - lib/brand_logo/strategies/browserconfig.rb
116
+ - lib/brand_logo/strategies/duckduckgo.rb
117
+ - lib/brand_logo/strategies/google.rb
118
+ - lib/brand_logo/strategies/json_ld.rb
119
+ - lib/brand_logo/strategies/link_tag.rb
120
+ - lib/brand_logo/strategies/manifest.rb
121
+ - lib/brand_logo/strategies/meta_tag.rb
122
+ - lib/brand_logo/url_resolver.rb
111
123
  - lib/brand_logo/version.rb
112
124
  homepage: https://github.com/AdVitam/brand_logo
113
125
  licenses:
@@ -132,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
132
144
  - !ruby/object:Gem::Version
133
145
  version: '0'
134
146
  requirements: []
135
- rubygems_version: 3.6.9
147
+ rubygems_version: 4.0.20
136
148
  specification_version: 4
137
149
  summary: Fetch the best logo or icon for any website from its domain
138
150
  test_files: []
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
- # typed: strict
3
-
4
- require 'sorbet-runtime'
5
- require 'nokogiri'
6
-
7
- module BrandLogo
8
- # Value Object wrapping a parsed HTML document.
9
- # Exposes only the subset of the Nokogiri API that strategies need,
10
- # preventing strategies from depending on Nokogiri directly.
11
- class ParsedDocument
12
- extend T::Sig
13
-
14
- sig { params(doc: Nokogiri::HTML::Document).void }
15
- def initialize(doc)
16
- @doc = T.let(doc, Nokogiri::HTML::Document)
17
- end
18
-
19
- # Returns all elements matching the CSS selector.
20
- sig { params(selector: String).returns(T::Array[T.untyped]) }
21
- def css(selector)
22
- @doc.css(selector).to_a
23
- end
24
-
25
- # Returns the first element matching the CSS selector, or nil.
26
- sig { params(selector: String).returns(T.untyped) }
27
- def at(selector)
28
- @doc.at(selector)
29
- end
30
-
31
- # Returns the href of the <base> tag if present.
32
- sig { returns(T.nilable(String)) }
33
- def base_href
34
- node = @doc.at('base')
35
- return nil unless node
36
-
37
- node['href']
38
- end
39
- end
40
-
41
- # Interface for parsing raw HTML into a ParsedDocument.
42
- # Strategies receive this via dependency injection.
43
- module HtmlParser
44
- extend T::Sig
45
- extend T::Helpers
46
-
47
- interface!
48
-
49
- sig { abstract.params(html: String).returns(ParsedDocument) }
50
- def parse(html); end
51
- end
52
-
53
- # Concrete implementation using the Nokogiri gem.
54
- class NokogiriParser
55
- extend T::Sig
56
- include HtmlParser
57
-
58
- sig { override.params(html: String).returns(ParsedDocument) }
59
- def parse(html)
60
- ParsedDocument.new(Nokogiri::HTML(html))
61
- end
62
- end
63
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
- # typed: strict
3
-
4
- require 'sorbet-runtime'
5
- require 'http'
6
-
7
- module BrandLogo
8
- # Interface for HTTP operations used by strategies.
9
- # Decouples strategies from the HTTP gem, enabling injection of test doubles.
10
- module HttpClient
11
- extend T::Sig
12
- extend T::Helpers
13
-
14
- interface!
15
-
16
- # Fetches the body of a URL. Returns nil on any failure (network, non-2xx, timeout).
17
- sig { abstract.params(url: String).returns(T.nilable(String)) }
18
- def get_body(url); end
19
-
20
- # Returns true if a HEAD request to the URL succeeds (2xx). Returns false on any failure.
21
- sig { abstract.params(url: String).returns(T::Boolean) }
22
- def head_success?(url); end
23
- end
24
-
25
- # Concrete HTTP client using the `http` gem.
26
- # Centralizes timeout and redirect configuration previously hardcoded
27
- # across ScrapingStrategy, DuckduckgoStrategy, and UrlNormalizer.
28
- class RealHttpClient
29
- extend T::Sig
30
- include HttpClient
31
-
32
- sig { params(config: Config).void }
33
- def initialize(config)
34
- @timeout = T.let(config.timeout, Integer)
35
- @max_hops = T.let(config.max_hops, Integer)
36
- end
37
-
38
- sig { override.params(url: String).returns(T.nilable(String)) }
39
- def get_body(url)
40
- response = HTTP
41
- .timeout(connect: @timeout, read: @timeout, write: @timeout)
42
- .follow(max_hops: @max_hops)
43
- .get(url)
44
- response.status.success? ? response.body.to_s : nil
45
- rescue StandardError
46
- nil
47
- end
48
-
49
- sig { override.params(url: String).returns(T::Boolean) }
50
- def head_success?(url)
51
- response = HTTP
52
- .timeout(connect: @timeout, read: @timeout, write: @timeout)
53
- .head(url)
54
- response.status.success?
55
- rescue StandardError
56
- false
57
- end
58
- end
59
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
- # typed: strict
3
-
4
- require 'sorbet-runtime'
5
- require 'fastimage'
6
-
7
- module BrandLogo
8
- # Interface for extracting image dimensions from a URL.
9
- # Implementations are injected into strategies, enabling testing without network calls.
10
- module ImageAnalyzer
11
- extend T::Sig
12
- extend T::Helpers
13
-
14
- interface!
15
-
16
- sig { abstract.params(url: String).returns(T::Hash[Symbol, T.nilable(Integer)]) }
17
- def dimensions(url); end
18
- end
19
-
20
- # Concrete implementation using the FastImage gem.
21
- # Avoids downloading the full image by parsing only the header bytes.
22
- class FastimageAnalyzer
23
- extend T::Sig
24
- include ImageAnalyzer
25
-
26
- sig { override.params(url: String).returns(T::Hash[Symbol, T.nilable(Integer)]) }
27
- def dimensions(url)
28
- result = FastImage.size(url)
29
- return { width: nil, height: nil } unless result
30
-
31
- { width: result[0], height: result[1] }
32
- rescue StandardError
33
- { width: nil, height: nil }
34
- end
35
- end
36
- end
@@ -1,117 +0,0 @@
1
- # frozen_string_literal: true
2
- # typed: strict
3
-
4
- require 'sorbet-runtime'
5
-
6
- module BrandLogo
7
- module Strategies
8
- # Abstract base class implementing the Template Method pattern.
9
- #
10
- # Subclasses implement `fetch_all` (the customization point).
11
- # `fetch` is the common algorithm: fetch_all → filter valid → select best.
12
- # This eliminates duplicated selection/validation logic across strategies.
13
- class BaseStrategy
14
- extend T::Sig
15
- extend T::Helpers
16
-
17
- abstract!
18
-
19
- UNKNOWN_DIMENSION_SCORE = T.let(-1, Integer)
20
-
21
- sig { params(config: Config).void }
22
- def initialize(config:)
23
- @config = T.let(config, Config)
24
- end
25
-
26
- # Returns ALL icons found for the domain (unfiltered).
27
- # Subclasses must implement this method.
28
- sig { abstract.params(domain: String).returns(T::Array[Icon]) }
29
- def fetch_all(domain); end
30
-
31
- # Returns the best valid icon for the domain, or nil if none found.
32
- # Delegates to fetch_all, filters by validity, then selects the best.
33
- sig { params(domain: String).returns(T.nilable(Icon)) }
34
- def fetch(domain)
35
- valid_icons = fetch_all(domain).select { |icon| valid_icon?(icon) }
36
- select_best_icon(valid_icons)
37
- end
38
-
39
- protected
40
-
41
- sig { params(icon: Icon).returns(T::Boolean) }
42
- def valid_icon?(icon)
43
- valid_dimensions?(icon) && valid_format?(icon)
44
- end
45
-
46
- private
47
-
48
- sig { params(icon: Icon).returns(T::Boolean) }
49
- def valid_dimensions?(icon)
50
- return true if svg_icon?(icon)
51
- return true if missing_dimensions?(icon)
52
-
53
- meets_minimum_dimensions?(icon) && within_maximum_dimensions?(icon)
54
- end
55
-
56
- sig { params(icon: Icon).returns(T::Boolean) }
57
- def valid_format?(icon)
58
- return true if icon.format != 'svg'
59
-
60
- @config.allow_svg
61
- end
62
-
63
- sig { params(icons: T::Array[Icon]).returns(T.nilable(Icon)) }
64
- def select_best_icon(icons)
65
- return nil if icons.empty?
66
-
67
- if @config.allow_svg
68
- svg_icon = icons.find { |icon| icon.format == 'svg' }
69
- return svg_icon if svg_icon
70
- end
71
-
72
- icons.max_by { |icon| icon_score(icon) }
73
- end
74
-
75
- sig { params(icon: Icon).returns(Integer) }
76
- def icon_score(icon)
77
- width = icon.dimensions[:width]
78
- height = icon.dimensions[:height]
79
- return UNKNOWN_DIMENSION_SCORE if width.nil? || height.nil?
80
-
81
- width * height
82
- end
83
-
84
- sig { params(icon: Icon).returns(T::Boolean) }
85
- def svg_icon?(icon)
86
- @config.allow_svg && icon.format == 'svg'
87
- end
88
-
89
- sig { params(icon: Icon).returns(T::Boolean) }
90
- def missing_dimensions?(icon)
91
- icon.dimensions[:width].nil? || icon.dimensions[:height].nil?
92
- end
93
-
94
- sig { params(icon: Icon).returns(T::Boolean) }
95
- def meets_minimum_dimensions?(icon)
96
- min = @config.min_dimensions
97
- width = icon.dimensions[:width]
98
- height = icon.dimensions[:height]
99
- return false if width.nil? || height.nil?
100
-
101
- width >= min[:width] && height >= min[:height]
102
- end
103
-
104
- sig { params(icon: Icon).returns(T::Boolean) }
105
- def within_maximum_dimensions?(icon)
106
- max = @config.max_dimensions
107
- return true if max.nil?
108
-
109
- width = icon.dimensions[:width]
110
- height = icon.dimensions[:height]
111
- return true if width.nil? || height.nil?
112
-
113
- width <= max[:width] && height <= max[:height]
114
- end
115
- end
116
- end
117
- end