shiplog 0.3.1 → 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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -4
  3. data/README.md +178 -7
  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 +10 -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/head_helper.rb +1 -1
  28. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  29. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  30. data/app/helpers/shiplog/permalink_helper.rb +24 -5
  31. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  32. data/app/jobs/shiplog/announce_job.rb +30 -0
  33. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  34. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  35. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  36. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  37. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  38. data/app/models/shiplog/announcements.rb +34 -0
  39. data/app/models/shiplog/area_detector.rb +2 -1
  40. data/app/models/shiplog/change_candidates.rb +4 -0
  41. data/app/models/shiplog/drafter.rb +4 -2
  42. data/app/models/shiplog/entry.rb +76 -6
  43. data/app/models/shiplog/markdown.rb +233 -0
  44. data/app/models/shiplog/newsletter/content.rb +172 -0
  45. data/app/models/shiplog/newsletter/csv.rb +32 -0
  46. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  47. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  48. data/app/models/shiplog/newsletter.rb +180 -0
  49. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  50. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  51. data/app/models/shiplog/period.rb +129 -0
  52. data/app/models/shiplog/public_links.rb +47 -0
  53. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  54. data/app/models/shiplog/rewriter.rb +23 -3
  55. data/app/models/shiplog/translator/prompt.rb +2 -2
  56. data/app/models/shiplog/translator.rb +1 -1
  57. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  58. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  59. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  60. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  61. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  62. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  63. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  64. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  65. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  66. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  67. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  68. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  69. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  70. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  72. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  74. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  75. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  76. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  78. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  80. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  82. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  83. data/config/admin_routes.rb +12 -0
  84. data/config/locales/en.yml +147 -0
  85. data/config/locales/es.yml +147 -0
  86. data/config/routes.rb +13 -0
  87. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  88. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  89. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  90. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  91. data/lib/shiplog/classes.rb +14 -1
  92. data/lib/shiplog/configuration.rb +135 -3
  93. data/lib/shiplog/engine.rb +2 -0
  94. data/{app/helpers → lib}/shiplog/head_tags_helper.rb +15 -5
  95. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  96. data/lib/shiplog/version.rb +1 -1
  97. data/lib/shiplog.rb +33 -0
  98. data/lib/tasks/shiplog.rake +22 -0
  99. metadata +57 -2
@@ -0,0 +1,44 @@
1
+ # The newsletter's tables (0.4.0, second part). Additive and reversible.
2
+ class Shiplog040Newsletter < ActiveRecord::Migration[8.1]
3
+ def change
4
+ # --- Newsletter ---
5
+ # Visitors who subscribed through Shiplog's form, and the addresses that unsubscribed (a suppression record,
6
+ # also for the host's own users, who are otherwise never stored). Double opt-in: confirmation_nonce is the
7
+ # single-use secret of the pending confirmation link, cleared when it is used; confirmed_at is set then;
8
+ # unsubscribed_at keeps an unsubscribed address suppressed; token reaches the preferences page without an
9
+ # account (rotated after each change there); source is the page the visitor signed up on (a consent record).
10
+ # Unique on the lowercased email.
11
+ create_table :shiplog_newsletter_subscribers do |t|
12
+ t.string :email, null: false
13
+ t.string :locale, null: false
14
+ t.string :token, null: false
15
+ t.string :confirmation_nonce
16
+ t.datetime :confirmed_at
17
+ t.datetime :unsubscribed_at
18
+ t.string :source
19
+ t.timestamps
20
+ end
21
+ add_index :shiplog_newsletter_subscribers, "lower(email)", unique: true, name: "index_shiplog_newsletter_subscribers_on_lower_email"
22
+ add_index :shiplog_newsletter_subscribers, :token, unique: true
23
+ add_index :shiplog_newsletter_subscribers, :confirmation_nonce, unique: true
24
+ add_index :shiplog_newsletter_subscribers, [ :confirmed_at, :unsubscribed_at ], name: "index_shiplog_newsletter_subscribers_on_status"
25
+
26
+ # One row per announcement and recipient ("User/42" for the host's users, "Subscriber/7" for Shiplog's
27
+ # subscribers), inserted before the email is sent: a retried job or a second click never sends twice.
28
+ # status is pending (claimed), sent or failed (error says why, without the address, for the admin's "Retry
29
+ # failed"). No email address is kept here; rows older than 180 days are purged.
30
+ create_table :shiplog_newsletter_deliveries do |t|
31
+ t.references :announcement, null: false, foreign_key: { to_table: :shiplog_entries, on_delete: :cascade }, index: false
32
+ t.string :recipient_key, null: false
33
+ t.string :locale, null: false
34
+ t.string :status, null: false, default: "pending"
35
+ t.datetime :sent_at
36
+ t.text :error
37
+ t.timestamps
38
+ end
39
+ add_index :shiplog_newsletter_deliveries, [ :announcement_id, :recipient_key ], unique: true, name: "index_shiplog_newsletter_deliveries_on_announcement_and_key"
40
+ add_index :shiplog_newsletter_deliveries, [ :announcement_id, :status ], name: "index_shiplog_newsletter_deliveries_on_announcement_and_status"
41
+ add_index :shiplog_newsletter_deliveries, :recipient_key
42
+ add_index :shiplog_newsletter_deliveries, :created_at
43
+ end
44
+ end
@@ -68,7 +68,20 @@ module Shiplog
68
68
  area_link: "rounded-full border border-gray-300 px-3 py-0.5 text-sm hover:border-indigo-500 dark:border-gray-700 [&.shiplog-areas__link--current]:border-indigo-600 [&.shiplog-areas__link--current]:bg-indigo-600 [&.shiplog-areas__link--current]:text-white",
