shiplog 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +172 -7
  4. data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
  5. data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
  6. data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
  7. data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
  8. data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
  9. data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
  10. data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
  11. data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
  12. data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
  13. data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
  14. data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
  15. data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
  16. data/app/controllers/shiplog/changelogs_controller.rb +21 -5
  17. data/app/controllers/shiplog/public_controller.rb +57 -0
  18. data/app/controllers/shiplog/roadmaps_controller.rb +3 -1
  19. data/app/frontend/shiplog/admin.css +9 -0
  20. data/app/frontend/shiplog/shiplog.css +47 -2
  21. data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
  22. data/app/helpers/shiplog/head_helper.rb +95 -0
  23. data/app/helpers/shiplog/head_tags_helper.rb +24 -0
  24. data/app/helpers/shiplog/permalink_helper.rb +149 -0
  25. data/app/helpers/shiplog/roadmap_helper.rb +9 -0
  26. data/app/helpers/shiplog/structured_data_helper.rb +74 -0
  27. data/app/helpers/shiplog/unseen_helper.rb +9 -0
  28. data/app/jobs/shiplog/detect_areas_job.rb +18 -0
  29. data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
  30. data/app/jobs/shiplog/translate_area_job.rb +25 -0
  31. data/app/jobs/shiplog/translate_entry_job.rb +25 -0
  32. data/app/models/shiplog/area.rb +156 -0
  33. data/app/models/shiplog/area_detector.rb +146 -0
  34. data/app/models/shiplog/area_translator.rb +77 -0
  35. data/app/models/shiplog/entry/images.rb +127 -0
  36. data/app/models/shiplog/entry.rb +204 -5
  37. data/app/models/shiplog/entry_translation.rb +15 -0
  38. data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
  39. data/app/models/shiplog/image_fetcher/sources.rb +41 -0
  40. data/app/models/shiplog/image_fetcher.rb +72 -0
  41. data/app/models/shiplog/image_type.rb +111 -0
  42. data/app/models/shiplog/rewriter/prompt.rb +31 -7
  43. data/app/models/shiplog/rewriter.rb +41 -23
  44. data/app/models/shiplog/setting.rb +19 -0
  45. data/app/models/shiplog/translator/prompt.rb +40 -0
  46. data/app/models/shiplog/translator.rb +99 -0
  47. data/app/views/layouts/shiplog/application.html.erb +1 -1
  48. data/app/views/shiplog/_empty.html.erb +1 -1
  49. data/app/views/shiplog/_head_tags.html.erb +5 -0
  50. data/app/views/shiplog/_pagination.html.erb +3 -3
  51. data/app/views/shiplog/admin/_nav.html.erb +2 -1
  52. data/app/views/shiplog/admin/areas/index.html.erb +78 -0
  53. data/app/views/shiplog/admin/entries/_entry_row.html.erb +3 -0
  54. data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
  55. data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
  56. data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
  57. data/app/views/shiplog/changelog_entries/show.html.erb +23 -0
  58. data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
  59. data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
  60. data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
  61. data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
  62. data/app/views/shiplog/changelogs/_search.html.erb +4 -2
  63. data/app/views/shiplog/changelogs/show.html.erb +13 -7
  64. data/app/views/shiplog/head/_feed.html.erb +1 -0
  65. data/app/views/shiplog/head/_meta.html.erb +3 -0
  66. data/app/views/shiplog/head/_open_graph.html.erb +6 -0
  67. data/app/views/shiplog/head/_structured_data.html.erb +1 -0
  68. data/app/views/shiplog/roadmaps/_card.html.erb +2 -2
  69. data/app/views/shiplog/roadmaps/_column.html.erb +3 -3
  70. data/app/views/shiplog/roadmaps/_shipped.html.erb +1 -1
  71. data/app/views/shiplog/roadmaps/show.html.erb +5 -2
  72. data/config/admin_routes.rb +16 -0
  73. data/config/locales/en.yml +85 -0
  74. data/config/locales/es.yml +223 -0
  75. data/config/routes.rb +5 -0
  76. data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
  77. data/lib/generators/shiplog/install/install_generator.rb +10 -1
  78. data/lib/generators/shiplog/install/templates/initializer.rb.tt +58 -1
  79. data/lib/shiplog/classes.rb +10 -1
  80. data/lib/shiplog/configuration.rb +105 -3
  81. data/lib/shiplog/engine.rb +8 -0
  82. data/lib/shiplog/github/queries.rb +13 -1
  83. data/lib/shiplog/version.rb +1 -1
  84. data/lib/shiplog.rb +73 -0
  85. data/lib/tasks/shiplog.rake +29 -0
  86. metadata +47 -1
