shiplog 0.3.2 → 0.4.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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +170 -2
  4. data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
  5. data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
  6. data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
  7. data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
  8. data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
  9. data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
  10. data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
  11. data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
  12. data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
  13. data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
  14. data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
  15. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  16. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  17. data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
  18. data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
  19. data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
  20. data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
  21. data/app/controllers/shiplog/newsletter_controller.rb +34 -0
  22. data/app/controllers/shiplog/public_controller.rb +6 -0
  23. data/app/frontend/shiplog/admin.css +5 -0
  24. data/app/frontend/shiplog/once.js +31 -0
  25. data/app/frontend/shiplog/shiplog.css +72 -0
  26. data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
  27. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  28. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  29. data/app/helpers/shiplog/permalink_helper.rb +20 -2
  30. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  31. data/app/jobs/shiplog/announce_job.rb +30 -0
  32. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  33. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  34. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  35. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  36. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  37. data/app/models/shiplog/announcements.rb +34 -0
  38. data/app/models/shiplog/area_detector.rb +2 -1
  39. data/app/models/shiplog/change_candidates.rb +4 -0
  40. data/app/models/shiplog/drafter.rb +4 -2
  41. data/app/models/shiplog/entry.rb +76 -6
  42. data/app/models/shiplog/markdown.rb +233 -0
  43. data/app/models/shiplog/newsletter/content.rb +172 -0
  44. data/app/models/shiplog/newsletter/csv.rb +32 -0
  45. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  46. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  47. data/app/models/shiplog/newsletter.rb +180 -0
  48. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  49. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  50. data/app/models/shiplog/period.rb +129 -0
  51. data/app/models/shiplog/public_links.rb +47 -0
  52. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  53. data/app/models/shiplog/rewriter.rb +23 -3
  54. data/app/models/shiplog/translator/prompt.rb +2 -2
  55. data/app/models/shiplog/translator.rb +1 -1
  56. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  57. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  58. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  59. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  60. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  61. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  62. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  63. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  64. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  65. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  66. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  67. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  68. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  69. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  70. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  72. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  74. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  75. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  76. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  78. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  80. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  82. data/config/admin_routes.rb +12 -0
  83. data/config/locales/en.yml +147 -0
  84. data/config/locales/es.yml +147 -0
  85. data/config/routes.rb +13 -0
  86. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  87. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  88. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  89. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  90. data/lib/shiplog/classes.rb +14 -1
  91. data/lib/shiplog/configuration.rb +132 -1
  92. data/lib/shiplog/engine.rb +2 -0
  93. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  94. data/lib/shiplog/version.rb +1 -1
  95. data/lib/shiplog.rb +32 -0
  96. data/lib/tasks/shiplog.rake +22 -0
  97. metadata +56 -1
@@ -21,14 +21,24 @@ module Shiplog
21
21
  @months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
22
22
  @result_count = scope.count if @query
23
23
  shown = @months.values.flatten
24
+ @announcements = announcements_for(@months.keys)
24
25
  preload_translations(shown)
25
26
  fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @areas.map(&:id), @query, @page, @older,
26
27
  shown.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
27
- shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown) ]
28
+ shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown),
29
+ @announcements.values.map { |announcement| [ announcement.id, announcement.slug, announcement.updated_at ] } ]
28
30
  end
29
31
 
30
32
  private
31
33
 
34
+ # The approved announcements of the months shown, by the month's first day (none while announcements are off).
35
+ def announcements_for(months)
36
+ return {} unless Shiplog.announcements? && months.any?
37
+
38
+ Entry.published_announcements.where(released_on: months.map(&:end_of_month))
39
+ .index_by { |announcement| announcement.released_on.beginning_of_month }
40
+ end
41
+
32
42
  # The areas to offer as filter links: those with published entries, in the admin's order, and only when there
33
43
  # are at least two (a single area filters nothing).
34
44
  def filter_areas
@@ -5,7 +5,8 @@ module Shiplog
5
5
  "shiplog.css" => { path: ASSET_DIRECTORY.join("shiplog.css"), type: "text/css" },
6
6
  "admin.css" => { path: ASSET_DIRECTORY.join("admin.css"), type: "text/css" },
