shiplog 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2826f599a903db09dc27e45e8b8b0e1b4b643bce51b36a995d913addec96abe
4
- data.tar.gz: d27c0d107c0dc01e1e0509eb6754cbebd5a210b1a5f049f0f3379c5f7dc63faa
3
+ metadata.gz: 485562aa63450450f32dbcb5b6565702f7e39bbf3fb84743cc49a8acd099d0ed
4
+ data.tar.gz: 9096af90498cd2561892963753b21a2026138253594ebb6d73ab0b6816f64b96
5
5
  SHA512:
6
- metadata.gz: 27a0a478a59b2770fee353de8b5305d6674fc485d9fe3cb79ab5e84f46ef4bbe590d68e3a063275c1de6f83e7603e32822b9e696da83f7c20b9ed3a200c3b7f8
7
- data.tar.gz: 6f5fca4e30d1511e38d3272cbe997a8232d6c5b14c80ea4263925a05c5b7378eb10dfb0f48d0e65ebf6069334f01f725cb24d4ffbd4baa50ee105018a19e4225
6
+ metadata.gz: 582ffdd4f37981e7c2a90829189f4f57e64a500f8f02542c3387bcd94764978b5eb96499da9245765292910da016f6f9f0a9d3752d5ff5d817a2b694cd404fa1
7
+ data.tar.gz: 617c35b629ec73ca599ef8cb4386d0bd3c6738ab78b801b93621e28b46e4c89cd68b63091661d42e1c44f71c5d301f280568c6f20bd76cf3f56c14075ce4b940
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-10-26)
4
+
5
+ ### Added
6
+
7
+ - schema.org structured data (JSON-LD) on the public pages, through the new `content_for :shiplog_structured_data` (one `<script type="application/ld+json">` per page). A permalink page is an `Article` (`headline`, `description`, `inLanguage`, `datePublished`, `dateModified`, `url`, `mainEntityOfPage`, and when available `image`, `articleSection` and `publisher`, plus `isPartOf` the changelog); the changelog list is a `CollectionPage` with an `ItemList` of the entries on the page (left out for search results and an empty list); the roadmap is a `CollectionPage` with an `ItemList` of its cards. Titles and descriptions follow the request locale and `config.canonical_url`. The JSON is built from a Hash with `<`, `>` and `&` escaped as `\u003c`, `\u003e` and `\u0026`, so a `</script>` in a title cannot break out of the tag.
8
+ - `shiplog_head_tags(except: [])` (`Shiplog::HeadTagsHelper`, with the data side in `Shiplog::HeadHelper`): one call in a host layout's `<head>` renders the description, canonical link, robots, Open Graph, Twitter card, Atom link and JSON-LD of the public pages, through the overridable partials `shiplog/_head_tags` and `shiplog/head/_meta`, `_open_graph`, `_feed` and `_structured_data`. `except:` takes `:description`, `:canonical`, `:robots`, `:open_graph`, `:twitter`, `:feed` and `:structured_data`; other keys raise `ArgumentError`. It returns an empty string outside Shiplog pages. It is available in every view of the host (a Rails `action_view` load hook includes `Shiplog::HeadTagsHelper`, which holds only this method). Shiplog's own layout uses it.
9
+ - A canonical link (new): a list page's own URL (its `area` and `page`, no other query parameters), a permalink's URL, honouring `config.canonical_url`. Search pages are `noindex` and have no canonical, so the two signals do not disagree. Twitter card tags (`twitter:card`, and `twitter:image`) are new too.
10
+ - `config.og_image` (`nil` or `->(entry, locale) { url }`, evaluated in the view): the share image of a permalink page without a screenshot, as `og:image` (with `og:image:alt`), `twitter:image`, `twitter:card` = `summary_large_image` and `Article.image`. Paths are made absolute; an error or a non-http(s) value is logged and ignored. Part of `render_digest`.
11
+ - `config.structured_data` (default `true`; `false` turns the JSON-LD off). Part of `render_digest`, so changing it refreshes cached pages.
12
+
13
+ ### Upgrading
14
+
15
+ - Custom `config.public_layout`: replace the `content_for(:shiplog_description)`, `:shiplog_og`, `:shiplog_feed`, `:shiplog_robots` and `:shiplog_structured_data` renders in the `<head>` with `<%= shiplog_head_tags %>`, and pass `except:` for the tags your layout writes itself (for example `except: [:open_graph, :twitter]` if it has its own share card). The `content_for` blocks are still filled as in 0.3.0 and still work, but are superseded: use `shiplog_head_tags` or the blocks, not both, because `shiplog_head_tags` does not read the blocks and rendering both duplicates the tags. Hosts that keep the blocks need no change.
16
+ - Hosts with a custom `config.public_layout` that stay on the blocks should render `<%= content_for(:shiplog_structured_data) %>` in the `<head>`, next to `content_for(:shiplog_og)`. Without it the pages work as before, with no structured data. Shiplog's own layout already does.
17
+
3
18
  ## 0.3.0 (2026-10-26)
4
19
 
5
20
  ### Added