@@ -0,0 +1,76 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # An entry's screenshots on the edit page: upload, alt text, remove. :id is the image's blob id, the key of
5
+ # the entry's order and alt texts. Not found while screenshots are off.
6
+ class ImagesController < ApplicationController
7
+ before_action :require_images
8
+ before_action :set_entry
9
+
10
+ def create
11
+ files = uploads
12
+ return back(alert: t("shiplog.admin.entries.images.none_selected", default: "Choose at least one image.")) if files.empty?
13
+
14
+ checked = files.index_with { |file| inspect_upload(file) }
15
+ rejected = checked.select { |_file, info| info.nil? }.keys
16
+ if rejected.any?
17
+ names = rejected.map { |file| file.original_filename.to_s.scrub.first(100) }.to_sentence
18
+ return back(alert: t("shiplog.admin.entries.images.rejected", names:, default: "Not added, only PNG, JPEG, GIF and WebP images up to 5 MB and 10,000 pixels a side are accepted: %{names}"))
19
+ end
20
+
21
+ @entry.add_images(checked.map { |file, info| attachable(file, info) }) # uploads first, locks only to attach
22
+ back(notice: t("shiplog.admin.entries.images.added", count: files.size, default: { one: "Image added.", other: "%{count} images added." }))
23
+ end
24
+
25
+ def update
26
+ return head(:not_found) unless @entry.find_image(params[:id])
27
+
28
+ @entry.update_image_alt(params[:id], params[:alt])
29
+ back(notice: t("shiplog.admin.entries.images.alt_saved", default: "Alt text saved."))
30
+ end
31
+
32
+ def destroy
33
+ return head(:not_found) unless @entry.find_image(params[:id])
34
+
35
+ @entry.remove_image(params[:id])
36
+ back(notice: t("shiplog.admin.entries.images.removed", default: "Image removed."))
37
+ end
38
+
39
+ private
40
+
41
+ def require_images
42
+ head :not_found unless Shiplog.images?
43
+ end
44
+
45
+ def set_entry
46
+ @entry = Entry.find(params[:entry_id])
47
+ end
48
+
49
+ def back(**flash)
50
+ redirect_to edit_entry_path(@entry, inbox_filter_params), **flash
51
+ end
52
+
53
+ def uploads
54
+ Array(params[:images]).select { |file| file.is_a?(ActionDispatch::Http::UploadedFile) }
55
+ end
56
+
57
+ # Decided by the file's bytes and size, never by the declared name or content type: the type, and the
58
+ # pixel size read from the header, which must be within the limits (see ImageType). Nil when refused.
59
+ def inspect_upload(file)
60
+ return unless file.size.between?(1, ImageType::MAX_BYTES)
61
+
62
+ file.rewind
63
+ ImageType.analyze(file.read(ImageType::MAX_BYTES + 1))
64
+ rescue ImageType::Invalid
65
+ nil
66
+ ensure
67
+ file.rewind
68
+ end
69
+
70
+ def attachable(file, info)
71
+ { io: file.tempfile, filename: file.original_filename.to_s.scrub.presence || "image", content_type: info.content_type, metadata: { width: info.width, height: info.height } }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,23 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ module Translations
5
+ # Translates the entry into the locale again, ignoring the stored digest and an admin's edit.
6
+ class RetranslationsController < ApplicationController
7
+ def create
8
+ entry = Entry.find(params[:entry_id])
9
+ locale = params[:translation_locale].to_s
10
+ return head(:not_found) unless Entry.translated_locale?(locale)
11
+
12
+ back = edit_entry_path(entry, inbox_filter_params)
13
+ return redirect_to(back, alert: t("shiplog.admin.entries.translations.only_approved", default: "Only approved entries are translated.")) unless entry.approved?
14
+ return redirect_to(back, alert: t("shiplog.admin.entries.translations.no_rewriter", default: "Translating needs config.rewriter, which is not set.")) unless Shiplog.config.rewriter
15
+
16
+ TranslateEntryJob.perform_later(entry, locale.to_sym, force: true)
17
+ redirect_to back, notice: t("shiplog.admin.entries.translations.queued", default: "Translation queued. Refresh in a moment.")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # An admin's edit of an entry's translation (title and body) into one of config.locales. It is published
5
+ # at once and kept until the English text changes: the translation is marked edited and made from the
6
+ # entry's current English text, so it shows even if it was stale.
7
+ class TranslationsController < ApplicationController
8
+ before_action :set_entry_and_locale
9
+
10
+ def update
11
+ translation = @entry.translations.find_or_initialize_by(locale: @locale)
12
+ translation.assign_attributes(translation_params)
13
+ translation.assign_attributes(edited_by_admin: true, source_digest: @entry.translation_source_digest)
14
+ if translation.save
15
+ redirect_to edit_entry_path(@entry, inbox_filter_params), notice: t("shiplog.admin.entries.translations.saved", default: "Translation saved.")
16
+ else
17
+ redirect_to edit_entry_path(@entry, inbox_filter_params), alert: translation.errors.full_messages.to_sentence
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def set_entry_and_locale
24
+ @entry = Entry.find(params[:entry_id])
25
+ @locale = params[:translation_locale].to_s
26
+ head :not_found unless Entry.translated_locale?(@locale)
27
+ end
28
+
29
+ def translation_params
30
+ params.expect(translation: %i[title body])
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -12,7 +12,7 @@ module Shiplog
12
12
  @page = page_number