7
7
  "board.js" => { path: ASSET_DIRECTORY.join("board.js"), type: "text/javascript" },
8
- "inbox.js" => { path: ASSET_DIRECTORY.join("inbox.js"), type: "text/javascript" }
8
+ "inbox.js" => { path: ASSET_DIRECTORY.join("inbox.js"), type: "text/javascript" },
9
+ "once.js" => { path: ASSET_DIRECTORY.join("once.js"), type: "text/javascript" }
9
10
  }.freeze
10
11
 
11
12
  # The version segment busts caches; stale versions are 404 so they are never cached as the current file.
@@ -0,0 +1,27 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # The double opt-in link: a page with a button (GET changes nothing, link scanners prefetch), and the POST
4
+ # that confirms. The token is signed and expires after 7 days (Newsletter::Tokens); an invalid one is a 404.
5
+ # Rendered in the subscriber's language, whatever the page's URL.
6
+ class ConfirmationsController < NewsletterController
7
+ around_action :with_subscriber
8
+
9
+ def show
10
+ end
11
+
12
+ def create
13
+ @subscriber.confirm!
14
+ render :create
15
+ end
16
+
17
+ private
18
+
19
+ def with_subscriber
20
+ @subscriber = Tokens.subscriber_for(params[:token])
21
+ return render_invalid_link unless @subscriber
22
+
23
+ I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) { yield }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # A subscriber's preferences, reached through the token of the newsletter's footer, without an account:
4
+ # change the language, unsubscribe, resubscribe (the token proves the mailbox, so no new confirmation), and
5
+ # "delete my data", which removes the row. The token is rotated after each change (the next page uses the
6
+ # new one; the link in the old email stops working). Rendered in the subscriber's language, and still
7
+ # available while config.newsletter is off, so an old email's link keeps working.
8
+ class PreferencesController < NewsletterController
9
+ skip_before_action :require_newsletter
10
+ before_action :require_newsletter_tables
11
+ around_action :with_subscriber
12
+
13
+ def show
14
+ end
15
+
16
+ # locale (one of the allowed ones) and/or subscribed ("0" unsubscribes from both lists, "1" resubscribes).
17
+ # The notice is worded in the language just chosen, which the next page is in.
18
+ def update
19
+ notice = "saved"
20
+ case params[:subscribed]
21
+ when "0"
22
+ Newsletter.unsubscribe(@subscriber.email)
23
+ notice = "unsubscribed"
24
+ when "1"
25
+ @subscriber.resubscribe!
26
+ notice = "resubscribed"
27
+ end
28
+ @subscriber.update!(locale: NewsletterSubscriber.resolve_locale(params[:locale])) if params[:locale].is_a?(String)
29
+ @subscriber.rotate_token!
30
+ I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) do
31
+ redirect_to newsletter_preferences_path(@subscriber.token), notice: t("shiplog.newsletter.preferences.#{notice}")
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @subscriber.destroy!
37
+ render :destroy
38
+ end
39
+
40
+ private
41
+
42
+ def with_subscriber
43
+ @subscriber = NewsletterSubscriber.find_by(token: params[:token].to_s)
44
+ return render_invalid_link unless @subscriber
45
+
46
+ I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) { yield }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,70 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # The subscribe form's target and the "check your inbox" page it leads to.
4
+ class SubscriptionsController < NewsletterController
5
+ PER_IP = 20
6
+ PER_EMAIL = 3
7
+ # All confirmation emails together, an hour: a ceiling on what a distributed bot can make Shiplog send.
8
+ GLOBAL = 300
9
+ WINDOW = 1.hour
10
+ # The honeypot's field name: nothing a browser autofills (no "website", "url", "phone"...).
11
+ HONEYPOT = :nl_extra
12
+
13
+ # The form sits on public pages that are served with ETags: a 304 revisits a cached page, whose CSRF token
14
+ # may belong to another session or be expired, and a host layout may cache it further. So the form sends no
15
+ # token and this action checks none: it changes no session state, and nothing happens until the address
16
+ # confirms by email (double opt-in), so a forged POST can at most send someone a confirmation email, which
17
+ # the rate limits bound.
18
+ skip_forgery_protection only: :create
19
+
20
+ # Rails.cache counts the attempts (a null store counts nothing: the limits are then off, as in tests). The
21
+ # per-address count is keyed by a digest of the normalised address (Newsletter.rate_limit_key), so the cache
22
+ # holds no address and "a+tag@gmail.com" and "a.b@gmail.com" count as one.
23
+ rate_limit to: PER_IP, within: WINDOW, by: -> { request.remote_ip }, with: -> { rate_limited }, name: "ip", only: :create
24
+ rate_limit to: PER_EMAIL, within: WINDOW, by: -> { submitted_email.present? ? Newsletter.rate_limit_key(submitted_email) : request.remote_ip }, with: -> { rate_limited }, name: "email", only: :create
25
+ rate_limit to: GLOBAL, within: WINDOW, by: -> { "all" }, with: -> { rate_limited }, name: "global", only: :create
26
+
27
+ # The same redirect for a new, pending, confirmed or unsubscribed address (and for a bot that filled the
28
+ # honeypot): the form cannot tell who is subscribed. Only an address that is not an email gets the form back.
29
+ def create
30
+ return redirect_to(newsletter_subscription_path) if params[HONEYPOT].present?
31
+
32
+ subscriber = NewsletterSubscriber.subscribe(submitted_email, locale: I18n.locale, source: submitted_source)
33
+ if subscriber
34
+ redirect_to newsletter_subscription_path
35
+ else
36
+ @error = t("shiplog.newsletter.form.invalid_email", default: "Enter a valid email address.")
37
+ @email = submitted_email
38
+ render :show, status: :unprocessable_content
39
+ end
40
+ end
41
+
42
+ # "Check your inbox to confirm."
43
+ def show
44
+ end
45
+
46
+ private
47
+
48
+ def submitted_email
49
+ value = params[:email]
50
+ value.is_a?(String) ? value.strip.downcase.first(NewsletterSubscriber::EMAIL_MAX_LENGTH + 1) : ""
51
+ end
52
+
53
+ # The page the visitor subscribed on: the form's `source` when it is a same-site path, else the referrer's
54
+ # path (NewsletterSubscriber.clean_source has the last word).
55
+ def submitted_source
56
+ value = params[:source]
57
+ return value if value.is_a?(String) && NewsletterSubscriber.clean_source(value)
58
+
59
+ referer = request.referer.to_s
60
+ URI.parse(referer).path if referer.present?
61
+ rescue URI::InvalidURIError
62
+ nil
63
+ end
64
+
65
+ def rate_limited
66
+ render "shiplog/newsletter/rate_limited", status: :too_many_requests
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,36 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # The unsubscribe link of every newsletter, for the host's users and Shiplog's subscribers alike: the token is
4
+ # the encrypted email (Newsletter::Tokens), so Shiplog needs no row for a host user until they unsubscribe.
5
+ # GET shows a page with a button; POST unsubscribes the address from both lists (Newsletter.unsubscribe,
6
+ # which keeps a suppression row and calls config.on_newsletter_unsubscribe). The POST is also the RFC 8058
7
+ # one-click target the List-Unsubscribe-Post header names: the mail provider sends it with the body
8
+ # "List-Unsubscribe=One-Click" and no CSRF token, so the action checks none; the token in the URL is what
9
+ # authorises it. Still available while config.newsletter is off: an old email's link must keep working.
10
+ class UnsubscriptionsController < NewsletterController
11
+ skip_before_action :require_newsletter
12
+ before_action :require_newsletter_tables
13
+ skip_forgery_protection only: :create
14
+
15
+ around_action :with_email
16
+
17
+ def show
18
+ end
19
+
20
+ def create
21
+ Newsletter.unsubscribe(@email)
22
+ render :create
23
+ end
24
+
25
+ private
26
+
27
+ def with_email
28
+ @email = Tokens.email_for(params[:token])
29
+ return render_invalid_link unless @email
30
+
31
+ subscriber = NewsletterSubscriber.find_by_email(@email)
32
+ I18n.with_locale(Newsletter.i18n_locale(subscriber ? subscriber.locale : I18n.locale)) { yield }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ module Shiplog
2
+ # Parent of the newsletter's public pages (subscribe, confirm, preferences, unsubscribe), all under /shiplog/
3
+ # so a host that constrains the engine's mount to its own paths lets them through. Subscribing and confirming
4
+ # answer 404 while the newsletter is off; the unsubscribe and preferences pages keep working as long as the
5
+ # tables exist (a link in an old email must still unsubscribe). None is ever cached: they are personal (a
6
+ # token in the URL) or carry a fresh CSRF token.
7
+ #
8
+ # None of them changes state on a GET: email link scanners prefetch links, so a link in an email opens a page
9
+ # with a button, and the button POSTs. The one-click unsubscribe (RFC 8058) is the exception by design: the
10
+ # mail provider POSTs it (see Newsletter::UnsubscriptionsController).
11
+ class NewsletterController < PublicController
12
+ helper Shiplog::NewsletterHelper
13
+
14
+ before_action :require_newsletter
15
+ before_action :no_store
16
+
17
+ private
18
+
19
+ def require_newsletter
20
+ head :not_found unless Shiplog.newsletter?
21
+ end
22
+
23
+ # For the pages that outlive config.newsletter: the tables must be there.
24
+ def require_newsletter_tables
25
+ head :not_found unless Newsletter.available?
26
+ end
27
+
28
+ # The page for a link that does not verify (tampered, expired, used, or a deleted subscriber). A 404 carries
29
+ # no hint of which.
30
+ def render_invalid_link
31
+ render "shiplog/newsletter/invalid", status: :not_found
32
+ end
33
+ end
34
+ end
@@ -11,12 +11,18 @@ module Shiplog
11
11
  helper Shiplog::PermalinkHelper
