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,29 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'fastimage'
5
+ require 'stringio'
6
+
7
+ module BrandLogo
8
+ module ImageProbe
9
+ extend T::Sig
10
+
11
+ BYTES = 65_536
12
+
13
+ Result = Data.define(:format, :dimensions)
14
+
15
+ sig { params(body: String).returns(T.nilable(Result)) }
16
+ def self.analyze(body)
17
+ image = FastImage.new(StringIO.new(body), raise_on_failure: false)
18
+ format = ImageFormat.from_fastimage(image.type)
19
+ return nil unless format
20
+
21
+ width, height = image.size
22
+ Result.new(format: format, dimensions: Dimensions.new(width: width, height: height))
23
+ # FastImage parses untrusted bytes and can fail outside its own error list.
24
+ rescue StandardError => e
25
+ Logging.logger.warn("ImageProbe: unreadable image (#{e.class}: #{e.message})")
26
+ nil
27
+ end
28
+ end
29
+ end
@@ -2,15 +2,9 @@
2
2
  # typed: strict
3
3
 
4
4
  require 'logger'
5
- require 'sorbet-runtime'
5
+ require_relative 'sorbet'
6
6
 
7
7
  module BrandLogo
8
- # Centralized logging for the gem.
9
- # Replaces the DebugLogger module's debug boolean pattern.
10
- #
11
- # Usage:
12
- # BrandLogo::Logging.logger.level = Logger::DEBUG # enable verbose output
13
- # BrandLogo::Logging.logger = MyCustomLogger.new # inject custom logger
14
8
  module Logging
15
9
  extend T::Sig
