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
@@ -0,0 +1,64 @@
1
+ module Shiplog
2
+ # Emails an approved announcement to every recipient (Newsletter::Recipients: the host's users, then the
3
+ # confirmed subscribers, minus the unsubscribed and the duplicates), once per recipient. For each chunk of
4
+ # NewsletterDelivery::CLAIM_SIZE it inserts the delivery rows first (one statement, skipping the rows that
5
+ # exist) and emails only the recipients it inserted, touching each row right before its send, so a retried
6
+ # job, a second click on Send or two workers never send twice: at most once per recipient. A failed delivery
7
+ # is recorded on its row (never raised: the chunk goes on) for the admin's "Retry failed", which runs the job
8
+ # with `retry_failed: true`: the failed and stalled rows are reclaimed in one statement, the ones reclaimed by
9
+ # this run are sent again, and the ones whose recipient is gone end as failed with the reason.
10
+ #
11
+ # The announcement's Markdown is rendered once per locale (Newsletter::Content); each message is then built with
12
+ # the recipient's own links. The job stops before anything is sent when the newsletter is off or the
13
+ # announcement cannot be sent (Newsletter.blockers: not approved, missing translations, no sender...).
14
+ class SendNewsletterJob < ApplicationJob
15
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
16
+
17
+ def perform(entry, retry_failed: false)
18
+ return log_skip(entry, "the newsletter is off") unless Newsletter.enabled?
19
+
20
+ blockers = Newsletter.blockers(entry)
21
+ return log_skip(entry, "it cannot be sent: #{blockers.join(", ")}") if blockers.any?
22
+
23
+ reclaimed = retry_failed ? NewsletterDelivery.reclaim(entry).to_set : Set.new
24
+ contents = Hash.new { |hash, locale| hash[locale] = Newsletter::Content.new(entry, locale) }
25
+ sent = 0
26
+ Newsletter::Recipients.new.each_batch do |batch|
27
+ batch.each_slice(NewsletterDelivery::CLAIM_SIZE) do |chunk|
28
+ claimed = NewsletterDelivery.claim(entry, chunk).to_set
29
+ chunk.each do |recipient|
30
+ next unless claimed.include?(recipient.key) || reclaimed.delete?(recipient.key)
31
+
32
+ sent += 1 if deliver(entry, recipient, contents[recipient.locale])
33
+ end
34
+ end
35
+ end
36
+ NewsletterDelivery.mark_missing(entry, reclaimed) # reclaimed, but no longer a recipient
37
+ Rails.logger.info("[shiplog] Newsletter of entry #{entry.id}: #{sent} emails sent in this run")
38
+ end
39
+
40
+ private
41
+
42
+ # Sends one email and records the outcome on the delivery row. True when it was sent.
43
+ def deliver(entry, recipient, content)
44
+ NewsletterDelivery.mark_sending(entry, recipient.key)
45
+ NewsletterMailer.announcement(entry, recipient.email, locale: recipient.locale, content:,
46
+ unsubscribe_url: Newsletter.unsubscribe_url(recipient.email), preferences_url: preferences_url(recipient)).deliver_now
47
+ NewsletterDelivery.mark_sent(entry, recipient.key)
48
+ true
49
+ rescue StandardError => error
50
+ NewsletterDelivery.mark_failed(entry, recipient.key, error)
51
+ Rails.logger.error("[shiplog] Newsletter of entry #{entry.id} to #{recipient.key} failed: #{error.class}: #{NewsletterDelivery.scrub(error.message)}")
52
+ false
53
+ end
54
+
55
+ # A subscriber's preferences page; a host user's own page when config.newsletter_unsubscribe_url gives one.
56
+ def preferences_url(recipient)
57
+ recipient.subscriber? ? Newsletter.preferences_url(recipient.record) : Newsletter.host_unsubscribe_url(recipient.record)
58
+ end
59
+
60
+ def log_skip(entry, reason)
61
+ Rails.logger.info("[shiplog] Newsletter of entry #{entry.id} not sent: #{reason}")
62
+ end
63
+ end
64
+ end
@@ -17,6 +17,8 @@ module Shiplog
17
17
 