13
13
  scope = Entry.where(status: @status)
14
14
  scope = scope.where(kind: @kind) if @kind
15
- records = scope.includes(:subject).order(updated_at: :desc, id: :desc)
15
+ records = scope.includes(:subject, :area).order(updated_at: :desc, id: :desc)
16
16
  .offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
17
17
  @more = records.size > PER_PAGE
18
18
  @entries = records.first(PER_PAGE)
@@ -23,11 +23,17 @@ module Shiplog
23
23
  end
24
24
 
25
25
  def update
26
- if @entry.update(entry_params)
26
+ @entry.assign_attributes(entry_params)
27
+ @entry.area_set_by_admin = true if @entry.area_id_changed? # the admin's choice, which detection never overwrites
28
+ if @entry.save
27
29
  redirect_to root_path(inbox_filter_params), notice: t("shiplog.admin.entries.saved")
28
30
  else
29
31
  render :edit, status: :unprocessable_content
30
32
  end
33
+ rescue ActiveRecord::InvalidForeignKey
34
+ # The selected area was deleted between the validation and the save.
35
+ @entry.errors.add(:area_id, t("shiplog.admin.entries.area_gone", default: "no longer exists"))
36
+ render :edit, status: :unprocessable_content
31
37
  rescue ActiveRecord::RecordNotUnique
32
38
  # Two concurrent kind switches can both pass the validation; the unique index has the last word.
33
39
  @entry.errors.add(:changelog_kind, :taken)
@@ -48,7 +54,7 @@ module Shiplog
48
54
  end
49
55
 
50
56
  def entry_params
51
- params.expect(entry: %i[title body released_on changelog_kind])
57
+ params.expect(entry: %i[title body released_on changelog_kind area_id])
52
58
  end
53
59
 
54
60
  def page_number