16
10
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ class Lookup
6
+ extend T::Sig
7
+
8
+ STAGES = T.let(%i[document remote external].freeze, T::Array[Symbol])
9
+
10
+ sig { params(context: Context, strategies: T::Array[Strategies::Base], ranker: Ranker).void }
11
+ def initialize(context:, strategies:, ranker:)
12
+ @context = context
13
+ @strategies = strategies
14
+ @ranker = ranker
15
+ end
16
+
17
+ sig { returns(T.nilable(Icon)) }
18
+ def best
19
+ icons = T.let([], T::Array[Icon])
20
+ STAGES.each do |stage|
21
+ break if @context.deadline.expired?
22
+ next if skip?(stage, icons)
23
+
24
+ icons = run(stage, icons)
25
+ winner = icons.first
26
+ return winner if winner && @ranker.good_enough?(winner)
27
+ end
28
+ icons.first
29
+ end
30
+
31
+ sig { returns(T::Array[Icon]) }
32
+ def all
33
+ STAGES.reduce(T.let([], T::Array[Icon])) do |icons, stage|
34
+ @context.deadline.expired? ? icons : run(stage, icons)
35
+ end
36
+ end
37
+
38
+ sig { returns(T::Boolean) }
39
+ def timed_out?
40
+ @context.deadline.expired?
41
+ end
42
+
43
+ private
44
+
45
+ sig { params(stage: Symbol, icons: T::Array[Icon]).returns(T::Boolean) }
46
+ def skip?(stage, icons)
47
+ stage == :external && icons.any? { |icon| @ranker.usable?(icon) }
48
+ end
49
+
50
+ sig { params(stage: Symbol, previous: T::Array[Icon]).returns(T::Array[Icon]) }
51
+ def run(stage, previous)
52
+ seen = previous.to_set(&:url)
53
+ candidates = collect(stage).uniq(&:url).reject { |icon| seen.include?(icon.url) }
54
+ BrandLogo::Logging.logger.debug("[#{@context.domain}] #{stage}: #{candidates.size} new candidate(s)")
55
+ @ranker.rank(previous + parallel(candidates) { |icon| measure(icon) }.compact)
56
+ end
57
+
58
+ sig { params(stage: Symbol).returns(T::Array[Icon]) }
59
+ def collect(stage)
60
+ strategies = @strategies.select { |strategy| strategy.stage == stage }
61
+ parallel(strategies) { |strategy| strategy.call(@context) }.flatten
62
+ end
63
+
64
+ sig { params(icon: Icon).returns(T.nilable(Icon)) }
65
+ def measure(icon)
66
+ result = @context.probe(icon.url)
67
+ return nil unless result
68
+
69
+ icon.with(format: result.format, dimensions: measured_dimensions(icon, result))
70
+ end
71
+
72
+ # FastImage reads only the first entry of an .ico, which is often smaller than the declared largest one.
73
+ sig { params(icon: Icon, result: ImageProbe::Result).returns(Dimensions) }
74
+ def measured_dimensions(icon, result)
75
+ measured = result.dimensions
76
+ return icon.dimensions unless measured.known?
77
+ return [measured, icon.dimensions].max_by(&:area) || measured if result.format == :ico
78
+
79
+ measured
80
+ end
81
+
82
+ sig do
83
+ type_parameters(:I, :O)
84
+ .params(
85
+ items: T::Array[T.type_parameter(:I)],
86
+ block: T.proc.params(item: T.type_parameter(:I)).returns(T.type_parameter(:O))
87
+ )
88
+ .returns(T::Array[T.type_parameter(:O)])
89
+ end
90
+ def parallel(items, &block)
91
+ Concurrency.map(items, size: @context.config.concurrency, &block)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'nokogiri'
5
+
6
+ module BrandLogo
7
+ Page = Data.define(:url, :document, :base_url) do
8
+ extend T::Sig
9
+
10
+ sig { params(context: Context).returns(T.nilable(Page)) }
11
+ def self.fetch(context)
12
+ domain = context.domain
13
+ ["https://#{domain}", "https://www.#{domain}", "http://#{domain}"].each do |candidate|
14
+ response = context.get(candidate)
15
+ return build(response) if response
16
+ end
17
+ nil
18
+ end
19
+
20
+ sig { params(response: Http::Response).returns(T.nilable(Page)) }
21
+ def self.build(response)
22
+ document = Nokogiri::HTML5(response.body)
23
+ declared = document.at_css('base[href]')&.[]('href')
24
+ base_url = UrlResolver.resolve(declared, response.url) || response.url
25
+ new(url: response.url, document: document, base_url: base_url)
26
+ rescue ArgumentError => e
27
+ Logging.logger.warn("Page: cannot parse #{response.url}: #{e.message}")
28
+ nil
29
+ end
30
+ private_class_method :build
31
+
32
+ sig { params(href: T.nilable(String)).returns(T.nilable(String)) }
33
+ def resolve(href)
34
+ UrlResolver.resolve(href, base_url)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ class Ranker
6
+ extend T::Sig
7
+
8
+ KIND_TIERS = T.let({
9
+ mask: 3, monochrome: 3, social: 2, tile: 1
10
+ }.freeze, T::Hash[Symbol, Integer])
11
+ UNUSABLE_TIER = 2
12
+ SCHEMES = T.let({
13
+ dark: /prefers-color-scheme\s*:\s*dark/i, light: /prefers-color-scheme\s*:\s*light/i
14
+ }.freeze, T::Hash[Symbol, Regexp])
15
+ DEFAULT_GOOD_SIZE = 128
16
+ MAX_ASPECT_RATIO = 1.2
17
+
18
+ sig { params(config: Config).void }
19
+ def initialize(config)
20
+ @config = config
21
+ end
22
+
23
+ sig { params(icons: T::Array[Icon]).returns(T::Array[Icon]) }
24
+ def rank(icons)
25
+ icons.select { |icon| valid?(icon) }
26
+ .each_with_index
27
+ .sort_by { |icon, index| [*penalty(icon), index] }
28
+ .map(&:first)
29
+ end
30
+
31
+ sig { params(icon: Icon).returns(T::Boolean) }
32
+ def usable?(icon)
33
+ valid?(icon) && KIND_TIERS.fetch(icon.kind, 0) < UNUSABLE_TIER
34
+ end
35
+
36
+ sig { params(icon: Icon).returns(T::Boolean) }
37
+ def good_enough?(icon)
38
+ return false unless usable?(icon)
39
+ return false if @config.prefer == :square && non_square?(icon)
40
+
41
+ icon.svg? || icon.dimensions.side >= (@config.target_size || DEFAULT_GOOD_SIZE)
42
+ end
43
+
44
+ private
45
+
46
+ sig { params(icon: Icon).returns(T::Boolean) }
47
+ def valid?(icon)
48
+ return false if icon.format.nil?
49
+ return @config.allow_svg if icon.svg?
50
+
51
+ within_limits?(icon.dimensions)
52
+ end
53
+
54
+ sig { params(dims: Dimensions).returns(T::Boolean) }
55
+ def within_limits?(dims)
56
+ min = @config.min_size
57
+ max = @config.max_size
58
+ (min.nil? || dims.at_least?(min)) && (max.nil? || dims.at_most?(max))
59
+ end
60
+
61
+ sig { params(icon: Icon).returns(T::Array[Numeric]) }
62
+ def penalty(icon)
63
+ [KIND_TIERS.fetch(icon.kind, 0), scheme_penalty(icon), *preference_penalty(icon), *size_penalty(icon)]
64
+ end
65
+
66
+ sig { params(icon: Icon).returns(Integer) }
67
+ def scheme_penalty(icon)
68
+ wanted = @config.color_scheme
69
+ return media_mentions?(icon, :dark) ? 1 : 0 unless wanted
70
+
71
+ opposite = wanted == :dark ? :light : :dark
72
+ return 0 if media_mentions?(icon, wanted)
73
+
74
+ media_mentions?(icon, opposite) ? 2 : 1
75
+ end
76
+
77
+ sig { params(icon: Icon, scheme: Symbol).returns(T::Boolean) }
78
+ def media_mentions?(icon, scheme)
79
+ SCHEMES.fetch(scheme).match?(icon.media.to_s)
80
+ end
81
+
82
+ sig { params(icon: Icon).returns(T::Array[Integer]) }
83
+ def preference_penalty(icon)
84
+ case @config.prefer
85
+ when :svg then [icon.svg? ? 0 : 1]
86
+ when :square then [non_square?(icon) ? 1 : 0, icon.svg? ? 0 : 1]
87
+ else []
88
+ end
89
+ end
90
+
91
+ sig { params(icon: Icon).returns(T::Boolean) }
92
+ def non_square?(icon)
93
+ w, h = icon.dimensions.to_pair
94
+ return false unless w && h
95
+
96
+ [w, h].max.fdiv([w, h].min) > MAX_ASPECT_RATIO
97
+ end
98
+
99
+ sig { params(icon: Icon).returns(T::Array[Numeric]) }
100
+ def size_penalty(icon)
101
+ target = @config.target_size
102
+ return [-area(icon)] unless target
103
+
104
+ side = icon.svg? ? target : icon.dimensions.side
105
+ return [0, target / 2.0, -area(icon)] if side.zero?
106
+
107
+ [side < target ? 1 : 0, (side - target).abs, -area(icon)]
108
+ end
109
+
110
+ sig { params(icon: Icon).returns(Numeric) }
111
+ def area(icon)
112
+ icon.svg? ? Float::INFINITY : icon.dimensions.area
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ begin
5
+ require 'sorbet-runtime'
6
+ rescue LoadError
7
+ require_relative 'sorbet_shim'
8
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ # typed: ignore
3
+
4
+ # No-op stand-in for sorbet-runtime so host apps are not forced to depend on it.
5
+ # Signatures are only enforced statically (`srb tc`); `sig` blocks are never evaluated.
6
+ module T
7
+ module Sig
8
+ def sig(*) = nil
9
+ end
10
+
11
+ module Helpers
12
+ def abstract! = nil
13
+ def interface! = nil
14
+ end
15
+
16
+ module Boolean; end
17
+
18
+ module Generic
19
+ def [](*) = self
20
+ end
21
+
22
+ module Array; extend Generic; end
23
+ module Hash; extend Generic; end
24
+ module Range; extend Generic; end
25
+
26
+ class << self
27
+ def let(value, _type) = value
28
+ def cast(value, _type) = value
29
+
30
+ def must(value)
31
+ raise TypeError, 'Passed `nil` into T.must' if value.nil?
32
+
33
+ value
34
+ end
35
+
36
+ def nilable(*) = nil
37
+ def any(*) = nil
38
+ def untyped = nil
39
+ def class_of(*) = nil
40
+ def noreturn = nil
41
+ def type_parameter(*) = nil
42
+ def proc = nil
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Strategies
6
+ module Base
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ interface!
11
+
12
+ sig { abstract.returns(Symbol) }
13
+ def stage; end
14
+
15
+ sig { abstract.params(context: Context).returns(T::Array[Icon]) }
16
+ def call(context); end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Strategies
6
+ class Browserconfig
7
+ extend T::Sig
8
+ include Base
9
+
10
+ TILES = T.let({
11
+ 'square70x70logo' => [70, 70],
12
+ 'square150x150logo' => [150, 150],
13
+ 'square310x310logo' => [310, 310],
14
+ 'wide310x150logo' => [310, 150]
15
+ }.freeze, T::Hash[String, [Integer, Integer]])
16
+
17
+ sig { override.returns(Symbol) }
18
+ def stage
19
+ :remote
20
+ end
21
+
22
+ sig { override.params(context: Context).returns(T::Array[Icon]) }
23
+ def call(context)
24
+ response = fetch_config(context)
25
+ document = response && parse(response)
26
+ return [] unless response && document
27
+
28
+ document.xpath('//*').filter_map { |node| build(node, response.url) }
29
+ end
30
+
31
+ private
32
+
33
+ sig { params(context: Context).returns(T.nilable(Http::Response)) }
34
+ def fetch_config(context)
35
+ page = context.page
36
+ return nil unless page
37
+
38
+ declared = page.document.at_css('meta[name="msapplication-config"]')&.[]('content').to_s.strip
39
+ url = page.resolve(declared) unless declared.casecmp?('none')
40
+ url && context.get(url)
41
+ end
42
+
43
+ sig { params(response: Http::Response).returns(T.nilable(Nokogiri::XML::Document)) }
44
+ def parse(response)
45
+ document = Nokogiri::XML(response.body, &:strict)
46
+ document.remove_namespaces!
47
+ document
48
+ rescue Nokogiri::XML::SyntaxError => e
49
+ Logging.logger.warn("Browserconfig: invalid XML at #{response.url}: #{e.message}")
50
+ nil
51
+ end
52
+
53
+ sig { params(node: Nokogiri::XML::Node, config_url: String).returns(T.nilable(Icon)) }
54
+ def build(node, config_url)
55
+ size = TILES[node.name.downcase]
56
+ url = UrlResolver.resolve(node['src'], config_url) if size
57
+ return nil unless size && url
58
+
59
+ width, height = size
60
+ Icon.new(
61
+ url: url,
62
+ source: :browserconfig,
63
+ kind: :tile,
64
+ format: ImageFormat.from_url(url),
65
+ dimensions: Dimensions.new(width: width, height: height)
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Strategies
6
+ class Duckduckgo
7
+ extend T::Sig
8
+ include Base
9
+
10
+ sig { override.returns(Symbol) }
11
+ def stage
12
+ :external
13
+ end
14
+
15
+ sig { override.params(context: Context).returns(T::Array[Icon]) }
16
+ def call(context)
17
+ [Icon.new(url: "https://icons.duckduckgo.com/ip3/#{context.domain}.ico", source: :duckduckgo)]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'cgi'
5
+
6
+ module BrandLogo
7
+ module Strategies
8
+ # Google's favicon service answers 404 for unknown domains, which the probe drops.
9
+ class Google
10
+ extend T::Sig
11
+ include Base
12
+
13
+ SIZE = 256
14
+
15
+ sig { override.returns(Symbol) }
16
+ def stage
17
+ :external
18
+ end
19
+
20
+ sig { override.params(context: Context).returns(T::Array[Icon]) }
21
+ def call(context)
22
+ [Icon.new(
23
+ url: "https://www.google.com/s2/favicons?domain=#{CGI.escape(context.domain)}&sz=#{SIZE}",
24
+ source: :google
25
+ )]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'json'
5
+
6
+ module BrandLogo
7
+ module Strategies
8
+ class JsonLd
9
+ extend T::Sig
10
+ include Base
11
+
12
+ ORGANIZATION_TYPES = T.let(%w[Organization Corporation NGO LocalBusiness Brand].freeze, T::Array[String])
13
+ ORGANIZATION_SUFFIXES = T.let(%w[Organization Business Store Brand].freeze, T::Array[String])
14
+ OWNER_KEYS = T.let(%w[publisher brand].freeze, T::Array[String])
15
+
16
+ sig { override.returns(Symbol) }
17
+ def stage
18
+ :document
19
+ end
20
+
21
+ sig { override.params(context: Context).returns(T::Array[Icon]) }
22
+ def call(context)
23
+ page = context.page
24
+ return [] unless page
25
+
26
+ logos = []
27
+ page.document.css('script[type="application/ld+json"]').each do |script|
28
+ collect(JSON.parse(script.text), false, logos)
29
+ rescue JSON::ParserError => e
30
+ Logging.logger.warn("JsonLd: invalid JSON at #{page.url}: #{e.message}")
31
+ end
32
+ logos.filter_map { |logo| build(page, logo) }
33
+ end
34
+
35
+ private
36
+
37
+ sig { params(node: T.untyped, owner: T::Boolean, logos: T::Array[T.untyped]).void }
38
+ def collect(node, owner, logos)
39
+ case node
40
+ when ::Array
41
+ node.each { |child| collect(child, owner, logos) }
42
+ when ::Hash
43
+ logos.concat(declared_logos(node)) if owner || organization?(node['@type'])
44
+ node.each { |key, child| collect(child, OWNER_KEYS.include?(key), logos) }
45
+ end
46
+ end
47
+
48
+ sig { params(node: T::Hash[String, T.untyped]).returns(T::Array[T.untyped]) }
49
+ def declared_logos(node)
50
+ declared = node['logo']
51
+ declared.is_a?(::Array) ? declared : [declared].compact
52
+ end
53
+
54
+ sig { params(types: T.untyped).returns(T::Boolean) }
55
+ def organization?(types)
56
+ Array(types).any? do |type|
57
+ name = type.to_s.split(%r{[/:#]}).last.to_s
58
+ ORGANIZATION_TYPES.include?(name) || name.end_with?(*ORGANIZATION_SUFFIXES)
59
+ end
60
+ end
61
+
62
+ sig { params(page: Page, logo: T.untyped).returns(T.nilable(Icon)) }
63
+ def build(page, logo)
64
+ declared = logo.is_a?(::Hash) ? (logo['url'] || logo['contentUrl']) : logo
65
+ url = page.resolve(declared) if declared.is_a?(String)
66
+ return nil unless url
67
+
68
+ Icon.new(url: url, source: :json_ld, kind: :logo, format: format_of(logo, url), dimensions: dimensions(logo))
69
+ end
70
+
71
+ sig { params(logo: T.untyped, url: String).returns(T.nilable(Symbol)) }
72
+ def format_of(logo, url)
73
+ mime = logo['encodingFormat'] if logo.is_a?(::Hash)
74
+ ImageFormat.from_mime(mime) || ImageFormat.from_url(url)
75
+ end
76
+
77
+ sig { params(logo: T.untyped).returns(Dimensions) }
78
+ def dimensions(logo)
79
+ return Dimensions.new unless logo.is_a?(::Hash)
80
+
81
+ Dimensions.new(width: pixels(logo['width']), height: pixels(logo['height']))
82
+ end
83
+
84
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
85
+ def pixels(value)
86
+ number = value.is_a?(Numeric) ? value.to_i : value.to_s[/\A\d+/]&.to_i
87
+ number if number&.positive?
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Strategies
6
+ class LinkTag
7
+ extend T::Sig
8
+ include Base
9
+
10
+ APPLE_TOUCH = T.let(%w[apple-touch-icon apple-touch-icon-precomposed].freeze, T::Array[String])
11
+
12
+ sig { override.returns(Symbol) }
13
+ def stage
14
+ :document
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
+ page.document.css('link[rel]').filter_map { |link| build(page, link) } << favicon(page)
23
+ end
24
+
25
+ private
26
+
27
+ sig { params(page: Page, link: Nokogiri::XML::Element).returns(T.nilable(Icon)) }
28
+ def build(page, link)
29
+ kind = kind_of(link['rel'].to_s.downcase.split)
30
+ url = page.resolve(link['href'])
31
+ return nil unless kind && url
32
+
33
+ Icon.new(
34
+ url: url,
35
+ source: :link_tag,
36
+ kind: kind,
37
+ format: ImageFormat.from_mime(link['type']) || ImageFormat.from_url(url),
38
+ dimensions: Dimensions.parse(link['sizes']),
39
+ media: link['media']
40
+ )
41
+ end
42
+
43
+ sig { params(tokens: T::Array[String]).returns(T.nilable(Symbol)) }
44
+ def kind_of(tokens)
45
+ if tokens.include?('mask-icon') then :mask
46
+ elsif tokens.intersect?(APPLE_TOUCH) then :apple_touch
47
+ elsif tokens.include?('icon') then :icon
48
+ end
49
+ end
50
+
51
+ sig { params(page: Page).returns(Icon) }
52
+ def favicon(page)
53
+ url = T.must(UrlResolver.resolve('/favicon.ico', page.url))
54
+ Icon.new(url: url, source: :link_tag, format: :ico)
55
+ end
56
+ end
57
+ end
58
+ end