jekyll-email-munge 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: a195700b73387bb7376e55b8d78320a4e06bb9e39efd706b7d67e55b15f8dc9f
4
+ data.tar.gz: 7b4cfd8a031f7d330369f7fe4783d2cc4700899daf7068353b5dcaa4e6b155dc
5
+ SHA512:
6
+ metadata.gz: 96dc1c53c43e50d0b108218987b025acf51a5b42206b464e48e65d17023d806f72df38b20015c74877a7e03d21c16bb7f7050eb9ffb8b7f240915dc2aa9fccfd
7
+ data.tar.gz: 14fec56e4bd3a03639a6b3e76451ffdc02f444923e55386fc064e1129b21d967eaf60bbb070bd47ab1c0473a65e294a8520c35454915ad3ba7621c67f179920d
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to **jekyll-email-munge** are tracked here. The format
4
+ follows [Keep a Changelog](https://keepachangelog.com/) and the project adheres
5
+ to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] — 2026-05-06
10
+
11
+ ### Added
12
+ - `{% munge_email "email" %}` Liquid tag — emits an `<a class="liame">` with an
13
+ AES-128-GCM ciphertext payload, a CSS-hidden decoy span, and an inline-SVG
14
+ noscript fallback. Source-code names use the readable `munge_email` form;
15
+ rendered HTML uses `liame` (`email` reversed) so a scraper regex for
16
+ `email|mail|mailto|contact` finds nothing in the served output.
17
+ - `{% munge_email_script %}` Liquid tag — emits the inline JS decoder (Web
18
+ Crypto AES-GCM) that reveals the address on click. `KEY_HEX` is interpolated
19
+ from `_config.yml` at build time.
20
+ - Configuration via `_config.yml` under the `email_munge:` key (`key_hex`,
21
+ `svg_color`, `decoy`).
22
+ - Deterministic IV derivation (per-email SHA-256) so build output is stable
23
+ across rebuilds without sacrificing GCM safety.
24
+
25
+ [Unreleased]: https://github.com/framallo/jekyll-email-munge/compare/v0.1.0...HEAD
26
+ [0.1.0]: https://github.com/framallo/jekyll-email-munge/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Federico Ramallo
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,368 @@
1
+ # jekyll-email-munge
2
+
3
+ Five-layer **email address munging** for Jekyll. Drop a Liquid tag, get a
4
+ `mailto:` link that looks normal to humans and ciphertext to scrapers.
5
+
6
+ ```liquid
7
+ {% munge_email "you@example.com" %}
8
+ ```
9
+
10
+ renders as something like:
11
+
12
+ ```html
13
+ <a href="#" class="liame"
14
+ data-liame="dDNiRy9OZGNYS3p1OWp0WnFM3VkIVJYNHNicw=="
15
+ rel="nofollow">Reach<span class="liame-decoy" aria-hidden="true">no-reply@spam.invalid</span> out</a>
16
+ <noscript> (or use the address shown here:
17
+ <svg ...><text>you@example.com</text></svg>)
18
+ </noscript>
19
+ ```
20
+
21
+ Click the link in a real browser → it opens `mailto:you@example.com`. View the
22
+ HTML source as a scraper → you see ciphertext, decoy noise, and an SVG.
23
+
24
+ > **Two namespaces, by design.** The Liquid tag (`munge_email`) and the config
25
+ > key (`email_munge`) are clear, discoverable names — they only ever appear in
26
+ > your **source code**. The **rendered HTML** uses `liame` (`email` reversed)
27
+ > for every class and attribute a scraper might regex for. Source code stays
28
+ > readable; rendered output stays scraper-hostile.
29
+
30
+ ---
31
+
32
+ ## What is "address munging"?
33
+
34
+ [Address munging](https://en.wikipedia.org/wiki/Address_munging) is the
35
+ canonical term — going back to early Usenet — for *defending an email address
36
+ on a public surface against bulk harvesters*, by any combination of encoding,
37
+ scrambling, encryption, decoy, or visual substitution. It deliberately covers
38
+ the whole spectrum from `name AT domain DOT com` to "encrypted blob revealed
39
+ on click," and that's exactly the spectrum this gem operates on.
40
+
41
+ We use the term over alternatives like *obfuscate* (undersells the encryption)
42
+ or *encrypt* (oversells it — the key is public on purpose) because munging is
43
+ the only word that's both technically and historically correct.
44
+
45
+ ---
46
+
47
+ ## Why
48
+
49
+ Email addresses on public sites are harvested by bots within hours of being
50
+ indexed. Most "munging" approaches (HTML entities, percent-encoding, JavaScript
51
+ concatenation, image rendering) defeat *some* harvesters but not others. This
52
+ gem stacks **five independently-effective** techniques from
53
+ [Spencer Mortensen's email obfuscation study][study] — each blocked 100% of
54
+ tested harvesters on its own:
55
+
56
+ 1. **AES-128-GCM encryption.** The HTML carries ciphertext only. Even if a
57
+ scraper extracts the `data-liame` attribute, decrypting it requires the key
58
+ *and* a JavaScript runtime.
59
+ 2. **JS conversion.** The decryption logic runs in the browser via Web Crypto.
60
+ Scrapers without a JS engine see only the encrypted blob.
61
+ 3. **User-interaction trigger.** Decryption only fires on `click`. Headless
62
+ bots that *do* execute JS but don't simulate user input never see the
63
+ address.
64
+ 4. **CSS-hidden decoy.** A fake address (`no-reply@spam.invalid` by default)
65
+ sits inside the link with `display: none`, visible to scrapers that strip
66
+ tags but hidden from sighted users.
67
+ 5. **SVG `<noscript>` fallback.** For visitors without JavaScript, the address
68
+ appears as text inside an inline `<svg><text>` element. Most regex-based
69
+ harvesters don't OCR or parse SVG.
70
+
71
+ [study]: https://spencermortensen.com/articles/email-obfuscation/
72
+
73
+ The goal is *defeating scrapers, not hiding the address from a determined
74
+ human.* The encryption key is published in plain sight (it has to be — the
75
+ browser needs it to decrypt). The win is that automated harvesters give up
76
+ long before they reach a working address.
77
+
78
+ ---
79
+
80
+ ## Installation
81
+
82
+ Add to your site's `Gemfile`:
83
+
84
+ ```ruby
85
+ group :jekyll_plugins do
86
+ gem "jekyll-email-munge"
87
+ end
88
+ ```
89
+
90
+ …then `bundle install`.
91
+
92
+ If your site doesn't use Bundler, add to `_config.yml`:
93
+
94
+ ```yaml
95
+ plugins:
96
+ - jekyll-email-munge
97
+ ```
98
+
99
+ > Hosted on **GitHub Pages**? Custom plugins aren't allowed there — deploy via
100
+ > Cloudflare Pages, Netlify, or `gh-pages` with a CI build instead.
101
+
102
+ ### Configuration
103
+
104
+ Add an `email_munge` block to `_config.yml`:
105
+
106
+ ```yaml
107
+ email_munge:
108
+ # 32 hex chars = 16 bytes = AES-128 key. Generate one with:
109
+ # ruby -ropenssl -e 'puts OpenSSL::Random.random_bytes(16).unpack1("H*")'
110
+ key_hex: "84afaaa6886a7b0d195454cc559795cb"
111
+
112
+ # Optional. Color of the noscript SVG fallback text.
113
+ svg_color: "#9c3f1d"
114
+
115
+ # Optional. The fake address shown inside the (CSS-hidden) decoy span.
116
+ decoy: "no-reply@spam.invalid"
117
+ ```
118
+
119
+ Then add the decoder script once per page — typically just before `</body>` in
120
+ your default layout:
121
+
122
+ ```liquid
123
+ <!-- _layouts/default.html -->
124
+ {% munge_email_script %}
125
+ </body>
126
+ ```
127
+
128
+ Finally, add a tiny CSS rule to hide the decoy span and align the SVG fallback.
129
+ Drop these into your stylesheet:
130
+
131
+ ```css
132
+ .liame-decoy { display: none; }
133
+ .liame-svg { vertical-align: middle; pointer-events: none; }
134
+ ```
135
+
136
+ That's the entire setup. You're done.
137
+
138
+ ---
139
+
140
+ ## Usage
141
+
142
+ Drop the tag wherever you'd normally write a `<a href="mailto:…">`:
143
+
144
+ ```liquid
145
+ {% munge_email "support@yourdomain.com" %}
146
+ ```
147
+
148
+ Default visible link text is `Reach out`. To change it, pass a second argument
149
+ with `|` marking where the decoy span gets injected:
150
+
151
+ ```liquid
152
+ {% munge_email "support@yourdomain.com" "Get|in touch" %}
153
+ {% munge_email "press@yourdomain.com" "Contact|the press team" %}
154
+ {% munge_email "hello@yourdomain.com" "Email|us" %}
155
+ ```
156
+
157
+ Renders as:
158
+
159
+ ```
160
+ Get<decoy> in touch
161
+ Contact<decoy> the press team
162
+ Email<decoy> us
163
+ ```
164
+
165
+ Without the `|`, the entire string is used as visible text and an empty decoy
166
+ follows — works fine, but splitting the text gives the decoy more cover.
167
+
168
+ ### Why split the text?
169
+
170
+ The decoy span sits *inside* the visible link and is hidden with
171
+ `display: none`. A scraper that reads the rendered DOM (or strips CSS) sees:
172
+
173
+ ```
174
+ Get no-reply@spam.invalid in touch
175
+ ```
176
+
177
+ Splitting the visible text means the decoy is interleaved with real text rather
178
+ than appended at the end — slightly harder for a scraper to recognize and strip.
179
+
180
+ ---
181
+
182
+ ## How it works (under the hood)
183
+
184
+ ### 1. Build time (Ruby)
185
+
186
+ When Jekyll renders `{% munge_email "user@example.com" %}`:
187
+
188
+ 1. The plugin reads `email_munge.key_hex` from `_config.yml`.
189
+ 2. It derives a deterministic IV from the email + key
190
+ (`SHA-256("liame-iv:<key_hex>:<email>")[0..11]`).
191
+ 3. It encrypts the email with **AES-128-GCM**, packs `iv | ciphertext | auth_tag`,
192
+ and base64-encodes the result.
193
+ 4. It emits the `<a>` element with the base64 payload in `data-liame`, the
194
+ decoy `<span>`, and an inline `<svg>` fallback.
195
+
196
+ The deterministic IV means rebuilds produce identical output — your git diffs
197
+ stay quiet and your CDN cache doesn't get invalidated on every build. IV reuse
198
+ with the *same* plaintext under the same key is safe under AES-GCM; reuse with
199
+ *different* plaintexts is what's catastrophic, and that doesn't happen here
200
+ because each email gets its own derived IV.
201
+
202
+ ### 2. Page load (JavaScript)
203
+
204
+ `{% munge_email_script %}` emits an inline `<script>` that:
205
+
206
+ 1. Reads `KEY_HEX` (baked into the script at build time).
207
+ 2. Attaches a `click` listener to every `[data-liame]` element.
208
+ 3. On click: base64-decodes, splits into IV / ciphertext / tag, calls
209
+ `crypto.subtle.decrypt` (Web Crypto API), and navigates to
210
+ `mailto:<decrypted>`.
211
+
212
+ The script is ~700 bytes minified and inlined — no extra request, no `defer`
213
+ race. It only does anything if the user actually clicks a munged link.
214
+
215
+ ### 3. The `liame` naming convention (in rendered output)
216
+
217
+ This is the central trick. **Anything a scraper can see in the rendered HTML**
218
+ uses `liame` (`email` reversed) instead of `email` / `mail` / `mailto` /
219
+ `contact`, so a regex grep for those tokens finds nothing in the output. The
220
+ plugin guarantees this by construction:
221
+
222
+ | Element | Class / attribute name |
223
+ | ------------------------ | ---------------------- |
224
+ | Anchor | `class="liame"` |
225
+ | Encrypted payload | `data-liame="..."` |
226
+ | Hidden decoy span | `class="liame-decoy"` |
227
+ | Fallback SVG | `class="liame-svg"` |
228
+ | `aria-label` on the SVG | `contact` |
229
+
230
+ The visible link text in the *default* output (`Reach out`) deliberately avoids
231
+ the words "email" or "mail" — keep that property when you customize the text.
232
+
233
+ **Source code is exempt** from this convention because scrapers don't read your
234
+ Liquid templates or `_config.yml`. The tag (`munge_email`) and config key
235
+ (`email_munge`) are written for human readability:
236
+
237
+ | Source-code thing | Reads as | Visible to scrapers? |
238
+ | ------------------------ | -------------------------- | -------------------- |
239
+ | Liquid tag | `{% munge_email %}` | No (build-time only) |
240
+ | Config key | `email_munge:` | No |
241
+ | Decoder script tag | `{% munge_email_script %}` | No |
242
+
243
+ ### 4. Where the key lives
244
+
245
+ There is **one** key, set in `_config.yml`. The plugin uses it to encrypt at
246
+ build time, and `{% munge_email_script %}` interpolates it into the inline JS
247
+ so the browser can decrypt. The key is therefore visible to anyone viewing the
248
+ page source — that's intentional. The encryption is a scraper-defeating layer,
249
+ not a secrecy mechanism.
250
+
251
+ If you want to **rotate the key**, generate a new one and update `key_hex` in
252
+ `_config.yml`. All existing payloads automatically re-encrypt on the next build
253
+ because the plugin re-runs.
254
+
255
+ ---
256
+
257
+ ## Customization
258
+
259
+ ### Per-link visible text
260
+
261
+ Already covered above:
262
+
263
+ ```liquid
264
+ {% munge_email "user@example.com" "Custom|visible text" %}
265
+ ```
266
+
267
+ ### Different decoy text
268
+
269
+ In `_config.yml`:
270
+
271
+ ```yaml
272
+ email_munge:
273
+ decoy: "abuse@spam-trap.invalid"
274
+ ```
275
+
276
+ Or pick something amusing — it's only seen by scrapers.
277
+
278
+ ### SVG fallback color
279
+
280
+ ```yaml
281
+ email_munge:
282
+ svg_color: "#3dff9a" # match your accent
283
+ ```
284
+
285
+ ### Multiple sites, one key
286
+
287
+ If you run several sites and want a single key for all of them, use the same
288
+ `key_hex` value in each site's `_config.yml`. Encrypted payloads are
289
+ interchangeable; the same `munge_email_script` decoder works on all of them.
290
+
291
+ If you rotate the key on one site, you must rotate it on all of them — the
292
+ deterministic IV depends on the key, so old ciphertext won't decrypt with a
293
+ new key.
294
+
295
+ ---
296
+
297
+ ## Browser support
298
+
299
+ The decoder uses **Web Crypto** (`crypto.subtle`), which has been available in
300
+ all major browsers since 2016. On legacy browsers without it, clicking the
301
+ munged link does nothing — but those users see the inline SVG fallback in
302
+ the `<noscript>` block (or, more practically, no `<noscript>` users have a
303
+ browser without Web Crypto).
304
+
305
+ If you need to support pre-2016 browsers, this gem isn't the right tool.
306
+
307
+ ---
308
+
309
+ ## Security notes
310
+
311
+ This gem provides **scraper resistance**, not encryption-grade secrecy. In
312
+ particular:
313
+
314
+ - The key is published in JS. Anyone who reads the rendered HTML can decrypt
315
+ every payload on the page. That's intentional and required for the design.
316
+ - AES-128 is used because the goal is to require *any* AES decryption, not
317
+ because the threat model needs 256-bit keys. If you ever need to actually
318
+ protect the addresses, this gem is the wrong tool — don't put the addresses
319
+ on the public site at all.
320
+ - The deterministic IV is safe specifically because each email gets its own
321
+ derived IV. Don't modify the IV derivation to use a constant — that would
322
+ be a real GCM misuse.
323
+
324
+ ---
325
+
326
+ ## Comparison with alternatives
327
+
328
+ | Approach | Layers | Maintenance |
329
+ | ------------------------------------------------ | ------ | ----------------- |
330
+ | Plain `mailto:` link | 0 | Easiest, harvested instantly |
331
+ | HTML entities / hex encoding | 1 | Defeats only naïve regex |
332
+ | `jekyll-email-protect` (percent-encoded mailto) | 1 | One filter, low ceremony |
333
+ | Concatenated JS string | 2 | Harvested by JS-aware bots |
334
+ | **jekyll-email-munge (this gem)** | **5** | One Liquid tag, build-time encryption |
335
+ | Pure image (e.g., a screenshot of the email) | 1 | OCR-defeated, terrible UX |
336
+
337
+ ---
338
+
339
+ ## Development
340
+
341
+ ```bash
342
+ git clone https://github.com/framallo/jekyll-email-munge.git
343
+ cd jekyll-email-munge
344
+ bundle install
345
+ rake build # produces pkg/jekyll-email-munge-X.Y.Z.gem
346
+ ```
347
+
348
+ To install your local checkout into a Jekyll site for testing:
349
+
350
+ ```ruby
351
+ # Gemfile in the consumer site
352
+ gem "jekyll-email-munge", path: "../jekyll-email-munge"
353
+ ```
354
+
355
+ ### Releasing
356
+
357
+ 1. Bump `Jekyll::EmailMunge::VERSION` in [lib/jekyll-email-munge/version.rb](lib/jekyll-email-munge/version.rb).
358
+ 2. Add a section to [CHANGELOG.md](CHANGELOG.md).
359
+ 3. Commit and tag: `git commit -am "release vX.Y.Z" && git tag vX.Y.Z`
360
+ 4. `rake release` (Bundler task — builds, tags, pushes to RubyGems).
361
+
362
+ You'll need a [RubyGems.org account with MFA enabled](https://guides.rubygems.org/setting-up-multifactor-authentication/) before pushing.
363
+
364
+ ---
365
+
366
+ ## License
367
+
368
+ [MIT](LICENSE.txt) © Federico Ramallo
@@ -0,0 +1,41 @@
1
+ require_relative "lib/jekyll-email-munge/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "jekyll-email-munge"
5
+ spec.version = Jekyll::EmailMunge::VERSION
6
+ spec.authors = ["Federico Ramallo"]
7
+ spec.email = ["framallo@gmail.com"]
8
+
9
+ spec.summary = "Address-munge email links in Jekyll: AES-encrypted payloads, decoy text, click-to-reveal, SVG fallback."
10
+ spec.description = <<~DESC
11
+ A Jekyll plugin for address munging — defending email addresses on public
12
+ sites against bulk harvesters. Stacks five independently-effective
13
+ techniques (AES-128-GCM encryption, JS conversion, click trigger,
14
+ CSS-hidden decoy, SVG noscript fallback) so scrapers see ciphertext while
15
+ humans see a normal mailto link. Drop in a Liquid tag —
16
+ `{% munge_email "user@example.com" %}` — and the plugin handles encryption
17
+ at build time, the decoder script, and the fallback markup.
18
+ DESC
19
+ spec.homepage = "https://github.com/framallo/jekyll-email-munge"
20
+ spec.license = "MIT"
21
+ spec.required_ruby_version = ">= 2.7.0"
22
+
23
+ spec.metadata["homepage_uri"] = spec.homepage
24
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
25
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
26
+ spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
27
+
28
+ spec.files = Dir[
29
+ "lib/**/*",
30
+ "README.md",
31
+ "LICENSE.txt",
32
+ "CHANGELOG.md",
33
+ "jekyll-email-munge.gemspec"
34
+ ]
35
+ spec.require_paths = ["lib"]
36
+
37
+ spec.add_runtime_dependency "jekyll", ">= 3.7", "< 5.0"
38
+
39
+ spec.add_development_dependency "bundler", "~> 2.0"
40
+ spec.add_development_dependency "rake", "~> 13.0"
41
+ end
@@ -0,0 +1 @@
1
+ (function(){var K='__KEY_HEX__';function h(s){var o=new Uint8Array(s.length/2);for(var i=0;i<s.length;i+=2)o[i/2]=parseInt(s.substr(i,2),16);return o}function b(s){var bin=atob(s),o=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)o[i]=bin.charCodeAt(i);return o}async function r(e){e.preventDefault();var el=e.currentTarget,p=el.getAttribute('data-liame');if(!p||!window.crypto||!window.crypto.subtle)return;try{var d=b(p),iv=d.slice(0,12),ct=d.slice(12),k=await crypto.subtle.importKey('raw',h(K),{name:'AES-GCM'},false,['decrypt']),pt=await crypto.subtle.decrypt({name:'AES-GCM',iv:iv},k,ct);window.location.href='mailto:'+new TextDecoder().decode(pt)}catch(_){}}document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('[data-liame]').forEach(function(el){el.addEventListener('click',r)})})})();
@@ -0,0 +1,32 @@
1
+ module Jekyll
2
+ module EmailMunge
3
+ # {% munge_email_script %}
4
+ #
5
+ # Emits the inline <script> that decrypts {% munge_email %} payloads on
6
+ # click. Call this once per page (typically just before </body> in your
7
+ # default layout). It reads email_munge.key_hex from _config.yml and bakes
8
+ # it into the script — that key is intentionally public; the goal is
9
+ # defeating scrapers, not hiding the address from a determined human.
10
+ class MungeEmailScriptTag < ::Liquid::Tag
11
+ DECODER_PATH = File.expand_path("decoder.js", __dir__).freeze
12
+
13
+ def render(context)
14
+ key_hex = context.registers[:site].config.dig("email_munge", "key_hex")
15
+ unless key_hex
16
+ raise "jekyll-email-munge: email_munge.key_hex missing in _config.yml"
17
+ end
18
+
19
+ js = decoder_source.gsub("__KEY_HEX__", key_hex)
20
+ %(<script>#{js}</script>)
21
+ end
22
+
23
+ private
24
+
25
+ def decoder_source
26
+ @@decoder_source ||= File.read(DECODER_PATH).rstrip
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ ::Liquid::Template.register_tag("munge_email_script", Jekyll::EmailMunge::MungeEmailScriptTag)
@@ -0,0 +1,105 @@
1
+ require "openssl"
2
+ require "base64"
3
+ require "cgi"
4
+ require "digest"
5
+
6
+ module Jekyll
7
+ module EmailMunge
8
+ # {% munge_email "user@example.com" %}
9
+ # {% munge_email "user@example.com" "Get|in touch" %}
10
+ #
11
+ # Renders a "munged" mailto link at build time. The address is encrypted
12
+ # with AES-128-GCM (key from _config.yml -> email_munge.key_hex) so only
13
+ # ciphertext appears in the served HTML. A CSS-hidden decoy span sits inside
14
+ # the link and a <noscript> inline-SVG fallback handles JS-disabled visitors.
15
+ #
16
+ # The "|" in the visible text marks where the decoy span is injected.
17
+ # Default text is "Reach|out" -> "Reach<decoy/> out".
18
+ #
19
+ # Naming convention note:
20
+ # The Liquid tag name (`munge_email`) and config key (`email_munge`) only
21
+ # appear in source code, never in the served HTML. The rendered output
22
+ # uses `liame` (`email` reversed) for every class/attribute a scraper
23
+ # might regex for: class="liame", data-liame, .liame-decoy, .liame-svg.
24
+ #
25
+ # See README for the companion {% munge_email_script %} tag and CSS.
26
+ class MungeEmailTag < ::Liquid::Tag
27
+ SYNTAX = /^\s*"([^"]+)"(?:\s+"([^"]+)")?\s*$/.freeze
28
+
29
+ def initialize(tag_name, markup, tokens)
30
+ super
31
+ m = markup.match(SYNTAX)
32
+ unless m
33
+ raise SyntaxError, %(jekyll-email-munge: tag syntax is {% munge_email "email" ["visible|text"] %})
34
+ end
35
+ @email = m[1]
36
+ @text = m[2] || "Reach|out"
37
+ end
38
+
39
+ def render(context)
40
+ cfg = (context.registers[:site].config["email_munge"] || {})
41
+ key_hex = cfg["key_hex"]
42
+ unless key_hex
43
+ raise "jekyll-email-munge: email_munge.key_hex missing in _config.yml. " \
44
+ "Generate one with `ruby -ropenssl -e 'puts OpenSSL::Random.random_bytes(16).unpack1(\"H*\")'`"
45
+ end
46
+ unless key_hex =~ /\A[0-9a-fA-F]{32}\z/
47
+ raise "jekyll-email-munge: email_munge.key_hex must be 32 hex characters (16 bytes / AES-128)"
48
+ end
49
+
50
+ decoy = cfg["decoy"] || "no-reply@spam.invalid"
51
+ svg_color = cfg["svg_color"] || "#9c3f1d"
52
+
53
+ payload = encrypt(@email, key_hex)
54
+ before, after = split_text(@text)
55
+ svg = inline_svg(@email, svg_color)
56
+
57
+ esc = ->(s) { CGI.escapeHTML(s) }
58
+ %(<a href="#" class="liame" data-liame="#{esc.call(payload)}" rel="nofollow">) +
59
+ %(#{esc.call(before)}) +
60
+ %(<span class="liame-decoy" aria-hidden="true">#{esc.call(decoy)}</span>) +
61
+ %(#{esc.call(after)}</a>) +
62
+ %(<noscript> (or use the address shown here: #{svg})</noscript>)
63
+ end
64
+
65
+ private
66
+
67
+ # "Reach|out" -> ["Reach", " out"]
68
+ # "Get|in touch" -> ["Get", " in touch"]
69
+ # "Email me" -> ["Email me", ""] # no "|" present
70
+ def split_text(text)
71
+ return [text, ""] unless text.include?("|")
72
+ left, right = text.split("|", 2)
73
+ right = " #{right}" unless right.empty? || right.start_with?(" ")
74
+ [left, right]
75
+ end
76
+
77
+ # Deterministic IV per email so rebuilds produce identical ciphertext
78
+ # (keeps git diffs and CDN caches stable). IV reuse with the same plaintext
79
+ # under the same key is safe under AES-GCM; reuse with *different*
80
+ # plaintexts under the same key is what's catastrophic, and that doesn't
81
+ # happen here because each email gets its own derived IV.
82
+ def encrypt(email, key_hex)
83
+ key = [key_hex].pack("H*")
84
+ iv = Digest::SHA256.digest("liame-iv:#{key_hex}:#{email}")[0, 12]
85
+ cipher = OpenSSL::Cipher.new("aes-128-gcm").encrypt
86
+ cipher.key = key
87
+ cipher.iv = iv
88
+ cipher.auth_data = ""
89
+ ct = cipher.update(email) + cipher.final
90
+ Base64.strict_encode64(iv + ct + cipher.auth_tag)
91
+ end
92
+
93
+ def inline_svg(email, color)
94
+ width = email.length * 9 + 20
95
+ %(<svg xmlns="http://www.w3.org/2000/svg" class="liame-svg" ) +
96
+ %(width="#{width}" height="20" viewBox="0 0 #{width} 20" ) +
97
+ %(aria-label="contact" role="img">) +
98
+ %(<text x="0" y="14" font-family="JetBrains Mono, monospace" font-size="13" fill="#{color}">) +
99
+ %(#{CGI.escapeHTML(email)}</text></svg>)
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ ::Liquid::Template.register_tag("munge_email", Jekyll::EmailMunge::MungeEmailTag)
@@ -0,0 +1,5 @@
1
+ module Jekyll
2
+ module EmailMunge
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ require_relative "jekyll-email-munge/version"
2
+ require_relative "jekyll-email-munge/munge_email_tag"
3
+ require_relative "jekyll-email-munge/munge_email_script_tag"
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-email-munge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Federico Ramallo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: bundler
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '2.0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ description: |
61
+ A Jekyll plugin for address munging — defending email addresses on public
62
+ sites against bulk harvesters. Stacks five independently-effective
63
+ techniques (AES-128-GCM encryption, JS conversion, click trigger,
64
+ CSS-hidden decoy, SVG noscript fallback) so scrapers see ciphertext while
65
+ humans see a normal mailto link. Drop in a Liquid tag —
66
+ `{% munge_email "user@example.com" %}` — and the plugin handles encryption
67
+ at build time, the decoder script, and the fallback markup.
68
+ email:
69
+ - framallo@gmail.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - LICENSE.txt
76
+ - README.md
77
+ - jekyll-email-munge.gemspec
78
+ - lib/jekyll-email-munge.rb
79
+ - lib/jekyll-email-munge/decoder.js
80
+ - lib/jekyll-email-munge/munge_email_script_tag.rb
81
+ - lib/jekyll-email-munge/munge_email_tag.rb
82
+ - lib/jekyll-email-munge/version.rb
83
+ homepage: https://github.com/framallo/jekyll-email-munge
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://github.com/framallo/jekyll-email-munge
88
+ changelog_uri: https://github.com/framallo/jekyll-email-munge/blob/main/CHANGELOG.md
89
+ bug_tracker_uri: https://github.com/framallo/jekyll-email-munge/issues
90
+ documentation_uri: https://github.com/framallo/jekyll-email-munge#readme
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 2.7.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 4.0.4
106
+ specification_version: 4
107
+ summary: 'Address-munge email links in Jekyll: AES-encrypted payloads, decoy text,
108
+ click-to-reveal, SVG fallback.'
109
+ test_files: []