12
12
  helper Shiplog::StructuredDataHelper
13
13
  helper Shiplog::HeadHelper
14
+ helper Shiplog::NewsletterHelper
14
15
  layout -> { Shiplog.config.public_layout }
15
16
 
16
17
  # Marks the controllers of the public pages, for shiplog_head_tags: checking this instead of the class keeps
17
18
  # host pages from autoloading Shiplog::PublicController (and the host's parent controller).
18
19
  def shiplog_public_page? = true
19
20
 
21
+ # The entry a permalink or announcement page shows (nil on the other pages), for config.on_changelog_seen and
22
+ # host layouts: `controller.shiplog_entry&.announcement?` tells an announcement visit apart.
23
+ def shiplog_entry = @entry
24
+ helper_method :shiplog_entry
25
+
20
26
  private
21
27
 
22
28
  # Calls config.on_changelog_seen for a visit to the changelog index or a permalink page. It runs before
@@ -134,7 +134,12 @@
134
134
  .shiplog-admin .shiplog-admin-images__controls { display: grid; flex: 1; gap: 0.5rem; min-width: 16rem; }
135
135
  .shiplog-admin .shiplog-admin__visually-hidden { border: 0; clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; }
136
136
  .shiplog-admin .shiplog-area-row__count { text-align: right; }