69
69
  entry_area: "text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
70
70
  entry_images: "mt-3 grid w-full gap-3",
71
- entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800"
71
+ entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800",
72
+ announcement: "rounded-xl border border-gray-200 border-l-4 border-l-indigo-600 bg-white p-5 dark:border-gray-800 dark:border-l-indigo-500 dark:bg-gray-900",
73
+ announcement_title: "mt-2 mb-4 text-2xl font-semibold text-gray-900 dark:text-white",
74
+ announcement_body: "leading-relaxed text-gray-700 dark:text-gray-300 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-indigo-600 [&_a]:underline dark:[&_a]:text-indigo-400 [&_p]:my-2",
75
+ month_announcement: "-mt-1 mb-3",
76
+ month_announcement_link: "text-sm font-semibold text-indigo-600 hover:underline dark:text-indigo-400",
77
+ newsletter: "my-10 rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-gray-800 dark:bg-gray-900/50",
78
+ newsletter_title: "text-lg font-semibold",
79
+ newsletter_hint: "mt-1 mb-3 text-sm text-gray-500 dark:text-gray-400",
80
+ newsletter_form: "flex flex-wrap items-center gap-2",
81
+ newsletter_label: "text-sm font-medium",
82
+ newsletter_input: "min-w-0 flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-900",
83
+ newsletter_button: "rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 [&.shiplog-newsletter__button--secondary]:bg-transparent [&.shiplog-newsletter__button--secondary]:text-gray-700 [&.shiplog-newsletter__button--secondary]:ring-1 [&.shiplog-newsletter__button--secondary]:ring-gray-300 dark:[&.shiplog-newsletter__button--secondary]:text-gray-300",
84
+ newsletter_notice: "mb-3 text-sm [&.shiplog-newsletter__notice--error]:text-red-600"
72
85
  }.freeze
73
86
 
74
87
  def copy_initializer
@@ -174,6 +174,56 @@ Shiplog.configure do |config|
174
174
  # decide which URLs set the locale (e.g. a /es scope). The default locale is dropped from the list.
175
175
  # config.locales = [] # e.g. [:es]
176
176
 