@@ -0,0 +1,26 @@
1
+ module Shiplog
2
+ # The permalink page of a published feature or change: /changelog/:slug.
3
+ class ChangelogEntriesController < PublicController
4
+ def show
5
+ @entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug])
6
+ return pass_to_host_routes unless @entry
7
+
8
+ notify_changelog_seen
9
+ @newer = @entry.newer_changelog_entry
10
+ @older = @entry.older_changelog_entry
11
+ preload_translations(@entry, @newer, @older)
12
+ fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, [ @entry, @newer, @older ].map { |entry| [ entry&.id, entry&.updated_at, entry&.area_id ] },
13
+ shiplog_image_etag_parts([ @entry ]), shiplog_translation_etag_parts([ @entry, @newer, @older ]) ]
14
+ end
15
+
16
+ private
17
+
18
+ # The engine is usually mounted at "/", so /changelog/:slug can shadow a host route below the changelog
19
+ # path (/changelog/archive, declared after the mount). An unknown slug answers 404 with X-Cascade: pass,
20
+ # which makes the router try the routes after the engine's mount; if none matches, the host's own 404 shows.
21
+ def pass_to_host_routes
22
+ response.set_header("X-Cascade", "pass")
23
+ head :not_found
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ module Shiplog
2
+ # The Atom feed of the latest published features and changes: /changelog.atom.
3
+ class ChangelogFeedsController < PublicController
4
+ LIMIT = 50
5
+
6
+ layout false
7
+ before_action :set_area
8
+
9
+ def show
10
+ scope = Entry.published_changelog.newest_first
11
+ scope = scope.where(area_id: @area.id) if @area
12
+ @entries = scope.includes(:area).with_images_loaded.limit(LIMIT).to_a
13
+ @entries.select!(&:permalink?) # no slug (or an invalid one) means no stable id: left out
14
+ preload_translations(@entries)
15
+ fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @entries.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
16
+ shiplog_image_etag_parts(@entries), shiplog_translation_etag_parts(@entries) ]
17
+ end
18
+ end
19
+ end
@@ -2,24 +2,40 @@ module Shiplog
2
2
  class ChangelogsController < PublicController
3
3
  PER_PAGE = 30
4
4
  MAX_PAGE = 100_000
5
+ MIN_FILTER_AREAS = 2
6
+
7
+ before_action :set_area, only: :show
5
8
 
6
9
  def show
7
10
  @page = page_number
8
- scope = Entry.approved.where(kind: %w[feature change]).where.not(released_on: nil)
9
- .order(released_on: :desc, approved_at: :desc, id: :desc)
11
+ scope = Entry.published_changelog.newest_first
10
12
  @query = search_query
11
- scope = scope.search(@query) if @query
12
- records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
13
+ scope = scope.search(@query, locale: I18n.locale) if @query
14
+ scope = scope.where(area_id: @area.id) if @area
15
+ @areas = filter_areas
16
+ records = scope.includes(:area).with_images_loaded.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
13
17
  return head(:not_found) if @page > 1 && records.empty?
14
18
 
19
+ notify_changelog_seen
15
20
  @older = records.size > PER_PAGE
16
21
  @months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
17
22
  @result_count = scope.count if @query
18
- fresh_when etag: [ *shiplog_etag_parts, @query, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
23
+ shown = @months.values.flatten
24
+ preload_translations(shown)
25
+ fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @areas.map(&:id), @query, @page, @older,
26
+ shown.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
27
+ shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown) ]
19
28
  end
20
29
 
21
30
  private
22
31
 
32
+ # The areas to offer as filter links: those with published entries, in the admin's order, and only when there
33
+ # are at least two (a single area filters nothing).
34
+ def filter_areas
35
+ areas = Area.ordered.where(id: Entry.published_changelog.where.not(area_id: nil).select(:area_id)).to_a
36
+ areas.size >= MIN_FILTER_AREAS ? areas : []
37
+ end
38
+
23
39
  # The normalised search query (words joined by one space), or nil when search is off or the query is blank.
24
40
  def search_query
25
41
  return unless Shiplog.config.changelog_search && params[:q].is_a?(String)
@@ -8,10 +8,67 @@ module Shiplog
8
8
  helper Shiplog::SwitcherHelper
9
9
  helper Shiplog::RoadmapHelper
10
10
  helper Shiplog::HostRoutesHelper
11
+ helper Shiplog::PermalinkHelper
12
+ helper Shiplog::StructuredDataHelper
13
+ helper Shiplog::HeadHelper
11
14
  layout -> { Shiplog.config.public_layout }
12
15
 
13
16
  private
14
17
 