data/README.md CHANGED
@@ -116,6 +116,8 @@ All settings live in `config/initializers/shiplog.rb`:
116
116
  | `cache_version` | `nil` | A String added to the public pages' ETag. Callables (`extra_pages`, `repository_labels`, `feedback_path`) cannot be fingerprinted, so bump it when the output of a callable you configured changes. |
117
117
  | `feedback_path` | `nil` | Target of the roadmap's "Suggest a feature" link, a String or a callable evaluated in the view. No link while `nil`. |
118
118
  | `changelog_search` | `true` | Search box on the public changelog and `?q=` filtering. `false` hides the form and ignores `q`. See [Changelog search](#changelog-search). |
119
+ | `structured_data` | `true` | schema.org JSON-LD on the public pages (see [Head tags](#head-tags)). `false` emits none. See [Structured data](#structured-data). Part of the ETag. |
120
+ | `og_image` | `nil` | The image shared with a permalink page that has no screenshot: `nil` or a callable `->(entry, locale) { url }` evaluated in the view (`instance_exec`, so `main_app` route helpers work). See [Head tags](#head-tags). Anything else raises `ArgumentError`. Callables are not part of the ETag: bump `cache_version` when its output changes. |
119
121
  | `on_changelog_seen` | `nil` | `->(controller) { ... }` called before each changelog index or permalink view renders (also for a 304 revisit), to stamp a visitor's "last seen" time. See [What's new](#whats-new-the-unseen-count). Anything but `nil` or a callable raises `ArgumentError`. |
120
122
  | `images` | `true` | [Screenshots](#screenshots) in entries. Effective only when Active Storage is loaded in the host; `false` turns fetching, upload and rendering off. `true` or `false`, anything else raises `ArgumentError`. |
121
123
  | `max_images_per_entry` | `4` | How many images are fetched from the GitHub body of a draft. A non-negative Integer; `0` fetches none (admin uploads are not capped). |
@@ -333,11 +335,7 @@ The public changelog has a search form above its months: `GET /changelog?q=csv`
333
335
  - The ETag includes the normalised query.
334
336
  - The model scope is `Shiplog::Entry.search(query)`.
335
337
 
336
- **Keeping search pages out of search engines.** A page with a query sets `content_for :shiplog_robots, "noindex"`. Shiplog's own layout turns that into `<meta name="robots" content="noindex">`. A host layout (`config.public_layout`) controls its own `<head>`, so emit it there:
337
-
338
- ```erb
339
- <%= tag.meta name: "robots", content: content_for(:shiplog_robots) if content_for?(:shiplog_robots) %>
340
- ```
338
+ **Keeping search pages out of search engines.** A page with a query is `noindex`: `shiplog_head_tags` renders `<meta name="robots" content="noindex">` (see [Head tags](#head-tags)). Hosts still on the older blocks read `content_for :shiplog_robots`.
341
339
 
342
340
  ## Product areas
343
341
 
@@ -392,23 +390,55 @@ Every approved feature and change has its own page at `GET /changelog/:slug` (un
392
390
  - **Entries approved before 0.3.0** get their slug from the 0.3.0 migration (oldest first, so the oldest keeps the plain slug on duplicate titles). `bin/rails shiplog:backfill_slugs` (idempotent, oldest first) does the same for entries that old code approved during a deploy window. The public pages never write, because a host may send GETs to a read replica: an approved entry without a slug is listed without a link, left out of the feed and has no page until it has a slug. An entry whose stored slug the route rejects is treated the same way.
393
391
  - **The page** shows the entry, a "← Back to the changelog" link to the entry's month anchor, and its newer and older neighbours (`rel="prev"` and `rel="next"`, as in the pagination). Drafts, discarded and roadmap entries and unknown slugs answer 404 with `X-Cascade: pass`: the engine is usually mounted at `/`, and its `/changelog/:slug` route would otherwise shadow a host route of yours below the changelog path (say `/changelog/archive`, declared after the mount). The router carries on with the routes after the mount, and your own 404 shows if none matches. Declare such host routes after `mount Shiplog::Engine`; before it they win anyway.
394
392
  - **Caching:** the ETag covers the entry and its neighbours' ids and `updated_at`.
395
- - **Head tags:** the page sets `content_for :title`, `content_for :shiplog_description` (the body, cut at 160 characters) and `content_for :shiplog_og` (Open Graph: `og:title`, `og:description`, `og:type` = `article`, `og:url`). Shiplog's own layout renders them. A host layout (`config.public_layout`) controls its own `<head>`, so render them there, as for `:shiplog_robots`:
393
+ - **Head tags:** the page's description (the body, cut at 160 characters), canonical link and Open Graph tags (`og:title`, `og:description`, `og:type` = `article`, `og:url`, and the image) come from `shiplog_head_tags`; see [Head tags](#head-tags).
394
+ - **Styling:** the `permalink` (the `article`), `permalink_back`, `permalink_nav` and `permalink_nav_link` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks). Override `shiplog/changelogs/_entry_content.html.erb` (locals `entry:`, `heading:`, `linked:`) to change what an entry shows in both the list and the permalink page.
395
+ - **Texts:** `shiplog.permalink.title`, `.back`, `.nav_label`, `.newer` and `.older` (with `title`); all have English defaults.
396
+
397
+ ## Structured data
398
+
399
+ The public pages describe themselves to search engines with schema.org JSON-LD: one `<script type="application/ld+json">` per page, rendered by `shiplog_head_tags` (see [Head tags](#head-tags); leave it out with `except: :structured_data`). The older `content_for :shiplog_structured_data` block still holds the same tag.
400
+
401
+ - **Permalink page:** an `Article` with `headline` (the title, at most 110 characters), `description` (the meta description), `inLanguage` (`I18n.locale`, e.g. `en`, `es`), `datePublished` (the release date), `dateModified`, `url` and `mainEntityOfPage` (the permalink, honouring `config.canonical_url`), `image` (absolute URLs of the screenshots, else the `config.og_image` image, left out without any), `articleSection` (the area's name, when it has one), `publisher` (an `Organization` named `config.product_name`, left out while it is `nil`) and `isPartOf` (the changelog's `CollectionPage`). Titles and bodies follow the request locale, translations included.
402
+ - **Changelog list:** a `CollectionPage` whose `mainEntity` is an `ItemList` of the entries on the current page (`position` counted on the page, `url`, `name`). Entries without a valid slug are skipped. Search results (they are `noindex`) and an empty list have none.
403
+ - **Roadmap:** a `CollectionPage` with an `ItemList` of the cards (`position` and `name`; roadmap cards have no permalink, so no `url`).
404
+ - **Safety:** the JSON is built from a Ruby Hash and `<`, `>` and `&` are written as `\u003c`, `\u003e` and `\u0026`, so a title containing `</script>` cannot close the tag.
405
+ - **Off:** `config.structured_data = false` emits none. It is part of the ETag.
406
+
407
+ ## Head tags
408
+
409
+ Shiplog's pages describe themselves in the `<head>`: description, canonical link, robots, Open Graph, Twitter card, the Atom feed link and JSON-LD. Shiplog's own layout renders them. If you set `config.public_layout` to a layout of your own, add one line to its `<head>`:
396
410
 
397
411
  ```erb
398
- <title><%= content_for(:title) %></title>
399
- <%= tag.meta name: "description", content: content_for(:shiplog_description) if content_for?(:shiplog_description) %>
400
- <%= content_for(:shiplog_og) %>
401
- <%= content_for(:shiplog_feed) %>
412
+ <head>
413
+ <title><%= content_for(:title) %></title>
414
+ <%= shiplog_head_tags %>
415
+ </head>
402
416
  ```
403
417
 
404
- - **Styling:** the `permalink` (the `article`), `permalink_back`, `permalink_nav` and `permalink_nav_link` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks). Override `shiplog/changelogs/_entry_content.html.erb` (locals `entry:`, `heading:`, `linked:`) to change what an entry shows in both the list and the permalink page.
405
- - **Texts:** `shiplog.permalink.title`, `.back`, `.nav_label`, `.newer` and `.older` (with `title`); all have English defaults.
418
+ `shiplog_head_tags` is available in every view of your app (the only Shiplog helper that is), and on any page that is not a Shiplog page it returns an empty string, so it is safe in a layout shared with your own pages. `<title>` stays yours: it is `content_for(:title)`.
419
+
420
+ - **What each page gets.** Permalink: `description`, `canonical` (the permalink), `og:title`, `og:description`, `og:type` = `article`, `og:url`, `og:image` and `og:image:alt` when there is an image, `twitter:card` (`summary_large_image` with an image, `summary` without) and `twitter:image`, the Atom link and the `Article` JSON-LD. Changelog list: `canonical` (the list's own URL, with its `area` and `page` but without any other query parameter), `og:title`, `og:type` = `website`, `og:url`, `twitter:card` = `summary`, the Atom link and the `CollectionPage`. Roadmap: the same without the Atom link. Each key is emitted once.
421
+ - **Search pages** (`?q=`) are `noindex` and have **no canonical** (and no `og:url`, no JSON-LD). The alternative, a canonical pointing at the plain list, would tell search engines to index a page while `noindex` tells them not to; mixed signals are ignored or worse, so a search page just asks not to be indexed.
422
+ - **`except:`** leaves groups out, for tags your layout already writes: `shiplog_head_tags(except: [:canonical, :twitter])`. Valid keys: `:description`, `:canonical`, `:robots`, `:open_graph` (the `og:*` tags), `:twitter` (the `twitter:*` tags), `:feed` and `:structured_data`. Any other key raises `ArgumentError`.
423
+ - **Overridable partials.** Copy one into your app at the same path to change it (like the other [partials](#4-override-the-partials)):
424
+
425
+ | Partial | Renders |
426
+ |---|---|
427
+ | `shiplog/_head_tags.html.erb` | renders the four below |
428
+ | `shiplog/head/_meta.html.erb` | `description`, canonical link and `robots` |
429
+ | `shiplog/head/_open_graph.html.erb` | `og:*` and `twitter:*` |
430
+ | `shiplog/head/_feed.html.erb` | the Atom `<link rel="alternate">` |
431
+ | `shiplog/head/_structured_data.html.erb` | the JSON-LD `<script>` |
432
+
433
+ Every partial gets the same locals: `head` (a Hash with `:kind` (`:permalink`, `:changelog` or `:roadmap`), `:title`, `:entry` (permalink only), `:image`, `:canonical` and `:description`), `description`, `canonical`, `robots`, `open_graph` and `twitter` (Hashes of tag name to content), `feed_url`, `feed_title` and `json_ld`. What `except:` removed arrives as `nil` or `{}`, so a partial that renders its locals respects it.
434
+ - **The share image.** The permalink page's image is the first screenshot (absolute URL), else what `config.og_image` returns, else none. `config.og_image` is `nil` or `->(entry, locale) { url }`, evaluated in the view (`instance_exec`, so `main_app.my_card_url(entry.slug)` works). A path (`"/cards/dark.png"`) is made absolute with `config.canonical_url` or the request's origin. A callable that raises, or returns anything but an http(s) URL or a path (`javascript:`, `//host`, `nil`), is logged once for the page (`[shiplog] config.og_image ...`) and the page renders without the image. The same image goes in `Article.image` of the JSON-LD. List pages and the roadmap get no image from Shiplog: your default applies, through your own tags (`except: [:open_graph, :twitter]`) or an override of `_open_graph`.
435
+ - **The older `content_for` blocks** (`:shiplog_description`, `:shiplog_og`, `:shiplog_feed`, `:shiplog_robots`, `:shiplog_structured_data`) are still filled exactly as in 0.3.0 and still work, but are superseded. Use `shiplog_head_tags` **or** the blocks, not both: `shiplog_head_tags` never reads the blocks, so a layout that renders both emits the tags twice. The blocks have neither the canonical link nor the Twitter tags. Upgrading: replace the `content_for` renders in your `<head>` with `shiplog_head_tags`, and pass `except:` for the tags your layout writes itself.
406
436
 
407
437
  ## Atom feed
408
438
 
409
439
  `GET /changelog.atom` serves the latest 50 approved features and changes (newest first) as an Atom feed. An entry has a stable id (a tag URI built from the permalink path, so a title edit does not change it), its permalink, `published` (the release date), `updated`, and its body as HTML. All URLs are absolute. The feed is cached with an ETag like the pages and answers 304 on a match; it does not call `on_changelog_seen`.
410
440
 
411
- The changelog and permalink pages advertise it with a `<link rel="alternate" type="application/atom+xml">` (`content_for :shiplog_feed`, rendered by Shiplog's layout; render it in a host layout too, see above) and the changelog shows a visible "Subscribe (Atom)" link (the `footnote_link` key). The feed has an `<author>` (the product name, or "Changelog"), which RFC 4287 requires when entries carry none. The feed is titled "Changelog", or "<product_name> changelog" when `config.product_name` is set, and follows the request locale. Texts: `shiplog.feed.title_with_product`, `.link_title` and `.subscribe`.
441
+ The changelog and permalink pages advertise it with a `<link rel="alternate" type="application/atom+xml">` (rendered by `shiplog_head_tags`, see [Head tags](#head-tags); the older `content_for :shiplog_feed` block holds the same link) and the changelog shows a visible "Subscribe (Atom)" link (the `footnote_link` key). The feed has an `<author>` (the product name, or "Changelog"), which RFC 4287 requires when entries carry none. The feed is titled "Changelog", or "<product_name> changelog" when `config.product_name` is set, and follows the request locale. Texts: `shiplog.feed.title_with_product`, `.link_title` and `.subscribe`.
412
442
 
413
443
  ### Canonical URL
414
444
 
@@ -9,6 +9,8 @@ module Shiplog
9
9
  helper Shiplog::RoadmapHelper
10
10
  helper Shiplog::HostRoutesHelper
11
11
  helper Shiplog::PermalinkHelper
12
+ helper Shiplog::StructuredDataHelper
13
+ helper Shiplog::HeadHelper
12
14
  layout -> { Shiplog.config.public_layout }
13
15
 
14
16
  private
@@ -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
@@ -40,12 +40,13 @@ module Shiplog
40
40
  # The id of a month's section on the changelog list.
41
41
  def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
42
42
 
43
- # The body, on one line and cut for a meta description. Plain text: tag helpers escape it.
44
- def shiplog_entry_description(entry)
45
- truncate(entry.display_body.to_s.squish, length: DESCRIPTION_LENGTH, separator: " ")
43
+ # The body, on one line and cut for a meta description. Plain text: tag helpers escape it. `escape: false`
44
+ # keeps the cut text raw (not HTML-escaped) for JSON, which escapes it its own way.
45
+ def shiplog_entry_description(entry, escape: true)
46
+ truncate(entry.display_body.to_s.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
46
47
  end
47
48
 
48
- # The Open Graph meta tags of a permalink page, for `content_for :shiplog_og`.
49
+ # The Open Graph meta tags of a permalink page, for `content_for :shiplog_og` (superseded by shiplog_head_tags).
49
50
  def shiplog_og_tags(entry, url:)
50
51
  tags = {
51
52
  "og:title" => entry.display_title, "og:description" => shiplog_entry_description(entry),
@@ -54,10 +55,45 @@ module Shiplog
54
55
  safe_join(tags.compact_blank.map { |property, content| tag.meta(property:, content:) }, "\n")
55
56
  end
56
57
 
57
- # The absolute URL of the image to show when the page is shared (the first image), or nil.
58
+ # The absolute URL of the image to show when the page is shared, or nil: the entry's first screenshot, else
59
+ # what config.og_image returns for it. Computed once per entry per render, so a failing callable is logged
60
+ # once for a page.
58
61
  def shiplog_entry_og_image(entry)
62
+ @shiplog_og_images ||= {}.compare_by_identity
63
+ return @shiplog_og_images[entry] if @shiplog_og_images.key?(entry)
64
+
59
65
  image = shiplog_images(entry).first
60
- shiplog_image_url(image) if image
66
+ @shiplog_og_images[entry] = (shiplog_image_url(image) if image) || shiplog_configured_og_image(entry)
67
+ end
68
+
69
+ # What config.og_image gives for an entry (called in the view with the entry and the locale), as an absolute
70
+ # http(s) URL. Nil, after a log line, when it raises or returns something else.
71
+ def shiplog_configured_og_image(entry)
72
+ callable = Shiplog.config.og_image
73
+ return unless callable
74
+
75
+ value = callable.is_a?(Proc) ? instance_exec(entry, I18n.locale, &callable) : callable.call(entry, I18n.locale)
76
+ url = shiplog_absolute_http_url(value)
77
+ Rails.logger.warn("[shiplog] config.og_image ignored for entry #{entry.id}: expected an http(s) URL or a path, got #{value.inspect}") unless url
78
+ url
79
+ rescue StandardError => error
80
+ Rails.logger.error("[shiplog] config.og_image failed for entry #{entry.id}: #{error.class}: #{error.message}")
81
+ nil
82
+ end
83
+
84
+ # An http(s) URL as is, a path ("/cards/a.png", not "//host") made absolute with config.canonical_url or the
85
+ # request's origin; nil for anything else (javascript:, data:, blank, spaces, non-strings).
86
+ def shiplog_absolute_http_url(value)
87
+ return unless value.is_a?(String)
88
+
89
+ value = value.strip
90
+ return if value.match?(/[[:space:][:cntrl:]]/)
91
+
92
+ value = "#{Shiplog.config.canonical_url || request.base_url}#{value}" if value.match?(%r{\A/(?![/\\])})
93
+ uri = URI.parse(value)
94
+ value if uri.is_a?(URI::HTTP) && uri.host.present?
95
+ rescue URI::InvalidURIError
96
+ nil
61
97
  end
62
98
 
63
99
  # The HTML of an entry in the Atom feed: its text, then its images with absolute URLs.
@@ -0,0 +1,74 @@
1
+ module Shiplog
2
+ # schema.org structured data (JSON-LD) of the public pages, for `shiplog_head_tags` (and `content_for :shiplog_structured_data`).
3
+ # Every method returns nil while config.structured_data is off, or when there is nothing worth describing.
4
+ module StructuredDataHelper
5
+ HEADLINE_LENGTH = 110
6
+ CONTEXT = "https://schema.org".freeze
7
+ # "<", ">" and "&" (and the line separators JavaScript treats as newlines) become JSON \u escapes: still the
8
+ # same string once parsed, but a "</script>" in a title cannot close the tag.
9
+ SCRIPT_ESCAPES = { "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026", "
" => "\\u2028", "
" => "\\u2029" }.freeze
10
+
11
+ # A <script type="application/ld+json"> tag for a Hash, or nil while structured data is off.
12
+ def shiplog_json_ld(data)
13
+ return unless Shiplog.config.structured_data && data
14
+
15
+ json = JSON.generate(data).gsub(/[<>&

]/, SCRIPT_ESCAPES)
16
+ # Safe: JSON.generate output with every character that could end the script element escaped above.
17
+ content_tag(:script, json.html_safe, type: "application/ld+json")
18
+ end
19
+
20
+ # The Article of a permalink page.
21
+ def shiplog_article_json_ld(entry)
22
+ return unless Shiplog.config.structured_data
23
+
24
+ url = shiplog_entry_url(entry)
25
+ images = shiplog_images(entry).filter_map { |image| shiplog_image_url(image) }
26
+ images = [ shiplog_entry_og_image(entry) ].compact if images.empty?
27
+ shiplog_json_ld({
28
+ "@context" => CONTEXT, "@type" => "Article",
29
+ "headline" => truncate(entry.display_title.to_s.squish, length: HEADLINE_LENGTH, escape: false),
30
+ "description" => shiplog_entry_description(entry, escape: false).presence,
31
+ "inLanguage" => I18n.locale.to_s,
32
+ "datePublished" => entry.released_on&.iso8601, "dateModified" => entry.updated_at&.iso8601,
33
+ "url" => url, "mainEntityOfPage" => url,
34
+ "image" => images.presence,
35
+ "articleSection" => entry.area&.display_name.presence,
36
+ "publisher" => shiplog_publisher_json_ld,
37
+ "isPartOf" => { "@type" => "CollectionPage", "url" => shiplog_absolute_url(:changelog) }
38
+ }.compact)
39
+ end
40
+
41
+ # The CollectionPage of the changelog list: an ItemList of the entries shown. Nil for search results and an
42
+ # empty list.
43
+ def shiplog_changelog_json_ld(entries, name:, url:)
44
+ return if @query
45
+
46
+ items = entries.filter_map { |entry| [ shiplog_entry_url(entry), entry.display_title ] if entry.permalink? }
47
+ return if items.empty?
48
+
49
+ shiplog_collection_json_ld(name:, url:, items: items.map { |item_url, title| { "url" => item_url, "name" => title } })
50
+ end
51
+
52
+ # The CollectionPage of the roadmap: an ItemList of the cards, which have no URL.
53
+ def shiplog_roadmap_json_ld(entries, name:, url:)
54
+ return if entries.empty?
55
+
56
+ shiplog_collection_json_ld(name:, url:, items: entries.map { |entry| { "name" => entry.display_title } })
57
+ end
58
+
59
+ private
60
+
61
+ def shiplog_collection_json_ld(name:, url:, items:)
62
+ elements = items.each_with_index.map { |item, index| { "@type" => "ListItem", "position" => index + 1, **item } }
63
+ shiplog_json_ld({
64
+ "@context" => CONTEXT, "@type" => "CollectionPage", "name" => name, "url" => url, "inLanguage" => I18n.locale.to_s,
65
+ "mainEntity" => { "@type" => "ItemList", "itemListElement" => elements }
66
+ })
67
+ end
68
+
69
+ def shiplog_publisher_json_ld
70
+ name = Shiplog.config.product_name.presence
71
+ { "@type" => "Organization", "name" => name } if name
72
+ end
73
+ end
74
+ end
@@ -4,10 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title><%= content_for(:title) %></title>
7
- <% if content_for?(:shiplog_robots) %><meta name="robots" content="<%= content_for(:shiplog_robots) %>"><% end %>
8
- <% if content_for?(:shiplog_description) %><meta name="description" content="<%= content_for(:shiplog_description) %>"><% end %>
9
- <%= content_for(:shiplog_og) %>
10
- <%= content_for(:shiplog_feed) %>
7
+ <%= shiplog_head_tags %>
11
8
  <%= csrf_meta_tags %>
12
9
  <%= csp_meta_tag %>
13
10
  </head>
@@ -0,0 +1,5 @@
1
+ <%# Renders the head tags of a Shiplog page; see Shiplog::HeadHelper. Override the partials below to change a group. %>
2
+ <%= render partial: "shiplog/head/meta", locals: local_assigns %>
3
+ <%= render partial: "shiplog/head/open_graph", locals: local_assigns %>
4
+ <%= render partial: "shiplog/head/feed", locals: local_assigns %>
5
+ <%= render partial: "shiplog/head/structured_data", locals: local_assigns %>
@@ -1,7 +1,5 @@
1
1
  <% content_for :title, t("shiplog.permalink.title", title: @entry.display_title, changelog: t("shiplog.changelog.title", default: "Changelog"), default: "%{title} · %{changelog}") %>
2
- <% content_for :shiplog_description, shiplog_entry_description(@entry) %>
3
- <% content_for :shiplog_og, shiplog_og_tags(@entry, url: shiplog_entry_url(@entry)) %>
4
- <% content_for :shiplog_feed, auto_discovery_link_tag(:atom, shiplog_absolute_url(:changelog_feed), title: t("shiplog.feed.link_title", default: "Changelog (Atom)")) %>
2
+ <% shiplog_head_page :permalink, title: @entry.display_title, entry: @entry, url: shiplog_entry_url(@entry), json_ld: shiplog_article_json_ld(@entry) %>
5
3
  <%= render layout: "shiplog/page", locals: { name: "permalink" } do %>
6
4
  <%= render "shiplog/page_header", title: t("shiplog.changelog.title", default: "Changelog"), lede: nil %>
7
5
  <article class="<%= shiplog_class(:permalink, @entry.kind) %>">
@@ -1,10 +1,9 @@
1
- <% content_for :title, t("shiplog.changelog.title", default: "Changelog") %>
2
- <% content_for :shiplog_feed, auto_discovery_link_tag(:atom, shiplog_absolute_url(:changelog_feed), title: t("shiplog.feed.link_title", default: "Changelog (Atom)")) %>
1
+ <% changelog_title = t("shiplog.changelog.title", default: "Changelog") %>
2
+ <% list_url = shiplog_absolute_url(:changelog, area: @area&.slug, page: (@page if @page > 1)) %>
3
+ <% content_for :title, changelog_title %>
4
+ <% shiplog_head_page :changelog, title: changelog_title, url: list_url, query: @query, json_ld: shiplog_changelog_json_ld(@months.values.flatten, name: changelog_title, url: list_url) %>
3
5
  <%= render layout: "shiplog/page", locals: { name: "changelog" } do %>
4
6
  <%= render "shiplog/page_header", title: t("shiplog.changelog.title", default: "Changelog"), lede: t("shiplog.changelog.lede", default: "New features and improvements, as they ship.") %>
5
- <% if @query %>
6
- <% content_for :shiplog_robots, "noindex" %>
7
- <% end %>
8
7
  <% if Shiplog.config.changelog_search %>
9
8
  <%= render "shiplog/changelogs/search", query: @query, count: @result_count %>
10
9
  <% end %>
@@ -0,0 +1 @@
1
+ <%= auto_discovery_link_tag(:atom, feed_url, title: feed_title) if feed_url.present? %>
@@ -0,0 +1,3 @@
1
+ <%= tag.meta(name: "description", content: description) if description.present? %>
2
+ <%= tag.link(rel: "canonical", href: canonical) if canonical.present? %>
3
+ <%= tag.meta(name: "robots", content: robots) if robots.present? %>
@@ -0,0 +1,6 @@
1
+ <% open_graph.each do |property, content| %>
2
+ <%= tag.meta(property:, content:) %>
3
+ <% end %>
4
+ <% twitter.each do |name, content| %>
5
+ <%= tag.meta(name:, content:) %>
6
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= json_ld if json_ld.present? %>
@@ -1,4 +1,7 @@
1
- <% content_for :title, t("shiplog.roadmap.title", default: "Roadmap") %>
1
+ <% roadmap_title = t("shiplog.roadmap.title", default: "Roadmap") %>
2
+ <% roadmap_url = shiplog_absolute_url(:roadmap) %>
3
+ <% content_for :title, roadmap_title %>
4
+ <% shiplog_head_page :roadmap, title: roadmap_title, url: roadmap_url, feed: false, json_ld: shiplog_roadmap_json_ld(@columns.values.flatten.map(&:entry), name: roadmap_title, url: roadmap_url) %>
2
5
  <%= render layout: "shiplog/page", locals: { name: "roadmap" } do %>
3
6
  <%= render "shiplog/page_header", title: t("shiplog.roadmap.title", default: "Roadmap"), lede: t("shiplog.roadmap.lede", default: "What we're working on and what comes next.") %>
4
7
  <div class="<%= shiplog_class(:columns) %>">
@@ -122,6 +122,10 @@ Shiplog.configure do |config|
122
122
  # The public changelog's search box and ?q= filtering. Set to false to turn search off.
123
123
  # config.changelog_search = true
124
124
 
125
+ # schema.org structured data (JSON-LD) on the public pages: an Article on each permalink, a CollectionPage on
126
+ # the changelog and the roadmap. Set to false to turn it off.
127
+ # config.structured_data = true
128
+
125
129
  # "What's new". Shiplog doesn't know your users, so you supply the "last seen" time. This is called with the
126
130
  # controller on every changelog index or permalink view, BEFORE the page renders (a 304 revisit included), so a
127
131
  # navbar badge in the same response is current. It may write and runs on every visit: keep it cheap, and write
@@ -135,9 +139,18 @@ Shiplog.configure do |config|
135
139
 
136
140
  # The 0.3.0 migration gives entries approved so far their permalink slug. The public pages never write (a GET
137
141
  # may hit a read replica), so an entry approved by older code during a deploy window has no permalink until
138
- # `bin/rails shiplog:backfill_slugs` runs. Permalink pages set `content_for :shiplog_og` (Open Graph tags),
139
- # `content_for :shiplog_description` and `content_for :shiplog_feed` (the Atom discovery link); Shiplog's own
140
- # layout renders them, so emit them in your layout's <head> when you set `config.public_layout`.
142
+ # `bin/rails shiplog:backfill_slugs` runs.
143
+
144
+ # With your own config.public_layout, add `<%%= shiplog_head_tags %%>` to its <head>: it renders the description,
145
+ # canonical link, robots, Open Graph, Twitter, Atom discovery and JSON-LD tags of the Shiplog pages (and nothing
146
+ # on your other pages). `except: [:feed, ...]` leaves out what your layout writes itself. The older
147
+ # `content_for :shiplog_og`, `:shiplog_description`, `:shiplog_feed`, `:shiplog_robots` and
148
+ # `:shiplog_structured_data` blocks still work but are superseded: use one or the other, never both.
149
+
150
+ # The image shared with a permalink page that has no screenshot (og:image, twitter:image, JSON-LD image): a
151
+ # callable evaluated in the view, so route helpers work. Return an http(s) URL or a path (made absolute); an
152
+ # error or any other value is logged and the page gets no image. List pages and the roadmap get none from Shiplog.
153
+ # config.og_image = ->(entry, locale) { main_app.url_for(controller: "/og_images", action: :show, id: entry.slug) }
141
154
 
142
155
  # Product areas. The rewriter's reply may name an area (an existing one, or a new name of 1-3 words); Shiplog
143
156
  # creates it while fewer than max_areas exist, so the AI cannot sprawl. The changelog offers an area filter
@@ -34,6 +34,13 @@ module Shiplog
34
34
  attr_accessor :feedback_path
35
35
  # Whether the public changelog has a search box and honours ?q= (default true).
36
36
  attr_accessor :changelog_search
37
+ # Whether the public pages carry schema.org JSON-LD (rendered by shiplog_head_tags; default true).
38
+ attr_accessor :structured_data
39
+ # The image shared with a permalink page when the entry has no screenshot: nil (default) or a callable
40
+ # `->(entry, locale) { "https://cdn.example.com/card.png" }` evaluated in the view (instance_exec, so
41
+ # `main_app` route helpers work). A path is made absolute with canonical_url or the request's origin; an
42
+ # error or a non-http(s) value is logged and the page renders without the image. See shiplog_head_tags.
43
+ attr_reader :og_image
37
44
  # Called with the controller on each changelog index or permalink view (200 or 304 answers), e.g. to stamp
38
45
  # the visitor's "last seen" time. It runs BEFORE the page renders (so a badge in the same response is
39
46
  # current), on every visit, and may write: keep it cheap, e.g. write only when the stored time is older than
@@ -70,7 +77,7 @@ module Shiplog
70
77
  stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
71
78
  extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
72
79
  repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
73
- stable.(feedback_path), changelog_search, images, max_images_per_entry, locales
80
+ stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image)
74
81
  ]
75
82
  Digest::SHA256.hexdigest(JSON.generate(data))
76
83
  end
@@ -156,6 +163,11 @@ module Shiplog
156
163
  @canonical_url = normalize_canonical_url(value)
157
164
  end
158
165
 
166
+ def og_image=(value)
167
+ raise ArgumentError, "og_image must be nil or a callable like ->(entry, locale) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
168
+ @og_image = value
169
+ end
170
+
159
171
  def on_changelog_seen=(value)
160
172
  raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
161
173
  @on_changelog_seen = value
@@ -227,6 +239,7 @@ module Shiplog
227
239
  @repository_labels = {}.freeze
228
240
  @roadmap_shipped_count = 4
229
241
  @changelog_search = true
242
+ @structured_data = true
230
243
  @max_areas = 8
231
244
  @images = true
232
245
  @max_images_per_entry = 4
@@ -2,6 +2,11 @@ module Shiplog
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace Shiplog
4
4
 
5
+ # shiplog_head_tags in every view, so a layout shared with the host's own pages can call it.
6
+ initializer "shiplog.head_tags_helper" do
7
+ ActiveSupport.on_load(:action_view) { include Shiplog::HeadTagsHelper }
8
+ end
9
+
5
10
  # Once per boot, after the host's initializers have set config.images: say why screenshots are off.
6
11
  config.after_initialize { Shiplog.log_images_status }
7
12
  end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shiplog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Trackberry
@@ -72,9 +72,12 @@ files:
72
72
  - app/helpers/shiplog/application_helper.rb
73
73
  - app/helpers/shiplog/callable_helper.rb
74
74
  - app/helpers/shiplog/frontend_helper.rb
75
+ - app/helpers/shiplog/head_helper.rb
76
+ - app/helpers/shiplog/head_tags_helper.rb
75
77
  - app/helpers/shiplog/host_routes_helper.rb
76
78
  - app/helpers/shiplog/permalink_helper.rb
77
79
  - app/helpers/shiplog/roadmap_helper.rb
80
+ - app/helpers/shiplog/structured_data_helper.rb
78
81
  - app/helpers/shiplog/styling_helper.rb
79
82
  - app/helpers/shiplog/switcher_helper.rb
80
83
  - app/helpers/shiplog/unseen_helper.rb
@@ -125,6 +128,7 @@ files:
125
128
  - app/views/layouts/shiplog/admin.html.erb
126
129
  - app/views/layouts/shiplog/application.html.erb
127
130
  - app/views/shiplog/_empty.html.erb
131
+ - app/views/shiplog/_head_tags.html.erb
128
132
  - app/views/shiplog/_page.html.erb
129
133
  - app/views/shiplog/_page_header.html.erb
130
134
  - app/views/shiplog/_pagination.html.erb
@@ -151,6 +155,10 @@ files:
151
155
  - app/views/shiplog/changelogs/_month.html.erb
152
156
  - app/views/shiplog/changelogs/_search.html.erb
153
157
  - app/views/shiplog/changelogs/show.html.erb
158
+ - app/views/shiplog/head/_feed.html.erb
159
+ - app/views/shiplog/head/_meta.html.erb
160
+ - app/views/shiplog/head/_open_graph.html.erb
161
+ - app/views/shiplog/head/_structured_data.html.erb
154
162
  - app/views/shiplog/roadmaps/_card.html.erb
155
163
  - app/views/shiplog/roadmaps/_column.html.erb
156
164
  - app/views/shiplog/roadmaps/_shipped.html.erb