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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/README.md +172 -7
- data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
- data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
- data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
- data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
- data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
- data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
- data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
- data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
- data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
- data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
- data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
- data/app/controllers/shiplog/changelogs_controller.rb +21 -5
- data/app/controllers/shiplog/public_controller.rb +57 -0
- data/app/controllers/shiplog/roadmaps_controller.rb +3 -1
- data/app/frontend/shiplog/admin.css +9 -0
- data/app/frontend/shiplog/shiplog.css +47 -2
- data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
- data/app/helpers/shiplog/head_helper.rb +95 -0
- data/app/helpers/shiplog/head_tags_helper.rb +24 -0
- data/app/helpers/shiplog/permalink_helper.rb +149 -0
- data/app/helpers/shiplog/roadmap_helper.rb +9 -0
- data/app/helpers/shiplog/structured_data_helper.rb +74 -0
- data/app/helpers/shiplog/unseen_helper.rb +9 -0
- data/app/jobs/shiplog/detect_areas_job.rb +18 -0
- data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
- data/app/jobs/shiplog/translate_area_job.rb +25 -0
- data/app/jobs/shiplog/translate_entry_job.rb +25 -0
- data/app/models/shiplog/area.rb +156 -0
- data/app/models/shiplog/area_detector.rb +146 -0
- data/app/models/shiplog/area_translator.rb +77 -0
- data/app/models/shiplog/entry/images.rb +127 -0
- data/app/models/shiplog/entry.rb +204 -5
- data/app/models/shiplog/entry_translation.rb +15 -0
- data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
- data/app/models/shiplog/image_fetcher/sources.rb +41 -0
- data/app/models/shiplog/image_fetcher.rb +72 -0
- data/app/models/shiplog/image_type.rb +111 -0
- data/app/models/shiplog/rewriter/prompt.rb +31 -7
- data/app/models/shiplog/rewriter.rb +41 -23
- data/app/models/shiplog/setting.rb +19 -0
- data/app/models/shiplog/translator/prompt.rb +40 -0
- data/app/models/shiplog/translator.rb +99 -0
- data/app/views/layouts/shiplog/application.html.erb +1 -1
- data/app/views/shiplog/_empty.html.erb +1 -1
- data/app/views/shiplog/_head_tags.html.erb +5 -0
- data/app/views/shiplog/_pagination.html.erb +3 -3
- data/app/views/shiplog/admin/_nav.html.erb +2 -1
- data/app/views/shiplog/admin/areas/index.html.erb +78 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +3 -0
- data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
- data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
- data/app/views/shiplog/changelog_entries/show.html.erb +23 -0
- data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
- data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
- data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
- data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
- data/app/views/shiplog/changelogs/_search.html.erb +4 -2
- data/app/views/shiplog/changelogs/show.html.erb +13 -7
- data/app/views/shiplog/head/_feed.html.erb +1 -0
- data/app/views/shiplog/head/_meta.html.erb +3 -0
- data/app/views/shiplog/head/_open_graph.html.erb +6 -0
- data/app/views/shiplog/head/_structured_data.html.erb +1 -0
- data/app/views/shiplog/roadmaps/_card.html.erb +2 -2
- data/app/views/shiplog/roadmaps/_column.html.erb +3 -3
- data/app/views/shiplog/roadmaps/_shipped.html.erb +1 -1
- data/app/views/shiplog/roadmaps/show.html.erb +5 -2
- data/config/admin_routes.rb +16 -0
- data/config/locales/en.yml +85 -0
- data/config/locales/es.yml +223 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
- data/lib/generators/shiplog/install/install_generator.rb +10 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +58 -1
- data/lib/shiplog/classes.rb +10 -1
- data/lib/shiplog/configuration.rb +105 -3
- data/lib/shiplog/engine.rb +8 -0
- data/lib/shiplog/github/queries.rb +13 -1
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +73 -0
- data/lib/tasks/shiplog.rake +29 -0
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|