18
+ # Calls config.on_changelog_seen for a visit to the changelog index or a permalink page. It runs before
19
+ # the page renders (the actions call it once they know the page exists, ahead of fresh_when and the
20
+ # implicit render), so a host navbar badge in the same response shows the count the callback just stored,
21
+ # also when the answer ends up a 304 (a revisit is a visit). Not called for 404s or the feed.
22
+ # The response is not sent yet, so the callback can still set cookies or headers. A failing callback is
23
+ # logged and never breaks the page.
24
+ def notify_changelog_seen
25
+ callback = Shiplog.config.on_changelog_seen
26
+ return unless callback && request.format.html?
27
+
28
+ callback.call(self)
29
+ rescue StandardError => error
30
+ Rails.logger.error("[shiplog] on_changelog_seen failed: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
31
+ end
32
+
33
+ # Sets @area from ?area=<slug>. A blank or non-string value is ignored; a slug no area has is a 404 (a
34
+ # renamed or deleted area has no page to show, rather than the unfiltered list under a filtered URL).
35
+ def set_area
36
+ slug = params[:area]
37
+ return unless slug.is_a?(String) && slug.present?
38
+
39
+ @area = Area.find_by(slug:)
40
+ head(:not_found) unless @area
41
+ end
42
+
43
+ # What the markup shows of the areas: names, translated names, slugs and order of every area (the tags and the filter), which
44
+ # entries' updated_at don't reflect when an area is renamed or entries are moved in bulk.
45
+ def shiplog_area_etag_parts
46
+ [ @area&.id, Area.ordered.pluck(:id, :name, :slug, :position, :name_translations) ]
47
+ end
48
+
49
+ # What the markup shows of the entries' screenshots: their blob ids, order and alt texts. Nothing while
50
+ # screenshots are off. Load the entries with `Entry.with_images_loaded` to avoid a query per entry.
51
+ def shiplog_image_etag_parts(entries)
52
+ Shiplog.images? ? entries.compact.map { |entry| [ entry.id, entry.image_etag_parts ] } : []
53
+ end
54
+
55
+ # Loads the translations the page shows (the request locale's) for these entries in one query; see
56
+ # Entry.preload_translations. Call it before the entries are rendered or their ETag is computed.
57
+ def preload_translations(*entries)
58
+ Entry.preload_translations(entries.flatten)
59
+ end
60
+
61
+ # What the markup shows of the entries' translations: which current translation each entry shows, and when
62
+ # it last changed. Nothing under a locale that is not translated.
63
+ def shiplog_translation_etag_parts(entries)
64
+ return [] unless Entry.translated_locale?(I18n.locale)
65
+
66
+ entries.compact.filter_map do |entry|
67
+ translation = entry.translation
68
+ [ entry.id, translation.id, translation.updated_at ] if translation
69
+ end
70
+ end
71
+
15
72
  # What the rendered markup depends on besides the data: the locale, the gem version and the styling
16
73
  # config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
17
74
  def shiplog_etag_parts
@@ -4,7 +4,9 @@ module Shiplog
4
4
  @columns = Roadmap.new(published_only: true).columns
5
5
  preload_cards(@columns)
6
6
  @shipped = recently_shipped
7
- fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped) ]
7
+ shown = [ *@columns.values.flatten.map(&:entry), *@shipped ]
8
+ preload_translations(shown)
9
+ fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped), shiplog_translation_etag_parts(shown) ]
8
10
  end
9
11
 
10
12
  private
@@ -103,6 +103,7 @@
103
103
  }
