bible270 1.4.2 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8792007d09956f4b53cbe9842007baea209a5eb0010758ef35e7d6f1977928f0
4
- data.tar.gz: 0c5d16299136af62a6e1466210e962ef6e8d48065d759d9b5b57450215471c31
3
+ metadata.gz: 7867bb4de49fe177447a5f7b1cfa9fa031e1926cdb8c4c73f2071eb8a7620c82
4
+ data.tar.gz: '0499e0931abdd3890f07945f7fc82f6de8dc5da1a156fbb8147e1b5b2be79596'
5
5
  SHA512:
6
- metadata.gz: 89cbba3ff6adfc107efacddb9b2d80903452488384983b488ecf733aa6e16d55451a0137626cda5a446591b2c28585201f49b612927a6ba80ad0b54a5889c377
7
- data.tar.gz: d2d501c3f9935405bf0b850124f2329b3c5355ccfdf5d7403688329a65f1bfdc4a779fc3d65d957eba242363393e6a057519ef509b337986f6426bb2a3c6ffdc
6
+ metadata.gz: 422848afb4e96f1a94cbfc26078f7c343afdb519e431c25aa6f2de10be0578d51fa65aa6d04683490b792685d5888474b8f711f0e1839ccd6ccaeb45c52c26d0
7
+ data.tar.gz: 81cd1f5df1028fa4064bc77ec5ee20866406d21c1ae5d4d992e8137bde59e5c6d6c505c7fc92b85111c931b7450da60b125692cb0bacbe63652e63012f9257da
data/CHANGELOG.md CHANGED
@@ -2,22 +2,52 @@
2
2
 
3
3
  All notable changes to bible270. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), treating pre-1.0 minor bumps as the place breaking changes may land.
4
4
 
5
- ## Unreleased Changes
5
+ ## [Unreleased]
6
6
 
7
7
  ### Added
8
8
 
9
- - let administrators disable the day-completion dove animation for individual readers
9
+ - add an on-demand Markdown formatting drawer and separate server-rendered Preview control for reflections and replies, with safe rendering, formatted notification emails, mention and browser-draft compatibility, and literal display of existing plain-text reflections; upgrading hosts must copy and run the new `body_format` comment migration
10
+
11
+ ### Fixed
12
+
13
+ - keep `json` below 3.0 while supported Rails releases still pass positional parse options, preventing session-cookie decoding from crashing every request under `json` 3.0
14
+
15
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...main)**
16
+
17
+ ## [1.4.2] - 2026-09-03
18
+
19
+ ### Added
20
+
21
+ - let administrators disable the day-completion dove animation for individual readers without affecting progress tracking; upgrading hosts must copy and run the new `completion_dove_disabled` reader migration before changing this setting
22
+
23
+ ### Changed
24
+
25
+ - keep the completion dove bright in dark mode, grow it to an 80%-of-viewport fit with a smoother rounded silhouette, and finish with a centered fade instead of veering offscreen
26
+ - show every reader who finished a day after the Reflections section, using compact 56px avatars ordered from earliest to latest completion
27
+
28
+ ### Fixed
29
+
30
+ - show the dove only once for each persisted day completion, including during concurrent final checkoffs, Turbo response replays, and stale-session retries
31
+
32
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.1...v1.4.2)**
33
+
34
+ ## [1.4.1] - 2026-09-02
10
35
 
11
36
  ### Changed
12
37
 
13
- - simplify day completion to one continuous server-confirmed dove flight from the lower center, eliminating the stagger between a pre-submit animation and the Turbo or HTML response
14
- - keep the dove bright in dark mode, grow it to an 80%-of-viewport fit, and finish with a quick fade instead of veering offscreen
38
+ - run the day-completion dove as one continuous, server-confirmed animation from the lower center, avoiding a stagger between submission and the Turbo or HTML response
39
+ - improve installer guidance for OmniAuth providers, deferred bundling, Active Storage, pending migrations, and OAuth callback setup
40
+ - detect existing engine mounts more reliably and move them above catch-all routes when necessary
15
41
 
16
42
  ### Fixed
17
43
 
18
- - show the dove only once for each persisted day-completion event, even when Turbo replays a response or retries a stale-session request
44
+ - reject invalid enrollment states and malformed or out-of-range admin day values instead of silently interpreting them
45
+ - apply admin profile name and avatar changes atomically, preventing partial updates when validation fails
46
+ - report broadcast queueing, delivery failures, and partial success accurately without recording an entirely failed broadcast as sent
47
+ - restrict reflection return redirects to safe application URLs
48
+ - honor the install generator's `--mailer-from` option, reject unsafe provider identifiers, and safely quote generated configuration values
19
49
 
20
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...main)**
50
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...v1.4.1)**
21
51
 
22
52
  ## [1.4.0] - 2026-09-02
23
53
 
@@ -631,6 +661,9 @@ This is the initial public release.
631
661
 
632
662
  * Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
633
663
 