177
+ # Monthly announcements: a short summary of what shipped in a month, drafted by config.rewriter from the month's
178
+ # approved entries and the small changes that got none, and approved in the admin like any entry. Schedule
179
+ # Shiplog::DraftAnnouncementJob early each month (it drafts the previous month), or use the inbox's
180
+ # "Draft announcement" buttons. Where it is published:
181
+ # :page (default) Shiplog renders /changelog/2026-10, links it from the month's heading and adds it to the feed
182
+ # :external your app publishes it (on_announcement_approved below); the month heading links to announcement_url
183
+ # false no announcements
184
+ # config.announcements = :page
185
+ # config.announcement_url = ->(announcement, locale) { main_app.news_path(announcement.slug) }
186
+
187
+ # Called with the announcement once it is approved and translated into every configured locale, once per approval
188
+ # (also sent as the `announcement_approved.shiplog` notification). The announcement offers display_title(locale),
189
+ # to_markdown(locale), to_html(locale), to_text(locale) and public_url, to publish it elsewhere or email it.
190
+ # config.on_announcement_approved = ->(announcement) { NewsletterJob.perform_later(announcement) }
191
+
192
+ # Newsletter: the approved announcement emailed, in each recipient's language, to your users who opted in and to
193
+ # the visitors who subscribed through Shiplog's form (double opt-in, stored in shiplog_newsletter_subscribers).
194
+ # Sending is an explicit admin action on the announcement's page, once per recipient. Needs newsletter_from,
195
+ # newsletter_address and canonical_url. Schedule Shiplog::PurgeNewsletterJob weekly (unconfirmed addresses).
196
+ # config.newsletter = false
197
+
198
+ # Your users who opted in: a callable returning objects (or a relation, read in batches) responding to #email,
199
+ # #locale and #id. Shiplog never stores them. Resolve the locale yourself (the user's, then a default).
200
+ # config.newsletter_recipients = -> { User.where(newsletter: true) }
201
+
202
+ # Your own unsubscribe or settings page for those users, linked in the footer as "Preferences". The one-click
203
+ # unsubscribe of mail providers always comes to Shiplog, which calls on_newsletter_unsubscribe below.
204
+ # config.newsletter_unsubscribe_url = ->(user) { Rails.application.routes.url_helpers.settings_url(host: "example.com") }
205
+
206
+ # Called with the lowercased email on every unsubscribe (one-click, the footer link, the preferences page), so your
207
+ # own setting follows: Shiplog suppresses the address itself either way, and skips it at send time whatever your
208
+ # list says. When a user opts in again in your app, call Shiplog::Newsletter.resubscribe(user.email) to lift the
209
+ # suppression. Errors are logged, never shown to the visitor.
210
+ # config.on_newsletter_unsubscribe = ->(email) { User.where("lower(email) = ?", email).update_all(newsletter: false) }
211
+
212
+ # The sender (also of the confirmation email) and the postal address in the footer (CAN-SPAM requires one).
213
+ # config.newsletter_from = "Acme <updates@example.com>"
214
+ # config.newsletter_address = "Acme Inc., 123 Main Street, Springfield"
215
+
216
+ # Branding: the logo (an http(s) URL, shown 140px wide) and the accent colour of buttons and links.
217
+ # config.newsletter_logo_url = "https://example.com/logo.png"
218
+ # config.newsletter_accent = "#0969da"
219
+
220
+ # Merged into every newsletter message (not the confirmation email): ActionMailer options or headers your
221
+ # delivery gem reads. With Postmark, a broadcast stream keeps complaints away from your transactional email.
222
+ # config.newsletter_mailer_options = { message_stream: "broadcast" }
223
+
224
+ # How many recipients a run claims and emails at a time (Postmark's batch limit by default).
225
+ # config.newsletter_batch_size = 500
226
+
177
227
  # Callables above cannot be part of the public pages' ETag. Bump this when the output of a callable you
178
228
  # configured changes (a new label, another path) so cached pages are refreshed.
179
229
  # config.cache_version = nil
@@ -58,7 +58,20 @@ module Shiplog
58
58
  area_link: "shiplog-areas__link",
59
59
  entry_area: "shiplog-entry__area",
60
60
  entry_images: "shiplog-entry__images",
61
- entry_image: "shiplog-entry__image"
61
+ entry_image: "shiplog-entry__image",
62
+ announcement: "shiplog-announcement",
63
+ announcement_title: "shiplog-announcement__title",
64
+ announcement_body: "shiplog-announcement__body",
65
+ month_announcement: "shiplog-month__announcement",
66
+ month_announcement_link: "shiplog-month__announcement-link",
67
+ newsletter: "shiplog-newsletter",
68
+ newsletter_title: "shiplog-newsletter__title",
69
+ newsletter_hint: "shiplog-newsletter__hint",
70
+ newsletter_form: "shiplog-newsletter__form",
71
+ newsletter_label: "shiplog-newsletter__label",
72
+ newsletter_input: "shiplog-newsletter__input",
73
+ newsletter_button: "shiplog-newsletter__button",
74
+ newsletter_notice: "shiplog-newsletter__notice"
62
75
  }.freeze
63
76
 
64
77
  KEYS = BEM.keys.freeze
@@ -8,6 +8,7 @@ module Shiplog
8
8
  :roadmap_path
9
9
 
10
10
  ADMIN_CHROMES = %i[standalone hosted].freeze
11
+ ANNOUNCEMENT_MODES = [ :page, :external, false ].freeze
11
12
  # Segments of only dots ("." and "..") are path traversal, not names.
12
13
  REPOSITORY_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
13
14
  REPOSITORY_OPTION_KEYS = %i[name changelog gem].freeze
@@ -46,8 +47,9 @@ module Shiplog
46
47
  # current), on every visit, and may write: keep it cheap, e.g. write only when the stored time is older than
