jekyll-fast-reader 0.1.1

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: 97561b14aaba228d2fa964d79b12d3036afb8193c187c5a81639559d95e49310
4
+ data.tar.gz: 2fc8049462c810fca413c2b375d0138be52218fb9f0386a567fd2d41f78e6a2b
5
+ SHA512:
6
+ metadata.gz: 222ca73894731ca26dcac052c70708f3cbad96fee091f60e20df808393c2f1cb6c1fd573ba70ce4481c1480c609b089bc57838cebeb3b06fe331b441bf257710
7
+ data.tar.gz: 2ce8a523077f9c6cb1cbf8713632c8f591efd40bf4e684ea6bce3db4156103028f063f2426ff71addb9474f7bc346f0f5ca8cdef65f0370cc4f145af8dffdd17
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 developerlee79
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # jekyll-fast-reader
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/jekyll-fast-reader.svg)](https://badge.fury.io/rb/jekyll-fast-reader)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-red.svg)](https://www.ruby-lang.org/)
6
+
7
+ Bionic Reading bolds the opening characters of each word so your eye anchors faster and your brain fills in the rest. This plugin wires that technique directly into your Jekyll build — no JavaScript, no manual markup.
8
+
9
+ - [Installation](#installation)
10
+ - [Getting Started](#getting-started)
11
+ - [Configuration](#configuration)
12
+ - [Liquid Filters](#liquid-filters)
13
+ - [How It Works](#how-it-works)
14
+ - [Development](#development)
15
+ - [Contributing](#contributing)
16
+ - [License](#license)
17
+
18
+ <br>
19
+
20
+ ## Installation
21
+
22
+ ### With Bundler
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "jekyll-fast-reader", "~> 0.1"
27
+ ```
28
+
29
+ Then run:
30
+
31
+ ```bash
32
+ bundle install
33
+ ```
34
+
35
+ ### Manual
36
+
37
+ ```bash
38
+ gem install jekyll-fast-reader
39
+ ```
40
+
41
+ Add the plugin to your site's `_config.yml`:
42
+
43
+ ```yaml
44
+ plugins:
45
+ - jekyll-fast-reader
46
+ ```
47
+
48
+ Requires Jekyll >= 4.0, < 5.0 and Ruby >= 2.7.
49
+
50
+ ### System dependencies
51
+
52
+ Nokogiri ships native extensions. On a fresh machine you may also need:
53
+
54
+ - **macOS:** `xcode-select --install`
55
+ - **Debian / Ubuntu:** `sudo apt install build-essential libxml2-dev libxslt1-dev`
56
+ - **Fedora / RHEL:** `sudo dnf install gcc make libxml2-devel libxslt-devel`
57
+
58
+ ### Compatibility
59
+
60
+ Works alongside [`jekyll-polyglot`](https://github.com/untra/polyglot) — the stylesheet and toggle script are re-emitted on every language pass so each localized copy of the site gets its own asset under its language prefix.
61
+
62
+ <br>
63
+
64
+ ## Getting Started
65
+
66
+ No template changes needed. Install the plugin and build — the stylesheet is injected into `<head>` automatically.
67
+
68
+ **Build and serve**
69
+
70
+ ```bash
71
+ bundle exec jekyll serve
72
+ ```
73
+
74
+ Words in configured collections will have their opening characters bolded. The plugin wraps anchors in `<span class="fr-anchor">` — for example:
75
+
76
+ ```html
77
+ <!-- input -->
78
+ <p>The quick brown fox jumps</p>
79
+
80
+ <!-- output -->
81
+ <p>The <span class="fr-anchor">qu</span>ick <span class="fr-anchor">br</span>own <span class="fr-anchor">fo</span>x <span class="fr-anchor">ju</span>mps</p>
82
+ ```
83
+
84
+ ("The" is a stop word and is left unwrapped.)
85
+
86
+ <br>
87
+
88
+ ## Configuration
89
+
90
+ If `_config.yml` defines `baseurl`, it is automatically prepended to both `css_output_path` and `js_output_path` so the injected `<link>` and `<script>` tags resolve under your site's prefix. Trailing slashes on `baseurl` are normalized.
91
+
92
+ All options are optional. Add a `fast_reader` key to `_config.yml` to override any default:
93
+
94
+ ```yaml
95
+ fast_reader:
96
+ enabled: true
97
+ collections:
98
+ - posts
99
+ exclude_selectors:
100
+ - code
101
+ - pre
102
+ - script
103
+ - style
104
+ - kbd
105
+ - samp
106
+ css_output_path: /assets/fast-reader.css
107
+ stop_words_extra: []
108
+ toggle: false
109
+ default_on: true
110
+ ```
111
+
112
+ | Key | Default | Description |
113
+ |-----|---------|-------------|
114
+ | `enabled` | `true` | Set to `false` to disable the plugin site-wide. To opt out a single document, use `fast_reader: false` in its front matter instead. |
115
+ | `collections` | `["posts"]` | Which Jekyll collections to process. Accepts either an Array of labels or a Hash mapping label → `true`/`false`/options-hash — see [Per-collection options](#per-collection-options). Pages do not belong to a collection — see [Per-document override](#per-document-override) to enable a single page. |
116
+ | `exclude_selectors` | see above | CSS selectors whose inner text is left untouched. **Replaces** the default list entirely — include the defaults if you want to keep them. |
117
+ | `css_output_path` | `"/assets/fast-reader.css"` | URL path for the generated stylesheet `<link>` |
118
+ | `js_output_path` | `"/assets/fast-reader.js"` | URL path for the toggle behavior script (only injected when `toggle: true`) |
119
+ | `stop_words_extra` | `[]` | Additional words to skip (merged with the built-in list) |
120
+ | `toggle` | `false` | Set to `true` to inject a fixed toggle button that enables/disables the effect at runtime without a rebuild. The button is wired by an external `fast-reader.js` (no inline `onclick`/`style`, CSP-safe) and persists state across navigation under `localStorage["fr-state"]` (`"on"` / `"off"`). Keyboard shortcut: <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd>. |
121
+ | `default_on` | `true` | When `true`, anchors render bold by default; the toggle removes the effect by adding `fr-disabled` to `<body>`. When `false`, anchors render with inherited weight by default; the plugin marks `<body>` with `fr-opt-in` at build time, and the toggle activates the effect by also adding `fast-reader`. |
122
+
123
+ ### Per-document override
124
+
125
+ Front matter flips the decision for a single document. Both opt-in and opt-out are supported:
126
+
127
+ ```yaml
128
+ ---
129
+ title: My Post
130
+ fast_reader: false # opt this document out, even if its collection is processed
131
+ ---
132
+ ```
133
+
134
+ ```yaml
135
+ ---
136
+ title: A Standalone Page
137
+ fast_reader: true # opt a page in, even though pages aren't in `collections`
138
+ ---
139
+ ```
140
+
141
+ Use `fast_reader: true` to enable Fast Reader on top-level pages (`about.md`, `index.html`, etc.) since they do not belong to any Jekyll collection.
142
+
143
+ ### Stop words
144
+
145
+ Common words — articles, prepositions, short conjunctions — are skipped by default so they don't get distracting anchors. The built-in list is:
146
+
147
+ ```
148
+ a, an, the, of, in, on, at, to, by, or, is, as,
149
+ and, but, for, nor, so, yet,
150
+ it, its, this, that
151
+ ```
152
+
153
+ Matching is case-insensitive. Extend the list per site:
154
+
155
+ ```yaml
156
+ fast_reader:
157
+ stop_words_extra:
158
+ - jekyll
159
+ - ruby
160
+ ```
161
+
162
+ ### Per-element opt-out
163
+
164
+ Add `data-fr-skip` to any element to prevent its inner text (and all descendants) from being processed. Useful for callouts, brand names, or quoted blocks that should keep their original weight:
165
+
166
+ ```html
167
+ <p>Most text gets bionic anchors.</p>
168
+ <aside data-fr-skip>
169
+ <p>This block is left alone.</p>
170
+ </aside>
171
+ ```
172
+
173
+ This is finer-grained than `exclude_selectors`, which is global, and does not require touching site config.
174
+
175
+ ### Per-collection options
176
+
177
+ `collections` also accepts a Hash so you can tune per-collection behavior:
178
+
179
+ ```yaml
180
+ fast_reader:
181
+ stop_words_extra: [jekyll]
182
+ collections:
183
+ posts: true # default behavior
184
+ drafts: false # explicitly skipped
185
+ notes:
186
+ stop_words_extra: [observability, cache] # adds to the global list, for this collection only
187
+ ```
188
+
189
+ `true` is equivalent to listing the label in the legacy Array form. `false` skips the collection even if it exists. A Hash value lets you override `stop_words_extra` per collection — values are concatenated ahead of the global list (per-collection words win on duplicates).
190
+
191
+ ### Accessibility
192
+
193
+ - **Keyboard shortcut.** When `toggle: true`, the button can be flipped with <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd>. The shortcut is suppressed while focus is inside an `<input>`, `<textarea>`, `<select>`, or any `contenteditable` element, so it never blocks typing. The button advertises this via `aria-keyshortcuts="Alt+Shift+B"` for assistive tech.
194
+ - **Focus indicator.** `#fr-toggle:focus-visible` paints a 2 px outline so keyboard users can see when the button is focused.
195
+ - **Reduced motion / visual emphasis.** Users with `prefers-reduced-motion: reduce` set in their OS get a stylesheet rule that resets `.fr-anchor` back to inherited weight — the bionic anchors disappear without requiring them to click the toggle.
196
+ - **Print.** Anchors are also reset when printing (`@media print`), so the printed output matches normal weight.
197
+
198
+ <br>
199
+
200
+ ## Liquid Filters
201
+
202
+ The plugin registers three Liquid filters you can use anywhere in your templates:
203
+
204
+ | Filter | Description |
205
+ |--------|-------------|
206
+ | `reading_time` | Returns a string like `"4 min"` based on a 250 wpm reading rate. HTML tags are stripped before counting. |
207
+ | `word_count` | Returns the integer word count of the input, ignoring HTML tags. |
208
+ | `fast_reader` | Wraps anchors around the words of a plain string — useful for excerpts, headings, or any text you want anchored without going through the document-level pipeline. Honors site-level `stop_words_extra`. |
209
+
210
+ ```liquid
211
+ <p class="meta">{{ content | reading_time }} · {{ content | word_count }} words</p>
212
+
213
+ <h2>{{ post.title | fast_reader }}</h2>
214
+ ```
215
+
216
+ <br>
217
+
218
+ ## How It Works
219
+
220
+ On each Jekyll build the plugin registers a post-render hook. For every document in the configured collections (and any page or document with `fast_reader: true` in its front matter) it:
221
+
222
+ 1. Parses the rendered HTML with Nokogiri
223
+ 2. Walks all text nodes, skipping `exclude_selectors`
224
+ 3. Tokenizes text into words, whitespace, and punctuation
225
+ 4. Skips stop words, tokens containing digits, and non-Latin tokens
226
+ 5. Wraps the opening characters of each qualifying word in `<span class="fr-anchor">`
227
+ 6. Replaces each text node with the enhanced fragment
228
+
229
+ The shipped stylesheet supports both `default_on` modes:
230
+
231
+ ```css
232
+ /* default_on: true (default) — bold by default, toggle disables */
233
+ .fr-anchor { font-weight: bold; }
234
+ .fr-disabled .fr-anchor { font-weight: inherit; }
235
+
236
+ /* default_on: false — inherited weight by default, toggle activates */
237
+ .fr-opt-in .fr-anchor { font-weight: inherit; }
238
+ .fr-opt-in.fast-reader .fr-anchor { font-weight: bold; }
239
+ ```
240
+
241
+ In `default_on: false` mode the plugin adds `fr-opt-in` to `<body>` at build time so the second pair of selectors becomes active. The toggle button (when enabled) flips the appropriate body class for the current mode.
242
+
243
+ The toggle button carries `data-fr-mode="default-on"` or `data-fr-mode="opt-in"`; the external `fast-reader.js` reads that attribute to decide whether to flip `fr-disabled` or `fast-reader` on `<body>`, then mirrors the choice into `localStorage["fr-state"]` so it sticks across pages.
244
+
245
+ Anchor length scales with word length:
246
+
247
+ | Word length | Bolded characters |
248
+ |-------------|-------------------|
249
+ | 1–3 | 1 |
250
+ | 4–6 | 2 |
251
+ | 7–9 | 3 |
252
+ | 10+ | 40% (ceiling) |
253
+
254
+ <br>
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ bundle install
260
+ bundle exec rake spec # run the test suite (RSpec, with SimpleCov coverage)
261
+ ```
262
+
263
+ <br>
264
+
265
+ ## Contributing
266
+
267
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/developerlee79/jekyll-fast-reader). This project is intended to be a safe, welcoming space for collaboration.
268
+
269
+ <br>
270
+
271
+ ## License
272
+
273
+ [MIT](LICENSE.txt)
@@ -0,0 +1,42 @@
1
+ .fr-anchor {
2
+ font-weight: bold;
3
+ }
4
+
5
+ .fr-disabled .fr-anchor {
6
+ font-weight: inherit;
7
+ }
8
+
9
+ .fr-opt-in .fr-anchor {
10
+ font-weight: inherit;
11
+ }
12
+
13
+ .fr-opt-in.fast-reader .fr-anchor {
14
+ font-weight: bold;
15
+ }
16
+
17
+ #fr-toggle {
18
+ position: fixed;
19
+ bottom: 1rem;
20
+ right: 1rem;
21
+ z-index: 9999;
22
+ padding: 0.4rem 0.8rem;
23
+ font-size: 0.8rem;
24
+ cursor: pointer;
25
+ }
26
+
27
+ #fr-toggle:focus-visible {
28
+ outline: 2px solid currentColor;
29
+ outline-offset: 2px;
30
+ }
31
+
32
+ @media print {
33
+ .fr-anchor {
34
+ font-weight: inherit;
35
+ }
36
+ }
37
+
38
+ @media (prefers-reduced-motion: reduce) {
39
+ .fr-anchor {
40
+ font-weight: inherit;
41
+ }
42
+ }
@@ -0,0 +1,47 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ var STORAGE_KEY = 'fr-state';
5
+ var btn = document.getElementById('fr-toggle');
6
+ if (!btn) return;
7
+
8
+ var mode = btn.getAttribute('data-fr-mode');
9
+
10
+ function applyState(active) {
11
+ if (mode === 'opt-in') {
12
+ document.body.classList.toggle('fast-reader', active);
13
+ } else {
14
+ document.body.classList.toggle('fr-disabled', !active);
15
+ }
16
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
17
+ }
18
+
19
+ try {
20
+ var saved = localStorage.getItem(STORAGE_KEY);
21
+ if (saved === 'on') applyState(true);
22
+ else if (saved === 'off') applyState(false);
23
+ } catch (e) {
24
+ // localStorage may be unavailable (private mode, disabled cookies, etc.)
25
+ }
26
+
27
+ btn.addEventListener('click', function () {
28
+ var active = btn.getAttribute('aria-pressed') !== 'true';
29
+ applyState(active);
30
+ try {
31
+ localStorage.setItem(STORAGE_KEY, active ? 'on' : 'off');
32
+ } catch (e) {
33
+ // ignore
34
+ }
35
+ });
36
+
37
+ document.addEventListener('keydown', function (e) {
38
+ if (!e.altKey || !e.shiftKey) return;
39
+ if (e.key !== 'B' && e.key !== 'b') return;
40
+
41
+ var t = e.target;
42
+ if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
43
+
44
+ e.preventDefault();
45
+ btn.click();
46
+ });
47
+ })();
@@ -0,0 +1,23 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class AnchorCalculator
4
+ BUCKET_MAP = {
5
+ 1 => 1,
6
+ 2 => 1,
7
+ 3 => 1,
8
+ 4 => 2,
9
+ 5 => 2,
10
+ 6 => 2,
11
+ 7 => 3,
12
+ 8 => 3,
13
+ 9 => 3
14
+ }.freeze
15
+
16
+ LONG_WORD_RATIO = 0.4
17
+
18
+ def self.anchor_length(word_length)
19
+ BUCKET_MAP.fetch(word_length) { (word_length * LONG_WORD_RATIO).ceil }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class AssetGenerator < Jekyll::Generator
4
+ priority :low
5
+
6
+ def generate(site)
7
+ config = site.instance_variable_get(:@fast_reader_config)
8
+ return unless config&.enabled?
9
+
10
+ ensure_asset(site, "fast-reader.css")
11
+ ensure_asset(site, "fast-reader.js") if config.toggle
12
+ end
13
+
14
+ private
15
+
16
+ def ensure_asset(site, filename)
17
+ return if site.static_files.any? { |f| f.is_a?(AssetStaticFile) && f.name == filename }
18
+
19
+ site.static_files << AssetStaticFile.new(site, filename)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class AssetStaticFile < Jekyll::StaticFile
4
+ GEM_ROOT = File.expand_path("../../..", __dir__).freeze
5
+
6
+ def initialize(site, filename = "fast-reader.css")
7
+ super(site, GEM_ROOT, "assets", filename)
8
+ end
9
+
10
+ # Jekyll::StaticFile caches source mtimes at the class level (::mtimes).
11
+ # With jekyll-polyglot, multiple language passes share the same process,
12
+ # so the cache entry written by the default-lang pass causes subsequent
13
+ # passes to see modified? == false and skip writing the language-prefixed
14
+ # copy. Always returning true ensures every pass writes its own copy.
15
+ def modified?
16
+ true
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ module Jekyll
2
+ module FastReader
3
+ # Single source of truth for the character classes shared by the tokenizer
4
+ # and the text processor. Keeping these in one place prevents the regexes
5
+ # from drifting apart (which previously caused inconsistent handling of the
6
+ # left curly quote U+2018).
7
+ module Characters
8
+ # Non-letter characters allowed inside a word: straight apostrophe (\x27),
9
+ # left/right curly quotes (U+2018/U+2019), and hyphen. Written as escapes
10
+ # so the source stays unambiguous in any editor.
11
+ INNER_JOINERS = "\\x27\\u2018\\u2019\\-".freeze
12
+
13
+ # Matches one such joiner, e.g. for stripping them before counting letters.
14
+ INNER_JOINERS_CLASS = /[#{INNER_JOINERS}]/.freeze
15
+
16
+ # Matches a single latin word, which must start and end with a letter and
17
+ # may contain joiners in between.
18
+ WORD = /[a-zA-Z](?:[a-zA-Z#{INNER_JOINERS}]*[a-zA-Z])?/.freeze
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,77 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class Configuration
4
+ DEFAULTS = {
5
+ "enabled" => true,
6
+ "collections" => ["posts"],
7
+ "exclude_selectors" => %w[code pre script style kbd samp],
8
+ "css_output_path" => "/assets/fast-reader.css",
9
+ "js_output_path" => "/assets/fast-reader.js",
10
+ "stop_words_extra" => [],
11
+ "toggle" => false,
12
+ "default_on" => true
13
+ }.freeze
14
+
15
+ attr_reader :exclude_selectors, :css_output_path, :js_output_path,
16
+ :stop_words_extra, :baseurl, :toggle, :default_on
17
+
18
+ def self.from(site)
19
+ new(site.config["fast_reader"] || {}, site.config["baseurl"].to_s.chomp("/"))
20
+ end
21
+
22
+ def initialize(config, baseurl = "")
23
+ merged = DEFAULTS.merge(config)
24
+ @enabled = merged["enabled"]
25
+ @collections_map = normalize_collections(merged["collections"])
26
+ @exclude_selectors = Array(merged["exclude_selectors"])
27
+ @css_output_path = merged["css_output_path"]
28
+ @js_output_path = merged["js_output_path"]
29
+ @stop_words_extra = Array(merged["stop_words_extra"]).map(&:to_s)
30
+ @toggle = merged["toggle"]
31
+ @default_on = merged["default_on"]
32
+ @baseurl = baseurl
33
+ freeze
34
+ end
35
+
36
+ def enabled?
37
+ @enabled
38
+ end
39
+
40
+ def collection_enabled?(label)
41
+ return false unless @collections_map.key?(label)
42
+
43
+ @collections_map[label] != false
44
+ end
45
+
46
+ def stop_words_for(label)
47
+ options = @collections_map[label]
48
+ return @stop_words_extra unless options.is_a?(Hash)
49
+
50
+ per_collection = Array(options["stop_words_extra"]).map(&:to_s)
51
+ per_collection + @stop_words_extra
52
+ end
53
+
54
+ private
55
+
56
+ def normalize_collections(value)
57
+ case value
58
+ when Array
59
+ value.each_with_object({}) { |label, h| h[label.to_s] = true }
60
+ when Hash
61
+ value.each_with_object({}) do |(k, v), h|
62
+ h[k.to_s] = normalize_collection_value(v)
63
+ end
64
+ else
65
+ {}
66
+ end
67
+ end
68
+
69
+ def normalize_collection_value(value)
70
+ return false if value == false
71
+ return value.transform_keys(&:to_s) if value.is_a?(Hash)
72
+
73
+ true
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,36 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class DocumentFilter
4
+ def initialize(config)
5
+ @config = config
6
+ end
7
+
8
+ def process?(document)
9
+ return false unless @config.enabled?
10
+
11
+ override = front_matter_override(document)
12
+ return override unless override.nil?
13
+
14
+ in_configured_collection?(document)
15
+ end
16
+
17
+ private
18
+
19
+ def front_matter_override(document)
20
+ return nil unless document.respond_to?(:data)
21
+
22
+ value = document.data["fast_reader"]
23
+ return nil unless value == true || value == false
24
+
25
+ value
26
+ end
27
+
28
+ def in_configured_collection?(document)
29
+ return false unless document.respond_to?(:collection)
30
+ return false if document.collection.nil?
31
+
32
+ @config.collection_enabled?(document.collection.label)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ module Jekyll
2
+ module FastReader
3
+ module Hooks
4
+ def self.register!
5
+ Jekyll::Hooks.register :site, :after_init do |site|
6
+ config = Configuration.from(site)
7
+ site.instance_variable_set(:@fast_reader_config, config)
8
+ site.instance_variable_set(:@fast_reader_filter, DocumentFilter.new(config))
9
+ site.instance_variable_set(:@fast_reader_transformer, Transformer.new(config))
10
+ end
11
+
12
+ %i[documents pages].each do |scope|
13
+ Jekyll::Hooks.register scope, :post_render do |renderable|
14
+ apply_transform(renderable)
15
+ end
16
+ end
17
+ end
18
+
19
+ def self.apply_transform(renderable)
20
+ site = renderable.site
21
+ config = site.instance_variable_get(:@fast_reader_config)
22
+ filter = site.instance_variable_get(:@fast_reader_filter)
23
+ transformer = site.instance_variable_get(:@fast_reader_transformer)
24
+ return unless config && filter && transformer && filter.process?(renderable)
25
+
26
+ label = renderable.collection&.label if renderable.respond_to?(:collection)
27
+ stop_words = label ? config.stop_words_for(label) : config.stop_words_extra
28
+ renderable.output = transformer.call(renderable.output, stop_words: stop_words)
29
+ end
30
+ private_class_method :apply_transform
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,53 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class HtmlWalker
4
+ def initialize(exclude_selectors)
5
+ @exclude_selectors = exclude_selectors
6
+ end
7
+
8
+ def walk(root, &block)
9
+ return if element_excluded?(root)
10
+
11
+ root.children.each do |node|
12
+ if node.text?
13
+ block.call(node) unless trails_anchor?(node)
14
+ elsif node.element?
15
+ walk(node, &block)
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ # The text immediately following an fr-anchor span is the unbolded tail of
23
+ # an already-processed word. Re-processing it would re-anchor that tail, so
24
+ # it is left alone. This keeps the transform idempotent and stops the
25
+ # fast_reader Liquid filter from colliding with the automatic pass.
26
+ def trails_anchor?(node)
27
+ previous = node.previous_sibling
28
+ previous&.element? && already_anchored?(previous)
29
+ end
30
+
31
+ def element_excluded?(node)
32
+ return false unless node.respond_to?(:element?) && node.element?
33
+ return true if node.respond_to?(:[]) && node["data-fr-skip"]
34
+ return true if already_anchored?(node)
35
+
36
+ @exclude_selectors.any? do |selector|
37
+ node.matches?(selector)
38
+ rescue Nokogiri::CSS::SyntaxError
39
+ Jekyll.logger.warn "FastReader:", "Invalid CSS selector ignored: #{selector.inspect}"
40
+ false
41
+ end
42
+ end
43
+
44
+ # Already-anchored spans (e.g. emitted by the fast_reader Liquid filter)
45
+ # must not be descended into, otherwise a second pass nests fr-anchor spans.
46
+ def already_anchored?(node)
47
+ return false unless node.respond_to?(:[])
48
+
49
+ node["class"].to_s.split.include?("fr-anchor")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ require "liquid"
2
+ require "nokogiri"
3
+
4
+ module Jekyll
5
+ module FastReader
6
+ module LiquidFilters
7
+ WORDS_PER_MINUTE = 250
8
+
9
+ def reading_time(input)
10
+ minutes = (fr_word_count(input).to_f / WORDS_PER_MINUTE).ceil
11
+ minutes = 1 if minutes < 1
12
+ "#{minutes} min"
13
+ end
14
+
15
+ def word_count(input)
16
+ fr_word_count(input)
17
+ end
18
+
19
+ def fast_reader(input)
20
+ config = fr_site_config
21
+ processor = TextProcessor.new(StopWords.new(config.stop_words_extra))
22
+ processor.process(input.to_s)
23
+ end
24
+
25
+ private
26
+
27
+ def fr_word_count(input)
28
+ Nokogiri::HTML.fragment(input.to_s).text.scan(/\S+/).length
29
+ end
30
+
31
+ def fr_site_config
32
+ site = @context && @context.registers && @context.registers[:site]
33
+ site&.instance_variable_get(:@fast_reader_config) || Configuration.new({})
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ Liquid::Template.register_filter(Jekyll::FastReader::LiquidFilters)
@@ -0,0 +1,21 @@
1
+ require "set"
2
+
3
+ module Jekyll
4
+ module FastReader
5
+ class StopWords
6
+ DEFAULT = Set.new(%w[
7
+ a an the of in on at to by or is as
8
+ and but for nor so yet
9
+ it its this that
10
+ ]).freeze
11
+
12
+ def initialize(extra = [])
13
+ @words = DEFAULT | Set.new(extra.map(&:downcase))
14
+ end
15
+
16
+ def stop?(word)
17
+ @words.include?(word.downcase)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,52 @@
1
+ require "cgi"
2
+
3
+ module Jekyll
4
+ module FastReader
5
+ class TextProcessor
6
+ def initialize(stop_words)
7
+ @stop_words = stop_words
8
+ end
9
+
10
+ def process(text)
11
+ Tokenizer.tokenize(text).map { |type, value| transform(type, value) }.join
12
+ end
13
+
14
+ private
15
+
16
+ def transform(type, value)
17
+ return CGI.escape_html(value) if type == :punctuation
18
+ return value unless type == :word
19
+ return value if @stop_words.stop?(value)
20
+
21
+ value.include?("-") ? wrap_hyphenated(value) : wrap_simple(value)
22
+ end
23
+
24
+ def wrap_hyphenated(word)
25
+ word.split("-").map { |part| wrap_simple(part) }.join("-")
26
+ end
27
+
28
+ def wrap_simple(word)
29
+ letters = word.gsub(Characters::INNER_JOINERS_CLASS, "")
30
+ return CGI.escape_html(word) if letters.empty?
31
+
32
+ anchor_len = AnchorCalculator.anchor_length(letters.length)
33
+ split_at = find_split_position(word, anchor_len)
34
+ anchor = word[0...split_at]
35
+ rest = word[split_at..]
36
+
37
+ return CGI.escape_html(word) if rest.nil? || rest.empty?
38
+
39
+ %(<span class="fr-anchor">#{CGI.escape_html(anchor)}</span>#{CGI.escape_html(rest)})
40
+ end
41
+
42
+ def find_split_position(word, anchor_len)
43
+ letter_count = 0
44
+ word.each_char.with_index do |char, i|
45
+ letter_count += 1 if char.match?(/[a-zA-Z]/)
46
+ return i + 1 if letter_count >= anchor_len
47
+ end
48
+ word.length
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ module Jekyll
2
+ module FastReader
3
+ class Tokenizer
4
+ PATTERN = /(#{Characters::WORD})|(\s+)|([^\sa-zA-Z]+)/
5
+
6
+ def self.tokenize(text)
7
+ text.scan(PATTERN).map do |word, whitespace, punctuation|
8
+ if word
9
+ [:word, word]
10
+ elsif whitespace
11
+ [:whitespace, whitespace]
12
+ else
13
+ [:punctuation, punctuation]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,86 @@
1
+ require "nokogiri"
2
+
3
+ module Jekyll
4
+ module FastReader
5
+ class Transformer
6
+ def initialize(config)
7
+ @config = config
8
+ @walker = HtmlWalker.new(config.exclude_selectors)
9
+ end
10
+
11
+ def call(html, stop_words: nil)
12
+ return html unless html =~ /<body\b/i
13
+
14
+ had_head = html.include?("</head>")
15
+ had_body = html.include?("</body>")
16
+
17
+ doc = Nokogiri::HTML(html)
18
+ body = doc.at_css("body")
19
+ return html unless body
20
+
21
+ words = stop_words || @config.stop_words_extra
22
+ processor = TextProcessor.new(StopWords.new(words))
23
+
24
+ text_nodes = []
25
+ @walker.walk(body) { |node| text_nodes << node }
26
+ text_nodes.each { |node| node.replace(processor.process(node.content)) }
27
+
28
+ inject_stylesheet(doc) if had_head
29
+ inject_script(doc) if had_head && @config.toggle
30
+ inject_body_class(body, "fr-opt-in") unless @config.default_on
31
+ inject_toggle(body) if had_body && @config.toggle
32
+
33
+ doc.to_html
34
+ end
35
+
36
+ private
37
+
38
+ def inject_stylesheet(doc)
39
+ head = doc.at_css("head")
40
+ return unless head
41
+
42
+ href = "#{@config.baseurl}#{@config.css_output_path}"
43
+ return if head.css('link[rel="stylesheet"]').any? { |l| l["href"] == href }
44
+
45
+ link = Nokogiri::XML::Node.new("link", doc)
46
+ link["rel"] = "stylesheet"
47
+ link["href"] = href
48
+ head.add_child(link)
49
+ end
50
+
51
+ def inject_script(doc)
52
+ head = doc.at_css("head")
53
+ return unless head
54
+
55
+ src = "#{@config.baseurl}#{@config.js_output_path}"
56
+ return if head.css("script").any? { |s| s["src"] == src }
57
+
58
+ script = Nokogiri::XML::Node.new("script", doc)
59
+ script["src"] = src
60
+ script["defer"] = "defer"
61
+ head.add_child(script)
62
+ end
63
+
64
+ def inject_body_class(body, class_name)
65
+ classes = body["class"].to_s.split
66
+ return if classes.include?(class_name)
67
+
68
+ body["class"] = (classes + [class_name]).join(" ")
69
+ end
70
+
71
+ def inject_toggle(body)
72
+ return if body.at_css("#fr-toggle")
73
+
74
+ button = Nokogiri::XML::Node.new("button", body.document)
75
+ button["id"] = "fr-toggle"
76
+ button["type"] = "button"
77
+ button["aria-label"] = "Toggle Fast Reader"
78
+ button["aria-pressed"] = @config.default_on ? "true" : "false"
79
+ button["aria-keyshortcuts"] = "Alt+Shift+B"
80
+ button["data-fr-mode"] = @config.default_on ? "default-on" : "opt-in"
81
+ button.content = "Fast Reader"
82
+ body.add_child(button)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,5 @@
1
+ module Jekyll
2
+ module FastReader
3
+ VERSION = "0.1.1"
4
+ end
5
+ end
@@ -0,0 +1,21 @@
1
+ require "jekyll"
2
+ require "nokogiri"
3
+ require "cgi"
4
+ require "set"
5
+
6
+ require_relative "jekyll/fast_reader/version"
7
+ require_relative "jekyll/fast_reader/configuration"
8
+ require_relative "jekyll/fast_reader/stop_words"
9
+ require_relative "jekyll/fast_reader/characters"
10
+ require_relative "jekyll/fast_reader/anchor_calculator"
11
+ require_relative "jekyll/fast_reader/tokenizer"
12
+ require_relative "jekyll/fast_reader/text_processor"
13
+ require_relative "jekyll/fast_reader/html_walker"
14
+ require_relative "jekyll/fast_reader/transformer"
15
+ require_relative "jekyll/fast_reader/document_filter"
16
+ require_relative "jekyll/fast_reader/asset_static_file"
17
+ require_relative "jekyll/fast_reader/asset_generator"
18
+ require_relative "jekyll/fast_reader/hooks"
19
+ require_relative "jekyll/fast_reader/liquid_filters"
20
+
21
+ Jekyll::FastReader::Hooks.register!
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-fast-reader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - developerlee79
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-04 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: '4.0'
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: '4.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: nokogiri
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.15.6
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '2.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 1.15.6
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: rspec
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '3.12'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.12'
67
+ - !ruby/object:Gem::Dependency
68
+ name: rake
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '13.0'
74
+ type: :development
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '13.0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: simplecov
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '0.22'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '0.22'
95
+ description: Transforms Jekyll post HTML by bolding the initial characters of each
96
+ word, optimised for fast visual reading. Toggle on/off via a CSS class.
97
+ email:
98
+ - developerlee79@users.noreply.github.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - LICENSE.txt
104
+ - README.md
105
+ - assets/fast-reader.css
106
+ - assets/fast-reader.js
107
+ - lib/jekyll-fast-reader.rb
108
+ - lib/jekyll/fast_reader/anchor_calculator.rb
109
+ - lib/jekyll/fast_reader/asset_generator.rb
110
+ - lib/jekyll/fast_reader/asset_static_file.rb
111
+ - lib/jekyll/fast_reader/characters.rb
112
+ - lib/jekyll/fast_reader/configuration.rb
113
+ - lib/jekyll/fast_reader/document_filter.rb
114
+ - lib/jekyll/fast_reader/hooks.rb
115
+ - lib/jekyll/fast_reader/html_walker.rb
116
+ - lib/jekyll/fast_reader/liquid_filters.rb
117
+ - lib/jekyll/fast_reader/stop_words.rb
118
+ - lib/jekyll/fast_reader/text_processor.rb
119
+ - lib/jekyll/fast_reader/tokenizer.rb
120
+ - lib/jekyll/fast_reader/transformer.rb
121
+ - lib/jekyll/fast_reader/version.rb
122
+ homepage: https://github.com/developerlee79/jekyll-fast-reader
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ homepage_uri: https://github.com/developerlee79/jekyll-fast-reader
127
+ source_code_uri: https://github.com/developerlee79/jekyll-fast-reader
128
+ changelog_uri: https://github.com/developerlee79/jekyll-fast-reader/blob/main/CHANGELOG.md
129
+ post_install_message:
130
+ rdoc_options: []
131
+ require_paths:
132
+ - lib
133
+ required_ruby_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: 2.7.0
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ requirements: []
144
+ rubygems_version: 3.1.6
145
+ signing_key:
146
+ specification_version: 4
147
+ summary: Jekyll plugin for accelerated reading via visual word anchoring
148
+ test_files: []