18
18
  def perform(entry, locale, force: false)
19
19
  Translator.new(entry, locale).call(force:)
20
+ # An approved announcement is announced once all its translations are current: this may have been the last.
21
+ AnnounceJob.perform_later(entry) if entry.announcement?
20
22
  rescue StandardError => error
21
23
  Rails.logger.error("[shiplog] Translating entry #{entry.id} into #{locale} failed (attempt #{executions} of #{ATTEMPTS}): #{error.class}: #{error.message}")
22
24
  raise
@@ -0,0 +1,51 @@
1
+ module Shiplog
2
+ # The newsletter's emails: an announcement to one recipient, and the confirmation of a visitor's subscription.
3
+ # Both have an HTML part (one 600px table column, styles inline, see app/views/shiplog/newsletter_mailer) and a
4
+ # text part, in the recipient's language (the chrome from Shiplog's I18n keys, the announcement's text from its
5
+ # translation). Hosts override the templates like any engine view. The sender is config.newsletter_from.
6
+ class NewsletterMailer < ActionMailer::Base
7
+ helper Shiplog::NewsletterMailerHelper
8
+
9
+ default from: -> { sender! }
10
+
11
+ # The announcement for `email` in `locale`. `unsubscribe_url` is the one-click target (the List-Unsubscribe
12
+ # headers Gmail and Yahoo require; Newsletter.unsubscribe_url for the address when not given, so a queued test
13
+ # send carries no token), `preferences_url` the page the footer's "Preferences" opens (a subscriber's page, or
14
+ # the host's own for its users; nil shows the unsubscribe page instead). `content` is the locale's rendered
15
+ # Content when the caller has it (SendNewsletterJob renders once per locale). config.newsletter_mailer_options
16
+ # are merged into the message (e.g. Postmark's message_stream); they cannot change the recipient or the subject.
17
+ def announcement(entry, email, locale:, unsubscribe_url: nil, preferences_url: nil, content: nil)
18
+ @content = content || Newsletter::Content.new(entry, locale)
19
+ @unsubscribe_url = unsubscribe_url || Newsletter.unsubscribe_url(email)
20
+ @preferences_url = preferences_url || @unsubscribe_url
21
+ headers["List-Unsubscribe"] = "<#{@unsubscribe_url}>"
22
+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
23
+ headers["Precedence"] = "bulk"
24
+ I18n.with_locale(Newsletter.i18n_locale(@content.locale)) do
25
+ mail(**Shiplog.config.newsletter_mailer_options, to: email, subject: @content.subject)
26
+ end
27
+ end
28
+
29
+ # The double opt-in email: a single-use link (valid 7 days) the subscriber must open, in the locale of the
30
+ # page they subscribed on. `subscriber` is a NewsletterSubscriber, or its id when queued (deliver_later): a
31
+ # row deleted or confirmed in the meantime sends nothing, quietly. Transactional: no mailer options, no
32
+ # List-Unsubscribe.
33
+ def confirmation(subscriber)
34
+ @subscriber = subscriber.is_a?(NewsletterSubscriber) ? subscriber : NewsletterSubscriber.find_by(id: subscriber)
35
+ @confirmation_url = @subscriber && Newsletter.confirmation_url(@subscriber)
36
+ return unless @confirmation_url
37
+
38
+ @product_name = Newsletter.product_name
39
+ I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) do
40
+ mail(to: @subscriber.email, subject: t("shiplog.newsletter.mail.confirm_subject", product: Newsletter.product_name))
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # The From address, or a clear error when none is configured (the job records it on the delivery row).
47
+ def sender!
48
+ Newsletter.from or raise ArgumentError, "[shiplog] config.newsletter_from is not set, so no newsletter email can be sent"
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ module Shiplog
2
+ # Monthly announcements: a short, AI-drafted summary of what shipped in a month, approved by an admin like every
3
+ # other entry (kind "announcement", subject a Shiplog::Period). Where it is published is config.announcements.
4
+ module Announcements
5
+ # The slug used when a feature or change approved before 0.4.0 already took the month's own ("2026-10").
6
+ FALLBACK_SUFFIX = "-update".freeze
7
+
8
+ # Drafts the announcement of the month containing `month` and enqueues its rewrite. Returns the new draft, or
9
+ # nil when announcements are off, the month has no approved feature or change (nothing to announce), or it
10
+ # has an announcement already (whatever its status).
11
+ def self.draft(month:)
12
+ return unless Shiplog.announcements?
13
+
14
+ starts_on = month.to_date.beginning_of_month
15
+ return unless Period.changelog_entries_between(starts_on..starts_on.end_of_month).exists?
16
+
17
+ period = Period.month(starts_on)
18
+ Drafter.draft(period, "announcement", released_on: period.ends_on, slug: slug_for(period))
19
+ end
20
+
21
+ # The months an admin can draft from the admin: the current one (so far) and the previous one.
22
+ def self.draftable_months(today = Date.current) = [ today.beginning_of_month, today.prev_month.beginning_of_month ]
23
+
24
+ # "2026-10", or when an older entry already has that slug "2026-10-update", then "2026-10-update-2", ...
25
+ def self.slug_for(period)
26
+ month = period.starts_on.strftime("%Y-%m")
27
+ candidates = Enumerator.new do |yielder|
28
+ yielder << month << "#{month}#{FALLBACK_SUFFIX}"
29
+ (2..).each { |suffix| yielder << "#{month}#{FALLBACK_SUFFIX}-#{suffix}" }
30
+ end
31
+ candidates.find { |slug| !Entry.where(slug:).where.not(subject: period).exists? }
32
+ end
33
+ end
34
+ end
@@ -99,9 +99,10 @@ module Shiplog
99
99
  end