47
48
  # the newest entry. Nil by default. Not part of the ETag: it doesn't change the markup.
48
49
  attr_reader :on_changelog_seen
49
- # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path):
50
- # callables cannot be part of the ETag, so this is how cached public pages learn about it.
50
+ # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path,
51
+ # og_image): callables cannot be part of the ETag, so this is how cached public pages learn about it. Also
52
+ # bump it after changing the layout's head or a shiplog/head/* partial: ETags don't cover layouts.
51
53
  attr_accessor :cache_version
52
54
  attr_reader :extra_pages, :repository_labels, :roadmap_shipped_count
53
55
  # How many product areas the AI may create (default 8). Past it, entries can only join existing areas.
@@ -58,6 +60,44 @@ module Shiplog
58
60
  attr_reader :images, :max_images_per_entry
59
61
  LOCALE_FORMAT = /\A[A-Za-z][A-Za-z0-9_-]*\z/
60
62
 
63
+ # Monthly announcements (see Shiplog::Announcements). `announcements` is where they are published:
64
+ # :page (default) Shiplog renders /changelog/2026-10, links it from the changelog's month heading and
65
+ # puts it in the Atom feed;
66
+ # :external no Shiplog page: the month heading links to `announcement_url`, and the host publishes the text
67
+ # itself (on_announcement_approved);
68
+ # false no announcements at all: nothing is drafted, rendered or announced.
69
+ attr_reader :announcements
70
+ # Where an announcement is published when `announcements` is :external: a callable
71
+ # `->(announcement, locale) { url }` evaluated in the view (instance_exec, so `main_app` route helpers work).
72
+ # A nil or unsafe result renders no link. Not part of the ETag: bump cache_version when its output changes.
73
+ attr_reader :announcement_url
74
+ # Called with the announcement (a Shiplog::Entry) once it is approved and every configured locale has a current
75
+ # translation, from Shiplog::AnnounceJob. Once per approval: an announcement regenerated and approved again
76
+ # is announced again. An `announcement_approved.shiplog` ActiveSupport notification is sent at the same time.
77
+ attr_reader :on_announcement_approved
78
+
79
+ # The newsletter (see Shiplog::Newsletter): the approved announcement emailed to the host's users who opted in
80
+ # and to the visitors who subscribed through Shiplog's form. Off by default, and nothing without announcements.
81
+ attr_reader :newsletter
82
+ # A callable returning the host's recipients, each responding to #email, #locale and #id (an Active Record
83
+ # relation is read in batches with find_each). Shiplog never stores them.
84
+ attr_reader :newsletter_recipients
85
+ # `->(recipient) { url }`: the host's own unsubscribe or settings page, linked in the footer for its users. The
86
+ # one-click unsubscribe (List-Unsubscribe-Post) always goes through Shiplog's endpoint, which calls
87
+ # on_newsletter_unsubscribe. Without it the footer links Shiplog's unsubscribe page.
88
+ attr_reader :newsletter_unsubscribe_url
89
+ # `->(email) { ... }`, called on every unsubscribe (one-click, the footer link or the preferences page), so the
90
+ # host turns its own user's setting off. Errors are logged, never shown to the visitor.
91
+ attr_reader :on_newsletter_unsubscribe
92
+ # The sender, e.g. "Acme <updates@acme.com>" (also used by the confirmation email); the postal address the
93
+ # footer carries (CAN-SPAM requires one); the logo (an http(s) URL) and the accent colour ("#rrggbb") of the
94
+ # email; extra ActionMailer options for every newsletter message, e.g. { message_stream: "broadcast" } for a
95
+ # Postmark broadcast stream; how many recipients a job claims at a time (default 500).
96
+ attr_reader :newsletter_from, :newsletter_address, :newsletter_logo_url, :newsletter_accent,
97
+ :newsletter_mailer_options, :newsletter_batch_size
98
+ ACCENT_FORMAT = /\A#(?:\h{3}|\h{6})\z/
99
+ MAX_NEWSLETTER_BATCH_SIZE = 10_000
100
+
61
101
  EXTRA_PAGE_KEYS = %i[label path].freeze
62
102
 
63
103
  # A path (not "//host", "/\host" or "/ x", which browsers read as other targets), an http(s) URL or mailto:.
@@ -77,7 +117,8 @@ module Shiplog
77
117
  stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
78
118
  extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
79
119
  repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
80
- stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image)
120
+ stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image),
121
+ announcements.to_s, stable.(announcement_url), newsletter
81
122
  ]
82
123
  Digest::SHA256.hexdigest(JSON.generate(data))
83
124
  end
