al_email_protect 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/lib/al_email_protect/version.rb +5 -0
- data/lib/al_email_protect.rb +163 -0
- data/lib/assets/al_email_protect/css/email-protect.css +48 -0
- data/lib/assets/al_email_protect/js/email-protect.js +121 -0
- metadata +142 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 45eeb1de42070ef099e8c97dac39d19b382a24176bb366cae134999a44a8f563
|
|
4
|
+
data.tar.gz: '08d20381439a1b9f8338fa52b62a784fa021b7a6a00823867379d01cec833631'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f035867e44a23b79ea54a50e63fbb6370a284d469fdeb35eabd95daa8b78324c4ce072bd20d2a1f3d1fbd8626f8700636e79256bd3024de7fa1613c9c0d12c9e
|
|
7
|
+
data.tar.gz: cc611264e9d7fb0d5dcb0350b2cd93f50b38ae9bad99510715afb760b917b29bdabb880198e2a9291486d6f47b80353c1a6b4a3e93726f4146944cd0a25c566d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-08-02
|
|
4
|
+
|
|
5
|
+
- Initial release of `al_email_protect`, implementing the email obfuscation proposed in
|
|
6
|
+
[alshedivat/al-folio#3540](https://github.com/alshedivat/al-folio/issues/3540) (source PR
|
|
7
|
+
[#3532](https://github.com/alshedivat/al-folio/pull/3532)).
|
|
8
|
+
- Addresses are split server-side and never appear as `user@host` or `mailto:` in the built HTML.
|
|
9
|
+
- `{% al_email_protect_link %}` tag, `al_email_obfuscate` filter, and `_styles` / `_scripts` asset tags.
|
|
10
|
+
- Vanilla-JS runtime (no jQuery), with a clipboard fallback for non-secure contexts and keyboard activation.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) al-folio maintainers
|
|
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,81 @@
|
|
|
1
|
+
# al_email_protect
|
|
2
|
+
|
|
3
|
+
Email obfuscation for [al-folio](https://github.com/alshedivat/al-folio) v1.x. Implements the plugin proposed in
|
|
4
|
+
[al-folio#3540](https://github.com/alshedivat/al-folio/issues/3540).
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
When `protect_email` is on, addresses are **never emitted as addresses**. The HTML contains no `user@host` string and no
|
|
9
|
+
`mailto:` target; the two halves are carried in separate attributes and rejoined by the browser when a visitor clicks.
|
|
10
|
+
|
|
11
|
+
This is the part that matters. An implementation that ships the plaintext address and rewrites it after
|
|
12
|
+
`DOMContentLoaded` protects nobody — harvesters parse markup, they do not run your JavaScript. The obfuscation here
|
|
13
|
+
happens at build time, in Liquid.
|
|
14
|
+
|
|
15
|
+
Clicking an address copies it to the clipboard and shows a brief toast, rather than opening a mail client.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Add to your `Gemfile`:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "al_email_protect", "= 1.0.0"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
and to `_config.yml` — **both lists, or the plugin is inert**:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
plugins:
|
|
29
|
+
- al_email_protect
|
|
30
|
+
|
|
31
|
+
protect_email: true # off by default
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
The al-folio theme calls the asset tags for you. In your own layouts:
|
|
37
|
+
|
|
38
|
+
```liquid
|
|
39
|
+
{% al_email_protect_link site.data.socials.email %}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Renders a click-to-copy element when enabled, and a plain `mailto:` link when disabled — so it is safe to call
|
|
43
|
+
unconditionally.
|
|
44
|
+
|
|
45
|
+
For contexts that show an address as text rather than a link (a CV contact block, say):
|
|
46
|
+
|
|
47
|
+
```liquid
|
|
48
|
+
{{ cv.email | al_email_obfuscate }}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
gives `someone [at] example [dot] com` when enabled, and the address unchanged when not.
|
|
52
|
+
|
|
53
|
+
## Behaviour worth knowing
|
|
54
|
+
|
|
55
|
+
- **Splits on the last `@`.** A quoted local part may legally contain one (RFC 5321).
|
|
56
|
+
- **Falls back rather than mangling.** A value that is not a usable address (`someone@localhost`, no dot in the domain)
|
|
57
|
+
renders as an ordinary `mailto:` link. Publishing a broken contact address is worse than publishing an unprotected one.
|
|
58
|
+
- **Clipboard needs a secure context.** `navigator.clipboard` is undefined over plain HTTP, so the runtime falls back to
|
|
59
|
+
a hidden-textarea copy, and if even that fails it shows the address so the visitor can copy it by hand.
|
|
60
|
+
- **Keyboard accessible.** The element is a focusable `<a>`; Enter and Space both activate it, and the toast is announced
|
|
61
|
+
via `role="status"`.
|
|
62
|
+
|
|
63
|
+
## Scope
|
|
64
|
+
|
|
65
|
+
This gem owns obfuscation of addresses it is asked to render. It does not scan arbitrary page text for things that look
|
|
66
|
+
like addresses — that is unreliable and would corrupt legitimate content.
|
|
67
|
+
|
|
68
|
+
`al_folio_cv` renders CV contact details; wiring `al_email_obfuscate` into those layouts is a change in that gem, not
|
|
69
|
+
this one. See [`docs/BOUNDARIES.md`](https://github.com/alshedivat/al-folio/blob/main/docs/BOUNDARIES.md).
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
bundle install
|
|
75
|
+
bundle exec rake test
|
|
76
|
+
npm ci && npm run lint:prettier
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "jekyll"
|
|
5
|
+
require "liquid"
|
|
6
|
+
require_relative "al_email_protect/version"
|
|
7
|
+
|
|
8
|
+
# Keeps published email addresses away from harvesters.
|
|
9
|
+
#
|
|
10
|
+
# The scraper-facing half is deliberately server-side: when the feature is on,
|
|
11
|
+
# no `mailto:` string and no `user@host` string is present in the HTML at all.
|
|
12
|
+
# Address harvesters read markup, not JavaScript, so an approach that ships the
|
|
13
|
+
# plaintext and merely rewrites it on DOMContentLoaded protects nobody. The
|
|
14
|
+
# address is emitted as two halves and only rejoined in the browser.
|
|
15
|
+
module AlEmailProtect
|
|
16
|
+
PLUGIN_ROOT = File.expand_path("..", __dir__)
|
|
17
|
+
# Jekyll writes a StaticFile to <dest>/<dir>/<name>, where <dir> is relative
|
|
18
|
+
# to the base below. That base must be lib/, not the gem root, or assets land
|
|
19
|
+
# at /lib/assets/... while every tag in this file points at /assets/... .
|
|
20
|
+
LIB_ROOT = __dir__
|
|
21
|
+
ASSETS_ROOT = File.join(LIB_ROOT, "assets")
|
|
22
|
+
|
|
23
|
+
class PluginStaticFile < Jekyll::StaticFile; end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def enabled?(site)
|
|
28
|
+
return false unless site
|
|
29
|
+
|
|
30
|
+
site.config["protect_email"] == true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Splits "someone@example.ac.uk" into ["someone", "example.ac.uk"].
|
|
34
|
+
#
|
|
35
|
+
# Splits on the LAST "@" because the local part of an address may legally
|
|
36
|
+
# contain one when quoted (RFC 5321), and getting this backwards would emit a
|
|
37
|
+
# broken address rather than a protected one. Returns nil for anything not
|
|
38
|
+
# usable as an address, so callers can fall back to rendering it verbatim
|
|
39
|
+
# instead of silently publishing a mangled contact.
|
|
40
|
+
def split_address(value)
|
|
41
|
+
address = value.to_s.strip
|
|
42
|
+
return nil if address.empty?
|
|
43
|
+
|
|
44
|
+
local, _, domain = address.rpartition("@")
|
|
45
|
+
return nil if local.empty? || domain.empty?
|
|
46
|
+
return nil unless domain.include?(".")
|
|
47
|
+
|
|
48
|
+
[local, domain]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# "someone [at] example [dot] com", for places that show an address as text
|
|
52
|
+
# rather than as a link (CV contact blocks, for instance).
|
|
53
|
+
def obfuscate_text(value)
|
|
54
|
+
parts = split_address(value)
|
|
55
|
+
return value.to_s unless parts
|
|
56
|
+
|
|
57
|
+
local, domain = parts
|
|
58
|
+
"#{local} [at] #{domain.gsub(".", " [dot] ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def escape(value)
|
|
62
|
+
CGI.escapeHTML(value.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Builds "example [dot] com" as alternating styled spans.
|
|
66
|
+
def domain_markup(domain)
|
|
67
|
+
separator = %(<span class="al-email-sep"> [dot] </span>)
|
|
68
|
+
domain.split(".").map { |label| %(<span class="al-email-text">#{escape(label)}</span>) }.join(separator)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# [relative_dir, filename] for everything this gem publishes. Exposed so the
|
|
72
|
+
# destination path can be asserted without booting a full Jekyll site.
|
|
73
|
+
def asset_entries
|
|
74
|
+
Dir.glob(File.join(ASSETS_ROOT, "**", "*")).sort.reject { |p| File.directory?(p) }.map do |source_path|
|
|
75
|
+
[File.dirname(source_path).sub("#{LIB_ROOT}/", ""), File.basename(source_path)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Copies the runtime into the built site. Skipped entirely when the feature is
|
|
80
|
+
# off, so a site that never enables it does not carry the asset.
|
|
81
|
+
class AssetsGenerator < Jekyll::Generator
|
|
82
|
+
safe true
|
|
83
|
+
priority :low
|
|
84
|
+
|
|
85
|
+
def generate(site)
|
|
86
|
+
return unless AlEmailProtect.enabled?(site)
|
|
87
|
+
|
|
88
|
+
Dir.glob(File.join(ASSETS_ROOT, "**", "*")).sort.each do |source_path|
|
|
89
|
+
next if File.directory?(source_path)
|
|
90
|
+
|
|
91
|
+
relative_dir = File.dirname(source_path).sub("#{LIB_ROOT}/", "")
|
|
92
|
+
site.static_files << PluginStaticFile.new(site, LIB_ROOT, relative_dir, File.basename(source_path))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class StylesTag < Liquid::Tag
|
|
98
|
+
def render(context)
|
|
99
|
+
site = context.registers[:site]
|
|
100
|
+
return "" unless AlEmailProtect.enabled?(site)
|
|
101
|
+
|
|
102
|
+
baseurl = site.config["baseurl"] || ""
|
|
103
|
+
%(<link rel="stylesheet" href="#{baseurl}/assets/al_email_protect/css/email-protect.css">\n)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class ScriptsTag < Liquid::Tag
|
|
108
|
+
def render(context)
|
|
109
|
+
site = context.registers[:site]
|
|
110
|
+
return "" unless AlEmailProtect.enabled?(site)
|
|
111
|
+
|
|
112
|
+
baseurl = site.config["baseurl"] || ""
|
|
113
|
+
%(<script defer src="#{baseurl}/assets/al_email_protect/js/email-protect.js"></script>\n)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# {% al_email_protect_link site.data.socials.email %}
|
|
118
|
+
#
|
|
119
|
+
# Emits a click-to-copy element carrying the address in halves. Falls back to
|
|
120
|
+
# a plain mailto: link when the feature is off, so a layout can call this
|
|
121
|
+
# unconditionally and get correct markup either way.
|
|
122
|
+
class LinkTag < Liquid::Tag
|
|
123
|
+
def initialize(tag_name, markup, options)
|
|
124
|
+
super
|
|
125
|
+
@markup = markup.strip
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def render(context)
|
|
129
|
+
value = context[@markup]
|
|
130
|
+
value = @markup.gsub(/\A["']|["']\z/, "") if value.nil?
|
|
131
|
+
return "" if value.to_s.strip.empty?
|
|
132
|
+
|
|
133
|
+
parts = AlEmailProtect.split_address(value)
|
|
134
|
+
unless AlEmailProtect.enabled?(context.registers[:site]) && parts
|
|
135
|
+
escaped = AlEmailProtect.escape(value)
|
|
136
|
+
return %(<a href="mailto:#{escaped}">#{escaped}</a>)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
local, domain = parts
|
|
140
|
+
[
|
|
141
|
+
%(<a href="#" class="al-email-protect" data-eu="#{AlEmailProtect.escape(local)}" data-ed="#{AlEmailProtect.escape(domain)}">),
|
|
142
|
+
%(<span class="al-email-text">#{AlEmailProtect.escape(local)}</span>),
|
|
143
|
+
%(<span class="al-email-sep"> [at] </span>),
|
|
144
|
+
AlEmailProtect.domain_markup(domain),
|
|
145
|
+
%(</a>),
|
|
146
|
+
].join
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# {{ cv.email | al_email_obfuscate }}
|
|
151
|
+
module Filters
|
|
152
|
+
def al_email_obfuscate(value)
|
|
153
|
+
return value.to_s unless AlEmailProtect.enabled?(@context.registers[:site])
|
|
154
|
+
|
|
155
|
+
AlEmailProtect.obfuscate_text(value)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
Liquid::Template.register_tag("al_email_protect_styles", AlEmailProtect::StylesTag)
|
|
161
|
+
Liquid::Template.register_tag("al_email_protect_scripts", AlEmailProtect::ScriptsTag)
|
|
162
|
+
Liquid::Template.register_tag("al_email_protect_link", AlEmailProtect::LinkTag)
|
|
163
|
+
Liquid::Template.register_filter(AlEmailProtect::Filters)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Styling for addresses rendered by al_email_protect.
|
|
2
|
+
Plain CSS with al-folio's CSS custom properties rather than Sass: _sass/ is
|
|
3
|
+
gem-owned by al_folio_core and a plugin must not add to the theme's build. */
|
|
4
|
+
|
|
5
|
+
.al-email-protect {
|
|
6
|
+
cursor: pointer;
|
|
7
|
+
text-decoration: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.al-email-protect:hover .al-email-text {
|
|
11
|
+
text-decoration: underline;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.al-email-text {
|
|
15
|
+
color: var(--global-theme-color, inherit);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.al-email-sep {
|
|
19
|
+
color: var(--global-text-color, inherit);
|
|
20
|
+
/* The " [at] " / " [dot] " markers are a rendering of the address, not extra
|
|
21
|
+
words to read out; the surrounding link already carries the full value. */
|
|
22
|
+
user-select: none;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#al-email-copied-toast {
|
|
26
|
+
position: fixed;
|
|
27
|
+
bottom: 20px;
|
|
28
|
+
left: 50%;
|
|
29
|
+
transform: translateX(-50%) translateY(80px);
|
|
30
|
+
background: var(--global-theme-color, rgba(0, 0, 0, 0.85));
|
|
31
|
+
color: #fff;
|
|
32
|
+
padding: 8px 20px;
|
|
33
|
+
border-radius: 6px;
|
|
34
|
+
font-size: 0.875rem;
|
|
35
|
+
z-index: 9999;
|
|
36
|
+
transition: transform 0.25s ease;
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#al-email-copied-toast.is-visible {
|
|
41
|
+
transform: translateX(-50%) translateY(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media (prefers-reduced-motion: reduce) {
|
|
45
|
+
#al-email-copied-toast {
|
|
46
|
+
transition: none;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Rejoins addresses that al_email_protect emitted in halves, and copies them to
|
|
2
|
+
// the clipboard on click. Vanilla by design: al-folio v1 dropped jQuery from the
|
|
3
|
+
// core runtime, and a plugin that reintroduced it would break sites that do not
|
|
4
|
+
// load the bootstrap-compat shim.
|
|
5
|
+
(function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
var TOAST_ID = "al-email-copied-toast";
|
|
9
|
+
var TOAST_MS = 1800;
|
|
10
|
+
var toastTimer = null;
|
|
11
|
+
|
|
12
|
+
function showToast(message) {
|
|
13
|
+
var toast = document.getElementById(TOAST_ID);
|
|
14
|
+
if (!toast) {
|
|
15
|
+
toast = document.createElement("div");
|
|
16
|
+
toast.id = TOAST_ID;
|
|
17
|
+
// Announce politely so screen readers report the copy without stealing focus.
|
|
18
|
+
toast.setAttribute("role", "status");
|
|
19
|
+
toast.setAttribute("aria-live", "polite");
|
|
20
|
+
document.body.appendChild(toast);
|
|
21
|
+
}
|
|
22
|
+
toast.textContent = message;
|
|
23
|
+
toast.classList.add("is-visible");
|
|
24
|
+
|
|
25
|
+
// Restart the timer rather than stacking them, or a second click hides the
|
|
26
|
+
// toast early while it is still animating in from the first.
|
|
27
|
+
if (toastTimer) {
|
|
28
|
+
clearTimeout(toastTimer);
|
|
29
|
+
}
|
|
30
|
+
toastTimer = setTimeout(function () {
|
|
31
|
+
toast.classList.remove("is-visible");
|
|
32
|
+
toastTimer = null;
|
|
33
|
+
}, TOAST_MS);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function legacyCopy(text) {
|
|
37
|
+
var field = document.createElement("textarea");
|
|
38
|
+
field.value = text;
|
|
39
|
+
field.setAttribute("readonly", "");
|
|
40
|
+
field.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
41
|
+
document.body.appendChild(field);
|
|
42
|
+
field.select();
|
|
43
|
+
var copied = false;
|
|
44
|
+
try {
|
|
45
|
+
copied = document.execCommand("copy");
|
|
46
|
+
} catch (err) {
|
|
47
|
+
copied = false;
|
|
48
|
+
}
|
|
49
|
+
document.body.removeChild(field);
|
|
50
|
+
return copied;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addressOf(element) {
|
|
54
|
+
var local = element.getAttribute("data-eu");
|
|
55
|
+
var domain = element.getAttribute("data-ed");
|
|
56
|
+
if (!local || !domain) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return local + "@" + domain;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function copy(element) {
|
|
63
|
+
var address = addressOf(element);
|
|
64
|
+
if (!address) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// navigator.clipboard is undefined on any page not served over a secure
|
|
69
|
+
// context, which includes plain-HTTP self-hosting and some previews. Fall
|
|
70
|
+
// back rather than throwing, so the click still does something useful.
|
|
71
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
72
|
+
navigator.clipboard.writeText(address).then(
|
|
73
|
+
function () {
|
|
74
|
+
showToast("Email copied to clipboard");
|
|
75
|
+
},
|
|
76
|
+
function () {
|
|
77
|
+
// Permission can still be refused; surface the address so the visitor
|
|
78
|
+
// can copy it by hand instead of getting silence.
|
|
79
|
+
showToast(address);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
showToast(legacyCopy(address) ? "Email copied to clipboard" : address);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Delegated so addresses rendered after load (search palette, lazy includes)
|
|
89
|
+
// are handled without re-binding.
|
|
90
|
+
function onActivate(event) {
|
|
91
|
+
var element = event.target.closest ? event.target.closest(".al-email-protect") : null;
|
|
92
|
+
if (!element) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
copy(element);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ready() {
|
|
100
|
+
document.addEventListener("click", onActivate);
|
|
101
|
+
|
|
102
|
+
// The element is an <a href="#">, so it is already focusable and reachable
|
|
103
|
+
// by keyboard, but Enter fires click while Space does not. Handle Space too.
|
|
104
|
+
document.addEventListener("keydown", function (event) {
|
|
105
|
+
if (event.key !== " " && event.key !== "Spacebar") {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
var element = event.target.closest ? event.target.closest(".al-email-protect") : null;
|
|
109
|
+
if (element) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
copy(element);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (document.readyState === "loading") {
|
|
117
|
+
document.addEventListener("DOMContentLoaded", ready);
|
|
118
|
+
} else {
|
|
119
|
+
ready();
|
|
120
|
+
}
|
|
121
|
+
})();
|
metadata
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: al_email_protect
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- al-folio maintainers
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-02 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.9'
|
|
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.9'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: liquid
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '4.0'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '6.0'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '4.0'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '6.0'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: bundler
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '2.0'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.0'
|
|
63
|
+
type: :development
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '2.0'
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '3.0'
|
|
73
|
+
- !ruby/object:Gem::Dependency
|
|
74
|
+
name: rake
|
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - "~>"
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '13.0'
|
|
80
|
+
type: :development
|
|
81
|
+
prerelease: false
|
|
82
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - "~>"
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '13.0'
|
|
87
|
+
- !ruby/object:Gem::Dependency
|
|
88
|
+
name: minitest
|
|
89
|
+
requirement: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - "~>"
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '5.0'
|
|
94
|
+
type: :development
|
|
95
|
+
prerelease: false
|
|
96
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - "~>"
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '5.0'
|
|
101
|
+
description: 'Hides mailto: addresses from scrapers and copies them to the clipboard
|
|
102
|
+
on click instead.'
|
|
103
|
+
email:
|
|
104
|
+
- maintainers@al-folio.dev
|
|
105
|
+
executables: []
|
|
106
|
+
extensions: []
|
|
107
|
+
extra_rdoc_files: []
|
|
108
|
+
files:
|
|
109
|
+
- CHANGELOG.md
|
|
110
|
+
- LICENSE
|
|
111
|
+
- README.md
|
|
112
|
+
- lib/al_email_protect.rb
|
|
113
|
+
- lib/al_email_protect/version.rb
|
|
114
|
+
- lib/assets/al_email_protect/css/email-protect.css
|
|
115
|
+
- lib/assets/al_email_protect/js/email-protect.js
|
|
116
|
+
homepage: https://github.com/al-org-dev/al-email-protect
|
|
117
|
+
licenses:
|
|
118
|
+
- MIT
|
|
119
|
+
metadata:
|
|
120
|
+
allowed_push_host: https://rubygems.org
|
|
121
|
+
homepage_uri: https://github.com/al-org-dev/al-email-protect
|
|
122
|
+
source_code_uri: https://github.com/al-org-dev/al-email-protect
|
|
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: '2.7'
|
|
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: Email obfuscation plugin for al-folio v1.x
|
|
142
|
+
test_files: []
|