137
+ .shiplog-admin .shiplog-admin-preview { background: var(--sa-bg); border-radius: 0.5rem; overflow-wrap: anywhere; padding: 0.75rem 1rem; }
138
+ .shiplog-admin .shiplog-admin-export { display: inline-flex; gap: 0.5rem; margin-left: 0.75rem; }
137
139
  .shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
140
+ .shiplog-admin .shiplog-admin-counts { margin-bottom: 1rem; width: auto; }
141
+ .shiplog-admin .shiplog-admin-counts__number { text-align: right; }
142
+ .shiplog-admin .shiplog-admin-newsletter__progress { margin: 0 0 0.5rem; }
138
143
 
139
144
  .shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
140
145
  .shiplog-admin .shiplog-board__title { font-size: 1.125rem; margin: 0 0 0.5rem; }
@@ -0,0 +1,31 @@
1
+ // Forms marked data-shiplog-once (the newsletter's Send, Send test and Retry) submit once: their buttons are
2
+ // disabled and marked busy as soon as the form is submitted, so a double click sends one request. The server is
3
+ // idempotent anyway (delivery rows are claimed atomically); this only spares the admin a confusing second request.
4
+ // Buttons come back when the page is restored from the back/forward cache.
5
+ const SELECTOR = "form[data-shiplog-once]"
6
+
7
+ document.addEventListener("submit", (event) => {
8
+ const form = event.target
9
+ if (!(form instanceof HTMLFormElement) || !form.matches(SELECTOR)) return
10
+
11
+ if (form.dataset.shiplogSubmitted === "true") {
12
+ event.preventDefault()
13
+ return
14
+ }
15
+ form.dataset.shiplogSubmitted = "true"
16
+ form.setAttribute("aria-busy", "true")
17
+ // Disabled after the submit event, so the clicked button's name and value are still sent.
18
+ setTimeout(() => {
19
+ form.querySelectorAll("button, input[type=submit]").forEach((button) => { button.disabled = true })
20
+ }, 0)
21
+ })
22
+
23
+ window.addEventListener("pageshow", (event) => {
24
+ if (!event.persisted) return
25
+
26
+ document.querySelectorAll(SELECTOR).forEach((form) => {
27
+ delete form.dataset.shiplogSubmitted
28
+ form.removeAttribute("aria-busy")
29
+ form.querySelectorAll("button, input[type=submit]").forEach((button) => { button.disabled = false })
30
+ })
31
+ })
@@ -285,6 +285,34 @@
285
285
  .shiplog-permalink__back:focus-visible,