100
100
 
101
101
  # Entries that may still get an area: drafts and approved ones with a title, no area, none chosen by an admin,
102
- # not asked about yet.
102
+ # not asked about yet. Announcements cover a whole month and never get one.
103
103
  def candidates
104
104
  Entry.where(status: %w[draft approved], area_id: nil, area_set_by_admin: false, area_detected_at: nil).where.not(title: [ nil, "" ])
105
+ .where.not(kind: "announcement")
105
106
  end
106
107
 
107
108
  # Ignores ids outside the batch and areas that don't exist. The update re-checks the conditions, so an
@@ -17,6 +17,10 @@ module Shiplog
17
17
  repository.pull_requests.awaiting_upgrade_check.where(merged_at: since..).reorder(:merged_at).pick(:merged_at)
18
18
  end
19
19
 
20
+ # Node ids of the pull requests (of any repository) linked to an issue that is public or has a public ancestor:
21
+ # the feature entry of that issue covers them. The lookup reads no instance state.
22
+ def self.public_tree_pull_request_ids(pull_requests) = allocate.send(:pull_request_ids_in_public_tree, pull_requests)
23
+
20
24
  def initialize(repository:, since:, before:)
21
25
  @repository = repository
22
26
  @window = since...before
@@ -14,12 +14,14 @@ module Shiplog
14
14
  REQUEUE_AFTER = 1.hour
15
15
  REQUEUE_LIMIT = 100
16
16
 
17
- # Returns the new entry, or nil when one already exists for the subject and kind.
18
- def self.draft(subject, kind, released_on: nil)
17
+ # Returns the new entry, or nil when one already exists for the subject and kind. `attributes` are set on a new
18
+ # entry only (an announcement's slug).
19
+ def self.draft(subject, kind, released_on: nil, **attributes)
19
20
  entry = Entry.create_or_find_by!(subject:, kind:) do |new_entry|
20
21
  new_entry.title = subject.title
21
22
  new_entry.body = subject.body
22
23
  new_entry.released_on = released_on
24
+ new_entry.assign_attributes(attributes)
23
25
  end
24
26
  return unless entry.previously_new_record?
25
27
 