@@ -168,11 +209,90 @@ module Shiplog
168
209
  @og_image = value
169
210
  end
170
211
 
212
+ def announcements=(value)
213
+ mode = value.is_a?(String) ? value.to_sym : value
214
+ unless ANNOUNCEMENT_MODES.include?(mode)
215
+ raise ArgumentError, "announcements must be :page, :external or false, got #{value.inspect}"
216
+ end
217
+ @announcements = mode
218
+ end
219
+
220
+ def announcement_url=(value)
221
+ raise ArgumentError, "announcement_url must be nil or a callable like ->(announcement, locale) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
222
+ @announcement_url = value
223
+ end
224
+
225
+ def on_announcement_approved=(value)
226
+ raise ArgumentError, "on_announcement_approved must be nil or a callable like ->(announcement) { ... }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
227
+ @on_announcement_approved = value
228
+ end
229
+
171
230
  def on_changelog_seen=(value)
172
231
  raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
173
232
  @on_changelog_seen = value
174
233
  end
175
234
 
235
+ def newsletter=(value)
236
+ raise ArgumentError, "newsletter must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
237
+ @newsletter = value
238
+ end
239
+
240
+ def newsletter_recipients=(value)
241
+ raise ArgumentError, "newsletter_recipients must be nil or a callable like -> { User.where(newsletter: true) }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
242
+ @newsletter_recipients = value
243
+ end
244
+
245
+ def newsletter_unsubscribe_url=(value)
246
+ raise ArgumentError, "newsletter_unsubscribe_url must be nil or a callable like ->(recipient) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
247
+ @newsletter_unsubscribe_url = value
248
+ end
249
+
250
+ def on_newsletter_unsubscribe=(value)
251
+ raise ArgumentError, "on_newsletter_unsubscribe must be nil or a callable like ->(email) { ... }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
252
+ @on_newsletter_unsubscribe = value
253
+ end
254
+
255
+ def newsletter_from=(value)
256
+ @newsletter_from = optional_string!(:newsletter_from, value, 'like "Acme <updates@acme.com>"')
257
+ end
258
+
259
+ def newsletter_address=(value)
260
+ @newsletter_address = optional_string!(:newsletter_address, value, "(the sender's postal address)")
261
+ end
262
+
263
+ def newsletter_logo_url=(value)
264
+ url = optional_string!(:newsletter_logo_url, value, "with an http(s) URL")
265
+ raise ArgumentError, "newsletter_logo_url must be an http(s) URL, got #{value.inspect}" if url && !url.match?(%r{\Ahttps?://\S+\z}i)
266
+ @newsletter_logo_url = url
267
+ end
268
+
269
+ def newsletter_accent=(value)
270
+ colour = optional_string!(:newsletter_accent, value, 'like "#0969da"')
271
+ raise ArgumentError, "newsletter_accent must be a hex colour like \"#0969da\", got #{value.inspect}" if colour && !ACCENT_FORMAT.match?(colour)
272
+ @newsletter_accent = colour&.downcase
273
+ end
274
+
275
+ # Merged into every newsletter message's `mail(...)` call: ActionMailer options (delivery_method,
276
+ # delivery_method_options) or headers your delivery gem reads (Postmark's message_stream). Keys that would
277
+ # change who receives the message or what it says are refused.
278
+ RESERVED_MAILER_OPTIONS = %i[to cc bcc from reply_to sender return_path subject body template_name template_path parts_order content_type charset mime_version date].freeze
279
+
280
+ def newsletter_mailer_options=(value)
281
+ options = value.nil? ? {} : value
282
+ raise ArgumentError, "newsletter_mailer_options must be a Hash like { message_stream: \"broadcast\" }, got #{value.inspect}" unless options.is_a?(Hash)
283
+ options = options.transform_keys { |key| key.to_s.to_sym }
284
+ reserved = options.keys & RESERVED_MAILER_OPTIONS
285
+ raise ArgumentError, "newsletter_mailer_options cannot set #{reserved.map(&:inspect).join(", ")}: the recipient, sender and content of a newsletter are Shiplog's" if reserved.any?
286
+ @newsletter_mailer_options = options.freeze
287
+ end
288
+
289
+ def newsletter_batch_size=(value)
290
+ unless value.is_a?(Integer) && value.between?(1, MAX_NEWSLETTER_BATCH_SIZE)
291
+ raise ArgumentError, "newsletter_batch_size must be an Integer between 1 and #{MAX_NEWSLETTER_BATCH_SIZE}, got #{value.inspect}"
292
+ end
293
+ @newsletter_batch_size = value
294
+ end
295
+
176
296
  # :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
177
297
  # layout of config.admin_layout (compact sub-nav, no page chrome of its own).
178
298
  def admin_chrome=(value)
@@ -244,6 +364,10 @@ module Shiplog
244
364
  @images = true
245
365
  @max_images_per_entry = 4
246
366
  @locales = [].freeze
367
+ @announcements = :page
368
+ @newsletter = false
369
+ @newsletter_mailer_options = {}.freeze
370
+ @newsletter_batch_size = 500
247
371
  end
248
372
 
249
373
  def resolved_github_token
@@ -282,6 +406,14 @@ module Shiplog
282
406
 
283
407
  private
284
408
 
409
+ # Nil (also for a blank String) or a stripped non-empty String; anything else raises.
410
+ def optional_string!(setting, value, hint)
411
+ return if value.nil? || (value.is_a?(String) && value.strip.empty?)
412
+ raise ArgumentError, "#{setting} must be nil or a String #{hint}, got #{value.inspect}" unless value.is_a?(String)
413
+
414
+ value.strip
415
+ end
416
+
285
417
  def normalize_canonical_url(value)
286
418
  return if value.nil? || (value.is_a?(String) && value.strip.empty?)
287
419
  raise ArgumentError, "canonical_url must be a String like \"https://example.com\", got #{value.inspect}" unless value.is_a?(String)
@@ -9,5 +9,7 @@ module Shiplog
9
9
 
10
10
  # Once per boot, after the host's initializers have set config.images: say why screenshots are off.
11
11
  config.after_initialize { Shiplog.log_images_status }
12
+ # And why the newsletter, when on, cannot be sent yet.
13
+ config.after_initialize { Shiplog.log_newsletter_status }
12
14
  end
13
15
  end
@@ -1,22 +1,32 @@
1
1
  module Shiplog
2
+ # Constants of shiplog_head_tags. Not included anywhere, so views of the host cannot see them.
3
+ module HeadTags
4
+ EXCEPT_KEYS = %i[description canonical robots open_graph twitter feed structured_data].freeze
5
+ end
6
+
2
7
  # The one helper added to every view of the host (like a pagination gem's `paginate`), so a layout shared
3
8
  # with the host's own pages can call `shiplog_head_tags`. The rest lives in Shiplog::HeadHelper, which only
4
9
  # the engine's controllers include.
10
+ #
11
+ # This file is required by lib/shiplog.rb rather than autoloaded: the engine includes the module into
12
+ # ActionView::Base from an on_load hook, which can run during boot before the autoloaders are set up, and
13
+ # ActionView::Base never reloads, so the module must not either.
5
14
  module HeadTagsHelper
6
- EXCEPT_KEYS = %i[description canonical robots open_graph twitter feed structured_data].freeze
7
-
8
15
  # The head tags of the current Shiplog page; an empty string on any other page. `except:` leaves groups
9
16
  # out, for tags your layout already writes: :description, :canonical, :robots, :open_graph, :twitter,
10
17
  # :feed, :structured_data. An unknown key raises ArgumentError.
11
18
  #
19
+ # Call it in the layout, not inside a component (ViewComponent, Phlex): there it gives an empty string.
12
20
  # Use it OR the older `content_for :shiplog_*` blocks in a layout, not both: both would emit the tags twice.
13
21
  def shiplog_head_tags(except: [])
14
22
  keys = Array(except).map { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
15
- unknown = keys - EXCEPT_KEYS
23
+ unknown = keys - HeadTags::EXCEPT_KEYS
16
24
  if unknown.any?
17
- raise ArgumentError, "Unknown shiplog_head_tags except: key(s) #{unknown.map(&:inspect).join(", ")}. Valid keys: #{EXCEPT_KEYS.map(&:inspect).join(", ")}"
25
+ raise ArgumentError, "Unknown shiplog_head_tags except: key(s) #{unknown.map(&:inspect).join(", ")}. Valid keys: #{HeadTags::EXCEPT_KEYS.map(&:inspect).join(", ")}"
18
26
  end
19
- return "".html_safe unless controller.is_a?(Shiplog::PublicController)
27
+ # A marker, not is_a?(Shiplog::PublicController): that would autoload the controller (and the host's
28
+ # parent controller) on every host page. Views that don't have the engine's helper (components) skip too.
29
+ return "".html_safe unless controller.respond_to?(:shiplog_public_page?) && respond_to?(:shiplog_render_head_tags, true)
20
30
 
21
31
  shiplog_render_head_tags(keys)
22
32
  end
@@ -0,0 +1,77 @@
1
+ module Shiplog
2
+ # Previews of the newsletter's emails, for hosts iterating on the design locally. Add the directory to your
3
+ # preview paths (config/environments/development.rb):
4
+ #
5
+ # config.action_mailer.preview_paths << Shiplog::Engine.root.join("lib/shiplog/previews").to_s
6
+ #
7
+ # then open /rails/mailers/shiplog/newsletter_mailer. The announcement is the newest approved one (with the
8
+ # month's real feature highlights), or a sample with sample highlights when there is none (no database rows
9
+ # needed); ?locale=es previews a configured locale.
10
+ class NewsletterMailerPreview < ActionMailer::Preview
11
+ SAMPLE_BODY = <<~MARKDOWN.freeze
12
+ This month we made imports faster and added a dark theme. Here is what shipped.
13
+
14
+ ## Highlights
15
+
16
+ ### Dark mode
17
+
18
+ Switch to a dark theme from your settings. Easier on the eyes at night.
19
+
20
+ ### Faster imports
21
+
22
+ Spreadsheets with thousands of rows now import in seconds.
23
+
24
+ ## Also improved
25
+
26
+ ### Billing
27
+
28
+ - Invoices show the tax breakdown.
29
+ - Refunds appear on the next statement.
30
+
31
+ ### Exports
32
+
33
+ - CSV files keep leading zeros.
34
+ MARKDOWN
35
+
36
+ SAMPLE_HIGHLIGHTS = [
37
+ { title: "Dark mode", summary: "Switch to a dark theme from your settings, easier on the eyes at night.", slug: "dark-mode",
38
+ image_url: "https://placehold.co/1200x700/png?text=Dark+mode", image_width: 1200, image_height: 700 },
39
+ { title: "Faster imports", summary: "Spreadsheets with thousands of rows now import in seconds.", slug: "faster-imports",
40
+ image_url: "https://placehold.co/1200x700/png?text=Faster+imports", image_width: 1200, image_height: 700 },
41
+ { title: "Shared views", summary: "Save a filtered view and share it with your team.", slug: "shared-views", image_url: nil }
42
+ ].freeze
43
+
44
+ def announcement
45
+ entry = Entry.published_announcements.order(released_on: :desc).first
46
+ locale = (params["locale"] || params[:locale]).presence || I18n.default_locale
47
+ email = "preview@example.com"
48
+ content = entry ? nil : Newsletter::Content.new(sample_announcement, locale, highlights: sample_highlights)
49
+ NewsletterMailer.announcement(entry || content.entry, email, locale:, content:, unsubscribe_url: Newsletter.unsubscribe_url(email),
50
+ preferences_url: Newsletter.preferences_url(sample_subscriber))
51
+ end
52
+
53
+ def confirmation
54
+ NewsletterMailer.confirmation(sample_subscriber)
55
+ end
56
+
57
+ private
58
+
59
+ def sample_announcement
60
+ month = Date.current.prev_month.beginning_of_month
61
+ Entry.new(kind: "announcement", status: "approved", title: Period.month_name(month), body: SAMPLE_BODY,
62
+ released_on: month.end_of_month, slug: month.strftime("%Y-%m"), subject: Period.new(starts_on: month, ends_on: month.end_of_month))
63
+ end
64
+
65
+ # Three cards, two with a (placeholder) screenshot, linking to sample permalinks under the email's origin.
66
+ def sample_highlights
67
+ SAMPLE_HIGHLIGHTS.map do |sample|
68
+ Newsletter::Content::Highlight.new(title: sample[:title], summary: sample[:summary], url: Newsletter.absolute_url(PublicLinks.path(:changelog_entry_path, sample[:slug])),
69
+ image_url: sample[:image_url], image_alt: sample[:title], image_width: sample[:image_width], image_height: sample[:image_height])
70
+ end
71
+ end
72
+
73
+ def sample_subscriber
74
+ NewsletterSubscriber.new(email: "preview@example.com", locale: I18n.default_locale.to_s, token: "preview-token", confirmation_nonce: "preview-nonce")
75
+ end
76
+ end
77
+ end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/shiplog.rb CHANGED
@@ -4,6 +4,7 @@ require "shiplog/configuration"
4
4
  require "shiplog/github/errors"
5
5
  require "shiplog/github/queries"
6
6
  require "shiplog/github/client"
7
+ require "shiplog/head_tags_helper"
7
8
  require "shiplog/engine"
8
9
  require "shiplog/admin/engine"
9
10
 
@@ -17,6 +18,38 @@ module Shiplog
17
18
  # at 99. Counted on approved_at (when the entry was approved), not released_on (the list's order). The host supplies the timestamp (Shiplog doesn't know its users): see config.on_changelog_seen.
18
19
  def unseen_count(since: nil) = Entry.unseen_count(since:)
19
20
 
21
+ # The number of approved monthly announcements approved after `since` (all of them while it is nil), capped
22
+ # at 99: the rare "what's new" signal, where unseen_count counts every daily entry. 0 while announcements are off.
23
+ def unseen_announcements_count(since: nil) = announcements? ? Entry.unseen_announcements_count(since:) : 0
24
+
25
+ # Whether monthly announcements are on (config.announcements is :page or :external).
26
+ def announcements? = config.announcements != false
27
+
28
+ # Whether Shiplog renders announcement pages (config.announcements is :page).
29
+ def announcement_pages? = config.announcements == :page
30
+
31
+ # Whether the newsletter is on: config.newsletter, and announcements (there is nothing else to send).
32
+ def newsletter? = config.newsletter && announcements?
33
+
34
+ # Logged once at boot (see Shiplog::Engine), while the newsletter is on: what keeps it from sending, a
35
+ # recipients callable without the unsubscribe hook (unsubscribes would not reach the host's list), and a
36
+ # cache that counts nothing (the subscribe form's rate limits are then off).
37
+ def log_newsletter_status
38
+ return unless newsletter?
39
+
40
+ missing = []
41
+ missing << "newsletter_from" if Newsletter.from.blank?
42
+ missing << "newsletter_address" if config.newsletter_address.blank?
43
+ missing << "canonical_url (or action_mailer.default_url_options)" if Newsletter.base_url.blank?
44
+ Rails.logger&.warn("[shiplog] config.newsletter is on, but the newsletter cannot be sent until these are set: #{missing.join(", ")}.") if missing.any?
45
+ if config.newsletter_recipients && config.on_newsletter_unsubscribe.nil?
46
+ Rails.logger&.warn("[shiplog] config.newsletter_recipients is set but config.on_newsletter_unsubscribe is not: Shiplog suppresses an address that unsubscribes, but your own list will not know.")
47
+ end
48
+ if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
49
+ Rails.logger&.warn("[shiplog] Rails.cache is a null store, so the newsletter's subscribe form is not rate limited. Configure a cache store.")
50
+ end
51
+ end
52
+
20
53
  ACTIVE_STORAGE_TABLES = %w[active_storage_attachments active_storage_blobs].freeze
21
54
 
22
55
  # Whether the host loaded Active Storage (its engine and the model macros). The declaration on Entry needs
@@ -8,6 +8,28 @@ unless Rake::Task.task_defined?("shiplog:backfill_slugs")
8
8
  puts "Shiplog: gave #{count} #{count == 1 ? "entry" : "entries"} a slug."
9
9
  end
10
10
 
11
+ desc "Draft the announcement of a month (MONTH=2026-10; the previous month by default) and queue its rewrite"
12
+ task draft_announcement: :environment do
13
+ month = ENV["MONTH"].present? ? Date.strptime(ENV["MONTH"], "%Y-%m") : Date.current.prev_month
14
+ entry = Shiplog::Announcements.draft(month:)
15
+ label = month.strftime("%Y-%m")
16
+ if entry
17
+ puts "Shiplog: drafted the #{label} announcement (entry #{entry.id})."
18
+ elsif !Shiplog.announcements?
19
+ puts "Shiplog: config.announcements is false, nothing drafted."
20
+ else
21
+ puts "Shiplog: nothing drafted for #{label}: it has an announcement already, or no approved feature or change."
22
+ end
23
+ rescue Date::Error
24
+ abort "Shiplog: MONTH must look like 2026-10, got #{ENV["MONTH"].inspect}."
25
+ end
26
+
27
+ desc "Delete the newsletter subscribers who never confirmed within 30 days and the delivery rows older than 180 days (what Shiplog::PurgeNewsletterJob does)"
28
+ task purge_newsletter: :environment do
29
+ counts = Shiplog::PurgeNewsletterJob.perform_now
30
+ puts "Shiplog: purged #{counts[:subscribers]} unconfirmed newsletter #{counts[:subscribers] == 1 ? "subscriber" : "subscribers"} and #{counts[:deliveries]} old delivery #{counts[:deliveries] == 1 ? "row" : "rows"}."
31
+ end
32
+
11
33
  desc "Translate approved entries and area names that have no current translation into config.locales (entries approved before 0.3.0 get none). BATCH_SIZE=100"
12
34
  task translate_approved: :environment do
13
35
  if Shiplog.config.locales.empty?