104
104
  .shiplog-admin .shiplog-badge--warning { border-color: #bf8700; color: #9a6700; }
105
105
  .shiplog-admin .shiplog-badge--error { border-color: #cf222e; color: #cf222e; }
106
+ .shiplog-admin .shiplog-badge--area { border-color: var(--sa-accent); color: var(--sa-accent); }
106
107
 
107
108
  .shiplog-admin .shiplog-button {
108
109
  background: var(--sa-surface);
@@ -115,6 +116,7 @@
115
116
  padding: 0.25rem 0.75rem;
116
117
  text-decoration: none;
117
118
  }
119
+ .shiplog-admin .shiplog-button:disabled { cursor: default; opacity: 0.5; }
118
120
  .shiplog-admin .shiplog-button--primary { background: var(--sa-accent); border-color: var(--sa-accent); color: #fff; }
119
121
 
120
122
  .shiplog-admin .shiplog-admin-split { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
@@ -125,6 +127,13 @@
125
127
  .shiplog-admin .shiplog-admin-form textarea { box-sizing: border-box; font: inherit; width: 100%; }
126
128
  .shiplog-admin .shiplog-admin-actions { border-top: 1px solid var(--sa-border); display: grid; gap: 0.75rem; margin-top: 1rem; padding-top: 1rem; }
127
129
  .shiplog-admin .shiplog-admin-inline-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
130
+ .shiplog-admin .shiplog-admin-images { margin-top: 1rem; }
131
+ .shiplog-admin .shiplog-admin-images__list { display: grid; gap: 1rem; list-style: none; margin: 0 0 1rem; padding: 0; }
132
+ .shiplog-admin .shiplog-admin-images__item { align-items: flex-start; border-bottom: 1px solid var(--sa-border); display: flex; flex-wrap: wrap; gap: 1rem; padding-bottom: 1rem; }
133
+ .shiplog-admin .shiplog-admin-images__thumb { border: 1px solid var(--sa-border); border-radius: 0.25rem; height: auto; max-height: 8rem; max-width: 12rem; object-fit: contain; }
134
+ .shiplog-admin .shiplog-admin-images__controls { display: grid; flex: 1; gap: 0.5rem; min-width: 16rem; }
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
+ .shiplog-admin .shiplog-area-row__count { text-align: right; }
128
137
  .shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
129
138
 
130
139
  .shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
@@ -152,7 +152,8 @@
152
152
  .shiplog-entries { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
153
153
 
154
154
  .shiplog-card,
155
- .shiplog-entry {
155
+ .shiplog-entry,
156
+ .shiplog-permalink {
156
157
  background: var(--sl-card);
157
158
  border: 1px solid var(--sl-border);
158
159
  border-radius: var(--sl-radius);
@@ -241,10 +242,14 @@
241
242
  width: 0.75rem;
242
243
  }
243
244
 
244
- .shiplog-entry { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
245
+ .shiplog-entry,
246
+ .shiplog-permalink { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
245
247
  .shiplog-entry__title,
246
248
  .shiplog-entry__body { flex-basis: 100%; }
247
249
  .shiplog-entry__title { font-size: 1rem; margin: 0.25rem 0 0; }
250
+ .shiplog-entry__title a { color: inherit; text-decoration: none; }
251
+ .shiplog-entry__title a:hover { color: var(--sl-accent); text-decoration: underline; }
252
+ .shiplog-entry__title a:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
248
253
  .shiplog-entry__body { color: var(--sl-muted); }
249
254
  .shiplog-entry__date { color: var(--sl-muted); font-size: 0.8125rem; }
250
255
 
@@ -256,6 +261,9 @@
256
261
  font-weight: 600;
257
262
  padding: 0 0.5rem;
258
263
  }
264
+ .shiplog-permalink .shiplog-entry__title { font-size: 1.5rem; }
265
+ .shiplog-permalink--feature { border-left: 4px solid var(--sl-accent); }
266
+ .shiplog-permalink--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
259
267
  .shiplog-entry--feature { border-left: 4px solid var(--sl-accent); padding: var(--sl-space) calc(var(--sl-space) * 1.25); }
260
268
  .shiplog-entry--feature .shiplog-entry__title { font-size: 1.25rem; }
261
269
  .shiplog-entry--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
@@ -264,6 +272,43 @@
264
272
  .shiplog-pagination__link { color: var(--sl-accent); }
265
273
  .shiplog-pagination__link--older { margin-left: auto; }
266
274
 
275
+ .shiplog-permalink__back { color: var(--sl-accent); display: inline-block; margin-top: var(--sl-space); }
276
+ .shiplog-permalink__nav {
277
+ display: flex;
278
+ flex-wrap: wrap;
279
+ gap: var(--sl-space);
280
+ justify-content: space-between;
281
+ margin-top: calc(var(--sl-space) * 2);
282
+ }
283
+ .shiplog-permalink__nav-link { color: var(--sl-accent); }
284
+ .shiplog-permalink__nav-link--older { margin-left: auto; text-align: right; }
285
+ .shiplog-permalink__back:focus-visible,
286
+ .shiplog-permalink__nav-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
287
+
288
+ .shiplog-entry__area {
289
+ color: var(--sl-muted);
290
+ font-size: 0.75rem;
291
+ font-weight: 600;
292
+ letter-spacing: 0.02em;
293
+ text-transform: uppercase;
294
+ }
295
+
296
+ .shiplog-entry__images { display: grid; flex-basis: 100%; gap: 0.75rem; margin-top: 0.5rem; }
297
+ .shiplog-entry__image { border: 1px solid var(--sl-border); border-radius: 0.5rem; display: block; height: auto; max-width: 100%; }
298
+
299
+ .shiplog-areas { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
300
+ .shiplog-areas__link {
301
+ border: 1px solid var(--sl-border);
302
+ border-radius: 1rem;
303
+ color: var(--sl-fg);
304
+ font-size: 0.875rem;
305
+ padding: 0.125rem 0.75rem;
306
+ text-decoration: none;
307
+ }
308
+ .shiplog-areas__link:hover { border-color: var(--sl-accent); }
309
+ .shiplog-areas__link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
310
+ .shiplog-areas__link--current { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
311
+
267
312
  .shiplog-search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
268
313
  .shiplog-search__input {
269
314
  flex: 1 1 14rem;
@@ -19,6 +19,21 @@ module Shiplog
19
19
  entry.title.presence || source_title(entry.subject)
20
20
  end
21
21
 
22
+ # The URL of an image's thumbnail: a 200x200 variant when the host can make them (image_processing and a
23
+ # processor, see Shiplog.image_variants?), else the original (the stylesheet sizes it).
24
+ def image_thumbnail_path(image)
25
+ source = Shiplog.image_variants? && image.variable? ? image.variant(resize_to_limit: [ 200, 200 ]) : image
26
+ polymorphic_path(source)
27
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
28
+ nil
29
+ end
30
+
31
+ # "Spanish (es)" for a locale of config.locales; the code alone when Shiplog has no name for the language.
32
+ def translation_language_label(locale)
33
+ name = Shiplog::Translator.language_name(locale)
34
+ name == locale.to_s ? name : "#{name} (#{locale})"
35
+ end
36
+
22
37
  def entries_filter_path(status: nil, kind: nil, page: nil)
23
38
  root_path({ status:, kind:, page: }.compact_blank)
24
39
  end
@@ -0,0 +1,95 @@
1
+ module Shiplog
2
+ # The data side of `<%= shiplog_head_tags %>` (see HeadTagsHelper, the only helper every view gets). Included
3
+ # in the engine's public controllers only.
4
+ #
5
+ # The views declare their page with #shiplog_head_page (which also fills the older `content_for` blocks, kept
6
+ # for backward compatibility); the partials below are overridable by the host app:
7
+ # shiplog/_head_tags renders the four below
8
+ # shiplog/head/_meta description, canonical, robots
9
+ # shiplog/head/_open_graph og:* and twitter:*
10
+ # shiplog/head/_feed the Atom alternate link
11
+ # shiplog/head/_structured_data the JSON-LD script
12
+ # Each receives the locals `head` (a Hash: :kind, :title, :url, :entry, :image, :canonical), `description`,
13
+ # `canonical`, `robots`, `open_graph` and `twitter` (Hashes of tag name => content), `feed_url`, `feed_title`
14
+ # and `json_ld`. Whatever `except:` removed arrives as nil or an empty Hash.
15
+ module HeadHelper
16
+ # Renders the head tags of the current page, leaving out the `skip` groups (see HeadTagsHelper, which
17
+ # validates and guards). An empty string when the page declared none.
18
+ def shiplog_render_head_tags(skip)
19
+ return "".html_safe unless @shiplog_head
20
+
21
+ render(partial: "shiplog/head_tags", locals: shiplog_head_locals(skip))
22
+ end
23
+
24
+ # Declares the page for shiplog_head_tags and fills the older content_for blocks (which shiplog_head_tags
25
+ # never reads). `kind` is :permalink,
26
+ # :changelog or :roadmap; the rest is what the page computed: entry (permalink), title, url (the page's
27
+ # absolute URL, nil for a search), query (a search), json_ld, feed (whether it advertises the Atom feed).
28
+ def shiplog_head_page(kind, title:, url: nil, entry: nil, query: nil, json_ld: nil, feed: true)
29
+ @shiplog_head = { kind:, title:, url:, entry:, query:, json_ld:, feed: }
30
+ shiplog_fill_legacy_blocks
31
+ end
32
+
33
+ private
34
+
35
+ def shiplog_fill_legacy_blocks
36
+ head = @shiplog_head
37
+ entry = head[:entry]
38
+ blocks = {
39
+ shiplog_description: (shiplog_entry_description(entry) if entry),
40
+ shiplog_og: (shiplog_og_tags(entry, url: head[:url]) if entry),
41
+ shiplog_robots: ("noindex" if head[:query]),
42
+ shiplog_feed: shiplog_head_feed_link(head),
43
+ shiplog_structured_data: head[:json_ld]
44
+ }
45
+ blocks.each { |name, value| content_for(name, value) if value.present? }
46
+ end
47
+
48
+ def shiplog_head_feed_link(head)
49
+ return unless head[:feed]
50
+
51
+ auto_discovery_link_tag(:atom, shiplog_absolute_url(:changelog_feed), title: shiplog_feed_title)
52
+ end
53
+
54
+ def shiplog_feed_title = t("shiplog.feed.link_title", default: "Changelog (Atom)")
55
+
56
+ def shiplog_head_locals(skip)
57
+ head = @shiplog_head
58
+ entry = head[:entry]
59
+ # A search page has no canonical: it is noindex, and noindex with a canonical sends search engines two
60
+ # signals that disagree.
61
+ canonical = head[:url] unless head[:query]
62
+ image = shiplog_entry_og_image(entry) if entry
63
+ description = shiplog_entry_description(entry) if entry
64
+ page = head.slice(:kind, :title, :entry).merge(canonical:, image:, description:)
65
+
66
+ {
67
+ head: page,
68
+ description: (description unless skip.include?(:description)),
69
+ canonical: (canonical unless skip.include?(:canonical)),
70
+ robots: ("noindex" if head[:query] && !skip.include?(:robots)),
71
+ open_graph: (skip.include?(:open_graph) ? {} : shiplog_head_open_graph(head, canonical, image, description)),
72
+ twitter: (skip.include?(:twitter) ? {} : shiplog_head_twitter(image)),
73
+ feed_url: (shiplog_absolute_url(:changelog_feed) if head[:feed] && !skip.include?(:feed)),
74
+ feed_title: shiplog_feed_title,
75
+ json_ld: (head[:json_ld] unless skip.include?(:structured_data))
76
+ }
77
+ end
78
+
79
+ def shiplog_head_open_graph(head, canonical, image, description)
80
+ entry = head[:entry]
81
+ {
82
+ "og:title" => (entry ? entry.display_title : head[:title]),
83
+ "og:description" => description,
84
+ "og:type" => (entry ? "article" : "website"),
85
+ "og:url" => canonical,
86
+ "og:image" => image,
87
+ "og:image:alt" => (entry.display_title if image)
88
+ }.compact_blank
89
+ end
90
+
91
+ def shiplog_head_twitter(image)
92
+ { "twitter:card" => (image ? "summary_large_image" : "summary"), "twitter:image" => image }.compact_blank
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,24 @@
1
+ module Shiplog
2
+ # The one helper added to every view of the host (like a pagination gem's `paginate`), so a layout shared
3
+ # with the host's own pages can call `shiplog_head_tags`. The rest lives in Shiplog::HeadHelper, which only
4
+ # the engine's controllers include.
5
+ module HeadTagsHelper
6
+ EXCEPT_KEYS = %i[description canonical robots open_graph twitter feed structured_data].freeze
7
+
8
+ # The head tags of the current Shiplog page; an empty string on any other page. `except:` leaves groups
9
+ # out, for tags your layout already writes: :description, :canonical, :robots, :open_graph, :twitter,
10
+ # :feed, :structured_data. An unknown key raises ArgumentError.
11
+ #
12
+ # Use it OR the older `content_for :shiplog_*` blocks in a layout, not both: both would emit the tags twice.
13
+ def shiplog_head_tags(except: [])
14
+ keys = Array(except).map { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
15
+ unknown = keys - EXCEPT_KEYS
16
+ if unknown.any?
17
+ raise ArgumentError, "Unknown shiplog_head_tags except: key(s) #{unknown.map(&:inspect).join(", ")}. Valid keys: #{EXCEPT_KEYS.map(&:inspect).join(", ")}"
18
+ end
19
+ return "".html_safe unless controller.is_a?(Shiplog::PublicController)
20
+
21
+ shiplog_render_head_tags(keys)
22
+ end
23
+ end
24
+ end