@@ -7,20 +7,31 @@ module Shiplog
7
7
  belongs_to :subject, polymorphic: true
8
8
  belongs_to :area, class_name: "Shiplog::Area", optional: true
9
9
  has_many :translations, class_name: "Shiplog::EntryTranslation", dependent: :destroy, inverse_of: :entry
10
+ # The newsletter's deliveries of an announcement (see NewsletterDelivery); the database cascades the delete.
11
+ has_many :newsletter_deliveries, class_name: "Shiplog::NewsletterDelivery", foreign_key: :announcement_id, dependent: :delete_all, inverse_of: :announcement
10
12
 
11
- enum :kind, %w[roadmap feature change].index_by(&:itself), validate: true
13
+ enum :kind, %w[roadmap feature change announcement].index_by(&:itself), validate: true
12
14
  enum :status, %w[draft approved discarded].index_by(&:itself), default: "draft", validate: true
13
15
 
14
16
  CHANGELOG_KINDS = %w[feature change].freeze
17
+ # The slug of a month's announcement ("2026-10"). Features and changes never get one of this shape, so an
18
+ # announcement's page URL is known before it is drafted.
19
+ ANNOUNCEMENT_SLUG = /\A\d{4}-\d{2}\z/
15
20
 
16
21
  validates :released_on, presence: true, if: -> { approved? && !roadmap? }
17
22
  validate :area_exists
18
23
  validate :kind_change_allowed, on: :update
19
24
  validate :kind_free_for_subject, on: :update
20
25
 
26
+ # An announcement that leaves "approved" may be announced again once it is approved again.
27
+ before_save :clear_announced_at, if: -> { announcement? && will_save_change_to_status? && !approved? }
28
+
21
29
  # Approving an entry, or changing the English text of an approved one, translates it into every configured
22
30
  # locale (see TranslateEntryJob). After the commit, so the job reads what was saved.
23
31
  after_commit :enqueue_translations, on: :update, if: :translation_due?
32
+ # Approving an announcement calls config.on_announcement_approved once its translations are current (see
33
+ # AnnounceJob, which TranslateEntryJob enqueues too).
34
+ after_commit :enqueue_announcement, on: :update, if: -> { announcement? && approved? && saved_change_to_status? }
24
35
 
25
36
  SEARCH_MAX_LENGTH = 100
26
37
  SEARCH_MAX_WORDS = 5
@@ -84,11 +95,20 @@ module Shiplog
84
95
  # at 99. One COUNT over the (status, approved_at) index. It filters on approved_at, not on released_on (the
85
96
  # list's order): "new since you last looked" is about when an entry appeared, so an entry approved today
86
97
  # with an earlier release date still counts as new.
87
- def self.unseen_count(since: nil)
88
- scope = approved.where(kind: CHANGELOG_KINDS)
98
+ def self.unseen_count(since: nil) = count_approved_since(CHANGELOG_KINDS, since)
99
+
100
+ # The number of approved announcements approved after `since` (all of them while it is nil), capped at 99.
101
+ def self.unseen_announcements_count(since: nil) = count_approved_since("announcement", since)
102
+
103
+ def self.count_approved_since(kinds, since)
104
+ scope = approved.where(kind: kinds)
89
105
  scope = scope.where("#{table_name}.approved_at > ?", since) if since
90
106
  scope.limit(UNSEEN_COUNT_CAP).count
91
107
  end
108
+ private_class_method :count_approved_since
109
+
110
+ # Approved announcements: the ones with a public page (config.announcements = :page) or a host link.
111
+ scope :published_announcements, -> { approved.where(kind: "announcement").where.not(released_on: nil) }
92
112
 
93
113
  # Gives every published changelog entry that has no slug one (oldest first, so the oldest keeps the plain
94
114
  # slug on duplicate titles). Returns how many it filled.
@@ -134,7 +154,11 @@ module Shiplog
134
154
  self.kind = value if CHANGELOG_KINDS.include?(kind) && CHANGELOG_KINDS.include?(value)
135
155
  end
136
156
 