286
286
  .shiplog-permalink__nav-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
287
287
 
288
+ .shiplog-announcement {
289
+ background: var(--sl-card);
290
+ border: 1px solid var(--sl-border);
291
+ border-left: 4px solid var(--sl-accent);
292
+ border-radius: var(--sl-radius);
293
+ padding: calc(var(--sl-space) * 1.25);
294
+ }
295
+ .shiplog-announcement .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
296
+ .shiplog-announcement__title { font-size: 1.5rem; line-height: 1.25; margin: 0.5rem 0 var(--sl-space); }
297
+ .shiplog-announcement__body { line-height: 1.6; }
298
+ .shiplog-announcement__body h3,
299
+ .shiplog-announcement__body h4,
300
+ .shiplog-announcement__body h5 { line-height: 1.3; margin: calc(var(--sl-space) * 1.5) 0 0.5rem; }
301
+ .shiplog-announcement__body h3 { font-size: 1.125rem; }
302
+ .shiplog-announcement__body h4 { font-size: 1rem; }
303
+ .shiplog-announcement__body p,
304
+ .shiplog-announcement__body ul,
305
+ .shiplog-announcement__body ol,
306
+ .shiplog-announcement__body blockquote { margin: 0.5rem 0; }
307
+ .shiplog-announcement__body ul,
308
+ .shiplog-announcement__body ol { padding-left: 1.25rem; }
309
+ .shiplog-announcement__body a { color: var(--sl-accent); }
310
+ .shiplog-announcement__body img { border-radius: var(--sl-radius); height: auto; max-width: 100%; }
311
+ .shiplog-announcement__body blockquote { border-left: 3px solid var(--sl-border); color: var(--sl-muted); margin-left: 0; padding-left: var(--sl-space); }
312
+ .shiplog-month__announcement { margin: -0.25rem 0 0.75rem; }
313
+ .shiplog-month__announcement-link { color: var(--sl-accent); font-weight: 600; }
314
+ .shiplog-month__announcement-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
315
+
288
316
  .shiplog-entry__area {
289
317
  color: var(--sl-muted);
290
318
  font-size: 0.75rem;
@@ -336,6 +364,50 @@
336
364
  .shiplog-search__clear { color: var(--sl-accent); margin-left: 0.5rem; }
337
365
  .shiplog-search__empty { color: var(--sl-muted); margin: 0; }
338
366
 
367
+ /* Newsletter: the subscribe form on the changelog and announcement pages, and the confirm / preferences /
368
+ unsubscribe pages (.shiplog-newsletter--page). */
369
+ .shiplog-newsletter {
370
+ background: var(--sl-card);
371
+ border: 1px solid var(--sl-border);
372
+ border-radius: var(--sl-radius);
373
+ margin: calc(var(--sl-space) * 2) 0;
374
+ padding: var(--sl-space) calc(var(--sl-space) * 1.25);
375
+ }
376
+ .shiplog-newsletter__title { font-size: 1.125rem; line-height: 1.3; margin: 0 0 0.25rem; }
377
+ .shiplog-newsletter__hint { color: var(--sl-muted); font-size: 0.875rem; margin: 0 0 0.75rem; }
378
+ .shiplog-newsletter__form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
379
+ .shiplog-newsletter__label { font-weight: 500; }
380
+ .shiplog-newsletter__input {
381
+ flex: 1 1 14rem;
382
+ min-width: 0;
383
+ font: inherit;
384
+ color: inherit;
385
+ background: var(--sl-bg);
386
+ border: 1px solid var(--sl-border);
387
+ border-radius: var(--sl-radius);
388
+ padding: 0.4rem 0.75rem;
389
+ }
390
+ .shiplog-newsletter__button {
391
+ font: inherit;
392
+ color: #fff;
393
+ background: var(--sl-accent);
394
+ border: 1px solid var(--sl-accent);
395
+ border-radius: var(--sl-radius);
396
+ padding: 0.4rem 1rem;
397
+ cursor: pointer;
398
+ }
399
+ .shiplog-newsletter__button--secondary { color: var(--sl-fg); background: transparent; border-color: var(--sl-border); }
400
+ .shiplog-newsletter__input:focus-visible,
401
+ .shiplog-newsletter__button:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
402
+ .shiplog-newsletter__notice { margin: 0 0 0.75rem; }
403
+ .shiplog-newsletter__notice--error { color: #cf222e; }
404
+ .shiplog-newsletter__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: var(--sl-space); }
405
+ .shiplog-newsletter__actions form { margin: 0; }
406
+ /* The honeypot: out of view for people (never display:none, which some bots detect), labelled for nobody. */
407
+ .shiplog-newsletter__trap { height: 1px; left: -10000px; overflow: hidden; position: absolute; top: auto; width: 1px; }
408
+ .shiplog-newsletter--page { margin-top: 0; }
409
+ .shiplog-newsletter--page p { margin: 0 0 0.75rem; }
410
+
339
411
  @media (max-width: 40rem) {
340
412
  .shiplog-page { padding: var(--sl-space) 0.75rem; }
341
413
  .shiplog-page__title { font-size: 1.5rem; }
@@ -9,8 +9,10 @@ module Shiplog
9
9
  subject.is_a?(Commit) ? subject.message.lines.drop(1).join.strip : subject.body.to_s
10
10
  end
11
11
 
12
+ # A commit's short sha, a period's month, or "owner/name#123".
12
13
  def source_reference(subject)
13
14
  return subject.sha.first(7) if subject.is_a?(Commit)
15
+ return subject.title if subject.is_a?(Period)
14
16
 
15
17
  "#{subject.repository.full_name}##{subject.number}"
16
18
  end
@@ -0,0 +1,28 @@
1
+ module Shiplog
2
+ # The newsletter's subscribe form. The changelog and announcement pages render it while config.newsletter is
3
+ # on; a host renders it on any page with `helper Shiplog::NewsletterHelper` and `<%= shiplog_newsletter_form %>`
4
+ # (or `render "shiplog/newsletter/form"` once the helper is included). Nothing is rendered while the newsletter
5
+ # is off. Uses the engine's own route helpers, or the proxy of the host's mount in host views (see SwitcherHelper).
6
+ module NewsletterHelper
7
+ include Shiplog::SwitcherHelper
8
+
9
+ # The form (and the bundled stylesheet once per host page, unless config.stylesheet is false). `source` is
10
+ # recorded with the subscription as the page it was made on; the request's path by default.
11
+ def shiplog_newsletter_form(source: nil)
12
+ return "".html_safe unless Shiplog.newsletter?
13
+
14
+ stylesheet = if Shiplog.config.stylesheet && !@_shiplog_stylesheet_emitted
15
+ @_shiplog_stylesheet_emitted = true
16
+ tag.link(rel: "stylesheet", nonce: content_security_policy_nonce,
17
+ href: shiplog_routes.frontend_path(version: Shiplog::VERSION, name: "shiplog.css"))
18
+ end
19
+ safe_join([ stylesheet, render("shiplog/newsletter/form", source:) ])
20
+ end
21
+
22
+ # The form's action, in the engine's pages and the host's alike.
23
+ def shiplog_newsletter_subscription_path = shiplog_routes.newsletter_subscription_path
24
+
25
+ # The page the subscription is recorded as made on: the given source, else the request's path.
26
+ def shiplog_newsletter_source(source) = (source.presence || request.path).to_s.first(NewsletterSubscriber::SOURCE_MAX_LENGTH)
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module Shiplog
2
+ # The inline styles of the newsletter templates: one place for the palette (light colours that email clients
3
+ # invert sanely in dark mode, no pure black) and the pieces an overriding template can reuse (buttons, cards).
4
+ module NewsletterMailerHelper
5
+ PALETTE = {
6
+ page: "#f3f4f6", card: "#ffffff", text: "#1f2328", muted: "#59636e", border: "#d1d9e0", soft: "#f6f8fa"
7
+ }.freeze
8
+ FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif".freeze
9
+ RADIUS = "10px".freeze
10
+
11
+ def shiplog_mail_palette = PALETTE
12
+
13
+ def shiplog_mail_font = FONT
14
+
15
+ # A button: a one-cell table (Outlook ignores padding on links) with the accent as background. `margin` is the
16
+ # table's CSS margin.
17
+ def shiplog_mail_button(label, url, accent, margin: "24px 0 0")
18
+ return if url.blank?
19
+
20
+ tag.table(role: "presentation", cellpadding: "0", cellspacing: "0", border: "0", style: "margin:#{margin};") do
21
+ tag.tr(tag.td(style: "background-color:#{accent};border-radius:6px;") do
22
+ link_to(label, url, style: "display:inline-block;padding:11px 20px;font-family:#{FONT};font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;")
23
+ end)
24
+ end
25
+ end
26
+
27
+ # A highlight's screenshot: full width, rounded top corners, sized from the blob's metadata when known.
28
+ def shiplog_mail_screenshot(highlight)
29
+ return unless highlight.image?
30
+
31
+ options = { alt: highlight.image_alt, width: 600, border: 0,
32
+ style: "display:block;width:100%;max-width:600px;height:auto;border:0;border-radius:#{RADIUS} #{RADIUS} 0 0;" }
33
+ options[:height] = (600.0 * highlight.image_height / highlight.image_width).round if highlight.image_width.to_i.positive? && highlight.image_height.to_i.positive?
34
+ link_to(image_tag(highlight.image_url, **options), highlight.url, style: "display:block;text-decoration:none;")
35
+ end
36
+ end
37
+ end
@@ -37,13 +37,30 @@ module Shiplog
37
37
  path ? link_to(name, path, html_options) : content_tag(:span, name, html_options.slice(:class))
38
38
  end
39
39
 
40
+ # Where a month heading's "Read the summary" link goes: the announcement's page (config.announcements = :page),
41
+ # or what config.announcement_url returns for it (:external; evaluated in the view with the announcement and
42
+ # the locale). Nil, so no link, when there is none, it raises or it is not a safe link.
43
+ def shiplog_announcement_href(announcement)
44
+ return shiplog_entry_path(announcement) if Shiplog.announcement_pages?
45
+
46
+ callable = Shiplog.config.announcement_url
47
+ return unless Shiplog.config.announcements == :external && callable
48
+
49
+ value = callable.is_a?(Proc) ? instance_exec(announcement, I18n.locale, &callable) : callable.call(announcement, I18n.locale)
50
+ value if Shiplog::Configuration.safe_url?(value)
51
+ rescue StandardError => error
52
+ Rails.logger.error("[shiplog] config.announcement_url failed for entry #{announcement.id}: #{error.class}: #{error.message}")
53
+ nil
54
+ end
55
+
40
56
  # The id of a month's section on the changelog list.
41
57
  def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
42
58
 
43
59
  # The body, on one line and cut for a meta description. Plain text: tag helpers escape it. `escape: false`
44
60
  # keeps the cut text raw (not HTML-escaped) for JSON, which escapes it its own way.
45
61
  def shiplog_entry_description(entry, escape: true)
46
- truncate(entry.display_body.to_s.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
62
+ text = entry.announcement? ? Shiplog::Markdown.to_text(entry.display_body) : entry.display_body.to_s
63
+ truncate(text.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
47
64
  end
48
65
 
49
66
  # The Open Graph meta tags of a permalink page, for `content_for :shiplog_og` (superseded by shiplog_head_tags).
@@ -103,7 +120,8 @@ module Shiplog
103
120
  url = shiplog_image_url(image)
104
121
  tag.p(tag.img(src: url, alt: entry.image_alt(image), **shiplog_image_size(image))) if url
105
122
  end
106
- safe_join([ simple_format(h(entry.display_body)), *images ], "\n")
123
+ text = entry.announcement? ? Shiplog::Markdown.to_html(entry.display_body) : simple_format(h(entry.display_body))
124
+ safe_join([ text, *images ], "\n")
107
125
  end
108
126
 
109
127
  # An entry's images in order, or none while screenshots are off.
@@ -5,5 +5,9 @@ module Shiplog
5
5
  module UnseenHelper
6
6
  # The number of approved features and changes approved after `since` (all of them while it is nil), at most 99.
7
7
  def shiplog_unseen_count(since) = Shiplog.unseen_count(since:)
8
+
9
+ # The number of approved monthly announcements approved after `since` (all of them while it is nil), at most 99:
10
+ # the rare signal for a badge, where shiplog_unseen_count counts every daily entry.
11
+ def shiplog_unseen_announcements_count(since) = Shiplog.unseen_announcements_count(since:)
8
12
  end
9
13
  end
@@ -0,0 +1,30 @@
1
+ module Shiplog
2
+ # Calls config.on_announcement_approved with an approved announcement, and sends the
3
+ # `announcement_approved.shiplog` notification, once its translations into every configured locale are current
4
+ # (or cannot be made: no rewriter). Enqueued when an announcement is approved and after each translation of one;
5
+ # whichever run finds it ready announces it, once per approval (Entry#announce!).
6
+ #
7
+ # The hook runs inside the announcement's row lock and transaction: keep it fast (enqueue your own job). A hook
8
+ # that raises, or a worker that dies mid-hook, leaves the announcement unannounced, and the retry calls it again;
9
+ # after the last attempt the error is logged and the admin can "Notify now". `force: true` (that button) does not
10
+ # wait for translations.
11
+ class AnnounceJob < ApplicationJob
12
+ ATTEMPTS = 3
13
+ EVENT = "announcement_approved.shiplog".freeze
14
+
15
+ retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
16
+ Rails.logger.error("[shiplog] Giving up announcing entry #{job.arguments.first.try(:id)} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
17
+ end
18
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
19
+
20
+ def perform(entry, force: false)
21
+ return unless Shiplog.announcements?
22
+
23
+ entry.announce!(force:) do
24
+ ActiveSupport::Notifications.instrument(EVENT, announcement: entry) do
25
+ Shiplog.config.on_announcement_approved&.call(entry)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ module Shiplog
2
+ # Drafts the announcement of a month (the previous one by default) and enqueues its rewrite. Schedule it once a
3
+ # month, early in the month (see the README's Scheduling section); running it again is harmless.
4
+ class DraftAnnouncementJob < ApplicationJob
5
+ def perform(month = nil)
6
+ Announcements.draft(month: month || Date.current.prev_month)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ module Shiplog
2
+ # Housekeeping of the newsletter's tables, to schedule weekly like ScanChangesJob (see the README's Scheduling
3
+ # section): deletes the subscribers who never confirmed within 30 days (NewsletterSubscriber::PENDING_FOR: an
4
+ # address typed by someone else, or a confirmation email that never arrived) and the delivery rows older than
5
+ # 180 days (NewsletterDelivery::RETENTION: the idempotency record of a send long done). Confirmed and
6
+ # unsubscribed rows are never touched. Returns { subscribers:, deliveries: } counts.
7
+ class PurgeNewsletterJob < ApplicationJob
8
+ def perform
9
+ subscribers = NewsletterSubscriber.purge_pending
10
+ deliveries = NewsletterDelivery.purge
11
+ Rails.logger.info("[shiplog] Purged #{subscribers} unconfirmed newsletter #{subscribers == 1 ? "subscriber" : "subscribers"} and #{deliveries} old delivery #{deliveries == 1 ? "row" : "rows"}")
12
+ { subscribers:, deliveries: }
13
+ end
14
+ end
15
+ end