jekyll-unirate 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: 545b7fd8b44a778643ca5ac64a96481a5b36f69db89711d0aa5e78516aa3df56
4
+ data.tar.gz: 65c8702502582332fddd832e698c5d368b365b237b9aa4ad5be3cfd6ec3e6eed
5
+ SHA512:
6
+ metadata.gz: 49fc0bbf2d4de414e2ef9c615be0849e81efdd53c9294760e0df16cc124722317b9cddbe5bd304ca5b0e798809f641d19673fbd56dc5cc57010df2403494e5fe
7
+ data.tar.gz: 6a7bb07ee5cda9a8f0ccac9d42221d38f7c8f37c0b4bf868d98a0c1d4e1fc6c0fd7cc1ef9cc7ad981af2178501cfea9c24be27f1d3a1bc52b4bd09ea3d4ccbd7
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-06-19
8
+
9
+ ### Added
10
+
11
+ - Initial release.
12
+ - Build-time `Jekyll::Generator` that fetches one single-base exchange-rate
13
+ snapshot from the UniRate API and exposes it to the build.
14
+ - Liquid filters: `unirate_rate`, `unirate_convert`, `unirate_money`,
15
+ `unirate_price`.
16
+ - Liquid tags: `{% unirate_rate %}`, `{% unirate_convert %}`,
17
+ `{% unirate_price %}`.
18
+ - `site.data["unirate"]` rate map for direct template iteration.
19
+ - Cross-rates derived on demand from the single-base snapshot.
20
+ - Graceful degradation: a failed fetch logs a warning and never breaks the
21
+ build; helpers fall back to unconverted amounts.
22
+
23
+ [0.1.0]: https://github.com/UniRate-API/jekyll-unirate/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Unirate Team
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,158 @@
1
+ # jekyll-unirate
2
+
3
+ [![CI](https://github.com/UniRate-API/jekyll-unirate/actions/workflows/ci.yml/badge.svg)](https://github.com/UniRate-API/jekyll-unirate/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/jekyll-unirate)](https://rubygems.org/gems/jekyll-unirate)
5
+
6
+ Currency conversion and live exchange rates for [Jekyll](https://jekyllrb.com),
7
+ powered by the [UniRate API](https://unirateapi.com).
8
+
9
+ `jekyll-unirate` fetches **one** exchange-rate snapshot at build time and
10
+ exposes Liquid filters and tags to convert amounts, format prices, and look up
11
+ rates anywhere in your site. Every cross-rate is derived on demand from a single
12
+ base snapshot, so one HTTP request covers all currency pairs — and if the API is
13
+ briefly unreachable, **your build still succeeds** with amounts rendered
14
+ unconverted rather than failing.
15
+
16
+ ## Installation
17
+
18
+ Add the gem to your site's `Gemfile`, in the `:jekyll_plugins` group:
19
+
20
+ ```ruby
21
+ group :jekyll_plugins do
22
+ gem "jekyll-unirate"
23
+ end
24
+ ```
25
+
26
+ Then `bundle install`. (Or add `jekyll-unirate` to the `plugins:` list in
27
+ `_config.yml` if you manage plugins there.)
28
+
29
+ ## Configuration
30
+
31
+ Configure the plugin under a `unirate:` key in `_config.yml`:
32
+
33
+ ```yaml
34
+ unirate:
35
+ base: USD # currency the rate snapshot is fetched against
36
+ default_currency: USD # display currency for formatting helpers
37
+ base_url: https://api.unirateapi.com
38
+ timeout: 30
39
+ # api_key: ... # prefer the UNIRATE_API_KEY env var (see below)
40
+ ```
41
+
42
+ ### API key
43
+
44
+ Get a free key at [unirateapi.com](https://unirateapi.com). Provide it via the
45
+ `UNIRATE_API_KEY` environment variable so it never gets committed to your site
46
+ repository:
47
+
48
+ ```bash
49
+ UNIRATE_API_KEY=your-key bundle exec jekyll build
50
+ ```
51
+
52
+ The env var takes precedence; `unirate.api_key` in `_config.yml` is a fallback.
53
+ If no key is configured, the build still runs — helpers just render amounts
54
+ unconverted and a warning is logged.
55
+
56
+ ## Usage
57
+
58
+ ### Filters
59
+
60
+ ```liquid
61
+ {{ "USD" | unirate_rate: "EUR" }} {%- comment %} 0.92 {% endcomment -%}
62
+ {{ 100 | unirate_convert: "USD", "EUR" }} {%- comment %} 92.0 {% endcomment -%}
63
+ {{ 92.5 | unirate_money: "EUR" }} {%- comment %} €92.50 {% endcomment -%}
64
+ {{ 100 | unirate_price: "USD", "EUR" }} {%- comment %} €92.00 {% endcomment -%}
65
+ ```
66
+
67
+ | Filter | Returns |
68
+ |---|---|
69
+ | `unirate_rate: from, to` | Numeric exchange rate (empty if unknown) |
70
+ | `unirate_convert: from, to` | Converted number (falls back to the input amount) |
71
+ | `unirate_money: currency` | A bare amount formatted with the currency symbol |
72
+ | `unirate_price: from, to` | Converted **and** formatted in the target currency |
73
+
74
+ Front-matter variables work as arguments too:
75
+
76
+ ```liquid
77
+ {{ page.price | unirate_price: page.base_currency, site.currency }}
78
+ ```
79
+
80
+ ### Tags
81
+
82
+ ```liquid
83
+ {% unirate_rate USD EUR %} {%- comment %} 0.92 {% endcomment -%}
84
+ {% unirate_convert 100 USD EUR %} {%- comment %} 92.0 {% endcomment -%}
85
+ {% unirate_price 100 USD EUR %} {%- comment %} €92.00 {% endcomment -%}
86
+ ```
87
+
88
+ Tag arguments may be literals (quoted or bare) or Liquid variables resolved
89
+ against the current context:
90
+
91
+ ```liquid
92
+ {% unirate_price item.price USD page.currency %}
93
+ ```
94
+
95
+ ### Raw rate data
96
+
97
+ The full snapshot is also published to `site.data["unirate"]` so you can iterate
98
+ it directly:
99
+
100
+ ```liquid
101
+ Rates as of build (base {{ site.data.unirate.base }}):
102
+ {% for pair in site.data.unirate.rates %}
103
+ 1 {{ site.data.unirate.base }} = {{ pair[1] }} {{ pair[0] }}
104
+ {% endfor %}
105
+ ```
106
+
107
+ `site.data.unirate.updated` is `true` when rates loaded successfully and `false`
108
+ when the fetch was skipped or failed.
109
+
110
+ ## Formatting
111
+
112
+ `unirate_money` / `unirate_price` use a lightweight built-in formatter (symbol
113
+ table + `#,##0.00` grouping, with zero-decimal currencies like JPY handled).
114
+ It is intentionally dependency-free and not a substitute for full locale-aware
115
+ i18n — for anything richer, use `unirate_convert` to get the number and format
116
+ it yourself.
117
+
118
+ ## Graceful degradation
119
+
120
+ This plugin is built to **never break your build**:
121
+
122
+ - No API key → fetch skipped, amounts render unconverted, warning logged.
123
+ - API error / network failure → warning logged, build continues, helpers fall
124
+ back to the input amount.
125
+
126
+ Mapped API responses: `401` (bad/missing key), `403` (Pro-gated endpoint),
127
+ `404` (unknown currency), `429` (rate limit), plus network and malformed-response
128
+ errors.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ bundle install
134
+ bundle exec rspec # WebMock-based mock tests
135
+ bundle exec rubocop
136
+ ```
137
+
138
+ <!-- unirate-ecosystem-footer:start -->
139
+ ## Other UniRate clients
140
+
141
+ UniRate ships official client libraries and framework integrations across the
142
+ ecosystem. The repos below are all maintained under the
143
+ [UniRate-API](https://github.com/UniRate-API) org.
144
+
145
+ - **Languages:** [Python](https://github.com/UniRate-API/unirate-api-python) · [Node.js / TypeScript](https://github.com/UniRate-API/unirate-api-nodejs) · [Go](https://github.com/UniRate-API/unirate-api-go) · [Rust](https://github.com/UniRate-API/unirate-api-rust) · [Java](https://github.com/UniRate-API/unirate-api-java) · [Ruby](https://github.com/UniRate-API/unirate-api-ruby) · [PHP](https://github.com/UniRate-API/unirate-api-php) · [.NET](https://github.com/UniRate-API/unirate-api-dotnet) · [Swift](https://github.com/UniRate-API/unirate-api-swift)
146
+ - **Web frameworks:** [NestJS](https://github.com/UniRate-API/nestjs-unirate) · [Next.js](https://github.com/UniRate-API/next-unirate) · [Nuxt](https://github.com/UniRate-API/nuxt-unirate) · [SvelteKit](https://github.com/UniRate-API/sveltekit-unirate) · [Django / Wagtail](https://github.com/UniRate-API/wagtail-unirate) · [FastAPI](https://github.com/UniRate-API/fastapi-unirate) · [Flask](https://github.com/UniRate-API/flask-unirate) · [React](https://github.com/UniRate-API/react-unirate) · [Vue](https://github.com/UniRate-API/vue-unirate) · [tRPC](https://github.com/UniRate-API/trpc-unirate)
147
+ - **Static-site generators:** [Astro](https://github.com/UniRate-API/astro-unirate) · [Eleventy](https://github.com/UniRate-API/eleventy-unirate) · [Hugo](https://github.com/UniRate-API/hugo-unirate) · [Jekyll](https://github.com/UniRate-API/jekyll-unirate)
148
+ - **Data / orchestration:** [Airflow](https://github.com/UniRate-API/airflow-provider-unirate) · [dbt](https://github.com/UniRate-API/dbt-unirate) · [LangChain](https://github.com/UniRate-API/langchain-unirate)
149
+ - **Workflow / no-code:** [n8n](https://github.com/UniRate-API/n8n-nodes-unirate) · [Google Sheets](https://github.com/UniRate-API/unirate-sheets) · [MCP server](https://github.com/UniRate-API/unirate-mcp)
150
+ - **Editors / tools:** [VS Code](https://github.com/UniRate-API/vscode-unirate) · [Obsidian](https://github.com/UniRate-API/obsidian-currency)
151
+ - **Specialty bridges:** [NodaMoney (.NET)](https://github.com/UniRate-API/UniRateApi.NodaMoney) · [money gem (Ruby)](https://github.com/UniRate-API/money-unirate-api) · [Laravel Money](https://github.com/UniRate-API/laravel-money-unirate)
152
+
153
+ Get a free API key at [unirateapi.com](https://unirateapi.com).
154
+ <!-- unirate-ecosystem-footer:end -->
155
+
156
+ ## License
157
+
158
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "jekyll/unirate/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "jekyll-unirate"
9
+ spec.version = Jekyll::Unirate::VERSION
10
+ spec.authors = ["Unirate Team"]
11
+ spec.email = ["admin@unirateapi.com"]
12
+
13
+ spec.summary = "UniRate currency conversion for Jekyll — Liquid tags & filters."
14
+ spec.description = "A Jekyll plugin backed by the UniRate API " \
15
+ "(https://unirateapi.com). Fetches one exchange-rate " \
16
+ "snapshot at build time and exposes Liquid filters and " \
17
+ "tags to convert, format, and look up currency rates, " \
18
+ "with cross-rates derived on demand. Fails gracefully so " \
19
+ "an API blip never breaks the build."
20
+ spec.homepage = "https://github.com/UniRate-API/jekyll-unirate"
21
+ spec.license = "MIT"
22
+
23
+ spec.required_ruby_version = ">= 3.0"
24
+
25
+ spec.metadata = {
26
+ "homepage_uri" => spec.homepage,
27
+ "source_code_uri" => spec.homepage,
28
+ "bug_tracker_uri" => "#{spec.homepage}/issues",
29
+ "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
30
+ "documentation_uri" => "https://unirateapi.com",
31
+ "rubygems_mfa_required" => "true"
32
+ }
33
+
34
+ spec.files = Dir[
35
+ "lib/**/*.rb",
36
+ "README.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE",
39
+ "jekyll-unirate.gemspec"
40
+ ]
41
+ spec.require_paths = ["lib"]
42
+
43
+ # Jekyll is the host framework, always present in a Jekyll site. Net/HTTP and
44
+ # JSON come from the standard library, so there are no other runtime deps.
45
+ spec.add_dependency "jekyll", ">= 3.7", "< 5.0"
46
+
47
+ spec.add_development_dependency "rake", "~> 13.0"
48
+ spec.add_development_dependency "rspec", "~> 3.12"
49
+ spec.add_development_dependency "rubocop", "~> 1.60"
50
+ spec.add_development_dependency "webmock", "~> 3.19"
51
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ require_relative "version"
9
+
10
+ module Jekyll
11
+ module Unirate
12
+ # Raised for any UniRate-specific failure: a network problem, an auth
13
+ # error, a Pro-gated endpoint, or an unexpected response shape. The
14
+ # generator rescues this and logs a warning so a transient API blip never
15
+ # breaks the site build.
16
+ class Error < StandardError; end
17
+
18
+ # Minimal stdlib-only HTTP client for the UniRate API
19
+ # (https://unirateapi.com). Pulls a single-base rate snapshot from
20
+ # `GET /api/rates`; cross-rates are derived from it by {Snapshot}.
21
+ #
22
+ # Zero runtime dependencies beyond Ruby's standard library — `net/http`
23
+ # and `json`. No third-party HTTP gem, so nothing extra to audit.
24
+ class Client
25
+ DEFAULT_BASE_URL = "https://api.unirateapi.com"
26
+ DEFAULT_TIMEOUT = 30
27
+
28
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT)
29
+ raise Error, "UniRate API key is required" if api_key.nil? || api_key.to_s.empty?
30
+
31
+ @api_key = api_key
32
+ @base_url = base_url
33
+ @timeout = timeout
34
+ end
35
+
36
+ # Fetch a single-base snapshot. Returns a {Snapshot} whose `rates` map is
37
+ # { "EUR" => BigDecimal, ... } expressed against +base+.
38
+ def fetch_snapshot(base)
39
+ base = base.to_s.upcase
40
+ body = http_get("/api/rates", "from" => base)
41
+ rates = body["rates"]
42
+ raise Error, 'Unexpected UniRate response: missing "rates"' unless rates.is_a?(Hash)
43
+
44
+ parsed = rates.each_with_object({}) do |(code, value), out|
45
+ out[code.to_s.upcase] = BigDecimal(value.to_s)
46
+ end
47
+ Snapshot.new(base: base, rates: parsed)
48
+ end
49
+
50
+ private
51
+
52
+ def http_get(path, params)
53
+ uri = URI.parse(@base_url)
54
+ uri.path = path
55
+ uri.query = URI.encode_www_form(params.merge("api_key" => @api_key))
56
+
57
+ req = Net::HTTP::Get.new(uri)
58
+ req["Accept"] = "application/json"
59
+ req["User-Agent"] = "jekyll-unirate/#{VERSION}"
60
+
61
+ parse(perform(uri, req))
62
+ end
63
+
64
+ def perform(uri, req)
65
+ Net::HTTP.start(uri.hostname, uri.port,
66
+ use_ssl: uri.scheme == "https",
67
+ open_timeout: @timeout,
68
+ read_timeout: @timeout) { |http| http.request(req) }
69
+ rescue StandardError => e
70
+ raise Error, "Network error talking to UniRate: #{e.message}"
71
+ end
72
+
73
+ def parse(response)
74
+ status = response.code.to_i
75
+ case status
76
+ when 200..299
77
+ JSON.parse(response.body.to_s)
78
+ when 401 then raise Error, "Missing or invalid UniRate API key (HTTP 401)"
79
+ when 403 then raise Error, "This UniRate endpoint requires a Pro subscription (HTTP 403)"
80
+ when 404 then raise Error, "Currency not found or no data available (HTTP 404)"
81
+ when 429 then raise Error, "UniRate rate limit exceeded (HTTP 429)"
82
+ else
83
+ raise Error, "UniRate API error (HTTP #{status}): #{response.body}"
84
+ end
85
+ rescue JSON::ParserError => e
86
+ raise Error, "Failed to parse UniRate response: #{e.message}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Unirate
5
+ # Resolves plugin configuration from `_config.yml` (the `unirate:` key)
6
+ # layered over environment variables and sensible defaults.
7
+ #
8
+ # unirate:
9
+ # base: USD # currency the snapshot is fetched against
10
+ # default_currency: USD # display currency for one-arg helpers
11
+ # base_url: https://api.unirateapi.com
12
+ # timeout: 30
13
+ # # api_key: prefer the UNIRATE_API_KEY env var over committing a key
14
+ #
15
+ # The API key resolves from (in order): the UNIRATE_API_KEY env var, then
16
+ # `unirate.api_key` in _config.yml. Keeping it in the environment avoids
17
+ # checking a secret into the site repo.
18
+ class Config
19
+ DEFAULTS = {
20
+ "base" => "USD",
21
+ "default_currency" => "USD",
22
+ "base_url" => Client::DEFAULT_BASE_URL,
23
+ "timeout" => Client::DEFAULT_TIMEOUT
24
+ }.freeze
25
+
26
+ def initialize(site_config = {})
27
+ raw = site_config && site_config["unirate"]
28
+ @settings = DEFAULTS.merge(stringify(raw))
29
+ end
30
+
31
+ def api_key
32
+ env = ENV.fetch("UNIRATE_API_KEY", nil)
33
+ return env unless env.nil? || env.empty?
34
+
35
+ @settings["api_key"]
36
+ end
37
+
38
+ def base = @settings["base"].to_s.upcase
39
+ def default_currency = @settings["default_currency"].to_s.upcase
40
+ def base_url = @settings["base_url"]
41
+ def timeout = @settings["timeout"]
42
+
43
+ private
44
+
45
+ def stringify(hash)
46
+ return {} unless hash.is_a?(Hash)
47
+
48
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ require_relative "snapshot"
6
+ require_relative "formatter"
7
+
8
+ module Jekyll
9
+ module Unirate
10
+ # Liquid filters for use in any Jekyll template. All are prefixed
11
+ # `unirate_` to avoid clashing with site- or theme-defined filters.
12
+ #
13
+ # {{ 100 | unirate_convert: "USD", "EUR" }} => 92.0
14
+ # {{ "USD" | unirate_rate: "EUR" }} => 0.92
15
+ # {{ 92.5 | unirate_money: "EUR" }} => "€92.50"
16
+ # {{ 100 | unirate_price: "USD", "EUR" }} => "€92.00"
17
+ #
18
+ # Every filter degrades gracefully when no snapshot is available or a pair
19
+ # is missing: `unirate_convert`/`unirate_price` fall back to the input
20
+ # amount (so a price still renders), `unirate_rate` returns nil. This keeps
21
+ # a transient API problem from breaking the rendered page.
22
+ module Filters
23
+ # Numeric exchange rate from +from+ to +to+ (Float), or nil if unknown.
24
+ def unirate_rate(from, to)
25
+ snapshot = Snapshot.current
26
+ return nil if snapshot.nil?
27
+
28
+ rate = snapshot.rate(from, to)
29
+ rate&.to_f
30
+ end
31
+
32
+ # Convert +amount+ from +from+ to +to+ (Float). Falls back to the input
33
+ # amount unchanged when the rate is unavailable.
34
+ def unirate_convert(amount, from, to)
35
+ snapshot = Snapshot.current
36
+ return to_number(amount) if snapshot.nil?
37
+
38
+ converted = snapshot.convert(amount, from, to)
39
+ converted ? converted.to_f : to_number(amount)
40
+ end
41
+
42
+ # Format a bare numeric +amount+ in +currency+ (e.g. "€92.50").
43
+ def unirate_money(amount, currency)
44
+ Formatter.format(amount, currency)
45
+ end
46
+
47
+ # Convert +amount+ from +from+ to +to+ and format it in +to+. Falls back
48
+ # to formatting the input amount in +to+ when no rate is available.
49
+ def unirate_price(amount, from, to)
50
+ Formatter.format(unirate_convert(amount, from, to), to)
51
+ end
52
+
53
+ private
54
+
55
+ def to_number(amount)
56
+ Float(amount)
57
+ rescue ArgumentError, TypeError
58
+ amount
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ Liquid::Template.register_filter(Jekyll::Unirate::Filters)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Jekyll
6
+ module Unirate
7
+ # Lightweight currency formatter. Ruby's stdlib has no locale-aware money
8
+ # formatting and we deliberately avoid a heavy dependency (e.g. the `money`
9
+ # gem) for a static-site helper, so this is a pragmatic symbol table plus
10
+ # fixed grouping: `#,##0.00` with the symbol prefixed (or the ISO code when
11
+ # the symbol is unknown). Good enough for prices on a static page; not a
12
+ # substitute for full i18n.
13
+ module Formatter
14
+ SYMBOLS = {
15
+ "USD" => "$", "EUR" => "€", "GBP" => "£", "JPY" => "¥",
16
+ "CNY" => "¥", "AUD" => "A$", "CAD" => "C$", "CHF" => "CHF ",
17
+ "HKD" => "HK$", "NZD" => "NZ$", "SEK" => "kr ", "KRW" => "₩",
18
+ "SGD" => "S$", "NOK" => "kr ", "MXN" => "MX$", "INR" => "₹",
19
+ "RUB" => "₽", "ZAR" => "R", "TRY" => "₺", "BRL" => "R$",
20
+ "TWD" => "NT$", "DKK" => "kr ", "PLN" => "zł ",
21
+ "THB" => "฿", "IDR" => "Rp", "HUF" => "Ft ", "CZK" => "Kč ",
22
+ "ILS" => "₪", "PHP" => "₱", "AED" => "د.إ ",
23
+ "SAR" => "ر.س ", "MYR" => "RM", "RON" => "lei ",
24
+ "NGN" => "₦", "BTC" => "₿"
25
+ }.freeze
26
+
27
+ # Currencies conventionally shown without decimal places.
28
+ ZERO_DECIMAL = %w[JPY KRW HUF IDR CLP VND ISK].freeze
29
+
30
+ module_function
31
+
32
+ # Format +amount+ in +currency+. Returns a String such as "€1,234.50".
33
+ def format(amount, currency, decimals: nil)
34
+ currency = currency.to_s.upcase
35
+ decimals ||= ZERO_DECIMAL.include?(currency) ? 0 : 2
36
+ rounded = BigDecimal(amount.to_s).round(decimals)
37
+ sign = rounded.negative? ? "-" : ""
38
+ number = group(rounded.abs, decimals)
39
+ symbol = SYMBOLS[currency]
40
+ # Sign goes outside the symbol ("-$1,234.50"), the conventional form.
41
+ symbol ? "#{sign}#{symbol}#{number}" : "#{sign}#{number} #{currency}"
42
+ end
43
+
44
+ # Group a non-negative decimal with thousands separators and fixed
45
+ # decimals. Calls Kernel.format explicitly — the bareword `format` would
46
+ # resolve to this module's own currency-formatting method.
47
+ def group(value, decimals)
48
+ whole, frac = Kernel.format("%.#{decimals}f", value).split(".")
49
+ whole = whole.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
50
+ frac ? "#{whole}.#{frac}" : whole
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ require_relative "client"
6
+ require_relative "snapshot"
7
+ require_relative "config"
8
+
9
+ module Jekyll
10
+ module Unirate
11
+ # Build-time generator that fetches ONE single-base UniRate snapshot and
12
+ # makes it available to the rest of the build. Runs early (high priority)
13
+ # so the snapshot is ready before any page renders.
14
+ #
15
+ # It does two things:
16
+ # 1. sets {Snapshot.current}, which the Liquid filters and tags read; and
17
+ # 2. populates `site.data["unirate"]` so templates can iterate the raw
18
+ # rate map directly (`{% for pair in site.data.unirate.rates %}`).
19
+ #
20
+ # A failed fetch is logged as a warning and never raised — the build
21
+ # continues and helpers degrade to unconverted amounts. This is the same
22
+ # "never break the build" contract the Hugo/Eleventy/Astro integrations use.
23
+ class Generator < Jekyll::Generator
24
+ priority :high
25
+
26
+ def generate(site)
27
+ config = Config.new(site.config)
28
+ Jekyll::Unirate.default_currency = config.default_currency
29
+
30
+ key = config.api_key
31
+ if key.nil? || key.to_s.empty?
32
+ Jekyll.logger.warn("UniRate:",
33
+ "no API key (set UNIRATE_API_KEY or unirate.api_key in _config.yml); " \
34
+ "skipping rate fetch")
35
+ return store_empty(site, config.base)
36
+ end
37
+
38
+ load_rates(site, config)
39
+ end
40
+
41
+ private
42
+
43
+ def load_rates(site, config)
44
+ client = Client.new(api_key: config.api_key, base_url: config.base_url, timeout: config.timeout)
45
+ snapshot = client.fetch_snapshot(config.base)
46
+ Snapshot.current = snapshot
47
+ site.data["unirate"] = {
48
+ "base" => snapshot.base,
49
+ "rates" => snapshot.rates.transform_values(&:to_f),
50
+ "updated" => true
51
+ }
52
+ Jekyll.logger.info("UniRate:", "loaded #{snapshot.rates.size} rates (base #{snapshot.base})")
53
+ rescue Error => e
54
+ Jekyll.logger.warn("UniRate:",
55
+ "rate fetch failed (#{e.message}); pages will render with unconverted amounts")
56
+ store_empty(site, config.base)
57
+ end
58
+
59
+ def store_empty(site, base)
60
+ site.data["unirate"] = { "base" => base, "rates" => {}, "updated" => false }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Jekyll
6
+ module Unirate
7
+ # An immutable single-base rate snapshot. One HTTP call to UniRate fetches
8
+ # base->code rates; every cross-rate is derived from this snapshot on
9
+ # demand (`rate(F,T) = base->T / base->F`), so the whole build shares one
10
+ # frozen view of the market and no per-pair caching can go stale.
11
+ class Snapshot
12
+ attr_reader :base, :rates
13
+
14
+ # Process-wide current snapshot, set by {Generator} at build time and
15
+ # read by the Liquid filters and tags. nil until the generator runs (or
16
+ # if the fetch failed) — callers must degrade gracefully when it is.
17
+ class << self
18
+ attr_accessor :current
19
+ end
20
+
21
+ # @param base [String] base currency the rates are expressed against
22
+ # @param rates [Hash{String=>BigDecimal}] base->code rate map
23
+ def initialize(base:, rates:)
24
+ @base = base.to_s.upcase
25
+ @rates = rates.freeze
26
+ freeze
27
+ end
28
+
29
+ # All currency codes available in this snapshot (including the base),
30
+ # sorted — handy for `{% for c in ... %}` style template loops.
31
+ def currencies
32
+ ([@base] + @rates.keys).uniq.sort
33
+ end
34
+
35
+ # Cross-rate from +from+ to +to+ as a BigDecimal, or nil if either
36
+ # currency is absent from the snapshot.
37
+ def rate(from, to)
38
+ from = from.to_s.upcase
39
+ to = to.to_s.upcase
40
+ return BigDecimal(1) if from == to
41
+
42
+ from_rate = base_rate(from)
43
+ to_rate = base_rate(to)
44
+ return nil if from_rate.nil? || to_rate.nil?
45
+
46
+ to_rate / from_rate
47
+ end
48
+
49
+ # Convert +amount+ from +from+ to +to+ as a BigDecimal, or nil if no
50
+ # rate is available for the pair.
51
+ def convert(amount, from, to)
52
+ r = rate(from, to)
53
+ return nil if r.nil?
54
+
55
+ BigDecimal(amount.to_s) * r
56
+ end
57
+
58
+ private
59
+
60
+ # base->iso rate (1 for the base itself); nil if not present.
61
+ def base_rate(iso)
62
+ return BigDecimal(1) if iso == @base
63
+
64
+ @rates[iso]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "liquid"
4
+
5
+ require_relative "snapshot"
6
+ require_relative "formatter"
7
+
8
+ module Jekyll
9
+ module Unirate
10
+ # Shared parsing/resolution for the UniRate Liquid tags. Each tag takes
11
+ # space-separated arguments that may be string/number literals OR Liquid
12
+ # variables resolved against the render context:
13
+ #
14
+ # {% unirate_rate USD EUR %}
15
+ # {% unirate_convert 100 USD EUR %}
16
+ # {% unirate_price item.price USD EUR %}
17
+ # {% unirate_price 100 USD page.currency %}
18
+ class BaseTag < Liquid::Tag
19
+ def initialize(tag_name, markup, tokens)
20
+ super
21
+ @args = markup.strip.split(/\s+/)
22
+ end
23
+
24
+ private
25
+
26
+ # Resolve a single argument: a quoted/bare literal, or a context lookup.
27
+ def resolve(arg, context)
28
+ return nil if arg.nil?
29
+
30
+ if arg =~ /\A["'](.*)["']\z/
31
+ ::Regexp.last_match(1)
32
+ else
33
+ looked_up = context[arg]
34
+ looked_up.nil? ? arg : looked_up
35
+ end
36
+ end
37
+
38
+ def snapshot
39
+ Snapshot.current
40
+ end
41
+ end
42
+
43
+ # `{% unirate_rate FROM TO %}` -> numeric rate, or empty string if unknown.
44
+ class RateTag < BaseTag
45
+ def render(context)
46
+ from = resolve(@args[0], context)
47
+ to = resolve(@args[1], context)
48
+ return "" if snapshot.nil?
49
+
50
+ rate = snapshot.rate(from, to)
51
+ rate ? rate.to_f.to_s : ""
52
+ end
53
+ end
54
+
55
+ # `{% unirate_convert AMOUNT FROM TO %}` -> converted number (falls back to
56
+ # the input amount when no rate is available).
57
+ class ConvertTag < BaseTag
58
+ def render(context)
59
+ amount = resolve(@args[0], context)
60
+ from = resolve(@args[1], context)
61
+ to = resolve(@args[2], context)
62
+ return amount.to_s if snapshot.nil?
63
+
64
+ converted = snapshot.convert(amount, from, to)
65
+ (converted ? converted.to_f : amount).to_s
66
+ end
67
+ end
68
+
69
+ # `{% unirate_price AMOUNT FROM TO %}` -> converted amount formatted in TO.
70
+ class PriceTag < BaseTag
71
+ def render(context)
72
+ amount = resolve(@args[0], context)
73
+ from = resolve(@args[1], context)
74
+ to = resolve(@args[2], context)
75
+ value = snapshot ? (snapshot.convert(amount, from, to) || amount) : amount
76
+ Formatter.format(value, to)
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ Liquid::Template.register_tag("unirate_rate", Jekyll::Unirate::RateTag)
83
+ Liquid::Template.register_tag("unirate_convert", Jekyll::Unirate::ConvertTag)
84
+ Liquid::Template.register_tag("unirate_price", Jekyll::Unirate::PriceTag)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Unirate
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "unirate/version"
4
+
5
+ module Jekyll
6
+ # UniRate currency integration for Jekyll. See {Generator}, {Filters} and the
7
+ # tag classes for the build-time and template-side entry points.
8
+ module Unirate
9
+ class << self
10
+ # Display currency used by one-argument helpers; set by {Generator} from
11
+ # `unirate.default_currency` in _config.yml. Defaults to "USD".
12
+ attr_writer :default_currency
13
+
14
+ def default_currency
15
+ @default_currency || "USD"
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ require_relative "unirate/client"
22
+ require_relative "unirate/snapshot"
23
+ require_relative "unirate/formatter"
24
+ require_relative "unirate/config"
25
+ require_relative "unirate/filters"
26
+ require_relative "unirate/tags"
27
+ require_relative "unirate/generator"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience entry point matching the gem name, so `require "jekyll-unirate"`
4
+ # (and Jekyll's auto-require of `jekyll_plugins` Gemfile gems) loads everything.
5
+ require_relative "jekyll/unirate"
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-unirate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Unirate Team
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jekyll
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.7'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '3.7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rake
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.12'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.12'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rubocop
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.60'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.60'
75
+ - !ruby/object:Gem::Dependency
76
+ name: webmock
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.19'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.19'
89
+ description: A Jekyll plugin backed by the UniRate API (https://unirateapi.com). Fetches
90
+ one exchange-rate snapshot at build time and exposes Liquid filters and tags to
91
+ convert, format, and look up currency rates, with cross-rates derived on demand.
92
+ Fails gracefully so an API blip never breaks the build.
93
+ email:
94
+ - admin@unirateapi.com
95
+ executables: []
96
+ extensions: []
97
+ extra_rdoc_files: []
98
+ files:
99
+ - CHANGELOG.md
100
+ - LICENSE
101
+ - README.md
102
+ - jekyll-unirate.gemspec
103
+ - lib/jekyll-unirate.rb
104
+ - lib/jekyll/unirate.rb
105
+ - lib/jekyll/unirate/client.rb
106
+ - lib/jekyll/unirate/config.rb
107
+ - lib/jekyll/unirate/filters.rb
108
+ - lib/jekyll/unirate/formatter.rb
109
+ - lib/jekyll/unirate/generator.rb
110
+ - lib/jekyll/unirate/snapshot.rb
111
+ - lib/jekyll/unirate/tags.rb
112
+ - lib/jekyll/unirate/version.rb
113
+ homepage: https://github.com/UniRate-API/jekyll-unirate
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://github.com/UniRate-API/jekyll-unirate
118
+ source_code_uri: https://github.com/UniRate-API/jekyll-unirate
119
+ bug_tracker_uri: https://github.com/UniRate-API/jekyll-unirate/issues
120
+ changelog_uri: https://github.com/UniRate-API/jekyll-unirate/blob/main/CHANGELOG.md
121
+ documentation_uri: https://unirateapi.com
122
+ rubygems_mfa_required: 'true'
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '3.0'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 3.5.22
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: UniRate currency conversion for Jekyll — Liquid tags & filters.
142
+ test_files: []