bible270 1.7.3 → 1.8.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 +4 -4
- data/CHANGELOG.md +2 -0
- data/README.md +32 -1
- data/app/controllers/bible270/readers_controller.rb +1 -0
- data/app/helpers/bible270/plan_helper.rb +5 -5
- data/app/views/bible270/admin/show.html.erb +22 -21
- data/app/views/bible270/comments/_editor.html.erb +1 -0
- data/app/views/bible270/notice_mailer/comment_notice.html.erb +1 -1
- data/app/views/bible270/profiles/edit.html.erb +1 -0
- data/app/views/bible270/readers/index.html.erb +1 -1
- data/app/views/bible270/readers/progress.html.erb +1 -1
- data/app/views/bible270/readers/show.html.erb +1 -1
- data/app/views/bible270/shared/_day_index.html.erb +2 -2
- data/app/views/bible270/shared/_markdown_toolbar.html.erb +41 -0
- data/app/views/bible270/shared/_reflections.html.erb +5 -1
- data/app/views/bible270/shared/_styles.html.erb +6 -0
- data/app/views/bible270/shared/_video_embeds.html.erb +46 -0
- data/app/views/layouts/bible270/application.html.erb +2 -1
- data/lib/bible270/comment_formatter.rb +47 -3
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270/youtube_video.rb +41 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8847a6c76ffa4c376eba0f756941756b7bee81ef73006db8eed81dcc536243c
|
|
4
|
+
data.tar.gz: af808d62015dcae17eb0be8811f20d3f2363854f34416f6e431b55737be34c2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b7794ad79750aa7c6111ffd5662780501690144b675bef3cf6e74cfac042c5e74f0b21ebac9c24bdf582001605db82a4d3d2bbf79f88220478d1ccee828ae1d
|
|
7
|
+
data.tar.gz: 29ed3d365cdf2dbe514a900d33bbf2db1a100270c8edf5db274f89496f3e29960acd1973f2484c71e7225beb27ca0f702bbedf7d17408ccba5ad1ea1f6c2b1b6
|
data/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,13 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
+
- add a YouTube Video formatting button for reflections and replies, with optional video names, validated click-to-load players in previews and web views, privacy tooltips, new-tab YouTube links, no autoplay or remote thumbnails, and ordinary video links in notification emails
|
|
9
10
|
- show Blue Letter Bible Scripture popups when readers hover over references in rendered reflections, replies, and Markdown previews; popups follow each reader's selected translation, handle original-language choices by testament, and rescan content added by Turbo without forcing a page reload
|
|
10
11
|
- let administrators sort the reader list and Reader Stats report by most or least recent reading activity, while keeping readers with no activity at the end
|
|
11
12
|
|
|
12
13
|
### Changed
|
|
13
14
|
|
|
15
|
+
- shorten the Community introduction and link signed-in readers to today's Day, make recent reflections editable from My Progress, expand the 270-day grids by default on reader and Profile pages, and move Admin completions below translation settings
|
|
14
16
|
- link reader email addresses directly to email composition and link recent-activity day labels to their Day pages with passage tooltips in Admin
|
|
15
17
|
- include each reader's last reading-activity date in the `bible270:readers:list` and Admin Reader Stats reports
|
|
16
18
|
|
data/README.md
CHANGED
|
@@ -287,7 +287,7 @@ their previous visit. Reflection and reply drafts are retained for up to 30 days
|
|
|
287
287
|
cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
|
|
288
288
|
Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
|
|
289
289
|
work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
|
|
290
|
-
quotations, bulleted and numbered lists, and
|
|
290
|
+
quotations, bulleted and numbered lists, links, and YouTube videos without requiring readers to know Markdown syntax.
|
|
291
291
|
A separate **Preview** control checks the current draft through the same server-side sanitizer used
|
|
292
292
|
after posting; formatting tools stay out of sight until requested. The source remains
|
|
293
293
|
editable in the ordinary textarea, drafts preserve its format, and all rendered HTML is restricted to that small safe
|
|
@@ -295,6 +295,31 @@ set. Existing reflections remain literal plain text until a reader intentionally
|
|
|
295
295
|
them. Upgrading hosts must copy and run migration `20260101000021`, which records whether each body is
|
|
296
296
|
plain text or Markdown; until then, the composer safely falls back to plain text.
|
|
297
297
|
|
|
298
|
+
The **Video** button accepts YouTube watch, share (`youtu.be`), Shorts, live, and embed URLs,
|
|
299
|
+
then asks for an optional video name. Leave it blank for **View video**, or name it to show
|
|
300
|
+
**View video ‘Name’**. The name is stored as the link text and is also used in notification emails.
|
|
301
|
+
It inserts an explicit Markdown link on its own paragraph, for example:
|
|
302
|
+
|
|
303
|
+
```markdown
|
|
304
|
+
[YouTube video](https://www.youtube.com/watch?v=aB3_dE-fG12 "bible270-video")
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Preview and posted reflections/replies show a small **View video** card. No YouTube player or remote
|
|
308
|
+
thumbnail is requested until the reader loads the video; the player uses `www.youtube-nocookie.com`
|
|
309
|
+
and does not autoplay. Loading it shares the reader's IP address and request metadata with YouTube;
|
|
310
|
+
privacy-enhanced mode does not make playback anonymous. The button tooltip reads “Loads player and
|
|
311
|
+
shares data with YouTube”; the link tooltip reads “Opens YouTube in a new tab.” An **Open on YouTube** link opens in a new
|
|
312
|
+
tab/window with `rel="nofollow ugc noopener"`, and remains available when JavaScript is disabled or
|
|
313
|
+
the provider disallows embedding. Notification emails retain ordinary
|
|
314
|
+
links, and unmarked links never become players automatically. Only validated YouTube URLs become
|
|
315
|
+
cards; unsupported marked links remain links. No uploads, API keys, extra gems, or database migrations
|
|
316
|
+
are needed for this feature. Shared links' start times and playlist parameters are not retained.
|
|
317
|
+
|
|
318
|
+
Hosts using Content Security Policy must add `https://www.youtube-nocookie.com` to their existing
|
|
319
|
+
`frame-src` directive. Do not replace the rest of the host's policy or broadly allow arbitrary frames.
|
|
320
|
+
Hosts with a custom engine layout should also render `bible270/shared/video_embeds` once, alongside the
|
|
321
|
+
other shared scripts, to enable click-to-load playback.
|
|
322
|
+
|
|
298
323
|
When `config.mention_notifications` is enabled, each reader can request email for every new reflection
|
|
299
324
|
and reply, only replies to their reflections and mentions of them, or none. Broad notifications are sent
|
|
300
325
|
once with eligible subscribers in BCC; direct reply and mention notices remain individually addressed.
|
|
@@ -632,6 +657,7 @@ The engine's CSS rides in a partial its own layout renders. Using your layout? A
|
|
|
632
657
|
```bash
|
|
633
658
|
bundle install
|
|
634
659
|
bundle exec rake test
|
|
660
|
+
node --test test/javascript/*.cjs
|
|
635
661
|
bundle exec rubocop --cache false
|
|
636
662
|
```
|
|
637
663
|
|
|
@@ -643,6 +669,11 @@ subsequent balancing. Set `PARALLEL_WORKERS` to tune the worker count. Use
|
|
|
643
669
|
`bundle exec rake test:serial` when a serial coverage run is specifically needed; it writes to the same
|
|
644
670
|
project-local `coverage/` directory.
|
|
645
671
|
|
|
672
|
+
The video UI tests use Node.js 22+ and its built-in test runner, with no npm dependencies. They run the
|
|
673
|
+
actual toolbar/player scripts against a small mocked DOM and also run as a separate CI job. They do
|
|
674
|
+
not contact YouTube; live playback should be checked in a browser on the deployed host, especially
|
|
675
|
+
when a host supplies a Content Security Policy.
|
|
676
|
+
|
|
646
677
|
### Get an email link for local testing
|
|
647
678
|
|
|
648
679
|
in the rails console: `rails c`
|
|
@@ -6,6 +6,7 @@ module Bible270
|
|
|
6
6
|
# Community is a directory of fellow readers, not a leaderboard. Keep the
|
|
7
7
|
# order predictable without implying that progress determines standing.
|
|
8
8
|
@readers = Reader.all.to_a.sort_by(&:sort_name)
|
|
9
|
+
@today_day = current_reader&.today_day
|
|
9
10
|
@show_admin_progress = Bible270.config.admin?(current_reader)
|
|
10
11
|
@days_completed = Reader.completed_days_by_id if @show_admin_progress
|
|
11
12
|
end
|
|
@@ -53,18 +53,18 @@ module Bible270
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Formats every reflection surface through the same sanitizer.
|
|
57
|
-
#
|
|
58
|
-
def b270_comment_body(comment, link_mentions: true)
|
|
56
|
+
# Formats every reflection surface through the same sanitizer. Emails opt out
|
|
57
|
+
# of interactive video cards and mention links requiring a mounted request path.
|
|
58
|
+
def b270_comment_body(comment, link_mentions: true, embed_videos: true)
|
|
59
59
|
format = comment.rendered_body_format
|
|
60
60
|
html = if link_mentions
|
|
61
61
|
readers = Bible270::Reader.mentioned_in(comment.body)
|
|
62
|
-
Bible270::CommentFormatter.html(comment.body, format: format) do |handle|
|
|
62
|
+
Bible270::CommentFormatter.html(comment.body, format: format, embed_videos: embed_videos) do |handle|
|
|
63
63
|
reader = readers.find { |candidate| candidate.answers_to?(handle) }
|
|
64
64
|
reader_path(reader) if reader
|
|
65
65
|
end
|
|
66
66
|
else
|
|
67
|
-
Bible270::CommentFormatter.html(comment.body, format: format)
|
|
67
|
+
Bible270::CommentFormatter.html(comment.body, format: format, embed_videos: embed_videos)
|
|
68
68
|
end
|
|
69
69
|
html.html_safe
|
|
70
70
|
end
|
|
@@ -113,27 +113,6 @@
|
|
|
113
113
|
numbers and are not touched.</p>
|
|
114
114
|
</div>
|
|
115
115
|
|
|
116
|
-
<div class="b270-panel">
|
|
117
|
-
<h2 class="b270-subhead">Completions</h2>
|
|
118
|
-
<%= form_with url: admin_reader_through_path(@reader), method: :patch, class: "b270-startform" do %>
|
|
119
|
-
<label class="b270-field">
|
|
120
|
-
<span>Mark complete through day (0 clears everything)</span>
|
|
121
|
-
<%= number_field_tag :day, @days_completed, min: 0, max: Bible270::Plan::DAYS %>
|
|
122
|
-
</label>
|
|
123
|
-
<%= submit_tag 'Apply', class: 'b270-btn' %>
|
|
124
|
-
<% end %>
|
|
125
|
-
<p class="b270-hint">This also clears anything after that day, so it sets their progress exactly.</p>
|
|
126
|
-
|
|
127
|
-
<div class="b270-grid" style="margin-top:16px">
|
|
128
|
-
<% (1..Bible270::Plan::DAYS).each do |d| %>
|
|
129
|
-
<%= button_to d, admin_reader_day_path(@reader, d), method: :post,
|
|
130
|
-
class: b270_day_cell_classes(d, reader: @reader),
|
|
131
|
-
title: b270_day_readings(d),
|
|
132
|
-
form: { style: 'display:contents', id: "day-#{d}" } %>
|
|
133
|
-
<% end %>
|
|
134
|
-
</div>
|
|
135
|
-
<p class="b270-hint">Click a day to toggle it complete.</p>
|
|
136
|
-
</div>
|
|
137
116
|
|
|
138
117
|
<div class="b270-panel">
|
|
139
118
|
<%= render "bible270/shared/reflections", comments: @comments, moderate: true %>
|
|
@@ -162,6 +141,28 @@
|
|
|
162
141
|
<% end %>
|
|
163
142
|
</div>
|
|
164
143
|
|
|
144
|
+
<div class="b270-panel">
|
|
145
|
+
<h2 class="b270-subhead">Completions</h2>
|
|
146
|
+
<%= form_with url: admin_reader_through_path(@reader), method: :patch, class: "b270-startform" do %>
|
|
147
|
+
<label class="b270-field">
|
|
148
|
+
<span>Mark complete through day (0 clears everything)</span>
|
|
149
|
+
<%= number_field_tag :day, @days_completed, min: 0, max: Bible270::Plan::DAYS %>
|
|
150
|
+
</label>
|
|
151
|
+
<%= submit_tag 'Apply', class: 'b270-btn' %>
|
|
152
|
+
<% end %>
|
|
153
|
+
<p class="b270-hint">This also clears anything after that day, so it sets their progress exactly.</p>
|
|
154
|
+
|
|
155
|
+
<div class="b270-grid" style="margin-top:16px">
|
|
156
|
+
<% (1..Bible270::Plan::DAYS).each do |d| %>
|
|
157
|
+
<%= button_to d, admin_reader_day_path(@reader, d), method: :post,
|
|
158
|
+
class: b270_day_cell_classes(d, reader: @reader),
|
|
159
|
+
title: b270_day_readings(d),
|
|
160
|
+
form: { style: 'display:contents', id: "day-#{d}" } %>
|
|
161
|
+
<% end %>
|
|
162
|
+
</div>
|
|
163
|
+
<p class="b270-hint">Click a day to toggle it complete.</p>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
165
166
|
<div class="b270-panel">
|
|
166
167
|
<h2 class="b270-subhead">Remove</h2>
|
|
167
168
|
<p class="b270-hint" style="margin-top:0">Deletes the reader together with every check-off and
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
<button type="button" data-b270-markdown-action="bullet-list" aria-label="Format as a bulleted list" title="Bulleted list">• List</button>
|
|
33
33
|
<button type="button" data-b270-markdown-action="number-list" aria-label="Format as a numbered list" title="Numbered list">1. List</button>
|
|
34
34
|
<button type="button" data-b270-markdown-action="link" aria-label="Add a link" title="Link">Link</button>
|
|
35
|
+
<button type="button" data-b270-markdown-action="video" aria-label="Add a YouTube video" title="YouTube video">Video</button>
|
|
35
36
|
</div>
|
|
36
37
|
</div>
|
|
37
38
|
</div>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<blockquote style="margin:0;padding:14px 16px;background:#ece6da;border-left:3px solid #a5812c;border-radius:8px;font-size:15px;line-height:1.55">
|
|
13
|
-
<%= b270_comment_body(@comment, link_mentions: false) %>
|
|
13
|
+
<%= b270_comment_body(@comment, link_mentions: false, embed_videos: false) %>
|
|
14
14
|
</blockquote>
|
|
15
15
|
|
|
16
16
|
<% if @day_url %>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<% content_for :title, "Community — #{Bible270.config.app_name}" %>
|
|
2
2
|
<p class="b270-eyebrow">Community</p>
|
|
3
3
|
<h1>Reading together</h1>
|
|
4
|
-
<p class="lede">
|
|
4
|
+
<p class="lede">Walking through the Word, sharing reflections and encouraging one another.<% if @today_day %> <%= link_to "Go to today (Day #{@today_day})", day_path(@today_day), class: "b270-todaylink" %><% end %></p>
|
|
5
5
|
|
|
6
6
|
<% if @readers.any? %>
|
|
7
7
|
<div class="b270-list">
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<% if @reader %>
|
|
14
14
|
<div class="b270-panel">
|
|
15
15
|
<%= render "bible270/shared/reflections", comments: @recent_comments,
|
|
16
|
-
title: "Your recent reflections",
|
|
16
|
+
title: "Your recent reflections", editable: true,
|
|
17
17
|
empty_text: "You have not written any reflections yet." %>
|
|
18
18
|
</div>
|
|
19
19
|
<% end %>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
<%# locals: (reader: nil) %>
|
|
1
|
+
<%# locals: (reader: nil, expanded: false) %>
|
|
2
2
|
<% if Bible270::Plan::DAYS.positive? %>
|
|
3
3
|
<% grid_reader = reader || current_reader %>
|
|
4
4
|
<% today = signed_in? && grid_reader == current_reader ? current_reader.today_day : nil %>
|
|
5
|
-
<details class="b270-index"
|
|
5
|
+
<details class="b270-index"<%= " open" if expanded %>>
|
|
6
6
|
<summary>View all <%= Bible270::Plan::DAYS %> days</summary>
|
|
7
7
|
<div class="b270-grid">
|
|
8
8
|
<% (1..Bible270::Plan::DAYS).each do |d| %>
|
|
@@ -105,6 +105,44 @@
|
|
|
105
105
|
update(editor, textarea, replacement, start, finish, 1, selected.length + 1);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
function youtubeVideoId(href) {
|
|
109
|
+
if (/[\s\x00-\x1f\x7f]/.test(href) || /%(?![0-9a-f]{2})/i.test(href)) return null;
|
|
110
|
+
let url;
|
|
111
|
+
try { url = new URL(href); } catch { return null; }
|
|
112
|
+
const hosts = ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"];
|
|
113
|
+
if (!/^https?:\/\//i.test(href) || !hosts.includes(url.hostname) || url.username || url.password || url.port) return null;
|
|
114
|
+
|
|
115
|
+
let id;
|
|
116
|
+
if (url.hostname === "youtu.be" || url.hostname === "www.youtu.be") id = url.pathname.slice(1);
|
|
117
|
+
else if (url.pathname === "/watch") {
|
|
118
|
+
const ids = url.searchParams.getAll("v");
|
|
119
|
+
if (ids.length === 1) id = ids[0];
|
|
120
|
+
} else id = url.pathname.match(/^\/(?:shorts|live|embed)\/([A-Za-z0-9_-]{11})$/)?.[1];
|
|
121
|
+
return /^[A-Za-z0-9_-]{11}$/.test(id || "") ? id : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function addVideo(editor, textarea) {
|
|
125
|
+
const href = window.prompt("YouTube video address (https://…)");
|
|
126
|
+
if (!href) return;
|
|
127
|
+
const id = youtubeVideoId(href.trim());
|
|
128
|
+
if (!id) {
|
|
129
|
+
window.alert("Enter a valid YouTube video URL. Use Link for other video sites.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const name = window.prompt("Video name (optional — leave blank for View video)");
|
|
134
|
+
if (name === null) return;
|
|
135
|
+
// Entity-encode punctuation so a name stays text, not Markdown or HTML.
|
|
136
|
+
const label = (name.trim().replace(/\s+/g, " ") || "YouTube video")
|
|
137
|
+
.replace(/[!-/:-@\[-`{-~]/g, (character) => `&#${character.codePointAt(0)};`);
|
|
138
|
+
const start = textarea.selectionStart;
|
|
139
|
+
const finish = textarea.selectionEnd;
|
|
140
|
+
const marker = `[${label}](https://www.youtube.com/watch?v=${id} "bible270-video")`;
|
|
141
|
+
const before = start > 0 ? "\n\n" : "";
|
|
142
|
+
const replacement = `${before}${marker}\n\n`;
|
|
143
|
+
update(editor, textarea, replacement, start, finish, replacement.length, replacement.length);
|
|
144
|
+
}
|
|
145
|
+
|
|
108
146
|
function requestPreview(editor, state) {
|
|
109
147
|
const textarea = editor.querySelector("textarea[name='comment[body]']");
|
|
110
148
|
const preview = editor.querySelector("[data-b270-preview-panel]");
|
|
@@ -181,6 +219,7 @@
|
|
|
181
219
|
setFormattingOpen(editor, false, false);
|
|
182
220
|
requestPreview(editor, state);
|
|
183
221
|
} else {
|
|
222
|
+
window.Bible270Videos?.reset(previewPanel);
|
|
184
223
|
setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
|
|
185
224
|
const textarea = editor.querySelector("textarea[name='comment[body]']");
|
|
186
225
|
resizeTextarea(textarea);
|
|
@@ -198,6 +237,8 @@
|
|
|
198
237
|
wrap(editor, textarea, "*", "*", "italic text");
|
|
199
238
|
} else if (action === "link") {
|
|
200
239
|
addLink(editor, textarea);
|
|
240
|
+
} else if (action === "video") {
|
|
241
|
+
addVideo(editor, textarea);
|
|
201
242
|
} else {
|
|
202
243
|
prefixLines(editor, textarea, action);
|
|
203
244
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (comments:, title: "Their reflections", moderate: false, empty_text: "None yet.") %>
|
|
1
|
+
<%# locals: (comments:, title: "Their reflections", moderate: false, editable: false, empty_text: "None yet.") %>
|
|
2
2
|
<%#
|
|
3
3
|
A reader's reflections. Three views showed near-identical markup: the admin
|
|
4
4
|
page, a reader's public page, and the progress page, differing only in whether
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
<%= link_to "Day #{comment.day}", day_path(comment.day) %>
|
|
22
22
|
· <%= b270_date(comment.created_at) %>
|
|
23
23
|
<% if moderate && comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
|
|
24
|
+
<% if editable && b270_can_edit?(comment) %>
|
|
25
|
+
<%= link_to "edit", day_path(comment.day, edit: comment.id, anchor: "comment-#{comment.id}"),
|
|
26
|
+
class: "b270-cedit" %>
|
|
27
|
+
<% end %>
|
|
24
28
|
</div>
|
|
25
29
|
|
|
26
30
|
<div class="b270-cbody"><%= b270_comment_body(comment) %></div>
|
|
@@ -265,6 +265,12 @@
|
|
|
265
265
|
.b270-cbody blockquote{margin:7px 0 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
|
|
266
266
|
.b270-cbody a{color:var(--teal);text-decoration:underline;text-decoration-color:var(--line)}
|
|
267
267
|
.b270-cbody a:hover{text-decoration-color:currentColor}
|
|
268
|
+
.b270-video{margin:10px 0;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--paper);width:100%;box-sizing:border-box;white-space:normal}
|
|
269
|
+
|
|
270
|
+
.b270 .b270-video button{padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink);font:inherit;cursor:pointer}
|
|
271
|
+
.b270-video button[hidden]{display:none}
|
|
272
|
+
.b270-video iframe{display:block;width:100%;aspect-ratio:16/9;border:0;border-radius:6px}
|
|
273
|
+
.b270-video a{display:block;margin-top:8px;font-size:13px}
|
|
268
274
|
.b270-editform{margin-top:6px}
|
|
269
275
|
.b270-editform textarea{min-height:64px}
|
|
270
276
|
.b270-editerror{margin:0 0 8px;font-size:13px;color:var(--oxblood)}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
2
|
+
(() => {
|
|
3
|
+
if (window.Bible270Videos) {
|
|
4
|
+
window.Bible270Videos.scan();
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function scan() {
|
|
9
|
+
document.querySelectorAll("[data-b270-video-load]").forEach((button) => {
|
|
10
|
+
button.hidden = !!button.closest(".b270-video")?.querySelector("iframe");
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function reset(root = document) {
|
|
15
|
+
root.querySelectorAll(".b270-video iframe").forEach((frame) => frame.remove());
|
|
16
|
+
root.querySelectorAll("[data-b270-video-load]").forEach((button) => {
|
|
17
|
+
button.hidden = false;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
document.addEventListener("click", (event) => {
|
|
22
|
+
const button = event.target.closest?.("[data-b270-video-load]");
|
|
23
|
+
if (!button) return;
|
|
24
|
+
const card = button.closest("[data-b270-video-id]");
|
|
25
|
+
const id = card?.dataset.b270VideoId;
|
|
26
|
+
if (!/^[A-Za-z0-9_-]{11}$/.test(id || "") || card.querySelector("iframe")) return;
|
|
27
|
+
|
|
28
|
+
const frame = document.createElement("iframe");
|
|
29
|
+
frame.src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=0`;
|
|
30
|
+
frame.title = "YouTube video player";
|
|
31
|
+
frame.allow = "encrypted-media; fullscreen; picture-in-picture";
|
|
32
|
+
frame.allowFullscreen = true;
|
|
33
|
+
// YouTube requires an origin referrer to identify the embedding site.
|
|
34
|
+
frame.referrerPolicy = "strict-origin-when-cross-origin";
|
|
35
|
+
card.insertBefore(frame, button);
|
|
36
|
+
button.hidden = true;
|
|
37
|
+
frame.focus();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
scan();
|
|
41
|
+
new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
|
|
42
|
+
document.addEventListener("turbo:load", scan);
|
|
43
|
+
document.addEventListener("turbo:before-cache", () => reset());
|
|
44
|
+
window.Bible270Videos = { scan, reset };
|
|
45
|
+
})();
|
|
46
|
+
</script>
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
<% if flash[:notice] %><div class="b270-flash notice"><%= flash[:notice] %></div><% end %>
|
|
21
21
|
<%= yield %>
|
|
22
22
|
<%# Pages with their own day grid opt out with content_for :hide_day_index %>
|
|
23
|
-
<%= render 'bible270/shared/day_index' unless content_for?(:hide_day_index) %>
|
|
23
|
+
<%= render 'bible270/shared/day_index', expanded: content_for?(:expand_day_index) unless content_for?(:hide_day_index) %>
|
|
24
24
|
<%= b270_footer %>
|
|
25
25
|
</div>
|
|
26
26
|
<%= render "bible270/shared/scripture_popups" %>
|
|
27
|
+
<%= render "bible270/shared/video_embeds" %>
|
|
27
28
|
</body>
|
|
28
29
|
</html>
|
|
@@ -5,6 +5,7 @@ require 'kramdown'
|
|
|
5
5
|
require 'nokogiri'
|
|
6
6
|
require 'rails-html-sanitizer'
|
|
7
7
|
require 'bible270/mentions'
|
|
8
|
+
require 'bible270/youtube_video'
|
|
8
9
|
|
|
9
10
|
module Bible270
|
|
10
11
|
# Converts stored reflection source into a deliberately small, safe subset of
|
|
@@ -15,22 +16,28 @@ module Bible270
|
|
|
15
16
|
MARKDOWN = 'markdown'
|
|
16
17
|
FORMATS = [PLAIN, MARKDOWN].freeze
|
|
17
18
|
ALLOWED_TAGS = %w[p br strong em blockquote ul ol li a].freeze
|
|
18
|
-
ALLOWED_ATTRIBUTES = %w[href rel].freeze
|
|
19
|
+
ALLOWED_ATTRIBUTES = %w[href rel title].freeze
|
|
20
|
+
VIDEO_MARKER = 'bible270-video'
|
|
21
|
+
VIDEO_PRIVACY = 'Loads player and shares data with YouTube'
|
|
19
22
|
LINK_REL = 'nofollow ugc noopener'
|
|
20
23
|
URL_PATTERN = %r{https?://[^\s<>"]*[^\s<>".,;:!?)\]\}]}i
|
|
21
|
-
SKIP_MENTIONS_IN = %w[a code pre].freeze
|
|
24
|
+
SKIP_MENTIONS_IN = %w[a button code pre].freeze
|
|
22
25
|
|
|
23
26
|
module_function
|
|
24
27
|
|
|
25
|
-
def html(body, format: PLAIN, &mention_path)
|
|
28
|
+
def html(body, format: PLAIN, embed_videos: false, &mention_path)
|
|
26
29
|
source = body.to_s
|
|
27
30
|
rendered = format == MARKDOWN ? markdown_html(source) : plain_html(source)
|
|
28
31
|
fragment = fragment_for(sanitize(rendered))
|
|
29
32
|
if format == MARKDOWN
|
|
33
|
+
replace_video_markers(fragment) if embed_videos
|
|
30
34
|
normalize_lists(fragment)
|
|
31
35
|
hard_wrap(fragment)
|
|
32
36
|
link_urls(fragment)
|
|
33
37
|
end
|
|
38
|
+
fragment.css('[title]').each { |node| node.remove_attribute('title') }
|
|
39
|
+
fragment.css('.b270-video button').each { |node| node['title'] = VIDEO_PRIVACY }
|
|
40
|
+
fragment.css('.b270-video a').each { |node| node['title'] = 'Opens YouTube in a new tab.' }
|
|
34
41
|
decorate_links(fragment)
|
|
35
42
|
link_mentions(fragment, &mention_path) if mention_path
|
|
36
43
|
fragment.to_html
|
|
@@ -135,6 +142,43 @@ module Bible270
|
|
|
135
142
|
end
|
|
136
143
|
private_class_method :link_urls
|
|
137
144
|
|
|
145
|
+
def replace_video_markers(fragment)
|
|
146
|
+
fragment.css('p').each do |paragraph|
|
|
147
|
+
children = paragraph.children.reject { |node| node.text? && node.text.strip.empty? }
|
|
148
|
+
next unless children.one?
|
|
149
|
+
|
|
150
|
+
link = children.first
|
|
151
|
+
next unless link.name == 'a' && link['title'] == VIDEO_MARKER
|
|
152
|
+
|
|
153
|
+
id = YouTubeVideo.id_from_url(link['href'].to_s)
|
|
154
|
+
paragraph.replace(video_card(id, fragment.document, name: link.text.strip)) if id
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
private_class_method :replace_video_markers
|
|
158
|
+
|
|
159
|
+
# These elements are generated only after sanitization; they are deliberately
|
|
160
|
+
# not part of the allowlist for user-supplied HTML.
|
|
161
|
+
def video_card(id, document, name:)
|
|
162
|
+
card = Nokogiri::XML::Node.new('div', document)
|
|
163
|
+
card['class'] = 'b270-video'
|
|
164
|
+
card['data-b270-video-id'] = id
|
|
165
|
+
|
|
166
|
+
button = Nokogiri::XML::Node.new('button', document)
|
|
167
|
+
button['type'] = 'button'
|
|
168
|
+
button['data-b270-video-load'] = ''
|
|
169
|
+
button['hidden'] = 'hidden'
|
|
170
|
+
button.content = name.empty? || name == 'YouTube video' ? 'View video' : "View video ‘#{name}’"
|
|
171
|
+
card.add_child(button)
|
|
172
|
+
|
|
173
|
+
link = Nokogiri::XML::Node.new('a', document)
|
|
174
|
+
link['href'] = "https://www.youtube.com/watch?v=#{id}"
|
|
175
|
+
link['target'] = '_blank'
|
|
176
|
+
link.content = 'Open on YouTube'
|
|
177
|
+
card.add_child(link)
|
|
178
|
+
card
|
|
179
|
+
end
|
|
180
|
+
private_class_method :video_card
|
|
181
|
+
|
|
138
182
|
def decorate_links(fragment)
|
|
139
183
|
fragment.css('a:not([href])').each do |link|
|
|
140
184
|
link.replace(Nokogiri::XML::Text.new(link.text, fragment.document))
|
data/lib/bible270/version.rb
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Bible270
|
|
6
|
+
module YouTubeVideo
|
|
7
|
+
HOSTS = %w[youtube.com www.youtube.com m.youtube.com youtu.be www.youtu.be].freeze
|
|
8
|
+
SHORT_HOSTS = %w[youtu.be www.youtu.be].freeze
|
|
9
|
+
ID_PATTERN = %r{\A[A-Za-z0-9_-]{11}\z}
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def id_from_url(url)
|
|
14
|
+
return if url.match?(%r{[[:space:][:cntrl:]]})
|
|
15
|
+
|
|
16
|
+
uri = URI.parse(url)
|
|
17
|
+
return unless uri.is_a?(URI::HTTP) && HOSTS.include?(uri.host&.downcase)
|
|
18
|
+
return if uri.userinfo || url.match?(%r{%(?![0-9a-f]{2})}i)
|
|
19
|
+
# Match the authority literally too: URI accepts empty ports and normalizes
|
|
20
|
+
# explicit default ports, which would otherwise conceal malformed input.
|
|
21
|
+
return unless url.match?(%r{\Ahttps?://#{Regexp.escape(uri.host)}(?::#{uri.default_port})?/}i)
|
|
22
|
+
|
|
23
|
+
id = candidate_id(uri)
|
|
24
|
+
id if id&.match?(ID_PATTERN)
|
|
25
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def candidate_id(uri)
|
|
30
|
+
if SHORT_HOSTS.include?(uri.host.downcase)
|
|
31
|
+
uri.path.delete_prefix('/')
|
|
32
|
+
elsif uri.path == '/watch'
|
|
33
|
+
ids = URI.decode_www_form(uri.query.to_s).filter_map { |key, value| value if key == 'v' }
|
|
34
|
+
ids.first if ids.one?
|
|
35
|
+
else
|
|
36
|
+
uri.path.match(%r{\A/(?:shorts|live|embed)/([A-Za-z0-9_-]{11})\z})&.[](1)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
private_class_method :candidate_id
|
|
40
|
+
end
|
|
41
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bible270
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -190,6 +190,7 @@ files:
|
|
|
190
190
|
- app/views/bible270/shared/_scripture_popups.html.erb
|
|
191
191
|
- app/views/bible270/shared/_styles.html.erb
|
|
192
192
|
- app/views/bible270/shared/_track_bars.html.erb
|
|
193
|
+
- app/views/bible270/shared/_video_embeds.html.erb
|
|
193
194
|
- app/views/bible270/sign_in_mailer/magic_link.html.erb
|
|
194
195
|
- app/views/bible270/sign_in_mailer/magic_link.text.erb
|
|
195
196
|
- app/views/layouts/bible270/application.html.erb
|
|
@@ -232,6 +233,7 @@ files:
|
|
|
232
233
|
- lib/bible270/translations.rb
|
|
233
234
|
- lib/bible270/versification.rb
|
|
234
235
|
- lib/bible270/version.rb
|
|
236
|
+
- lib/bible270/youtube_video.rb
|
|
235
237
|
- lib/generators/bible270/install/install_generator.rb
|
|
236
238
|
- lib/generators/bible270/install/templates/bible270.rb.tt
|
|
237
239
|
- lib/generators/bible270/install/templates/omniauth.rb.tt
|