env_style 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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +304 -0
- data/app/controllers/env_style/application_controller.rb +9 -0
- data/app/controllers/env_style/favicons_controller.rb +43 -0
- data/app/helpers/env_style/favicon_helper.rb +20 -0
- data/config/routes.rb +6 -0
- data/lib/env_style/configuration.rb +68 -0
- data/lib/env_style/engine.rb +16 -0
- data/lib/env_style/environment.rb +51 -0
- data/lib/env_style/favicon.rb +119 -0
- data/lib/env_style/raster_tinter.rb +80 -0
- data/lib/env_style/source.rb +182 -0
- data/lib/env_style/svg_tinter.rb +156 -0
- data/lib/env_style/version.rb +5 -0
- data/lib/env_style.rb +54 -0
- metadata +129 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f87c2b9a5857a93ec7c9d6613a4611a8008a9cdb3d2c9aee4ef4b3eaa4a18155
|
|
4
|
+
data.tar.gz: 6b016e7ba90eba8ee50af1cdbb2288aef0bbaca5a12333468e1ccde9397af496
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: adc331a51d5f5d17379d0b00c413189b7e884f2a32be3db7beb66bd2a861fca3057d2d42995ee58b44673ce8068811a45c0256b07b279d42a12a4de03ef28934
|
|
7
|
+
data.tar.gz: 6de84f78750a3436f57c6c451a927e872efd38f341a0cc44d0c81a29894cc339006eec937970df5ceced67d6d337edf01299c23260aeb211f59849bcdf00048f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial release.
|
|
12
|
+
- Mountable Rails engine that tints an application's favicon a distinct color per environment at runtime, leaving production untouched.
|
|
13
|
+
- Environment resolution mirroring env.style: `config.environment` → `ENV["ENV_STYLE_ENV"]` → `Rails.env`, with per-environment colors and a `default_color` for unknown non-production environments.
|
|
14
|
+
- Source resolution with zero-config auto-discovery (`public/icon.svg` → `public/icon.png` → `public/favicon.svg` → `public/favicon.ico`, SVG preferred), an explicit `config.source` override (contained within `Rails.root`), and per-environment source hashes. The served `Content-Type` reflects the source's true media type, sniffed from its magic bytes.
|
|
15
|
+
- SVG tinting with no image library: a `fill`/`stroke`/`currentColor` swap for monochrome marks and a luminance-preserving `<feColorMatrix>` duotone for multicolor marks. Color spellings are canonicalized (3-digit hex, common CSS names) when counting distinctness, so a mark written `#000`/`black`/`#000000` is treated as monochrome.
|
|
16
|
+
- Raster tinting with libvips (`ruby-vips`): a luminance-preserving recolor with alpha preserved and `exclude_colors`, plus `exclude_tolerance` to also preserve the anti-aliased halo of near-matching pixels around an excluded region.
|
|
17
|
+
- Opt-in `rasterize_svg` to render an SVG source to a tinted PNG at a configurable `rasterize_size` (default `32` px), staying crisp on hi-dpi tabs.
|
|
18
|
+
- `env_style_favicon_tags` view helper that emits the favicon `<link>` for the resolved source, working identically in Hotwire and Inertia apps.
|
|
19
|
+
- On-demand, deterministic tinting keyed on `(source digest, environment, color, format, exclude_colors, exclude_tolerance, rasterize_size)`, served with a strong ETag and `Cache-Control: no-cache, must-revalidate`, so browsers cache the icon and revalidate with a `304 Not Modified` before any image work.
|
|
20
|
+
- Graceful degradation: if tinting ever fails, the favicon route serves the untouched source (non-cacheably) instead of raising, matching the never-break-the-page contract.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MilkStraw AI
|
|
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,304 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/logo.png" alt="EnvStyle logo" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">EnvStyle</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://badge.fury.io/rb/env_style"><img src="https://badge.fury.io/rb/env_style.svg" alt="Gem Version"></a>
|
|
9
|
+
<a href="https://github.com/milkstrawai/env_style/actions/workflows/ci.yml"><img src="https://github.com/milkstrawai/env_style/actions/workflows/ci.yml/badge.svg" alt="Build Status"></a>
|
|
10
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/Ruby-%3E%3D%203.2-CC342D.svg" alt="Ruby >= 3.2">
|
|
12
|
+
<img src="https://img.shields.io/badge/Rails-%3E%3D%207.2-D30001.svg" alt="Rails >= 7.2">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<strong>A distinct favicon color per environment, so you never deploy to the wrong tab.</strong>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
EnvStyle is a mountable Rails engine that tints your app's favicon a different color in development, staging, and preview — while leaving **production untouched**. It's the Rails equivalent of [env.style][env-style], but it runs at **request time** instead of build time, and it sits below your front-end framework, so it works identically in Hotwire and Inertia apps and depends on neither.
|
|
20
|
+
|
|
21
|
+
## Table of Contents
|
|
22
|
+
|
|
23
|
+
- [The Problem](#the-problem)
|
|
24
|
+
- [The Solution](#the-solution)
|
|
25
|
+
- [Requirements](#requirements)
|
|
26
|
+
- [Installation](#installation)
|
|
27
|
+
- [Quick Start](#quick-start)
|
|
28
|
+
- [Usage](#usage)
|
|
29
|
+
- [Configuration](#configuration)
|
|
30
|
+
- [How It Works](#how-it-works)
|
|
31
|
+
- [Operational Notes](#operational-notes)
|
|
32
|
+
- [Troubleshooting](#troubleshooting)
|
|
33
|
+
- [Development](#development)
|
|
34
|
+
- [Contributing](#contributing)
|
|
35
|
+
- [License](#license)
|
|
36
|
+
|
|
37
|
+
## The Problem
|
|
38
|
+
|
|
39
|
+
You have five tabs open: production, staging, a preview branch, and two local servers. They all render the same favicon. You run a destructive rake task, submit a form, or click "Delete" — in the wrong tab. Every tab looks the same, so the only defense is reading the URL every single time.
|
|
40
|
+
|
|
41
|
+
[env.style][env-style] solves this beautifully for build-time front-ends, but it has two gaps for a Rails app:
|
|
42
|
+
|
|
43
|
+
- It restyles at **build time**, so a build promoted from staging to production keeps its staging tint (see [rails/rails#44211][rails-44211] for the friction this causes).
|
|
44
|
+
- It's coupled to a JS build pipeline, which doesn't map cleanly onto a server-rendered Rails app that may or may not use Hotwire, Inertia, or a bundler at all.
|
|
45
|
+
|
|
46
|
+
## The Solution
|
|
47
|
+
|
|
48
|
+
EnvStyle serves the favicon from a route it owns and tints it **when the request comes in**, based on the environment resolved at that moment. A build promoted to production re-resolves and serves the untouched icon — no rebuild, no stale tint. Because the whole mechanism is a server route plus one `<link>` tag in your layout `<head>`, it is completely framework agnostic: no JavaScript, no coupling to Turbo, Stimulus, Inertia, or Vue.
|
|
49
|
+
|
|
50
|
+
- **Runtime, not build time.** The environment is resolved per request.
|
|
51
|
+
- **Production untouched.** Production is never tinted and does zero image work.
|
|
52
|
+
- **Framework agnostic.** A route and a `<link>` tag — that's the entire integration surface.
|
|
53
|
+
- **Zero-config.** With no initializer, it auto-discovers your icon and uses a built-in color map. Adding the helper is the only required step.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- Ruby >= 3.2
|
|
58
|
+
- Rails >= 7.2 (`railties`, `actionpack`, `activesupport`)
|
|
59
|
+
- [libvips][libvips] — only for tinting **raster** icons (PNG/ICO). SVG icons need no system dependency. libvips already ships in the default Rails 8 Dockerfile (it is Active Storage's default variant processor), so most modern apps already have it.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
Add the gem to your `Gemfile`:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
gem "env_style"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Install it:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
bundle install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If you serve a **raster** favicon (PNG/ICO), install libvips (skip this for SVG-only apps):
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
# macOS
|
|
79
|
+
brew install vips
|
|
80
|
+
|
|
81
|
+
# Debian / Ubuntu
|
|
82
|
+
apt-get install -y libvips
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Mount the engine in `config/routes.rb`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Rails.application.routes.draw do
|
|
89
|
+
mount EnvStyle::Engine => "/env_style"
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Quick Start
|
|
94
|
+
|
|
95
|
+
Three snippets and you're done. First, the Gemfile (above). Then mount the engine (above). Then add the helper to your layout `<head>` — the **same line** whether you use Hotwire or Inertia:
|
|
96
|
+
|
|
97
|
+
```erb
|
|
98
|
+
<%# app/views/layouts/application.html.erb %>
|
|
99
|
+
<head>
|
|
100
|
+
<%= env_style_favicon_tags %>
|
|
101
|
+
</head>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
That's it. With no initializer, EnvStyle auto-discovers `public/icon.svg` (or `public/icon.png`, `public/favicon.svg`, `public/favicon.ico`), tints it blue in development and amber in staging/preview, and leaves production untouched.
|
|
105
|
+
|
|
106
|
+
## Usage
|
|
107
|
+
|
|
108
|
+
### Environment resolution
|
|
109
|
+
|
|
110
|
+
The environment name is resolved in this order (mirroring env.style):
|
|
111
|
+
|
|
112
|
+
1. `config.environment` (explicit override)
|
|
113
|
+
2. `ENV["ENV_STYLE_ENV"]`
|
|
114
|
+
3. `Rails.env`
|
|
115
|
+
|
|
116
|
+
The resolved name is mapped to a color via `config.colors`, falling back to `default_color` for any non-production environment that isn't listed. **`production` is never tinted**, and a `nil` `default_color` leaves unknown environments untouched too.
|
|
117
|
+
|
|
118
|
+
### Colors
|
|
119
|
+
|
|
120
|
+
The built-in map covers the common environments:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
{
|
|
124
|
+
"development" => "#3b82f6", # blue
|
|
125
|
+
"staging" => "#f59e0b", # amber
|
|
126
|
+
"preview" => "#f59e0b" # amber
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Override or extend it in the initializer (see [Configuration](#configuration)). Any CSS color string works for SVG sources; raster sources require a hex color.
|
|
131
|
+
|
|
132
|
+
### Source resolution
|
|
133
|
+
|
|
134
|
+
You never need the icon's public URL — only its bytes on disk — so its location and asset pipeline are irrelevant. Resolution has two tiers:
|
|
135
|
+
|
|
136
|
+
**Auto-discovery (default).** The first existing path wins, preferring SVG:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
public/icon.svg → public/icon.png → public/favicon.svg → public/favicon.ico
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Explicit `config.source` (override).** A `Rails.root`-relative path (string or `Pathname`) always wins, for any non-conventional name or location:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
c.source = Rails.root.join("app/frontend/images/logo.svg")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The path must resolve **within `Rails.root`** — absolute paths under the root are fine, but a path that escapes it (via `..` or an unrelated absolute path) is ignored, so deriving `source` from untrusted input can't be used to read arbitrary files.
|
|
149
|
+
|
|
150
|
+
### Per-environment icons
|
|
151
|
+
|
|
152
|
+
Pass a hash to use a different icon per environment (like env.style). Environments absent from the hash fall back to auto-discovery:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
c.source = {
|
|
156
|
+
"development" => "public/icon-dev.svg",
|
|
157
|
+
"staging" => "public/icon-staging.png"
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Raster tinting and `exclude_colors`
|
|
162
|
+
|
|
163
|
+
Raster icons are recolored with a luminance-preserving tint (shading is kept; the hue shifts to the environment color), with **alpha preserved**. Colors listed in `exclude_colors` are left exactly as they are — handy for keeping a white background white:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
c.exclude_colors = ["#ffffff"]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
An exact-match exclusion can leave a faint tinted halo around the mark, because the anti-aliased edge pixels next to (say) a white background aren't *exactly* white and so still get tinted. Widen the match with `exclude_tolerance` — the per-channel sRGB distance (0–255) a pixel may sit from an excluded color and still be preserved. It defaults to `0` (exact match only); a small value such as `8` also keeps the near-matching halo:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
c.exclude_colors = ["#ffffff"]
|
|
173
|
+
c.exclude_tolerance = 8
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### SVG rasterization (opt-in)
|
|
177
|
+
|
|
178
|
+
By default, SVG sources stay SVG (crisp, scalable, no system dependency). If you specifically want a tinted **PNG** rendered from an SVG source, opt in:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
c.rasterize_svg = true
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The PNG is rendered at `rasterize_size` pixels per edge (default `32`) rather than the SVG's intrinsic size, so a small `viewBox` still yields a crisp favicon on hi-dpi tabs. Bump it for sharper output:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
c.rasterize_size = 64
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
This path uses libvips' SVG loader, which requires a libvips built with librsvg.
|
|
191
|
+
|
|
192
|
+
### Disabling
|
|
193
|
+
|
|
194
|
+
`enabled` is the master switch. When it's `false`, `env_style_favicon_tags` emits nothing, so no favicon `<link>` is added to your pages. (The mounted route still responds if requested directly, streaming the source untouched — but nothing links to it.)
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
c.enabled = !Rails.env.production?
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Configuration
|
|
201
|
+
|
|
202
|
+
An initializer is entirely optional. When you want one:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# config/initializers/env_style.rb
|
|
206
|
+
EnvStyle.configure do |c|
|
|
207
|
+
c.enabled = true # master switch
|
|
208
|
+
c.source = Rails.root.join("public/icon.svg") # optional; auto-discovered otherwise
|
|
209
|
+
c.colors = { "development" => "#3b82f6",
|
|
210
|
+
"staging" => "#f59e0b",
|
|
211
|
+
"preview" => "#f59e0b" }
|
|
212
|
+
c.default_color = "#6b7280" # unknown non-prod environments
|
|
213
|
+
c.exclude_colors = ["#ffffff"] # raster: preserve these pixels
|
|
214
|
+
c.exclude_tolerance = 0 # raster: per-channel sRGB match slack
|
|
215
|
+
c.environment = ENV["ENV_STYLE_ENV"] # optional environment override
|
|
216
|
+
c.rasterize_svg = false # opt-in: render SVG → PNG via libvips
|
|
217
|
+
c.rasterize_size = 32 # edge length (px) of the rasterized PNG
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
| Option | Default | Purpose |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `enabled` | `true` | Master switch; `false` stops the helper from emitting a `<link>` (the route still responds if hit directly). |
|
|
224
|
+
| `source` | `nil` (auto-discover) | Icon path, `Pathname`, or per-environment hash. |
|
|
225
|
+
| `colors` | built-in map | Environment name → tint color. |
|
|
226
|
+
| `default_color` | `"#6b7280"` | Tint for non-production environments not in `colors`; `nil` to skip them. |
|
|
227
|
+
| `exclude_colors` | `[]` | Raster pixels to preserve verbatim. |
|
|
228
|
+
| `exclude_tolerance` | `0` | Per-channel sRGB distance (0–255) a raster pixel may sit from an `exclude_colors` entry and still be preserved; `0` matches exactly, a small value keeps the anti-aliased halo. |
|
|
229
|
+
| `environment` | `nil` | Explicit environment override (highest precedence). |
|
|
230
|
+
| `rasterize_svg` | `false` | Render SVG sources to tinted PNG via libvips. |
|
|
231
|
+
| `rasterize_size` | `32` | Edge length in pixels of the rasterized PNG (only when `rasterize_svg` is `true`); larger renders crisper on hi-dpi tabs. |
|
|
232
|
+
| `logger` | `Rails.logger` | Where the "no source icon found" warning is written. |
|
|
233
|
+
|
|
234
|
+
## How It Works
|
|
235
|
+
|
|
236
|
+
### Rendering pipeline
|
|
237
|
+
|
|
238
|
+
`env_style_favicon_tags` renders a `<link>` that points at the engine route (`/env_style/favicon.svg` or `/env_style/favicon.png`, chosen from the resolved source). When the browser requests it, `EnvStyle::FaviconsController` resolves the environment and either tints the source or — for production and any untouched environment — streams the source bytes verbatim.
|
|
239
|
+
|
|
240
|
+
- **SVG** is tinted by rewriting markup: a monochrome mark has its `fill`/`currentColor` swapped; a multicolor mark is wrapped in a group carrying a luminance-preserving `<feColorMatrix>` duotone filter. No image library involved.
|
|
241
|
+
- **Raster** is tinted with libvips: luminance scales the tint per pixel, alpha is carried through, and `exclude_colors` pixels are masked back to their originals.
|
|
242
|
+
|
|
243
|
+
### Caching
|
|
244
|
+
|
|
245
|
+
Tinting happens on demand and is deterministic in `(source digest, environment, color, format)` — plus `exclude_colors` for raster output. Rather than keep a server-side cache coherent across workers, EnvStyle leans on HTTP: every response carries a strong **ETag** over that tuple and `Cache-Control: no-cache, must-revalidate`. Browsers and proxies cache the icon and revalidate with a conditional request, which the controller answers with a `304 Not Modified` **before** doing any image work — so a favicon is tinted at most once per client cache-miss, and a promoted deploy always re-resolves the environment instead of serving a stale-env icon.
|
|
246
|
+
|
|
247
|
+
### Framework compatibility
|
|
248
|
+
|
|
249
|
+
The mechanism sits at the Rails/document layer, below your front-end framework:
|
|
250
|
+
|
|
251
|
+
- **Hotwire.** A plain server route plus a layout `<head>` tag. Turbo never touches it.
|
|
252
|
+
- **Inertia.** The `<link>` renders in the root ERB layout on the initial full-page load and persists across client-side visits (the document is never reloaded). The `/env_style/favicon.*` request carries no `X-Inertia` header, so Inertia's middleware passes it straight through, and SSR is unaffected because the root layout `<head>` is still Rails-rendered. *Caveat:* if your app sets its favicon via Inertia's `<Head>` component, manage it in one place — a client-side favicon set there would override this one.
|
|
253
|
+
|
|
254
|
+
### Design notes / rejected alternatives
|
|
255
|
+
|
|
256
|
+
- **No-dependency, SVG-only.** Rejected as the sole strategy: Safari has historically cached and mishandled SVG favicons, and many apps ship raster icons. EnvStyle handles SVG with zero dependencies but also supports raster.
|
|
257
|
+
- **Pure-Ruby raster gems.** `chunky_png` is PNG-only; `pura-image` can't recolor. Neither covers the raster tinting requirement.
|
|
258
|
+
- **Runtime-detect-with-squircle fallback.** Rejected as non-deterministic across machines.
|
|
259
|
+
- **libvips.** Chosen as the one robust raster backend. It already ships with modern Rails (the default Rails 8 Dockerfile, Active Storage's default variant processor), and the dependency is stated upfront so it is never a surprise.
|
|
260
|
+
|
|
261
|
+
## Operational Notes
|
|
262
|
+
|
|
263
|
+
- **Production does zero image work.** Production is untouched — the controller streams the source bytes with no tinting.
|
|
264
|
+
- **Performance.** Repeat requests from a client that already has the icon short-circuit to a `304 Not Modified` before any tinting, so image work happens only on a genuine cache-miss — a few milliseconds for a small icon. There is no server-side cache to grow or invalidate.
|
|
265
|
+
- **libvips.** Required only for raster tinting. SVG-only apps never load it (`ruby-vips` is required lazily).
|
|
266
|
+
- **Source changes.** Because the ETag includes the source digest, editing the source icon produces a new validator and a freshly tinted result on the next request.
|
|
267
|
+
- **Scope.** v1 tints the favicon only — no apple-touch or PWA icon variants.
|
|
268
|
+
|
|
269
|
+
## Troubleshooting
|
|
270
|
+
|
|
271
|
+
- **`env_style: no source icon found — set EnvStyle.config.source`** — no icon was auto- discovered. Add `public/icon.svg` (or `.png`), or set `config.source`. The helper emits nothing and nothing crashes.
|
|
272
|
+
- **The favicon isn't tinted in development.** Confirm the engine is mounted, the helper is in your layout `<head>`, and the resolved environment has a color (it isn't `production`, and `default_color` isn't `nil`).
|
|
273
|
+
- **A pure-black raster (PNG/ICO) icon barely changes color.** Raster tinting is luminance-preserving, so a pixel with zero luminance stays dark for any tint. Prefer an SVG source (the default) or a non-pure-black raster for a visible tint.
|
|
274
|
+
- **`Vips::Error` / "class \"svgload\" not found" with `rasterize_svg = true`.** Your libvips was built without librsvg. Install a librsvg-enabled libvips, or leave `rasterize_svg` `false` and serve tinted SVG.
|
|
275
|
+
- **`LoadError: cannot load such file -- vips`** — install libvips (see [Installation](#installation)); it is only needed for raster icons.
|
|
276
|
+
|
|
277
|
+
## Development
|
|
278
|
+
|
|
279
|
+
```sh
|
|
280
|
+
bin/setup # install dependencies
|
|
281
|
+
bundle exec rake # run the tests and RuboCop
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Tests run against a bare Rails app under `test/dummy` that mounts the engine and includes both a Hotwire-style and an Inertia-style layout. To exercise every supported Rails version:
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
bundle exec appraisal install
|
|
288
|
+
bundle exec appraisal rake test
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
With [mise][mise] installed, `mise run test` and `mise run appraisal` wrap the above.
|
|
292
|
+
|
|
293
|
+
## Contributing
|
|
294
|
+
|
|
295
|
+
Bug reports and pull requests are welcome at <https://github.com/milkstrawai/env_style>. Please add tests for any change and keep `bundle exec rake` green.
|
|
296
|
+
|
|
297
|
+
## License
|
|
298
|
+
|
|
299
|
+
Released under the [MIT License](LICENSE.txt).
|
|
300
|
+
|
|
301
|
+
[env-style]: https://github.com/QuadDepo/env.style
|
|
302
|
+
[rails-44211]: https://github.com/rails/rails/issues/44211
|
|
303
|
+
[libvips]: https://www.libvips.org/
|
|
304
|
+
[mise]: https://mise.jdx.dev/
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Base controller for the engine. It inherits straight from
|
|
5
|
+
# ActionController::Base rather than the host's ApplicationController so the
|
|
6
|
+
# public favicon route is never gated by the host's authentication filters.
|
|
7
|
+
class ApplicationController < ActionController::Base
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Serves the (possibly tinted) favicon. Bytes are deterministic, so the
|
|
5
|
+
# response carries a strong ETag and revalidation headers — a promoted deploy
|
|
6
|
+
# re-resolves the environment instead of serving a stale-env icon.
|
|
7
|
+
class FaviconsController < ApplicationController
|
|
8
|
+
def show
|
|
9
|
+
favicon = Favicon.new(root: Rails.root)
|
|
10
|
+
return head :not_found unless favicon.available?
|
|
11
|
+
|
|
12
|
+
fresh_when(strong_etag: favicon.etag)
|
|
13
|
+
response.cache_control.replace(no_cache: true, extras: ["must-revalidate"])
|
|
14
|
+
return if performed?
|
|
15
|
+
|
|
16
|
+
send_favicon(favicon)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Tint failures (e.g. a non-hex color on a raster source) must never break
|
|
22
|
+
# the tab: fall back to the untouched source, labeled with the source's own
|
|
23
|
+
# content type so the bytes are never mislabeled.
|
|
24
|
+
def send_favicon(favicon)
|
|
25
|
+
send_data(favicon.bytes, type: favicon.content_type, disposition: "inline")
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
EnvStyle.logger.warn("env_style: favicon tinting failed (#{e.class}: #{e.message}); serving source untouched")
|
|
28
|
+
serve_untouched(favicon)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The ETag stamped by fresh_when describes the intended tint output, not
|
|
32
|
+
# these fallback bytes, so drop it and mark the response no-store. (Rack's
|
|
33
|
+
# ETag middleware may re-add a weak body-digest validator downstream; that is
|
|
34
|
+
# harmless — it differs from the tinted validator, and no-store forbids
|
|
35
|
+
# caching anyway.) This stops a client from caching the fallback under the
|
|
36
|
+
# tinted validator and then getting a 304 once tinting recovers.
|
|
37
|
+
def serve_untouched(favicon)
|
|
38
|
+
response.headers.delete("ETag")
|
|
39
|
+
response.cache_control.replace(no_store: true)
|
|
40
|
+
send_data(favicon.source_bytes, type: favicon.source_content_type, disposition: "inline")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Mixed into the host application's views so a single line in the layout head
|
|
5
|
+
# emits the right <link> for the resolved source and environment.
|
|
6
|
+
module FaviconHelper
|
|
7
|
+
# Emits the favicon <link> tag, or nothing when EnvStyle is disabled or no
|
|
8
|
+
# source icon can be found. The tag always points at the engine route; the
|
|
9
|
+
# controller decides whether to tint or stream the source untouched.
|
|
10
|
+
def env_style_favicon_tags
|
|
11
|
+
return unless EnvStyle.config.enabled
|
|
12
|
+
|
|
13
|
+
favicon = EnvStyle::Favicon.new(root: Rails.root)
|
|
14
|
+
return unless favicon.available?
|
|
15
|
+
|
|
16
|
+
href = favicon.format == EnvStyle::Favicon::PNG ? env_style.png_favicon_path : env_style.svg_favicon_path
|
|
17
|
+
tag.link(rel: "icon", type: favicon.content_type, href: href)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Built-in per-environment tint colors. Production is intentionally absent so
|
|
5
|
+
# it is never tinted; unknown non-production environments fall back to
|
|
6
|
+
# {Configuration#default_color}.
|
|
7
|
+
DEFAULT_COLORS = {
|
|
8
|
+
"development" => "#3b82f6",
|
|
9
|
+
"staging" => "#f59e0b",
|
|
10
|
+
"preview" => "#f59e0b"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
# Neutral gray used for non-production environments with no explicit color.
|
|
14
|
+
DEFAULT_COLOR = "#6b7280"
|
|
15
|
+
|
|
16
|
+
# Holds every knob EnvStyle exposes. Defaults make the gem work with no
|
|
17
|
+
# initializer at all; a host app overrides what it needs.
|
|
18
|
+
class Configuration
|
|
19
|
+
# Master switch. When false, the helper emits no favicon link (the route
|
|
20
|
+
# still serves the source untouched if requested directly).
|
|
21
|
+
attr_accessor :enabled
|
|
22
|
+
|
|
23
|
+
# Source icon path (String/Pathname, Rails.root-relative) or a per-env Hash.
|
|
24
|
+
# nil means auto-discover (see EnvStyle::Source).
|
|
25
|
+
attr_accessor :source
|
|
26
|
+
|
|
27
|
+
# Environment name => tint color (any CSS color string).
|
|
28
|
+
attr_accessor :colors
|
|
29
|
+
|
|
30
|
+
# Tint color for non-production environments not listed in #colors.
|
|
31
|
+
attr_accessor :default_color
|
|
32
|
+
|
|
33
|
+
# Colors preserved as-is when tinting raster icons (e.g. white backgrounds).
|
|
34
|
+
attr_accessor :exclude_colors
|
|
35
|
+
|
|
36
|
+
# Per-channel sRGB tolerance (0..255) for #exclude_colors matching. 0 keeps
|
|
37
|
+
# only exact matches; a higher value also preserves the anti-aliased halo of
|
|
38
|
+
# near-matching pixels around an excluded region.
|
|
39
|
+
attr_accessor :exclude_tolerance
|
|
40
|
+
|
|
41
|
+
# Explicit environment override, highest precedence in resolution.
|
|
42
|
+
attr_accessor :environment
|
|
43
|
+
|
|
44
|
+
# When true, SVG sources are rasterized to PNG via libvips instead of
|
|
45
|
+
# served as tinted SVG.
|
|
46
|
+
attr_accessor :rasterize_svg
|
|
47
|
+
|
|
48
|
+
# Edge length in pixels of the rasterized PNG when #rasterize_svg is true.
|
|
49
|
+
# Larger values render crisper favicons on hi-dpi tabs.
|
|
50
|
+
attr_accessor :rasterize_size
|
|
51
|
+
|
|
52
|
+
# Logger for EnvStyle warnings; defaults to Rails.logger at runtime.
|
|
53
|
+
attr_accessor :logger
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@enabled = true
|
|
57
|
+
@source = nil
|
|
58
|
+
@colors = DEFAULT_COLORS.dup
|
|
59
|
+
@default_color = DEFAULT_COLOR
|
|
60
|
+
@exclude_colors = []
|
|
61
|
+
@exclude_tolerance = 0
|
|
62
|
+
@environment = nil
|
|
63
|
+
@rasterize_svg = false
|
|
64
|
+
@rasterize_size = 32
|
|
65
|
+
@logger = nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# The mountable engine. Isolating the namespace keeps the favicon route and
|
|
5
|
+
# controller from colliding with the host app, and the initializer makes the
|
|
6
|
+
# +env_style_favicon_tags+ helper available in the host's layouts.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace EnvStyle
|
|
9
|
+
|
|
10
|
+
initializer "env_style.view_helpers" do
|
|
11
|
+
ActiveSupport.on_load(:action_view) do
|
|
12
|
+
include EnvStyle::FaviconHelper
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Resolves the current environment name and the tint color it maps to.
|
|
5
|
+
#
|
|
6
|
+
# Resolution order mirrors env.style:
|
|
7
|
+
# config.environment -> ENV["ENV_STYLE_ENV"] -> Rails.env
|
|
8
|
+
#
|
|
9
|
+
# Production is never tinted, and a disabled configuration or an environment
|
|
10
|
+
# with no color leaves the favicon untouched.
|
|
11
|
+
class Environment
|
|
12
|
+
PRODUCTION = "production"
|
|
13
|
+
|
|
14
|
+
def initialize(config: EnvStyle.config, rails_env: self.class.rails_env,
|
|
15
|
+
env_style_env: ENV.fetch("ENV_STYLE_ENV", nil))
|
|
16
|
+
@config = config
|
|
17
|
+
@rails_env = presence(rails_env)
|
|
18
|
+
@env_style_env = presence(env_style_env)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The resolved environment name, or nil when it cannot be determined.
|
|
22
|
+
def name
|
|
23
|
+
presence(@config.environment) || @env_style_env || @rails_env
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether the favicon should be tinted for this environment.
|
|
27
|
+
def tint?
|
|
28
|
+
!color.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The tint color for this environment, or nil when it must stay untouched.
|
|
32
|
+
def color
|
|
33
|
+
return nil unless @config.enabled
|
|
34
|
+
return nil if name.nil? || name == PRODUCTION
|
|
35
|
+
|
|
36
|
+
presence(@config.colors[name]) || presence(@config.default_color)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rails.env as a string when Rails is loaded, otherwise nil.
|
|
40
|
+
def self.rails_env
|
|
41
|
+
Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def presence(value)
|
|
47
|
+
string = value.to_s
|
|
48
|
+
string unless string.empty?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module EnvStyle
|
|
6
|
+
# Ties the pieces together: resolves the source and environment, decides the
|
|
7
|
+
# output format, and returns the bytes to serve — tinted for non-production
|
|
8
|
+
# environments, verbatim otherwise.
|
|
9
|
+
#
|
|
10
|
+
# Tinting happens on demand. The result is deterministic, so the strong ETag
|
|
11
|
+
# lets browsers and proxies cache it and revalidate with a 304 — there is no
|
|
12
|
+
# server-side cache to keep coherent across workers.
|
|
13
|
+
class Favicon
|
|
14
|
+
SVG = :svg
|
|
15
|
+
PNG = :png
|
|
16
|
+
CONTENT_TYPES = {
|
|
17
|
+
SVG => SvgTinter::CONTENT_TYPE,
|
|
18
|
+
PNG => RasterTinter::CONTENT_TYPE
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(config: EnvStyle.config, environment: Environment.new(config: config),
|
|
22
|
+
root: Source.rails_root)
|
|
23
|
+
@config = config
|
|
24
|
+
@environment = environment
|
|
25
|
+
@source = Source.new(config: config, root: root, env_name: environment.name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def available?
|
|
29
|
+
@source.found?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The format of the bytes actually served. An untouched favicon streams the
|
|
33
|
+
# source verbatim, so its format (and content type) follow the source — never
|
|
34
|
+
# the tint-output format, which would mislabel the bytes.
|
|
35
|
+
def format
|
|
36
|
+
return @format if defined?(@format)
|
|
37
|
+
|
|
38
|
+
@format = tinted? ? tinted_format : source_format
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The content type of the bytes actually served: the tint-output type when
|
|
42
|
+
# tinted, otherwise the source's own media type. A non-PNG raster served
|
|
43
|
+
# untouched (say a real favicon.ico) is labeled with its true type rather
|
|
44
|
+
# than the .png route it happens to travel.
|
|
45
|
+
def content_type
|
|
46
|
+
tinted? ? CONTENT_TYPES.fetch(format) : source_content_type
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The media type of the untouched source, used on the degradation path so
|
|
50
|
+
# source bytes are never mislabeled with the tint-output type.
|
|
51
|
+
def source_content_type
|
|
52
|
+
@source.content_type
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tinted?
|
|
56
|
+
@environment.tint?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A strong validator for the served bytes. exclude_colors and its matching
|
|
60
|
+
# tolerance are folded in for raster output: both change which pixels are preserved.
|
|
61
|
+
def etag
|
|
62
|
+
@etag ||= Digest::SHA256.hexdigest(etag_inputs.join("|"))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Bytes to serve: tinted when the environment calls for it, otherwise the
|
|
66
|
+
# untouched source (zero image work).
|
|
67
|
+
def bytes
|
|
68
|
+
return @bytes if defined?(@bytes)
|
|
69
|
+
|
|
70
|
+
@bytes = tinted? ? render : source_bytes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The untouched source bytes, used both for passthrough and as the graceful
|
|
74
|
+
# fallback when tinting fails.
|
|
75
|
+
def source_bytes
|
|
76
|
+
@source.read
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def tinted_format
|
|
82
|
+
@source.raster? || @config.rasterize_svg ? PNG : SVG
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def source_format
|
|
86
|
+
@source.raster? ? PNG : SVG
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def etag_inputs
|
|
90
|
+
inputs = [@source.digest, @environment.name, @environment.color, format]
|
|
91
|
+
inputs.push(@config.exclude_colors.join(","), @config.exclude_tolerance, @config.rasterize_size) if format == PNG
|
|
92
|
+
inputs
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render
|
|
96
|
+
case format
|
|
97
|
+
when SVG
|
|
98
|
+
SvgTinter.new(color: @environment.color).call(@source.read)
|
|
99
|
+
when PNG
|
|
100
|
+
RasterTinter.new(color: @environment.color, exclude_colors: @config.exclude_colors,
|
|
101
|
+
tolerance: @config.exclude_tolerance)
|
|
102
|
+
.call(raster_source_bytes)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def raster_source_bytes
|
|
107
|
+
@source.raster? ? @source.read : rasterize_svg(@source.read)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Rasterize at a target pixel size rather than the SVG intrinsic size: a
|
|
111
|
+
# small viewBox would otherwise yield a low-res favicon on hi-dpi tabs.
|
|
112
|
+
# thumbnail_buffer drives the SVG loader scale to hit +rasterize_size+.
|
|
113
|
+
def rasterize_svg(svg_bytes)
|
|
114
|
+
require "vips"
|
|
115
|
+
|
|
116
|
+
Vips::Image.thumbnail_buffer(svg_bytes, @config.rasterize_size).write_to_buffer(".png")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Tints a raster icon (PNG/ICO/...) with libvips through ruby-vips.
|
|
5
|
+
#
|
|
6
|
+
# The recolor is luminance-preserving: each pixel's perceptual brightness
|
|
7
|
+
# scales the tint color, so shading survives while the hue shifts. Alpha is
|
|
8
|
+
# carried through untouched, and any pixel within +tolerance+ of a color in
|
|
9
|
+
# +exclude_colors+ (for example a white background) is left exactly as it was.
|
|
10
|
+
# A +tolerance+ of 0 keeps only exact matches; a larger value also preserves
|
|
11
|
+
# the anti-aliased halo of near-matching pixels around an excluded region.
|
|
12
|
+
#
|
|
13
|
+
# +vips+ is required lazily so SVG-only applications never need libvips.
|
|
14
|
+
class RasterTinter
|
|
15
|
+
CONTENT_TYPE = "image/png"
|
|
16
|
+
|
|
17
|
+
def initialize(color:, exclude_colors: [], tolerance: 0)
|
|
18
|
+
@color = color.to_s
|
|
19
|
+
@exclude_colors = Array(exclude_colors)
|
|
20
|
+
@tolerance = tolerance
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def content_type
|
|
24
|
+
CONTENT_TYPE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(bytes)
|
|
28
|
+
require "vips"
|
|
29
|
+
|
|
30
|
+
image = Vips::Image.new_from_buffer(bytes, "").colourspace("srgb")
|
|
31
|
+
alpha = image.bands >= 4 ? image.extract_band(3) : nil
|
|
32
|
+
rgb = image.extract_band(0, n: 3)
|
|
33
|
+
|
|
34
|
+
recolored = apply_exclusions(rgb, tint(rgb))
|
|
35
|
+
recolored = recolored.bandjoin(alpha) if alpha
|
|
36
|
+
recolored.cast(:uchar).copy(interpretation: :srgb).write_to_buffer(".png")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Scale the tint by each pixel's luminance (0..1), preserving shading.
|
|
42
|
+
def tint(rgb)
|
|
43
|
+
red, green, blue = rgb_channels(@color)
|
|
44
|
+
scale = rgb.colourspace("b-w").cast(:float) / 255.0
|
|
45
|
+
|
|
46
|
+
(scale * red).bandjoin([scale * green, scale * blue]).cast(:uchar)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Keep original pixels wherever they are within tolerance of an excluded color.
|
|
50
|
+
def apply_exclusions(rgb, tinted)
|
|
51
|
+
mask = exclusion_mask(rgb)
|
|
52
|
+
return tinted if mask.nil?
|
|
53
|
+
|
|
54
|
+
mask.ifthenelse(rgb, tinted)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def exclusion_mask(rgb)
|
|
58
|
+
masks = @exclude_colors.map { |color| color_match_mask(rgb, color) }
|
|
59
|
+
masks.reduce(:|)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A pixel matches when it is within +tolerance+ of the target on every channel.
|
|
63
|
+
# Casting to float first avoids uchar underflow when a channel sits below the
|
|
64
|
+
# target; with tolerance 0 this reduces to an exact per-channel equality.
|
|
65
|
+
def color_match_mask(rgb, color)
|
|
66
|
+
distance = (rgb.cast(:float) - rgb_channels(color)).abs
|
|
67
|
+
distance.relational_const(:lesseq, @tolerance).bandand
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rgb_channels(color)
|
|
71
|
+
hex = color.to_s.delete_prefix("#")
|
|
72
|
+
hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
|
|
73
|
+
unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
74
|
+
raise ConfigurationError, "env_style: raster tinting needs a hex color, got #{color.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
hex.scan(/../).map { |pair| pair.to_i(16) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module EnvStyle
|
|
7
|
+
# Resolves and reads the source icon: the file whose bytes we tint and serve.
|
|
8
|
+
#
|
|
9
|
+
# Resolution has two tiers:
|
|
10
|
+
# 1. Explicit +config.source+ (a Rails.root-relative path/Pathname, or a
|
|
11
|
+
# per-environment Hash) always wins.
|
|
12
|
+
# 2. Otherwise auto-discovery walks a conventional list, preferring SVG.
|
|
13
|
+
#
|
|
14
|
+
# Nothing found never raises: it logs an actionable warning (outside
|
|
15
|
+
# production) and reports itself as not found.
|
|
16
|
+
class Source
|
|
17
|
+
# Auto-discovery candidates, in priority order. SVG is preferred over raster.
|
|
18
|
+
AUTO_DISCOVERY = %w[
|
|
19
|
+
public/icon.svg
|
|
20
|
+
public/icon.png
|
|
21
|
+
public/favicon.svg
|
|
22
|
+
public/favicon.ico
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# Sniffs SVG by content so a mislabeled extension still resolves correctly.
|
|
26
|
+
SVG_SNIFF = /<svg[\s>]/i
|
|
27
|
+
|
|
28
|
+
SVG_MEDIA_TYPE = "image/svg+xml"
|
|
29
|
+
|
|
30
|
+
# Raster media types keyed by their leading magic bytes, checked in order.
|
|
31
|
+
# WEBP is matched separately because its "WEBP" marker sits at byte 8, past
|
|
32
|
+
# the leading "RIFF" chunk id.
|
|
33
|
+
RASTER_SIGNATURES = {
|
|
34
|
+
"\x89PNG".b => "image/png",
|
|
35
|
+
"\x00\x00\x01\x00".b => "image/vnd.microsoft.icon",
|
|
36
|
+
"GIF8".b => "image/gif",
|
|
37
|
+
"\xFF\xD8\xFF".b => "image/jpeg",
|
|
38
|
+
"BM".b => "image/bmp"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# Media type for an unrecognized raster. Conservative on purpose: the only
|
|
42
|
+
# raster engine route is +.png+, and browsers sniff the bytes themselves, so
|
|
43
|
+
# a wrong-but-harmless label never breaks the tab.
|
|
44
|
+
DEFAULT_RASTER_MEDIA_TYPE = "image/png"
|
|
45
|
+
|
|
46
|
+
MISSING_MESSAGE = "env_style: no source icon found — set EnvStyle.config.source"
|
|
47
|
+
|
|
48
|
+
def initialize(config: EnvStyle.config, root: self.class.rails_root, env_name: nil)
|
|
49
|
+
@config = config
|
|
50
|
+
@root = root && Pathname(root)
|
|
51
|
+
@env_name = env_name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The resolved icon path, or nil when nothing was found.
|
|
55
|
+
def path
|
|
56
|
+
return @path if defined?(@path)
|
|
57
|
+
|
|
58
|
+
@path = resolve_path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def found?
|
|
62
|
+
!path.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Raw binary bytes of the source icon, or nil when not found.
|
|
66
|
+
def read
|
|
67
|
+
return @read if defined?(@read)
|
|
68
|
+
|
|
69
|
+
@read = path&.binread
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# :svg or :raster, decided by content; nil when not found.
|
|
73
|
+
def format
|
|
74
|
+
return nil unless found?
|
|
75
|
+
|
|
76
|
+
svg_content? ? :svg : :raster
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def svg?
|
|
80
|
+
format == :svg
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def raster?
|
|
84
|
+
format == :raster
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The true media type of the resolved source, sniffed from its bytes; nil
|
|
88
|
+
# when not found. SVG keeps the content-based sniff (a mislabeled extension
|
|
89
|
+
# still resolves), every other type is matched on its magic bytes, and an
|
|
90
|
+
# unrecognized raster falls back to {DEFAULT_RASTER_MEDIA_TYPE}. Unlike
|
|
91
|
+
# +format+, this distinguishes non-PNG rasters so untouched passthrough bytes
|
|
92
|
+
# are labeled with what they actually are.
|
|
93
|
+
def content_type
|
|
94
|
+
return nil unless found?
|
|
95
|
+
return SVG_MEDIA_TYPE if svg?
|
|
96
|
+
|
|
97
|
+
raster_content_type
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Content digest used to key caches and ETags; nil when not found.
|
|
101
|
+
def digest
|
|
102
|
+
return nil unless found?
|
|
103
|
+
|
|
104
|
+
Digest::SHA256.hexdigest(read)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.rails_root
|
|
108
|
+
Rails.root if defined?(Rails) && Rails.respond_to?(:root)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def resolve_path
|
|
114
|
+
candidate_path || warn_missing
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def candidate_path
|
|
118
|
+
case @config.source
|
|
119
|
+
when Hash then hash_source_path
|
|
120
|
+
when nil then auto_discovered_path
|
|
121
|
+
else file_at(@config.source)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def hash_source_path
|
|
126
|
+
raw = @config.source[@env_name]
|
|
127
|
+
raw.nil? ? auto_discovered_path : file_at(raw)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def auto_discovered_path
|
|
131
|
+
return nil if @root.nil?
|
|
132
|
+
|
|
133
|
+
AUTO_DISCOVERY.map { |relative| @root.join(relative) }.find(&:file?)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def file_at(raw)
|
|
137
|
+
return nil if @root.nil?
|
|
138
|
+
|
|
139
|
+
candidate = @root.join(raw.to_s)
|
|
140
|
+
return nil unless candidate.file?
|
|
141
|
+
|
|
142
|
+
within_root?(candidate) ? candidate : nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Keeps an explicit source (relative, absolute, traversal, or symlink) from
|
|
146
|
+
# escaping Rails.root. realpath resolves symlinks and "..", so only files
|
|
147
|
+
# physically under the root are accepted; absolute paths under it still work.
|
|
148
|
+
def within_root?(path)
|
|
149
|
+
real = path.realpath
|
|
150
|
+
root = @root.realpath
|
|
151
|
+
real == root || real.to_s.start_with?("#{root}/")
|
|
152
|
+
rescue SystemCallError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def svg_content?
|
|
157
|
+
head = read.to_s.byteslice(0, 1024).to_s.force_encoding(Encoding::BINARY)
|
|
158
|
+
head.match?(SVG_SNIFF)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def raster_content_type
|
|
162
|
+
head = read.to_s.byteslice(0, 16).to_s.force_encoding(Encoding::BINARY)
|
|
163
|
+
return "image/webp" if webp?(head)
|
|
164
|
+
|
|
165
|
+
RASTER_SIGNATURES.each { |magic, media_type| return media_type if head.start_with?(magic) }
|
|
166
|
+
DEFAULT_RASTER_MEDIA_TYPE
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def webp?(head)
|
|
170
|
+
head.start_with?("RIFF".b) && head.byteslice(8, 4) == "WEBP".b
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def warn_missing
|
|
174
|
+
logger.warn(MISSING_MESSAGE) unless @env_name == Environment::PRODUCTION
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def logger
|
|
179
|
+
@config.logger || EnvStyle.logger
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvStyle
|
|
4
|
+
# Tints an SVG icon by rewriting its markup — no image library required.
|
|
5
|
+
#
|
|
6
|
+
# A monochrome mark (one color, or currentColor, or an implicit default) has
|
|
7
|
+
# its fill/stroke swapped for the tint. A multicolor mark is wrapped in a
|
|
8
|
+
# group carrying a luminance-preserving <feColorMatrix> duotone filter, so the
|
|
9
|
+
# artwork keeps its shading while shifting to the tint hue.
|
|
10
|
+
class SvgTinter
|
|
11
|
+
CONTENT_TYPE = "image/svg+xml"
|
|
12
|
+
FILTER_ID = "env-style-tint"
|
|
13
|
+
|
|
14
|
+
# Values that are not real colors and must never be swapped.
|
|
15
|
+
NON_COLORS = ["none", "transparent", "inherit", "currentcolor", ""].freeze
|
|
16
|
+
|
|
17
|
+
# Common CSS named colors mapped to canonical 6-digit hex, so that a mark
|
|
18
|
+
# written as "black" and "#000" is recognised as one color when counting
|
|
19
|
+
# distinctness. Used only for the count — never for the output markup.
|
|
20
|
+
CSS_COLORS = {
|
|
21
|
+
"aqua" => "#00ffff",
|
|
22
|
+
"black" => "#000000",
|
|
23
|
+
"blue" => "#0000ff",
|
|
24
|
+
"cyan" => "#00ffff",
|
|
25
|
+
"fuchsia" => "#ff00ff",
|
|
26
|
+
"gray" => "#808080",
|
|
27
|
+
"green" => "#008000",
|
|
28
|
+
"grey" => "#808080",
|
|
29
|
+
"lime" => "#00ff00",
|
|
30
|
+
"magenta" => "#ff00ff",
|
|
31
|
+
"maroon" => "#800000",
|
|
32
|
+
"navy" => "#000080",
|
|
33
|
+
"olive" => "#808000",
|
|
34
|
+
"orange" => "#ffa500",
|
|
35
|
+
"purple" => "#800080",
|
|
36
|
+
"red" => "#ff0000",
|
|
37
|
+
"silver" => "#c0c0c0",
|
|
38
|
+
"teal" => "#008080",
|
|
39
|
+
"white" => "#ffffff",
|
|
40
|
+
"yellow" => "#ffff00"
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Luminance weights (Rec. 709) used to preserve shading in the duotone.
|
|
44
|
+
LUMINANCE = [0.2126, 0.7152, 0.0722].freeze
|
|
45
|
+
|
|
46
|
+
# A CSS color value inside a style declaration: a hex, a functional notation
|
|
47
|
+
# (which may contain spaces/commas), or a plain token. Anchored so it never
|
|
48
|
+
# runs past the declaration's ; or the attribute's closing quote.
|
|
49
|
+
STYLE_VALUE = /#[0-9a-fA-F]+|(?:rgb|hsl)a?\([^)]*\)|[^;"'\s)]+/
|
|
50
|
+
|
|
51
|
+
def initialize(color:)
|
|
52
|
+
@color = color.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def content_type
|
|
56
|
+
CONTENT_TYPE
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call(svg)
|
|
60
|
+
svg = svg.to_s
|
|
61
|
+
colors = distinct_colors(svg)
|
|
62
|
+
if colors.size >= 2
|
|
63
|
+
duotone(svg, colored: colors.any?)
|
|
64
|
+
else
|
|
65
|
+
monochrome(svg, colored: colors.any?)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def monochrome(svg, colored:)
|
|
72
|
+
had_color = colored || svg.match?(/currentColor/i)
|
|
73
|
+
|
|
74
|
+
out = svg.gsub(/currentColor/i, @color)
|
|
75
|
+
out = swap_attributes(out)
|
|
76
|
+
out = swap_style(out)
|
|
77
|
+
out = inject_root_fill(out) unless had_color
|
|
78
|
+
out
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def duotone(svg, colored:)
|
|
82
|
+
rgb = rgb_floats(@color)
|
|
83
|
+
return monochrome(svg, colored: colored) if rgb.nil?
|
|
84
|
+
|
|
85
|
+
filter = %(<feColorMatrix type="matrix" values="#{color_matrix(rgb)}"/>)
|
|
86
|
+
defs = %(<defs><filter id="#{FILTER_ID}" color-interpolation-filters="sRGB">#{filter}</filter></defs>)
|
|
87
|
+
|
|
88
|
+
svg.sub(%r{(<svg\b[^>]*>)(.*)(</svg>)}mi) do
|
|
89
|
+
group = %(<g filter="url(##{FILTER_ID})">#{Regexp.last_match(2)}</g>)
|
|
90
|
+
"#{Regexp.last_match(1)}#{defs}#{group}#{Regexp.last_match(3)}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def swap_attributes(svg)
|
|
95
|
+
svg.gsub(/((?:fill|stroke|stop-color)\s*=\s*)(["'])(.*?)\2/i) do
|
|
96
|
+
prefix = Regexp.last_match(1)
|
|
97
|
+
quote = Regexp.last_match(2)
|
|
98
|
+
value = Regexp.last_match(3)
|
|
99
|
+
paint = keep?(value) ? value : @color
|
|
100
|
+
"#{prefix}#{quote}#{paint}#{quote}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def swap_style(svg)
|
|
105
|
+
svg.gsub(/\b(fill|stroke|stop-color)\s*:\s*(#{STYLE_VALUE})/i) do
|
|
106
|
+
property = Regexp.last_match(1)
|
|
107
|
+
value = Regexp.last_match(2)
|
|
108
|
+
keep?(value) ? Regexp.last_match(0) : "#{property}:#{@color}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def inject_root_fill(svg)
|
|
113
|
+
svg.sub(/<svg\b/i) { |match| %(#{match} fill="#{@color}") }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def distinct_colors(svg)
|
|
117
|
+
colors = svg.scan(/(?:fill|stroke|stop-color)\s*=\s*["']([^"']+)["']/i).flatten
|
|
118
|
+
colors += svg.scan(/(?:fill|stroke|stop-color)\s*:\s*(#{STYLE_VALUE})/i).flatten
|
|
119
|
+
colors.map { |color| color.strip.downcase }.reject { |color| keep?(color) }.map { |color| canonical(color) }.uniq
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Canonicalizes an already-stripped, downcased color token so that
|
|
123
|
+
# equivalent spellings collapse: named colors become hex and 3-digit hex
|
|
124
|
+
# expands to 6 digits. Anything else (functional notations, unknown names)
|
|
125
|
+
# is returned unchanged.
|
|
126
|
+
def canonical(color)
|
|
127
|
+
color = CSS_COLORS.fetch(color, color)
|
|
128
|
+
return color unless color.start_with?("#")
|
|
129
|
+
|
|
130
|
+
hex = color.delete_prefix("#")
|
|
131
|
+
hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
|
|
132
|
+
"##{hex}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def keep?(value)
|
|
136
|
+
normalized = value.strip.downcase
|
|
137
|
+
NON_COLORS.include?(normalized) || normalized.start_with?("url(")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def color_matrix(rgb)
|
|
141
|
+
rows = rgb.map do |channel|
|
|
142
|
+
weights = LUMINANCE.map { |weight| format("%.4f", weight * channel) }.join(" ")
|
|
143
|
+
"#{weights} 0 0"
|
|
144
|
+
end
|
|
145
|
+
(rows + ["0 0 0 1 0"]).join(" ")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def rgb_floats(color)
|
|
149
|
+
hex = color.delete_prefix("#")
|
|
150
|
+
hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
|
|
151
|
+
return nil unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
152
|
+
|
|
153
|
+
hex.scan(/../).map { |pair| pair.to_i(16) / 255.0 }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/env_style.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
|
|
5
|
+
require_relative "env_style/version"
|
|
6
|
+
require_relative "env_style/configuration"
|
|
7
|
+
require_relative "env_style/environment"
|
|
8
|
+
require_relative "env_style/source"
|
|
9
|
+
require_relative "env_style/svg_tinter"
|
|
10
|
+
require_relative "env_style/raster_tinter"
|
|
11
|
+
require_relative "env_style/favicon"
|
|
12
|
+
|
|
13
|
+
# EnvStyle tints an application's favicon a distinct color per environment, so
|
|
14
|
+
# side-by-side browser tabs are visually distinguishable. Production is left
|
|
15
|
+
# untouched, and setting {Configuration#enabled} to false stops the helper from
|
|
16
|
+
# adding the favicon link.
|
|
17
|
+
module EnvStyle
|
|
18
|
+
# Base class for every error EnvStyle raises.
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Raised when the gem is misconfigured (bad color, unreadable source, ...).
|
|
22
|
+
class ConfigurationError < Error; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# The process-wide configuration. Memoized; reset with {reset_configuration!}.
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
alias config configuration
|
|
30
|
+
|
|
31
|
+
# Yields the configuration for block-style setup.
|
|
32
|
+
#
|
|
33
|
+
# EnvStyle.configure do |c|
|
|
34
|
+
# c.colors = { "staging" => "#f59e0b" }
|
|
35
|
+
# end
|
|
36
|
+
def configure
|
|
37
|
+
yield(configuration)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Rebuilds the configuration from defaults. Primarily used by tests.
|
|
41
|
+
def reset_configuration!
|
|
42
|
+
@configuration = Configuration.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The logger EnvStyle writes warnings to: the configured logger, else
|
|
46
|
+
# Rails' logger when Rails is loaded, else a plain STDOUT logger.
|
|
47
|
+
def logger
|
|
48
|
+
configuration.logger || (Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)) ||
|
|
49
|
+
Logger.new($stdout)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
require_relative "env_style/engine" if defined?(Rails::Engine)
|
metadata
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: env_style
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ali Hamdi Ali Fadel
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.2'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: railties
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '7.2'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ruby-vips
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.2'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.2'
|
|
68
|
+
description: EnvStyle is a mountable Rails engine that tints your app's favicon a
|
|
69
|
+
distinct color per environment at runtime, so side-by-side browser tabs are visually
|
|
70
|
+
distinguishable. It adds a route and a <link> tag to your layout, works with any
|
|
71
|
+
front-end (Hotwire, Inertia, or plain Rails), and leaves production untouched.
|
|
72
|
+
email:
|
|
73
|
+
- aliosm1997@gmail.com
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- CHANGELOG.md
|
|
79
|
+
- LICENSE.txt
|
|
80
|
+
- README.md
|
|
81
|
+
- app/controllers/env_style/application_controller.rb
|
|
82
|
+
- app/controllers/env_style/favicons_controller.rb
|
|
83
|
+
- app/helpers/env_style/favicon_helper.rb
|
|
84
|
+
- config/routes.rb
|
|
85
|
+
- lib/env_style.rb
|
|
86
|
+
- lib/env_style/configuration.rb
|
|
87
|
+
- lib/env_style/engine.rb
|
|
88
|
+
- lib/env_style/environment.rb
|
|
89
|
+
- lib/env_style/favicon.rb
|
|
90
|
+
- lib/env_style/raster_tinter.rb
|
|
91
|
+
- lib/env_style/source.rb
|
|
92
|
+
- lib/env_style/svg_tinter.rb
|
|
93
|
+
- lib/env_style/version.rb
|
|
94
|
+
homepage: https://github.com/milkstrawai/env_style
|
|
95
|
+
licenses:
|
|
96
|
+
- MIT
|
|
97
|
+
metadata:
|
|
98
|
+
allowed_push_host: https://rubygems.org
|
|
99
|
+
homepage_uri: https://github.com/milkstrawai/env_style
|
|
100
|
+
source_code_uri: https://github.com/milkstrawai/env_style
|
|
101
|
+
changelog_uri: https://github.com/milkstrawai/env_style/blob/main/CHANGELOG.md
|
|
102
|
+
rubygems_mfa_required: 'true'
|
|
103
|
+
post_install_message: |
|
|
104
|
+
env_style tints raster favicons (PNG/ICO) with libvips through ruby-vips.
|
|
105
|
+
Install libvips if it is not already present:
|
|
106
|
+
|
|
107
|
+
macOS: brew install vips
|
|
108
|
+
Debian: apt-get install -y libvips
|
|
109
|
+
|
|
110
|
+
SVG favicons need no system dependency, and libvips already ships in the
|
|
111
|
+
default Rails 8 Dockerfile, so most modern apps have it already.
|
|
112
|
+
rdoc_options: []
|
|
113
|
+
require_paths:
|
|
114
|
+
- lib
|
|
115
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
116
|
+
requirements:
|
|
117
|
+
- - ">="
|
|
118
|
+
- !ruby/object:Gem::Version
|
|
119
|
+
version: 3.2.0
|
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0'
|
|
125
|
+
requirements: []
|
|
126
|
+
rubygems_version: 3.6.9
|
|
127
|
+
specification_version: 4
|
|
128
|
+
summary: Per-environment favicon tinting for Rails.
|
|
129
|
+
test_files: []
|