jekyll-shopsavvy 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 105a94a928827ffde68ed93ff535c84638f1fb95c7976ecb94c1a4cdec6ccb6f
4
+ data.tar.gz: 49deeab2b516ba83a90f49035de488d82631a54d6674be06d997aeceaac620e6
5
+ SHA512:
6
+ metadata.gz: 0e43d86bc0764ac5731a52f36ffe28de5d2b5616814245203e5f997adcb490afaf373956334b345851407bb93fbc5cc0ec0cfca63f3d652d3b11117057533a62
7
+ data.tar.gz: 9a1cc160af8db9dc163dfb6022de1936b26698771074bf945f0a46dec7214dc39c492422bb63b7bd658ce87989c54756119f2f038a3ca45d989f60a363908dc2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShopSavvy by Monolith Technologies, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # jekyll-shopsavvy
2
+
3
+ Jekyll plugin that adds Liquid tags and filters for embedding **live product cards, deal feeds, and price lookups** powered by the [ShopSavvy Data API](https://shopsavvy.com/data). Everything runs at build time, so the generated site stays static.
4
+
5
+ [Documentation](https://shopsavvy.com/integrations/jekyll) · [Get an API key](https://shopsavvy.com/data) · [Other integrations](https://shopsavvy.com/integrations)
6
+
7
+ ## Install
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ group :jekyll_plugins do
13
+ gem "jekyll-shopsavvy"
14
+ end
15
+ ```
16
+
17
+ Then:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ Register the plugin in `_config.yml`:
24
+
25
+ ```yaml
26
+ plugins:
27
+ - jekyll-shopsavvy
28
+
29
+ shopsavvy:
30
+ api_key: ENV["SHOPSAVVY_API_KEY"]
31
+ ```
32
+
33
+ > **Heads up:** GitHub Pages safe mode whitelists only specific plugins, and `jekyll-shopsavvy` is not on that list. To deploy a Jekyll site that uses this gem to GitHub Pages, build with GitHub Actions and publish the resulting `_site/` directory — that path supports any plugin.
34
+
35
+ ## Tags
36
+
37
+ ### `{% shopsavvy_product %}`
38
+
39
+ Renders a product with offers from across retailers.
40
+
41
+ ```liquid
42
+ {% shopsavvy_product "012345678905" %}
43
+ {% shopsavvy_product "B0DGHYDZSB" layout="inline" %}
44
+ {% shopsavvy_product "012345678905" layout="table" retailer="amazon" limit=10 %}
45
+ ```
46
+
47
+ Layouts: `card` (default), `inline`, `table`.
48
+
49
+ ### `{% shopsavvy_deals %}`
50
+
51
+ Renders a grid of trending deals.
52
+
53
+ ```liquid
54
+ {% shopsavvy_deals category="electronics" limit=8 %}
55
+ {% shopsavvy_deals sort="discount" grade="A" %}
56
+ ```
57
+
58
+ ### `{% shopsavvy_price %}`
59
+
60
+ Inline price text — useful for headlines or sentences.
61
+
62
+ ```liquid
63
+ The current best price is {% shopsavvy_price "012345678905" %}.
64
+ ```
65
+
66
+ ## Filter
67
+
68
+ ```liquid
69
+ {% assign product = "012345678905" | shopsavvy_lookup %}
70
+ <h2>{{ product.data[0].name }}</h2>
71
+ ```
72
+
73
+ ## Configuration
74
+
75
+ Read in this order:
76
+
77
+ 1. `ENV["SHOPSAVVY_API_KEY"]`
78
+ 2. `_config.yml` → `shopsavvy.api_key`
79
+
80
+ ## Test
81
+
82
+ ```bash
83
+ ./test.sh
84
+ ```
85
+
86
+ ## License
87
+
88
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,60 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Jekyll
6
+ module Shopsavvy
7
+ # Thin HTTP wrapper that calls the ShopSavvy Data API at build time.
8
+ # Caches responses per Jekyll site build to keep rebuilds fast.
9
+ class Client
10
+ DEFAULT_BASE_URL = "https://api.shopsavvy.com/v1".freeze
11
+
12
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, cache: nil)
13
+ raise ArgumentError, "ShopSavvy API key is required" if api_key.nil? || api_key.empty?
14
+ @api_key = api_key
15
+ @base_url = base_url
16
+ @cache = cache || {}
17
+ end
18
+
19
+ def product_details(identifier)
20
+ get("/products/details", id: identifier)
21
+ end
22
+
23
+ def current_offers(identifier, retailer: nil)
24
+ params = { id: identifier }
25
+ params[:retailer] = retailer if retailer
26
+ get("/products/offers", **params)
27
+ end
28
+
29
+ def price_history(identifier, days: 90)
30
+ get("/products/history", id: identifier, days: days)
31
+ end
32
+
33
+ def deals(category: nil, limit: 10, sort: "trending", grade: nil)
34
+ params = { limit: limit, sort: sort }
35
+ params[:category] = category if category
36
+ params[:min_grade] = grade if grade
37
+ get("/deals", **params)
38
+ end
39
+
40
+ private
41
+
42
+ def get(path, **params)
43
+ cache_key = [path, params].to_s
44
+ return @cache[cache_key] if @cache.key?(cache_key)
45
+
46
+ uri = URI.parse("#{@base_url}#{path}")
47
+ uri.query = URI.encode_www_form(params) unless params.empty?
48
+
49
+ request = Net::HTTP::Get.new(uri)
50
+ request["Authorization"] = "Bearer #{@api_key}"
51
+ request["User-Agent"] = "jekyll-shopsavvy/#{VERSION}"
52
+
53
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
54
+ body = JSON.parse(response.body)
55
+ @cache[cache_key] = body
56
+ body
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,177 @@
1
+ require "shellwords"
2
+
3
+ module Jekyll
4
+ module Shopsavvy
5
+ # Parses key=value style arguments out of a Liquid tag's markup string.
6
+ module ArgsParser
7
+ module_function
8
+
9
+ def parse(markup)
10
+ tokens = Shellwords.split(markup.to_s)
11
+ positional = []
12
+ named = {}
13
+ tokens.each do |t|
14
+ if t.include?("=")
15
+ k, v = t.split("=", 2)
16
+ named[k.to_sym] = v
17
+ else
18
+ positional << t
19
+ end
20
+ end
21
+ [positional, named]
22
+ end
23
+ end
24
+
25
+ # Site-level helpers: shared client and cache.
26
+ module SiteHelpers
27
+ module_function
28
+
29
+ def client_for(context)
30
+ site = context.registers[:site]
31
+ site.config["__shopsavvy_client"] ||= begin
32
+ api_key = ENV["SHOPSAVVY_API_KEY"] || site.config.dig("shopsavvy", "api_key")
33
+ Client.new(api_key: api_key, cache: (site.config["__shopsavvy_cache"] ||= {}))
34
+ rescue ArgumentError => e
35
+ Jekyll.logger.warn("ShopSavvy:", e.message)
36
+ nil
37
+ end
38
+ end
39
+ end
40
+
41
+ # {% shopsavvy_product <identifier> [layout="card"] [retailer="..."] [limit=5] %}
42
+ class ProductTag < Liquid::Tag
43
+ LAYOUTS = %w[card inline table].freeze
44
+
45
+ def initialize(tag_name, markup, tokens)
46
+ super
47
+ positional, @named = ArgsParser.parse(markup)
48
+ @identifier = positional.first || @named[:upc] || @named[:asin] || @named[:id] || @named[:url]
49
+ @layout = (@named[:layout] || "card").to_s
50
+ @layout = "card" unless LAYOUTS.include?(@layout)
51
+ @retailer = @named[:retailer]
52
+ @limit = (@named[:limit] || 5).to_i
53
+ end
54
+
55
+ def render(context)
56
+ client = SiteHelpers.client_for(context)
57
+ return "" unless client && @identifier
58
+
59
+ identifier = Liquid::Template.parse(@identifier).render(context)
60
+ product = client.product_details(identifier)
61
+ offers = client.current_offers(identifier, retailer: @retailer)
62
+ offer_list = (offers["data"] || []).first(@limit)
63
+
64
+ case @layout
65
+ when "inline" then render_inline(product, offer_list)
66
+ when "table" then render_table(product, offer_list)
67
+ else render_card(product, offer_list)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def render_card(product, offers)
74
+ item = (product["data"] || []).first || {}
75
+ cheapest = offers.min_by { |o| o["price"].to_f } || {}
76
+ offers_html = offers.map { |o| %(<li><span>#{o["retailer"]}</span><span>$#{o["price"]}</span></li>) }.join
77
+ <<~HTML
78
+ <div class="shopsavvy-card">
79
+ #{item["image"] ? %(<img class="shopsavvy-card__image" src="#{item["image"]}" alt="#{item["name"]}" loading="lazy" />) : ""}
80
+ <div class="shopsavvy-card__body">
81
+ <h3 class="shopsavvy-card__name">#{item["name"]}</h3>
82
+ <p class="shopsavvy-card__price">$#{cheapest["price"]} <span class="shopsavvy-card__retailer">at #{cheapest["retailer"]}</span></p>
83
+ <ul class="shopsavvy-card__offers">#{offers_html}</ul>
84
+ <a class="shopsavvy-card__cta" href="https://shopsavvy.com" rel="noopener">Compare on ShopSavvy</a>
85
+ </div>
86
+ </div>
87
+ HTML
88
+ end
89
+
90
+ def render_inline(product, offers)
91
+ item = (product["data"] || []).first || {}
92
+ cheapest = offers.min_by { |o| o["price"].to_f } || {}
93
+ %(<span class="shopsavvy-inline"><strong>#{item["name"]}</strong> — <span class="shopsavvy-inline__price">$#{cheapest["price"]} at #{cheapest["retailer"]}</span></span>)
94
+ end
95
+
96
+ def render_table(product, offers)
97
+ item = (product["data"] || []).first || {}
98
+ rows = offers.map do |o|
99
+ %(<tr><td>#{o["retailer"]}</td><td>$#{o["price"]}</td><td>#{o["condition"] || "new"}</td><td>#{o["availability"] ? "Yes" : "—"}</td></tr>)
100
+ end.join
101
+ <<~HTML
102
+ <div class="shopsavvy-table-wrap">
103
+ <p class="shopsavvy-table__title">#{item["name"]}</p>
104
+ <table class="shopsavvy-table">
105
+ <thead><tr><th>Retailer</th><th>Price</th><th>Condition</th><th>In stock</th></tr></thead>
106
+ <tbody>#{rows}</tbody>
107
+ </table>
108
+ </div>
109
+ HTML
110
+ end
111
+ end
112
+
113
+ # {% shopsavvy_deals [category="..."] [limit=10] [sort="trending"] [grade="A"] %}
114
+ class DealsTag < Liquid::Tag
115
+ def initialize(tag_name, markup, tokens)
116
+ super
117
+ _positional, named = ArgsParser.parse(markup)
118
+ @category = named[:category]
119
+ @limit = (named[:limit] || 10).to_i
120
+ @sort = named[:sort] || "trending"
121
+ @grade = named[:grade]
122
+ end
123
+
124
+ def render(context)
125
+ client = SiteHelpers.client_for(context)
126
+ return "" unless client
127
+ deals = client.deals(category: @category, limit: @limit, sort: @sort, grade: @grade)
128
+ items = deals["data"] || []
129
+ body = items.map do |d|
130
+ %(<a class="shopsavvy-deal" href="#{d["url"] || "https://shopsavvy.com"}" rel="noopener">) +
131
+ (d["image"] ? %(<img src="#{d["image"]}" alt="#{d["name"]}" loading="lazy" />) : "") +
132
+ %(<div class="shopsavvy-deal__body"><p class="shopsavvy-deal__name">#{d["name"]}</p>) +
133
+ %(<p class="shopsavvy-deal__price"><span class="shopsavvy-deal__sale">$#{d["price"]}</span>) +
134
+ (d["strikethrough"] ? %(<span class="shopsavvy-deal__msrp">$#{d["strikethrough"]}</span>) : "") +
135
+ %(</p><p class="shopsavvy-deal__retailer">#{d["retailer"]}</p></div></a>)
136
+ end.join
137
+ %(<div class="shopsavvy-deals-grid">#{body}</div>)
138
+ end
139
+ end
140
+
141
+ # {% shopsavvy_price <identifier> %} — emits a plain price string.
142
+ class PriceTag < Liquid::Tag
143
+ def initialize(tag_name, markup, tokens)
144
+ super
145
+ positional, _named = ArgsParser.parse(markup)
146
+ @identifier = positional.first
147
+ end
148
+
149
+ def render(context)
150
+ client = SiteHelpers.client_for(context)
151
+ return "" unless client && @identifier
152
+ identifier = Liquid::Template.parse(@identifier).render(context)
153
+ offers = client.current_offers(identifier)
154
+ cheapest = (offers["data"] || []).min_by { |o| o["price"].to_f }
155
+ cheapest ? "$#{cheapest["price"]}" : ""
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ # {{ "012345678905" | shopsavvy_lookup }}
162
+ module Jekyll
163
+ module Shopsavvy
164
+ module Filters
165
+ def shopsavvy_lookup(identifier)
166
+ site = @context.registers[:site]
167
+ api_key = ENV["SHOPSAVVY_API_KEY"] || site.config.dig("shopsavvy", "api_key")
168
+ return {} if api_key.nil?
169
+ client = Jekyll::Shopsavvy::Client.new(api_key: api_key, cache: (site.config["__shopsavvy_cache"] ||= {}))
170
+ client.product_details(identifier.to_s)
171
+ rescue StandardError => e
172
+ Jekyll.logger.warn("ShopSavvy:", "lookup failed: #{e.message}")
173
+ {}
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,5 @@
1
+ module Jekyll
2
+ module Shopsavvy
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ require "jekyll"
2
+ require_relative "jekyll/shopsavvy/version"
3
+ require_relative "jekyll/shopsavvy/client"
4
+ require_relative "jekyll/shopsavvy/tags"
5
+
6
+ Liquid::Template.register_tag("shopsavvy_product", Jekyll::Shopsavvy::ProductTag)
7
+ Liquid::Template.register_tag("shopsavvy_deals", Jekyll::Shopsavvy::DealsTag)
8
+ Liquid::Template.register_tag("shopsavvy_price", Jekyll::Shopsavvy::PriceTag)
9
+ Liquid::Template.register_filter(Jekyll::Shopsavvy::Filters)
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-shopsavvy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ShopSavvy by Monolith Technologies, Inc.
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-05-09 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rspec
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '3.12'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '3.12'
46
+ - !ruby/object:Gem::Dependency
47
+ name: bundler
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ description: Adds {% shopsavvy_product %}, {% shopsavvy_deals %}, {% shopsavvy_price
61
+ %} Liquid tags and a shopsavvy_lookup filter for embedding live product cards, deal
62
+ feeds, and price lookups in Jekyll sites at build time.
63
+ email:
64
+ - business@shopsavvy.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - LICENSE
70
+ - README.md
71
+ - lib/jekyll-shopsavvy.rb
72
+ - lib/jekyll/shopsavvy/client.rb
73
+ - lib/jekyll/shopsavvy/tags.rb
74
+ - lib/jekyll/shopsavvy/version.rb
75
+ homepage: https://shopsavvy.com/integrations/jekyll
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://shopsavvy.com/integrations/jekyll
80
+ source_code_uri: https://github.com/shopsavvy/jekyll-shopsavvy
81
+ documentation_uri: https://shopsavvy.com/integrations/jekyll
82
+ changelog_uri: https://github.com/shopsavvy/jekyll-shopsavvy/blob/main/CHANGELOG.md
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 2.7.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.6.2
98
+ specification_version: 4
99
+ summary: Jekyll Liquid tags and filters powered by the ShopSavvy Data API
100
+ test_files: []