157
+ # The digest of what a draft was written from: the GitHub title and body, or for an announcement the month's
158
+ # entries (Period#content_digest). A draft whose stored digest differs is flagged in the admin.
137
159
  def self.digest_for(subject)
160
+ return subject.content_digest if subject.is_a?(Period)
161
+
138
162
  Digest::SHA256.hexdigest([ subject.title, subject.body ].join("\n"))
139
163
  end
140
164
 
@@ -200,6 +224,40 @@ module Shiplog
200
224
  translated.blank? && body.present? ? body : (translated || body)
201
225
  end
202
226
 
227
+ # Whether every configured locale has a current translation (always true without config.locales).
228
+ def translations_current? = Shiplog.config.locales.all? { |locale| translation(locale) }
229
+
230
+ # The title, Markdown body, HTML and plain text of an announcement in a locale (the default one when omitted):
231
+ # the current translation when there is one, else the English text. For config.on_announcement_approved and the
232
+ # admin's export.
233
+ def to_markdown(locale = I18n.default_locale) = display_body(locale).to_s
234
+ def to_html(locale = I18n.default_locale) = Markdown.to_html(display_body(locale))
235
+ def to_text(locale = I18n.default_locale) = Markdown.to_text(display_body(locale))
236
+
237
+ # The absolute URL of the entry's public page (see PublicLinks), or nil without a slug. For an announcement
238
+ # with config.announcements = :external, the host's own page is elsewhere (config.announcement_url).
239
+ def public_url = PublicLinks.entry_url(self)
240
+
241
+ # Whether the translations an announcement waits for are there: all current, or none can be made because there
242
+ # is no rewriter (then hosts get the English text, or an admin's hand-made translations).
243
+ def translations_ready? = Shiplog.config.rewriter.nil? || translations_current?
244
+
245
+ # Runs the block (the host's hook, see AnnounceJob) once for this approval, when this is an approved announcement
246
+ # not announced yet whose translations are ready (`force: true` skips that last check: the admin's "Notify
247
+ # now"). The block runs under the row lock and announced_at is stored in the same transaction, so two jobs
248
+ # never both run it, and a block that raises (or a worker that dies) stores nothing and is run again by the
249
+ # retry. Returns whether the block ran.
250
+ def announce!(force: false)
251
+ with_lock do
252
+ reload
253
+ next false unless announcement? && approved? && announced_at.nil? && (force || translations_ready?)
254
+
255
+ yield
256
+ update_columns(announced_at: Time.current)
257
+ true
258
+ end
259
+ end
260
+
203
261
  def source_changed?
204
262
  source_digest.present? && source_digest != self.class.digest_for(subject)
205
263
  end
@@ -214,16 +272,28 @@ module Shiplog
214
272
  Shiplog.config.locales.each { |locale| TranslateEntryJob.perform_later(self, locale) }
215
273
  end
216
274
 
275
+ # Features and changes get a slug when approved; an announcement gets its month's slug when drafted.
217
276
  def permalinked? = CHANGELOG_KINDS.include?(kind)
218
277
 
278
+ # Written whatever this instance holds: AnnounceJob sets the column with update_columns, so an instance loaded
279
+ # before it may still hold nil and would otherwise see no change to save.
280
+ def clear_announced_at
281
+ self.announced_at = nil
282
+ attribute_will_change!("announced_at")
283
+ end
284
+
285
+ def enqueue_announcement
286
+ AnnounceJob.perform_later(self)
287
+ end
288
+
219
289
  # The title parameterized and cut at 80 characters, made unique with -2, -3, ...; entry-<id> when the title
220
- # has nothing left. The uniqueness check can lose a race, which the unique index catches (see
221
- # retrying_on_slug_conflict).
290
+ # has nothing left. A month's shape ("2026-10") is kept for announcements and gets a suffix too. The
291
+ # uniqueness check can lose a race, which the unique index catches (see retrying_on_slug_conflict).
222
292
  def generate_slug
223
293
  base = dashed_title.first(SLUG_MAX_LENGTH).gsub(/\A-+|-+\z/, "").presence || "entry-#{id}"