664
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.4.2...HEAD
665
+ [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
666
+ [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
634
667
  [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
635
668
  [1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
636
669
  [1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Bible 270
2
2
 
3
- [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://coveralls.io/repos/github/avonderluft/bible270/badge.svg?branch=main&cache=20260812-1)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
3
+ [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/avonderluft/bible270?branch=main&cacheSeconds=300)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
4
4
 
5
5
  A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
6
6
 
@@ -283,7 +283,14 @@ filters, and paginates older threads. Signed-in readers see which conversations
283
283
  their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
284
284
  cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
285
285
  Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
286
- work without JavaScript.
286
+ work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
287
+ quotations, bulleted and numbered lists, and links without requiring readers to know Markdown syntax.
288
+ A separate **Preview** control checks the current draft through the same server-side sanitizer used
289
+ after posting; formatting tools stay out of sight until requested. The source remains
290
+ editable in the ordinary textarea, drafts preserve its format, and all rendered HTML is restricted to that small safe
291
+ set. Existing reflections remain literal plain text until a reader intentionally formats or converts
292
+ them. Upgrading hosts must copy and run migration `20260101000021`, which records whether each body is
293
+ plain text or Markdown; until then, the composer safely falls back to plain text.
287
294
 
288
295
  When `config.mention_notifications` is enabled, each reader can request email for every new reflection
289
296
  and reply, only replies to their reflections and mentions of them, or none. Administrators can change
@@ -483,7 +490,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
483
490
  |-------|-------|
484
491
  | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation, passage-reader, and reminder preferences |
485
492
  | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
486
- | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
493
+ | `bible270_comments` | a plain-text or safely rendered Markdown reflection on a day, optionally scoped to a track, public to all |
487
494
  | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
488
495
 
489
496
  A day is "complete" once all three tracks are ticked. Progress and comments are **public** by
@@ -496,6 +503,7 @@ GET / days#index overview + community + calendar
496
503
  GET /day/:day days#show readings, who's finished, reflections
497
504
  POST /day/:day/toggle/:track checkoffs#toggle
498
505
  POST /day/:day/comments comments#create
506
+ POST /comments/preview comments#preview
499
507
  DELETE /comments/:id comments#destroy
500
508
  GET /community readers#index reader directory
501
509
  GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
@@ -11,6 +11,14 @@ module Bible270
11
11
  current_reader&.mark_reflections_seen!(visited_at)
12
12
  end
13
13
 
14
+ def preview
15
+ return unless require_reader!
16
+
17
+ attributes = { body: params.dig(:comment, :body).to_s }
18
+ attributes[:body_format] = CommentFormatter::MARKDOWN if Comment.body_format_column?
19
+ render partial: 'bible270/comments/body', locals: { comment: Comment.new(attributes) }
20
+ end
21
+
14
22
  def create
15
23
  return unless require_reader!
16
24
 
@@ -134,7 +142,9 @@ module Bible270
134
142
  end
135
143
 
136
144
  def comment_params
137
- params.require(:comment).permit(:body, :track, :parent_id)
145
+ permitted = %i[body track parent_id]
146
+ permitted << :body_format if Comment.body_format_column?
147
+ params.require(:comment).permit(*permitted)
138
148
  end
139
149
 
140
150
  # Scoped to the reader, so someone else's reflection is simply not found —
@@ -18,7 +18,7 @@ module Bible270
18
18
  @readings = Plan.readings_for(@day)
19
19
  @comments = Comment.threads_for_day(@day)
20
20
  @editing_comment_id = editing_comment_id
21
- @new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
21
+ @new_comment = Comment.new_for_composer(day: @day, parent_id: reply_parent&.id)
22
22
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
23
23
  @day_just_completed = flash[:b270_day_just_completed].to_i == @day
24
24
  @completion_event_id = flash[:b270_completion_event_id] if @day_just_completed
@@ -53,40 +53,46 @@ module Bible270
53
53
  end
54
54
  end
55
55
 
56
- # The mark beside the title in the header. Larger than the favicon and inlined
57
- # as SVG, so it stays crisp at any size and needs no asset pipeline.
58
- # Renders "@handle" as a link to that reader. An unresolved or ambiguous handle
59
- # is left as plain text, which is how a writer discovers it found nobody.
60
- #
61
- # Built by walking the matches and escaping everything between them, rather
62
- # than escaping afterwards: the body is reader-supplied, so the only safe
63
- # construction is one where every non-link fragment is escaped by hand.
64
- def b270_with_mentions(body)
65
- text = body.to_s
66
- readers = Bible270::Reader.mentioned_in(text)
56
+ # Formats every reflection surface through the same sanitizer. Web views link
57
+ # resolved mentions; email can opt out because it has no mounted request path.
58
+ def b270_comment_body(comment, link_mentions: true)
59
+ format = comment.rendered_body_format
60
+ html = if link_mentions
61
+ readers = Bible270::Reader.mentioned_in(comment.body)
62
+ Bible270::CommentFormatter.html(comment.body, format: format) do |handle|
63
+ reader = readers.find { |candidate| candidate.answers_to?(handle) }
64
+ reader_path(reader) if reader
65
+ end
66
+ else
67
+ Bible270::CommentFormatter.html(comment.body, format: format)
68
+ end
69
+ html.html_safe
70
+ end
67
71
 
68
- pieces = []
69
- cursor = 0
72
+ def b270_comment_text(comment)
73
+ Bible270::CommentFormatter.text(comment.body, format: comment.rendered_body_format)
74
+ end
70
75
 
71
- text.to_enum(:scan, Bible270::Mentions::PATTERN).each do
72
- match = Regexp.last_match
73
- pieces << ERB::Util.html_escape(text[cursor...match.begin(0)])
76
+ def b270_editor_rows(body, columns: 48, minimum: 4)
77
+ rows = body.to_s.split("\n", -1).sum do |line|
78
+ [1, (line.length.to_f / columns).ceil].max
79
+ end
80
+ [minimum, rows].max
81
+ end
74
82
 
75
- handle = Bible270::Mentions.normalize(match[1])
83
+ # Backward-compatible plain-text entry point for host views that called the old
84
+ # mention helper directly.
85
+ def b270_with_mentions(body)
86
+ readers = Bible270::Reader.mentioned_in(body)
87
+ html = Bible270::CommentFormatter.html(body, format: Bible270::CommentFormatter::PLAIN) do |handle|
76
88
  reader = readers.find { |candidate| candidate.answers_to?(handle) }
77
- pieces << if reader
78
- link_to(match[0], reader_path(reader), class: 'b270-mention')
79
- else
80
- ERB::Util.html_escape(match[0])
81
- end
82
-
83
- cursor = match.end(0)
89
+ reader_path(reader) if reader
84
90
  end
85
-
86
- pieces << ERB::Util.html_escape(text[cursor..].to_s)
87
- safe_join(pieces)
91
+ html.html_safe
88
92
  end
89
93
 
94
+ # The mark beside the title in the header. Larger than the favicon and inlined
95
+ # as SVG, so it stays crisp at any size and needs no asset pipeline.
90
96
  def b270_mark(size: 38)
91
97
  configured = Bible270.config.header_mark
92
98
  return nil if configured == false
@@ -3,6 +3,7 @@
3
3
  module Bible270
4
4
  class ApplicationMailer < ActionMailer::Base
5
5
  default from: -> { Bible270.config.mailer_from }
6
+ helper Bible270::PlanHelper
6
7
  # `layout nil` means "normal lookup", which finds the host app's mailer layout and
7
8
  # nests our complete HTML document inside theirs. Only `false` disables it.
8
9
  layout false
@@ -11,6 +11,8 @@ module Bible270
11
11
  # the paper background; an outline glyph like U+2661 is drawn by the font
12
12
  # rather than the emoji set and needs hand-tuning to match.
13
13
  HEART = '❤️'
14
+ BODY_FORMAT_COLUMN = 'body_format'
15
+ BODY_FORMATS = CommentFormatter::FORMATS
14
16
  ThreadPage = Struct.new(:threads, :activity_by_id, :page, :pages, :total, keyword_init: true)
15
17
 
16
18
  self.table_name = 'bible270_comments'
@@ -26,6 +28,7 @@ module Bible270
26
28
 
27
29
  validates :day, inclusion: { in: 1..Plan::DAYS }
28
30
  validates :body, presence: true, length: { maximum: 4000 }
31
+ validates :body_format, inclusion: { in: BODY_FORMATS }, if: :body_format_column?
29
32
  # A reflection can be about one track or about the whole day. The form's
30
33
  # "The whole day" option submits an empty string, so normalise it to NULL
31
34
  # before validating — allow_nil alone rejects "" with
@@ -54,6 +57,23 @@ module Bible270
54
57
  }
55
58
  scope :recent, -> { approved.order(created_at: :desc) }
56
59
 
60
+ def self.body_format_column?
61
+ column_names.include?(BODY_FORMAT_COLUMN)
62
+ rescue ActiveRecord::StatementInvalid
63
+ false
64
+ end
65
+
66
+ def self.new_for_composer(attributes = {})
67
+ attributes = attributes.merge(BODY_FORMAT_COLUMN => CommentFormatter::MARKDOWN) if body_format_column?
68
+ new(attributes)
69
+ end
70
+
71
+ def body_format_column? = self.class.body_format_column?
72
+
73
+ def rendered_body_format
74
+ body_format_column? ? body_format.presence || CommentFormatter::PLAIN : CommentFormatter::PLAIN
75
+ end
76
+
57
77
  # One page of top-level reflections ordered by the newest activity in each
58
78
  # conversation. Loading only IDs and timestamps keeps older archives cheap to
59
79
  # sort, while the displayed page still eager-loads its readers, replies and likes.
@@ -22,7 +22,7 @@
22
22
  <% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
23
23
  <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
- <p class="b270-cbody"><%= comment.body %></p>
25
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
26
26
  <div style="display:flex;gap:10px;align-items:center;margin-top:6px">
27
27
  <% if comment.hidden? %>
28
28
  <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
@@ -0,0 +1,6 @@
1
+ <%# locals: (comment:) %>
2
+ <% if comment.body.to_s.strip.empty? %>
3
+ <p class="b270-hint">Nothing to preview yet.</p>
4
+ <% else %>
5
+ <%= b270_comment_body(comment) %>
6
+ <% end %>
@@ -32,7 +32,7 @@
32
32
  <% if editing %>
33
33
  <%= render partial: "bible270/comments/edit_fields", locals: { comment: comment } %>
34
34
  <% else %>
35
- <p class="b270-cbody"><%= b270_with_mentions(comment.body) %></p>
35
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
36
36
  <% end %>
37
37
 
38
38
  <%# Only a top-level reflection can be replied to: one level deep, so a reply
@@ -13,7 +13,8 @@
13
13
  <p class="b270-editerror"><%= comment.errors.full_messages.to_sentence %></p>
14
14
  <% end %>
15
15
 
16
- <%= f.text_area :body %>
16
+ <%= render "bible270/comments/editor", form: f, comment: comment,
17
+ label_text: (comment.reply? ? "Reply" : "Reflection") %>
17
18
  <div class="row">
18
19
  <% unless comment.reply? %>
19
20
  <%= f.select :track,
@@ -0,0 +1,57 @@
1
+ <%# locals: (form:, comment:, label_text:, placeholder: nil) %>
2
+ <% editor_key = comment.persisted? ? "edit-#{comment.id}" : "new" %>
3
+ <% textarea_id = "b270-comment-body-#{editor_key}" %>
4
+ <% options_id = "b270-mention-options-#{editor_key}" %>
5
+ <% write_id = "b270-comment-write-#{editor_key}" %>
6
+ <% preview_id = "b270-comment-preview-#{editor_key}" %>
7
+ <% drawer_id = "b270-comment-formatting-#{editor_key}" %>
8
+ <% markdown_available = Bible270::Comment.body_format_column? %>
9
+
10
+ <div class="b270-mention-composer" data-b270-mention-composer
11
+ data-suggestions-url="<%= mention_suggestions_path %>">
12
+ <div class="b270-markdown-editor" data-b270-markdown-editor data-b270-editor-key="<%= editor_key %>"
13
+ data-b270-preview-url="<%= comment_preview_path if markdown_available %>"
14
+ data-b270-formatting-open="false" data-b270-editor-mode="write">
15
+ <% if markdown_available %>
16
+ <div class="b270-editor-utilities" data-b270-editor-utilities hidden>
17
+ <button type="button" class="b270-editor-utility" aria-expanded="false"
18
+ aria-controls="<%= drawer_id %>" data-b270-formatting-toggle>
19
+ Formatting <span class="b270-format-chevron" aria-hidden="true">▾</span>
20
+ </button>
21
+ <button type="button" class="b270-editor-utility" aria-pressed="false"
22
+ aria-controls="<%= write_id %> <%= preview_id %>" data-b270-preview-toggle>Preview</button>
23
+ </div>
24
+ <div id="<%= drawer_id %>" class="b270-formatting-drawer" aria-hidden="true"
25
+ data-b270-formatting-drawer inert>
26
+ <div class="b270-formatting-drawer-inner">
27
+ <div class="b270-markdown-toolbar" role="toolbar" aria-label="Reflection formatting"
28
+ data-b270-markdown-toolbar>
29
+ <button type="button" data-b270-markdown-action="bold" aria-label="Bold selected text" title="Bold (Ctrl+B)"><span class="b270-format-glyph b270-format-bold" aria-hidden="true">B</span></button>
30
+ <button type="button" data-b270-markdown-action="italic" aria-label="Italicize selected text" title="Italic (Ctrl+I)"><span class="b270-format-glyph b270-format-italic" aria-hidden="true">I</span></button>
31
+ <button type="button" data-b270-markdown-action="quote" aria-label="Format as a quotation" title="Quotation">“ ”</button>
32
+ <button type="button" data-b270-markdown-action="bullet-list" aria-label="Format as a bulleted list" title="Bulleted list">• List</button>
33
+ <button type="button" data-b270-markdown-action="number-list" aria-label="Format as a numbered list" title="Numbered list">1. List</button>
34
+ <button type="button" data-b270-markdown-action="link" aria-label="Add a link" title="Link">Link</button>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <%= form.hidden_field :body_format, data: { b270_body_format: true } %>
39
+ <% end %>
40
+
41
+ <div id="<%= write_id %>" data-b270-write-panel>
42
+ <%= form.label :body, label_text, for: textarea_id, class: "b270-sr-only" %>
43
+ <%= form.text_area :body, id: textarea_id, placeholder: placeholder,
44
+ rows: (b270_editor_rows(comment.body) if comment.persisted?),
45
+ role: "combobox", autocomplete: "off", data: { b270_autosize: comment.persisted? },
46
+ aria: { autocomplete: "list", expanded: "false", haspopup: "listbox", controls: options_id } %>
47
+ </div>
48
+ <% if markdown_available %>
49
+ <div id="<%= preview_id %>" class="b270-markdown-preview b270-cbody"
50
+ aria-label="Reflection preview" aria-live="polite" data-b270-preview-panel hidden></div>
51
+ <% end %>
52
+ </div>
53
+
54
+ <div class="b270-mention-options" id="<%= options_id %>" role="listbox"
55
+ data-b270-mention-options hidden></div>
56
+ <p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
57
+ </div>
@@ -18,18 +18,9 @@
18
18
  </p>
19
19
  <% end %>
20
20
  <%= f.hidden_field :parent_id %>
21
- <div class="b270-mention-composer" data-b270-mention-composer
22
- data-suggestions-url="<%= mention_suggestions_path %>">
23
- <%= f.label :body, (posting_reply ? "Reply" : "Reflection"), class: "b270-sr-only" %>
24
- <%= f.text_area :body,
25
- placeholder: posting_reply ? "Write your reply…" : "Share a reflection on today's reading…",
26
- role: "combobox", autocomplete: "off",
27
- aria: { autocomplete: "list", expanded: "false", haspopup: "listbox",
28
- controls: "b270-mention-options" } %>
29
- <div class="b270-mention-options" id="b270-mention-options" role="listbox"
30
- data-b270-mention-options hidden></div>
31
- <p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
32
- </div>
21
+ <%= render "bible270/comments/editor", form: f, comment: comment,
22
+ label_text: (posting_reply ? "Reply" : "Reflection"),
23
+ placeholder: (posting_reply ? "Write your reply…" : "Share a reflection on today's reading…") %>
33
24
  <p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
34
25
  <div class="row">
35
26
  <%= f.select :track,
@@ -10,5 +10,6 @@
10
10
  <% end %>
11
11
 
12
12
  <%= turbo_stream.replace "new_comment_form" do %>
13
- <%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day), posted: true %>
13
+ <%= render "bible270/comments/form", day: @day,
14
+ comment: Bible270::Comment.new_for_composer(day: @day), posted: true %>
14
15
  <% end %>
@@ -27,6 +27,7 @@
27
27
  </div>
28
28
  <%= render "bible270/shared/comment_drafts" %>
29
29
  <%= render "bible270/shared/mention_typeahead" %>
30
+ <%= render "bible270/shared/markdown_toolbar" %>
30
31
  <% end %>
31
32
 
32
33
  <%= render "bible270/days/completers", day: @day, completers: @completers %>
@@ -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
- <%= simple_format(@comment.body) %>
13
+ <%= b270_comment_body(@comment, link_mentions: false) %>
14
14
  </blockquote>
15
15
 
16
16
  <% if @day_url %>
@@ -1,6 +1,6 @@
1
1
  <%= @introduction %>
2
2
 
3
- <%= @comment.body %>
3
+ <%= b270_comment_text(@comment) %>
4
4
 
5
5
  <% if @day_url %>
6
6
  Read it and reply: <%= @day_url %>
@@ -56,6 +56,7 @@
56
56
  const key = form.dataset.b270DraftKey;
57
57
  const body = form.querySelector("textarea[name='comment[body]']");
58
58
  const track = form.querySelector("select[name='comment[track]']");
59
+ const format = form.querySelector("[data-b270-body-format]");
59
60
  if (!key || !body) return;
60
61
 
61
62
  if (!body.value.trim()) {
@@ -65,8 +66,10 @@
65
66
  }
66
67
 
67
68
  const trackValue = track ? track.value : "";
69
+ const bodyFormat = format ? format.value : null;
68
70
  const current = readDraft(key);
69
- if (current && current.body === body.value && current.track === trackValue) {
71
+ if (current && current.body === body.value && current.track === trackValue &&
72
+ current.bodyFormat === bodyFormat) {
70
73
  setStatus(form, "Draft saved in this browser.");
71
74
  return;
72
75
  }
@@ -74,6 +77,7 @@
74
77
  const draft = {
75
78
  body: body.value,
76
79
  track: trackValue,
80
+ bodyFormat,
77
81
  savedAt: Date.now(),
78
82
  revision: `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
79
83
  };
@@ -115,10 +119,14 @@
115
119
 
116
120
  const body = form.querySelector("textarea[name='comment[body]']");
117
121
  const track = form.querySelector("select[name='comment[track]']");
122
+ const format = form.querySelector("[data-b270-body-format]");
118
123
  if (body) body.value = draft.body;
119
124
  if (track && Array.from(track.options).some((option) => option.value === draft.track)) {
120
125
  track.value = draft.track;
121
126
  }
127
+ if (format) {
128
+ format.value = ["plain", "markdown"].includes(draft.bodyFormat) ? draft.bodyFormat : "plain";
129
+ }
122
130
  setStatus(form, "Draft restored from this browser.");
123
131
  }
124
132
 
@@ -0,0 +1,239 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270MarkdownToolbar) {
4
+ window.Bible270MarkdownToolbar.scan();
5
+ return;
6
+ }
7
+
8
+ const openEditors = new Set();
9
+
10
+ function editorKey(editor) {
11
+ return editor.dataset.b270EditorKey;
12
+ }
13
+
14
+ function select(textarea, start, finish) {
15
+ textarea.focus();
16
+ textarea.setSelectionRange(start, finish);
17
+ }
18
+
19
+ function resizeTextarea(textarea) {
20
+ if (!textarea || textarea.dataset.b270Autosize !== "true") return;
21
+
22
+ textarea.style.height = "auto";
23
+ const borders = textarea.offsetHeight - textarea.clientHeight;
24
+ textarea.style.height = `${textarea.scrollHeight + borders}px`;
25
+ }
26
+
27
+ function initializeAutosize(textarea) {
28
+ if (!textarea || textarea.dataset.b270Autosize !== "true" ||
29
+ textarea.dataset.b270AutosizeReady === "true") return;
30
+
31
+ textarea.dataset.b270AutosizeReady = "true";
32
+ textarea.addEventListener("input", () => resizeTextarea(textarea));
33
+ window.requestAnimationFrame(() => resizeTextarea(textarea));
34
+ }
35
+
36
+ function resizeEditors() {
37
+ document.querySelectorAll("textarea[data-b270-autosize='true']").forEach(resizeTextarea);
38
+ }
39
+
40
+ function update(editor, textarea, replacement, start, finish, selectedStart, selectedEnd) {
41
+ textarea.value = `${textarea.value.slice(0, start)}${replacement}${textarea.value.slice(finish)}`;
42
+ const format = editor.querySelector("[data-b270-body-format]");
43
+ if (format) format.value = "markdown";
44
+ select(textarea, start + selectedStart, start + selectedEnd);
45
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
46
+ }
47
+
48
+ function wrap(editor, textarea, before, after, placeholder) {
49
+ const start = textarea.selectionStart;
50
+ const finish = textarea.selectionEnd;
51
+ const selected = textarea.value.slice(start, finish) || placeholder;
52
+ const replacement = `${before}${selected}${after}`;
53
+ update(editor, textarea, replacement, start, finish, before.length, before.length + selected.length);
54
+ }
55
+
56
+ function prefixLines(editor, textarea, action) {
57
+ const selectionStart = textarea.selectionStart;
58
+ const selectionEnd = textarea.selectionEnd;
59
+ const start = textarea.value.lastIndexOf("\n", Math.max(selectionStart - 1, 0)) + 1;
60
+ const nextNewline = textarea.value.indexOf("\n", selectionEnd);
61
+ const finish = nextNewline === -1 ? textarea.value.length : nextNewline;
62
+ const source = textarea.value.slice(start, finish) || (action === "quote" ? "quoted text" : "list item");
63
+ const lines = source.split("\n");
64
+ const replacement = lines.map((line, index) => {
65
+ if (action === "quote") return `> ${line}`;
66
+ if (action === "number-list") return `${index + 1}. ${line}`;
67
+ return `- ${line}`;
68
+ }).join("\n");
69
+ update(editor, textarea, replacement, start, finish, 0, replacement.length);
70
+ }
71
+
72
+ function addLink(editor, textarea) {
73
+ const href = window.prompt("Link address (https://…)");
74
+ if (!href) return;
75
+
76
+ const start = textarea.selectionStart;
77
+ const finish = textarea.selectionEnd;
78
+ const selected = textarea.value.slice(start, finish) || "link text";
79
+ const replacement = `[${selected}](${href.trim()})`;
80
+ update(editor, textarea, replacement, start, finish, 1, selected.length + 1);
81
+ }
82
+
83
+ function requestPreview(editor, state) {
84
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
85
+ const preview = editor.querySelector("[data-b270-preview-panel]");
86
+ const url = editor.dataset.b270PreviewUrl;
87
+ if (!textarea || !preview || !url) return;
88
+
89
+ if (!textarea.value.trim()) {
90
+ preview.innerHTML = '<p class="b270-hint">Nothing to preview yet.</p>';
91
+ return;
92
+ }
93
+
94
+ if (state.abort) state.abort.abort();
95
+ state.abort = new AbortController();
96
+ preview.textContent = "Loading preview…";
97
+ const body = new URLSearchParams({ "comment[body]": textarea.value });
98
+ const token = document.querySelector("meta[name='csrf-token']")?.content;
99
+ const headers = {
100
+ Accept: "text/html",
101
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
102
+ };
103
+ if (token) headers["X-CSRF-Token"] = token;
104
+
105
+ fetch(url, {
106
+ method: "POST",
107
+ credentials: "same-origin",
108
+ cache: "no-store",
109
+ redirect: "error",
110
+ headers,
111
+ body: body.toString(),
112
+ signal: state.abort.signal
113
+ }).then((response) => {
114
+ if (!response.ok) throw new Error("Preview unavailable");
115
+ return response.text();
116
+ }).then((html) => {
117
+ preview.innerHTML = html;
118
+ }).catch((error) => {
119
+ if (error.name !== "AbortError") preview.textContent = "Preview unavailable. Return to Write and try again.";
120
+ });
121
+ }
122
+
123
+ function setFormattingOpen(editor, open, remember = true) {
124
+ const drawer = editor.querySelector("[data-b270-formatting-drawer]");
125
+ const toggle = editor.querySelector("[data-b270-formatting-toggle]");
126
+ if (!drawer || !toggle) return;
127
+
128
+ editor.dataset.b270FormattingOpen = open ? "true" : "false";
129
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
130
+ drawer.setAttribute("aria-hidden", open ? "false" : "true");
131
+ drawer.inert = !open;
132
+ if (!remember) return;
133
+
134
+ if (open) openEditors.add(editorKey(editor));
135
+ else openEditors.delete(editorKey(editor));
136
+ }
137
+
138
+ function showMode(editor, state, mode) {
139
+ const previewing = mode === "preview";
140
+ const writePanel = editor.querySelector("[data-b270-write-panel]");
141
+ const previewPanel = editor.querySelector("[data-b270-preview-panel]");
142
+ const previewToggle = editor.querySelector("[data-b270-preview-toggle]");
143
+ const formattingToggle = editor.querySelector("[data-b270-formatting-toggle]");
144
+ if (!writePanel || !previewPanel || !previewToggle || !formattingToggle) return;
145
+
146
+ editor.dataset.b270EditorMode = mode;
147
+ writePanel.hidden = previewing;
148
+ previewPanel.hidden = !previewing;
149
+ previewToggle.textContent = previewing ? "Write" : "Preview";
150
+ previewToggle.setAttribute("aria-pressed", previewing ? "true" : "false");
151
+ previewToggle.setAttribute("aria-label", previewing ? "Return to writing" : "Preview reflection");
152
+ formattingToggle.disabled = previewing;
153
+ if (previewing) {
154
+ setFormattingOpen(editor, false, false);
155
+ requestPreview(editor, state);
156
+ } else {
157
+ setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
158
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
159
+ resizeTextarea(textarea);
160
+ textarea?.focus();
161
+ }
162
+ }
163
+
164
+ function apply(editor, action) {
165
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
166
+ if (!textarea) return;
167
+
168
+ if (action === "bold") {
169
+ wrap(editor, textarea, "**", "**", "bold text");
170
+ } else if (action === "italic") {
171
+ wrap(editor, textarea, "*", "*", "italic text");
172
+ } else if (action === "link") {
173
+ addLink(editor, textarea);
174
+ } else {
175
+ prefixLines(editor, textarea, action);
176
+ }
177
+ }
178
+
179
+ function initialize(editor) {
180
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
181
+ if (!textarea) return;
182
+ initializeAutosize(textarea);
183
+ if (editor.dataset.b270MarkdownReady === "true") return;
184
+
185
+ const utilities = editor.querySelector("[data-b270-editor-utilities]");
186
+ const toolbar = editor.querySelector("[data-b270-markdown-toolbar]");
187
+ const drawer = editor.querySelector("[data-b270-formatting-drawer]");
188
+ const formattingToggle = editor.querySelector("[data-b270-formatting-toggle]");
189
+ const previewToggle = editor.querySelector("[data-b270-preview-toggle]");
190
+ if (!utilities || !toolbar || !drawer || !formattingToggle || !previewToggle) return;
191
+
192
+ editor.dataset.b270MarkdownReady = "true";
193
+ if (editor.closest("form")?.dataset.b270DraftPosted === "true") openEditors.delete(editorKey(editor));
194
+ const state = { abort: null };
195
+ utilities.hidden = false;
196
+ setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
197
+ formattingToggle.addEventListener("click", () => {
198
+ setFormattingOpen(editor, editor.dataset.b270FormattingOpen !== "true");
199
+ });
200
+ previewToggle.addEventListener("click", () => {
201
+ const mode = editor.dataset.b270EditorMode === "preview" ? "write" : "preview";
202
+ showMode(editor, state, mode);
203
+ });
204
+ toolbar.querySelectorAll("[data-b270-markdown-action]").forEach((button) => {
205
+ button.addEventListener("click", () => apply(editor, button.dataset.b270MarkdownAction));
206
+ });
207
+ toolbar.addEventListener("keydown", (event) => {
208
+ if (event.key !== "Escape") return;
209
+
210
+ event.preventDefault();
211
+ setFormattingOpen(editor, false);
212
+ formattingToggle.focus();
213
+ });
214
+ textarea.addEventListener("keydown", (event) => {
215
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
216
+ const action = event.key.toLocaleLowerCase() === "b" ? "bold" :
217
+ event.key.toLocaleLowerCase() === "i" ? "italic" : null;
218
+ if (!action) return;
219
+
220
+ event.preventDefault();
221
+ apply(editor, action);
222
+ });
223
+ }
224
+
225
+ function scan() {
226
+ document.querySelectorAll("[data-b270-markdown-editor]").forEach(initialize);
227
+ }
228
+
229
+ scan();
230
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
231
+ window.addEventListener("pageshow", () => {
232
+ scan();
233
+ resizeEditors();
234
+ });
235
+ window.addEventListener("resize", resizeEditors);
236
+ document.addEventListener("turbo:load", scan);
237
+ window.Bible270MarkdownToolbar = { scan };
238
+ })();
239
+ </script>
@@ -23,7 +23,7 @@
23
23
  <% if moderate && comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
25
 
26
- <p class="b270-cbody"><%= comment.body %></p>
26
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
27
27
 
28
28
  <% if moderate %>
29
29
  <div style="display:flex;gap:10px;margin-top:6px">
@@ -256,7 +256,15 @@
256
256
  .b270-likes:active .b270-liketooltip{display:block}
257
257
  .b270-liker{display:block;white-space:nowrap}
258
258
  .b270-creply{margin-top:6px}
259
- .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
259
+ .b270-cbody{margin:3px 0 0;line-height:1.2}
260
+ .b270-cbody p{margin:0;white-space:pre-wrap}
261
+ .b270-cbody p+p,.b270-cbody p+ul,.b270-cbody p+ol,.b270-cbody p+blockquote,
262
+ .b270-cbody ul+p,.b270-cbody ol+p,.b270-cbody blockquote+p{margin-top:8px}
263
+ .b270-cbody ul,.b270-cbody ol{margin:6px 0 0;padding-left:24px}
264
+ .b270-cbody li{margin:2px 0;white-space:pre-wrap}
265
+ .b270-cbody blockquote{margin:7px 0 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
266
+ .b270-cbody a{color:var(--teal);text-decoration:underline;text-decoration-color:var(--line)}
267
+ .b270-cbody a:hover{text-decoration-color:currentColor}
260
268
  .b270-editform{margin-top:6px}
261
269
  .b270-editform textarea{min-height:64px}
262
270
  .b270-editerror{margin:0 0 8px;font-size:13px;color:var(--oxblood)}
@@ -268,6 +276,27 @@
268
276
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
269
277
  .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
270
278
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
279
+ .b270-form textarea[data-b270-autosize=false]{min-height:114px}
280
+ .b270-editor-utilities[hidden]{display:none}
281
+ .b270-editor-utilities{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 2px 5px}
282
+ .b270 .b270-editor-utility{padding:2px 0;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
283
+ .b270 .b270-editor-utility:hover,.b270 .b270-editor-utility:focus-visible{color:var(--gold)}
284
+ .b270 .b270-editor-utility:disabled{color:var(--faint);opacity:.55;cursor:default}
285
+ .b270-format-chevron{display:inline-block;margin-left:2px;transition:transform .14s ease}
286
+ .b270-markdown-editor[data-b270-formatting-open=true] .b270-format-chevron{transform:rotate(180deg)}
287
+ .b270-formatting-drawer{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .14s ease,opacity .1s ease}
288
+ .b270-markdown-editor[data-b270-formatting-open=true] .b270-formatting-drawer{grid-template-rows:1fr;opacity:1}
289
+ .b270-formatting-drawer-inner{min-height:0;overflow:hidden}
290
+ .b270-markdown-toolbar{display:flex;align-items:center;gap:2px;flex-wrap:wrap;padding:5px;border:1px solid var(--line);border-bottom:0;border-radius:10px 10px 0 0;background:var(--paper)}
291
+ .b270 .b270-markdown-toolbar button{min-height:32px;padding:5px 9px;border:0;border-radius:6px;background:transparent;color:var(--muted);font:inherit;font-size:12.5px;cursor:pointer}
292
+ .b270 .b270-markdown-toolbar button:hover,.b270 .b270-markdown-toolbar button:focus-visible{background:var(--card);color:var(--ink)}
293
+ .b270-format-glyph{display:inline-block;min-width:13px;font-family:Georgia,"Times New Roman",serif;font-size:18px;line-height:1}
294
+ .b270-format-bold{font-weight:700}
295
+ .b270-format-italic{font-style:italic;font-weight:600}
296
+ .b270-markdown-editor[data-b270-formatting-open=true] textarea{border-radius:0 0 10px 10px}
297
+ .b270-markdown-preview{min-height:76px;margin:0;padding:11px 13px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
298
+ .b270-markdown-preview[hidden]{display:none}
299
+ .b270-markdown-preview>.b270-hint{margin:0}
271
300
  .b270-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
272
301
  .b270-mention-composer{position:relative}
273
302
  .b270-mention-options{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);overflow:hidden;
data/config/routes.rb CHANGED
@@ -13,6 +13,7 @@ Bible270::Engine.routes.draw do
13
13
  constraints: { day: %r{\d+} }
14
14
  post 'day/:day/comments', to: 'comments#create', as: :day_comments,
15
15
  constraints: { day: %r{\d+} }
16
+ post 'comments/preview', to: 'comments#preview', as: :comment_preview
16
17
  post 'comments/:id/like', to: 'likes#toggle', as: :like_comment
17
18
  patch 'comments/:id', to: 'comments#update'
18
19
  delete 'comments/:id', to: 'comments#destroy', as: :comment
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddBodyFormatToBible270Comments < ActiveRecord::Migration[7.0]
4
+ def up
5
+ add_column :bible270_comments, :body_format, :string, null: false, default: 'plain'
6
+ end
7
+
8
+ def down
9
+ remove_column :bible270_comments, :body_format
10
+ end
11
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'kramdown'
5
+ require 'nokogiri'
6
+ require 'rails-html-sanitizer'
7
+ require 'bible270/mentions'
8
+
9
+ module Bible270
10
+ # Converts stored reflection source into a deliberately small, safe subset of
11
+ # HTML. Plain bodies remain literal; Markdown bodies gain only the formatting
12
+ # exposed by the reflection toolbar.
13
+ module CommentFormatter
14
+ PLAIN = 'plain'
15
+ MARKDOWN = 'markdown'
16
+ FORMATS = [PLAIN, MARKDOWN].freeze
17
+ ALLOWED_TAGS = %w[p br strong em blockquote ul ol li a].freeze
18
+ ALLOWED_ATTRIBUTES = %w[href rel].freeze
19
+ LINK_REL = 'nofollow ugc noopener'
20
+ SKIP_MENTIONS_IN = %w[a code pre].freeze
21
+
22
+ module_function
23
+
24
+ def html(body, format: PLAIN, &mention_path)
25
+ source = body.to_s
26
+ rendered = format == MARKDOWN ? markdown_html(source) : plain_html(source)
27
+ fragment = fragment_for(sanitize(rendered))
28
+ hard_wrap(fragment) if format == MARKDOWN
29
+ decorate_links(fragment)
30
+ link_mentions(fragment, &mention_path) if mention_path
31
+ fragment.to_html
32
+ end
33
+
34
+ def text(body, format: PLAIN)
35
+ return body.to_s if format != MARKDOWN
36
+
37
+ fragment = fragment_for(html(body, format: format))
38
+ fragment.css('a[href]').each do |link|
39
+ href = link['href'].to_s
40
+ next if href.empty? || link.text == href
41
+
42
+ link.add_next_sibling(Nokogiri::XML::Text.new(" (#{href})", fragment.document))
43
+ end
44
+ fragment.css('br').each do |break_node|
45
+ break_node.replace(Nokogiri::XML::Text.new("\n", fragment.document))
46
+ end
47
+ fragment.css('p, blockquote, li').each do |block|
48
+ block.add_next_sibling(Nokogiri::XML::Text.new("\n", fragment.document))
49
+ end
50
+ fragment.text.lines.map(&:strip).join("\n").gsub(%r{\n{3,}}, "\n\n").strip
51
+ end
52
+
53
+ def markdown_html(source)
54
+ Kramdown::Document.new(source).to_html
55
+ end
56
+ private_class_method :markdown_html
57
+
58
+ def plain_html(source)
59
+ paragraphs = source.split(%r{(?:\r?\n){2,}}, -1).filter_map do |paragraph|
60
+ next if paragraph.empty?
61
+
62
+ escaped = ERB::Util.html_escape(paragraph).gsub(%r{\r?\n}, "<br>\n")
63
+ "<p>#{escaped}</p>"
64
+ end
65
+ paragraphs.join("\n")
66
+ end
67
+ private_class_method :plain_html
68
+
69
+ def sanitize(rendered)
70
+ Rails::Html::SafeListSanitizer.new.sanitize(
71
+ rendered,
72
+ tags: ALLOWED_TAGS,
73
+ attributes: ALLOWED_ATTRIBUTES
74
+ )
75
+ end
76
+ private_class_method :sanitize
77
+
78
+ def fragment_for(rendered)
79
+ Nokogiri::HTML::DocumentFragment.parse(rendered)
80
+ end
81
+ private_class_method :fragment_for
82
+
83
+ def hard_wrap(fragment)
84
+ fragment.xpath('.//p//text() | .//li//text()').to_a.each do |node|
85
+ next unless node.text.include?("\n")
86
+
87
+ replacement = Nokogiri::HTML::DocumentFragment.parse('')
88
+ node.text.split("\n", -1).each_with_index do |line, index|
89
+ replacement.add_child(Nokogiri::XML::Node.new('br', fragment.document)) if index.positive?
90
+ replacement.add_child(Nokogiri::XML::Text.new(line, fragment.document))
91
+ end
92
+ node.replace(replacement)
93
+ end
94
+ end
95
+ private_class_method :hard_wrap
96
+
97
+ def decorate_links(fragment)
98
+ fragment.css('a:not([href])').each do |link|
99
+ link.replace(Nokogiri::XML::Text.new(link.text, fragment.document))
100
+ end
101
+ fragment.css('a[href]').each { |link| link['rel'] = LINK_REL }
102
+ end
103
+ private_class_method :decorate_links
104
+
105
+ def link_mentions(fragment)
106
+ text_nodes = fragment.xpath('.//text()').to_a
107
+ text_nodes.each do |node|
108
+ next if node.ancestors.any? { |ancestor| SKIP_MENTIONS_IN.include?(ancestor.name) }
109
+ next unless node.text.match?(Mentions::PATTERN)
110
+
111
+ replacement = Nokogiri::HTML::DocumentFragment.parse('')
112
+ cursor = 0
113
+ node.text.to_enum(:scan, Mentions::PATTERN).each do
114
+ match = Regexp.last_match
115
+ replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor...match.begin(0)], fragment.document))
116
+ href = yield Mentions.normalize(match[1])
117
+ if href
118
+ link = Nokogiri::XML::Node.new('a', fragment.document)
119
+ link['href'] = href
120
+ link['class'] = 'b270-mention'
121
+ link['rel'] = LINK_REL
122
+ link.content = match[0]
123
+ replacement.add_child(link)
124
+ else
125
+ replacement.add_child(Nokogiri::XML::Text.new(match[0], fragment.document))
126
+ end
127
+ cursor = match.end(0)
128
+ end
129
+ replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor..].to_s, fragment.document))
130
+ node.replace(replacement)
131
+ end
132
+ end
133
+ private_class_method :link_mentions
134
+ end
135
+ end
@@ -30,6 +30,7 @@ module Bible270
30
30
  'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
31
31
  'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
32
32
  'AddCompletionDoveDisabledToBible270Readers' => :check_completion_dove_disabled,
33
+ 'AddBodyFormatToBible270Comments' => :check_comment_body_format,
33
34
  'CreateActiveStorageTables' => :check_active_storage
34
35
  }.freeze
35
36
 
@@ -209,6 +210,17 @@ module Bible270
209
210
  table_missing(:bible270_readers, columns: %i[completion_dove_disabled])
210
211
  end
211
212
 
213
+ def check_comment_body_format
214
+ missing = table_missing(:bible270_comments, columns: %i[body_format])
215
+ return missing if missing.any?
216
+
217
+ column = connection.columns(:bible270_comments).find { |candidate| candidate.name == 'body_format' }
218
+ missing = []
219
+ missing << 'column bible270_comments.body_format default plain' unless column&.default == 'plain'
220
+ missing << 'column bible270_comments.body_format null false' unless column && !column.null
221
+ missing
222
+ end
223
+
212
224
  # The Bible270 installer can install Active Storage for reader avatars. A
213
225
  # copied database can therefore have the same timestamp mismatch for Rails'
214
226
  # generated Active Storage migration as it has for the engine migrations.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.4.2'
4
+ VERSION = '1.5.0'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -7,6 +7,7 @@ require 'bible270/plan'
7
7
  require 'bible270/email_sign_in'
8
8
  require 'bible270/names'
9
9
  require 'bible270/mentions'
10
+ require 'bible270/comment_formatter'
10
11
  require 'bible270/avatars'
11
12
  require 'bible270/translations'
12
13
  require 'bible270/favicon'
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.2
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -9,6 +9,40 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "<"
17
+ - !ruby/object:Gem::Version
18
+ version: '3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "<"
24
+ - !ruby/object:Gem::Version
25
+ version: '3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: kramdown
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '3'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '2.5'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '3'
12
46
  - !ruby/object:Gem::Dependency
13
47
  name: rails
14
48
  requirement: !ruby/object:Gem::Requirement
@@ -106,9 +140,11 @@ files:
106
140
  - app/views/bible270/admin/index.html.erb
107
141
  - app/views/bible270/admin/show.html.erb
108
142
  - app/views/bible270/checkoffs/toggle.turbo_stream.erb
143
+ - app/views/bible270/comments/_body.html.erb
109
144
  - app/views/bible270/comments/_comment.html.erb
110
145
  - app/views/bible270/comments/_edit_fields.html.erb
111
146
  - app/views/bible270/comments/_edit_form.html.erb
147
+ - app/views/bible270/comments/_editor.html.erb
112
148
  - app/views/bible270/comments/_form.html.erb
113
149
  - app/views/bible270/comments/create.turbo_stream.erb
114
150
  - app/views/bible270/comments/destroy.turbo_stream.erb
@@ -145,6 +181,7 @@ files:
145
181
  - app/views/bible270/shared/_header.html.erb
146
182
  - app/views/bible270/shared/_interaction_status.html.erb
147
183
  - app/views/bible270/shared/_interaction_ui.html.erb
184
+ - app/views/bible270/shared/_markdown_toolbar.html.erb
148
185
  - app/views/bible270/shared/_mention_typeahead.html.erb
149
186
  - app/views/bible270/shared/_nav_links.html.erb
150
187
  - app/views/bible270/shared/_passage_source.html.erb
@@ -176,8 +213,10 @@ files:
176
213
  - db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
177
214
  - db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
178
215
  - db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb
216
+ - db/migrate/20260101000021_add_body_format_to_bible270_comments.rb
179
217
  - lib/bible270.rb
180
218
  - lib/bible270/avatars.rb
219
+ - lib/bible270/comment_formatter.rb
181
220
  - lib/bible270/configuration.rb
182
221
  - lib/bible270/daily_reminders.rb
183
222
  - lib/bible270/email_sign_in.rb