224
294
  candidate = base
225
295
  suffix = 2
226
- while self.class.where(slug: candidate).where.not(id:).exists?
296
+ while ANNOUNCEMENT_SLUG.match?(candidate) || self.class.where(slug: candidate).where.not(id:).exists?
227
297
  tail = "-#{suffix}"
228
298
  candidate = "#{base.first(SLUG_MAX_LENGTH - tail.length).delete_suffix("-")}#{tail}"
229
299
  suffix += 1
@@ -0,0 +1,233 @@
1
+ module Shiplog
2
+ # A small, dependency-free Markdown subset for announcement bodies (written by an LLM, edited by an admin, shown
3
+ # on public pages, in the feed and in emails). XSS-safe by construction: every piece of source text is escaped
4
+ # before it is placed in a tag we build ourselves, and raw HTML is never passed through.
5
+ module Markdown
6
+ HEADING = /\A {0,3}(\#{1,6})[ \t]+(.*?)(?:[ \t]+\#+)?[ \t]*\z/
7
+ RULE = /\A {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*\z/
8
+ ITEM = /\A([ \t]*)([-*+]|\d+[.)])[ \t]+(.*)\z/
9
+ QUOTE = /\A {0,3}>[ \t]?(.*)\z/
10
+ LINK = /\G\[((?:\\.|[^\]\\])*)\]\(((?:[^()\\]|\\.|\([^()]*\))*)\)/
11
+ IMAGE = /\G!\[((?:\\.|[^\]\\])*)\]\(((?:[^()\\]|\\.|\([^()]*\))*)\)/
12
+ ESCAPABLE = %w[* _ [ ] ( ) ` \\ # ! -].freeze
13
+ LINK_URL = %r{\A(?:https?://|mailto:|#|/(?![/\\]))}i
14
+ IMAGE_URL = %r{\A(?:https?://|/(?![/\\]))}i
15
+ CONTROL_CHARS = /[[:cntrl:]]/
16
+
17
+ # Block-level HTML for +text+. Heading levels are shifted by +heading_offset+ (clamped to h1..h6). +styles+
18
+ # maps tag names to inline styles ({ "p" => "margin:0 0 12px;" }) for emails, where stylesheets are ignored.
19
+ def self.to_html(text, heading_offset: 0, styles: {})
20
+ html = blocks(text).map { |block| render_block_html(block, heading_offset, styles) }.join("\n")
21
+ html.html_safe # every fragment was escaped or built by us
22
+ end
23
+
24
+ # Plain text (no HTML escaping) for emails and meta descriptions; blocks are separated by a blank line.
25
+ def self.to_text(text)
26
+ blocks(text).filter_map { |block| render_block_text(block) }.join("\n\n")
27
+ end
28
+
29
+ # Splits the source into [:heading, level, text], [:paragraph, text], [:quote, text], [:list, ordered, items]
30
+ # and [:rule] blocks.
31
+ def self.blocks(text)
32
+ lines = text.to_s.gsub(/\r\n?/, "\n").split("\n")
33
+ result = []
34
+ until lines.empty?
35
+ line = lines.first
36
+ if line.strip.empty?
37
+ lines.shift
38
+ elsif (match = line.match(HEADING))
39
+ lines.shift
40
+ result << [ :heading, match[1].length, match[2] ]
41
+ elsif line.match?(RULE)
42
+ lines.shift
43
+ result << [ :rule ]
44
+ elsif line.match?(ITEM)
45
+ result << take_list(lines)
46
+ elsif line.match?(QUOTE)
47
+ result << [ :quote, take_while(lines) { |l| l.match(QUOTE)&.[](1) }.join("\n") ]
48
+ else
49
+ result << [ :paragraph, take_paragraph(lines).join("\n").strip ]
50
+ end
51
+ end
52
+ result
53
+ end
54
+ private_class_method :blocks
55
+
56
+ def self.block_start?(line)
57
+ line.match?(HEADING) || line.match?(RULE) || line.match?(ITEM) || line.match?(QUOTE)
58
+ end
59
+ private_class_method :block_start?
60
+
61
+ # Shifts consecutive lines for which the block returns a (non-nil) value, and returns those values.
62
+ def self.take_while(lines)
63
+ taken = []
64
+ while lines.any? && (value = yield(lines.first))
65
+ taken << value
66
+ lines.shift
67
+ end
68
+ taken
69
+ end
70
+ private_class_method :take_while
71
+
72
+ def self.take_paragraph(lines)
73
+ [ lines.shift ] + take_while(lines) { |l| l unless l.strip.empty? || block_start?(l) }
74
+ end
75
+ private_class_method :take_paragraph
76
+
77
+ # Items of one list kind. Indented items continue the list, indented non-item lines continue the last item, and
78
+ # blank lines are skipped only when the next non-blank line is another item of the same kind.
79
+ def self.take_list(lines)
80
+ ordered = ordered_item?(lines.first)
81
+ items = []
82
+ until lines.empty?
83
+ line = lines.first
84
+ if line.strip.empty?
85
+ following = lines.find { |l| !l.strip.empty? }
86
+ break unless following&.match?(ITEM) && !following.match?(RULE) && ordered_item?(following) == ordered
87
+ lines.shift
88
+ elsif line.match?(RULE) || (line.match?(ITEM) && ordered_item?(line) != ordered)
89
+ break
90
+ elsif (match = line.match(ITEM))
91
+ items << match[3].strip
92
+ lines.shift
93
+ elsif line.match?(/\A[ \t]+\S/) && !line.match?(HEADING) && !line.match?(QUOTE)
94
+ items[-1] = "#{items.last}\n#{line.strip}"
95
+ lines.shift
96
+ else
97
+ break
98
+ end
99
+ end
100
+ [ :list, ordered, items ]
101
+ end
102
+ private_class_method :take_list
103
+
104
+ def self.ordered_item?(line) = line.match(ITEM)[2].match?(/\d/)
105
+ private_class_method :ordered_item?
106
+
107
+ # In HTML mode, +styles+ (a Hash, maybe empty) stands for the html flag of the inline renderers.
108
+ def self.render_block_html(block, heading_offset, styles)
109
+ case block.first
110
+ when :heading
111
+ level = (block[1] + heading_offset).clamp(1, 6)
112
+ "#{open("h#{level}", styles)}#{inline(block[2], styles)}</h#{level}>"
113
+ when :paragraph then "#{open("p", styles)}#{inline(block[1], styles)}</p>"
114
+ when :quote then "#{open("blockquote", styles)}#{open("p", styles)}#{inline(block[1], styles)}</p></blockquote>"
115
+ when :rule then open("hr", styles)
116
+ when :list
117
+ tag = block[1] ? "ol" : "ul"
118
+ items = block[2].map { |item| "#{open("li", styles)}#{inline(item, styles)}</li>" }.join("\n")
119
+ "#{open(tag, styles)}\n#{items}\n</#{tag}>"
120
+ end
121
+ end
122
+ private_class_method :render_block_html
123
+
124
+ # "<p>" or, with an inline style for the tag, "<p style="...">" (the style escaped like any attribute).
125
+ def self.open(tag, styles) = "<#{tag}#{style(tag, styles)}>"
126
+ private_class_method :open
127
+
128
+ def self.style(tag, styles)
129
+ value = styles[tag] if styles.is_a?(Hash)
130
+ value.present? ? %( style="#{escape(value, true)}") : ""
131
+ end
132
+ private_class_method :style
133
+
134
+ def self.render_block_text(block)
135
+ case block.first
136
+ when :heading then inline(block[2], false)
137
+ when :paragraph, :quote then inline(block[1], false)
138
+ when :list
139
+ block[2].each_with_index.map { |item, i| "#{block[1] ? "#{i + 1}." : "-"} #{inline(item, false)}" }.join("\n")
140
+ end
141
+ end
142
+ private_class_method :render_block_text
143
+
144
+ # Scans left to right; whatever is not a recognised construct is literal (and escaped in HTML mode).
145
+ def self.inline(source, html)
146
+ out = +""
147
+ i = 0
148
+ while i < source.length
149
+ output, i = inline_token(source, i, html)
150
+ out << output
151
+ end
152
+ out
153
+ end
154
+ private_class_method :inline
155
+
156
+ # Consumes one construct (or one literal character) at index +i+; returns its output and the next index.
157
+ def self.inline_token(source, i, html)
158
+ char = source[i]
159
+ if char == "\\" && ESCAPABLE.include?(source[i + 1])
160
+ [ escape(source[i + 1], html), i + 2 ]
161
+ elsif char == "`" && source[i + 1] == "`"
162
+ run = source[i..][/\A`+/]
163
+ [ run, i + run.length ] # a run of backticks (such as a ``` fence) is literal text
164
+ elsif char == "`" && (close = source.index("`", i + 1))
165
+ code = source[(i + 1)...close]
166
+ [ html ? "#{open("code", html)}#{escape(code, true)}</code>" : code, close + 1 ]
167
+ elsif char == "!" && (match = source.match(IMAGE, i))
168
+ [ image(match[1], match[2], html), i + match[0].length ]
169
+ elsif char == "[" && (match = source.match(LINK, i))
170
+ [ link(match[1], match[2], html), i + match[0].length ]
171
+ elsif char == "*" && (emphasis = emphasis(source, i, html))
172
+ emphasis
173
+ else
174
+ [ escape(char, html), i + 1 ]
175
+ end
176
+ end
177
+ private_class_method :inline_token
178
+
179
+ # **strong** or *em* when a closing marker exists and the content does not start with a space.
180
+ def self.emphasis(source, i, html)
181
+ marker = source[i, 2] == "**" ? "**" : "*"
182
+ start = i + marker.length
183
+ close = closing_marker(source, start, marker)
184
+ return if close.nil? || close == start || source[start].match?(/\s/)
185
+
186
+ inner = inline(source[start...close], html)
187
+ tag = marker == "**" ? "strong" : "em"
188
+ [ html ? "#{open(tag, html)}#{inner}</#{tag}>" : inner, close + marker.length ]
189
+ end
190
+ private_class_method :emphasis
191
+
192
+ # Index of the next unescaped +marker+ at or after +from+.
193
+ def self.closing_marker(source, from, marker)
194
+ i = from
195
+ while i < source.length
196
+ if source[i] == "\\"
197
+ i += 2
198
+ elsif source[i, marker.length] == marker
199
+ return source[i + marker.length] == "*" && marker == "**" ? i + 1 : i # "***": the last two close
200
+ else
201
+ i += 1
202
+ end
203
+ end
204
+ nil
205
+ end
206
+ private_class_method :closing_marker
207
+
208
+ # An anchor for allowed URLs, otherwise just the (escaped) text. Plain text shows "text (url)".
209
+ def self.link(label, url, html)
210
+ text = inline(label, html)
211
+ return text unless allowed_url?(url, LINK_URL)
212
+ return (text == url ? text : "#{text} (#{url})") unless html
213
+
214
+ %(<a href="#{escape(url, true)}"#{style("a", html)}>#{text}</a>)
215
+ end
216
+ private_class_method :link
217
+
218
+ # An img tag for allowed URLs, otherwise just the escaped alt text.
219
+ def self.image(alt, url, html)
220
+ text = inline(alt, false)
221
+ return escape(text, html) unless html && allowed_url?(url, IMAGE_URL)
222
+
223
+ %(<img src="#{escape(url, true)}" alt="#{escape(text, true)}" loading="lazy"#{style("img", html)}>)
224
+ end
225
+ private_class_method :image
226
+
227
+ def self.allowed_url?(url, pattern) = url.match?(pattern) && !url.match?(CONTROL_CHARS)
228
+ private_class_method :allowed_url?
229
+
230
+ def self.escape(text, html) = html ? ERB::Util.html_escape(text).to_str : text
231
+ private_class_method :escape
232
+ end
233
+ end