shiplog 0.3.0 → 0.3.2

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: a624887b92e388e7234f1c9051437c7c8732b624f448ae4ed6a50834668835b3
4
+ data.tar.gz: 1a0f18b1f55b919bf9fcf04da75379666428a49fd312c75ea55c6d064e613959
5
5
  SHA512:
6
- metadata.gz: 27a0a478a59b2770fee353de8b5305d6674fc485d9fe3cb79ab5e84f46ef4bbe590d68e3a063275c1de6f83e7603e32822b9e696da83f7c20b9ed3a200c3b7f8
7
- data.tar.gz: 6f5fca4e30d1511e38d3272cbe997a8232d6c5b14c80ea4263925a05c5b7378eb10dfb0f48d0e65ebf6069334f01f725cb24d4ffbd4baa50ee105018a19e4225
6
+ metadata.gz: 3dd499eff9f0656078de85134f584ee13eddea6e98d9ad431d363da5bb4c53ce8c06c784914fc4ad2fdf5f30a4a2bc9c53632eed87b3dae1bbbd907efb1cfc88
7
+ data.tar.gz: c2322fb8cde5158aa51656e04d885faede534ab939e0a83677245ea1ab6130d11806cc7145f456f9b211b6b8fe7d2b7e821c198261660b23fe974db1dfee8226
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 (2026-10-26)
4
+
5
+ 0.3.1 hosts should upgrade.
6
+
7
+ ### Fixed
8
+
9
+ - Boot failure in 0.3.1: when a host initializer or gem loaded `ActionView::Base` early (for example `ActionView::Base.field_error_proc = ...` in `config/initializers`), the engine's `action_view` hook ran before the autoloaders existed and raised `NameError: uninitialized constant Shiplog::HeadTagsHelper`. `Shiplog::HeadTagsHelper` now lives in `lib/shiplog/head_tags_helper.rb` and is required explicitly, so it is neither autoloaded nor reloaded (it is included into a class that never reloads).
10
+ - `shiplog_head_tags` raised `NoMethodError` in a view that lacks Shiplog's helpers, such as a ViewComponent or Phlex component rendered on a Shiplog page. It returns an empty string there: call it in the layout.
11
+ - `shiplog_head_tags` no longer autoloads `Shiplog::PublicController` (and the host's parent controller) when a host page or mailer renders a layout that calls it. Shiplog's public controllers answer a `shiplog_public_page?` marker instead.
12
+ - `og:image` and `Article.image` agree: `og:image` is the first screenshot whose URL can be built (it skipped to the callable when the first one had none), `Article.image` all of them, otherwise the `config.og_image` image.
13
+ - The `EXCEPT_KEYS` constant is no longer visible in the host's views (it moved to `Shiplog::HeadTags`).
14
+
15
+ ### Changed
16
+
17
+ - The legacy `content_for :shiplog_og` block includes the `config.og_image` fallback image when it is set (it did so in 0.3.1; the 0.3.1 notes said it was exactly as in 0.3.0).
18
+
19
+ ### Upgrading
20
+
21
+ - Bump `config.cache_version` when you change your layout's head or override a `shiplog/head/*` partial: ETags don't cover layouts.
22
+ - `config.og_image` output is not part of the ETag (only its presence is): bump `cache_version` when it changes.
23
+ - With `except: :canonical`, `og:url` still carries Shiplog's canonical (which honours `config.canonical_url`). A host writing its own canonical should make it agree, and should not emit one on search pages, where Shiplog sets `noindex` (`content_for(:shiplog_robots)` is `"noindex"` there).
24
+
25
+ ## 0.3.1 (2026-10-26)
26
+
27
+ ### Added
28
+
29
+ - schema.org structured data (JSON-LD) on the public pages, rendered by `shiplog_head_tags` (the legacy route is `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.
30
+ - `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). **0.3.1 can fail to boot, see 0.3.2.** Shiplog's own layout uses it.
31
+ - 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.
32
+ - `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. Only its presence is part of `render_digest`, not its output: bump `config.cache_version` when the output changes.
33
+ - `config.structured_data` (default `true`; `false` turns the JSON-LD off). Part of `render_digest`, so changing it refreshes cached pages.
34
+
35
+ ### Upgrading
36
+
37
+ - 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 still work (`:shiplog_og` now also carries the `config.og_image` fallback image), 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.
38
+ - 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.
39
+
3
40
  ## 0.3.0 (2026-10-26)
4
41
 
5
42
  ### Added
data/README.md CHANGED
@@ -113,9 +113,11 @@ All settings live in `config/initializers/shiplog.rb`:
113
113
  | `extra_pages` | `[]` | Pages added to the switcher after Roadmap and Changelog: `[{ label:, path: }]`, each a String or a callable evaluated in the view. See [Product updates navigation](#product-updates-navigation). Anything else raises `ArgumentError`. |
114
114
  | `repository_labels` | `{}` | Tag shown on a roadmap card for the repository of its issue: `{ "owner/name" => "iPhone & iPad" }`. Values are Strings or callables evaluated in the view; repositories without one show no tag. |
115
115
  | `roadmap_shipped_count` | `4` | How many recently shipped changelog entries (features and changes) the roadmap lists below its columns. `0` hides the section. |
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. |
116
+ | `cache_version` | `nil` | A String added to the public pages' ETag. Callables (`extra_pages`, `repository_labels`, `feedback_path`, `og_image`) cannot be fingerprinted, so bump it when the output of a callable you configured changes. Also bump it when you change your layout's head or override a `shiplog/head/*` partial: ETags don't cover layouts. |
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`. Only its presence is part of the ETag, not its output: 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,58 @@ 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. Call it in the layout, not inside a component (ViewComponent, Phlex): in a view that doesn't have Shiplog's helpers it also returns an empty string. `<title>` stays yours: it is `content_for(:title)`.
419
+
420
+ After changing your layout's `<head>` or overriding a `shiplog/head/*` partial, bump `config.cache_version`: ETags don't cover layouts, so visitors with a cached page would keep the old tags.
421
+
422
+ - **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.
423
+ - **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.
424
+ - **`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`.
425
+ - **`except: :canonical` and `og:url`.** `except: :canonical` drops only the `<link rel="canonical">`. `og:url` keeps Shiplog's canonical, which honours `config.canonical_url`; to change it, drop `:open_graph` too. A host that writes its own canonical should make it agree with that one, and should not emit a canonical on search pages, where Shiplog sets `noindex`: Shiplog fills `content_for(:shiplog_robots)` with `"noindex"` there (and leaves it empty elsewhere), which your layout can check.
426
+ - **Overridable partials.** Copy one into your app at the same path to change it (like the other [partials](#4-override-the-partials)):
427
+
428
+ | Partial | Renders |
429
+ |---|---|
430
+ | `shiplog/_head_tags.html.erb` | renders the four below |
431
+ | `shiplog/head/_meta.html.erb` | `description`, canonical link and `robots` |
432
+ | `shiplog/head/_open_graph.html.erb` | `og:*` and `twitter:*` |
433
+ | `shiplog/head/_feed.html.erb` | the Atom `<link rel="alternate">` |
434
+ | `shiplog/head/_structured_data.html.erb` | the JSON-LD `<script>` |
435
+
436
+ 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.
437
+ - **The share image.** The permalink page's image is the first screenshot whose URL can be built (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. `Article.image` of the JSON-LD lists all the screenshots with a URL, else that same callable image. 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`.
438
+ - **The older `content_for` blocks** (`:shiplog_description`, `:shiplog_og`, `:shiplog_feed`, `:shiplog_robots`, `:shiplog_structured_data`) are still filled as in 0.3.0 and still work, but are superseded. The one difference: `:shiplog_og` now includes the `config.og_image` fallback image when that is set and the entry has no screenshot. 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
439
 
407
440
  ## Atom feed
408
441
 
409
442
  `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
443
 
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`.
444
+ 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
445
 
413
446
  ### Canonical URL
414
447
 
@@ -9,8 +9,14 @@ 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
 
16
+ # Marks the controllers of the public pages, for shiplog_head_tags: checking this instead of the class keeps
17
+ # host pages from autoloading Shiplog::PublicController (and the host's parent controller).
18
+ def shiplog_public_page? = true
19
+
14
20
  private
15
21
 
16
22
  # Calls config.on_changelog_seen for a visit to the changelog index or a permalink page. It runs before
@@ -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, :entry, :canonical, :image, :description), `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
@@ -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,46 @@ 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 with a URL, 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)
59
- image = shiplog_images(entry).first
60
- shiplog_image_url(image) if image
62
+ @shiplog_og_images ||= {}.compare_by_identity
63
+ return @shiplog_og_images[entry] if @shiplog_og_images.key?(entry)
64
+
65
+ # Same rule as the JSON-LD Article image: screenshots whose URL can't be built are skipped.
66
+ screenshot = shiplog_images(entry).lazy.filter_map { |image| shiplog_image_url(image) }.first
67
+ @shiplog_og_images[entry] = screenshot || shiplog_configured_og_image(entry)
68
+ end
69
+
70
+ # What config.og_image gives for an entry (called in the view with the entry and the locale), as an absolute
71
+ # http(s) URL. Nil, after a log line, when it raises or returns something else.
72
+ def shiplog_configured_og_image(entry)
73
+ callable = Shiplog.config.og_image
74
+ return unless callable
75
+
76
+ value = callable.is_a?(Proc) ? instance_exec(entry, I18n.locale, &callable) : callable.call(entry, I18n.locale)
77
+ url = shiplog_absolute_http_url(value)
78
+ 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
79
+ url
80
+ rescue StandardError => error
81
+ Rails.logger.error("[shiplog] config.og_image failed for entry #{entry.id}: #{error.class}: #{error.message}")
82
+ nil
83
+ end
84
+
85
+ # An http(s) URL as is, a path ("/cards/a.png", not "//host") made absolute with config.canonical_url or the
86
+ # request's origin; nil for anything else (javascript:, data:, blank, spaces, non-strings).
87
+ def shiplog_absolute_http_url(value)
88
+ return unless value.is_a?(String)
89
+
90
+ value = value.strip
91
+ return if value.match?(/[[:space:][:cntrl:]]/)
92
+
93
+ value = "#{Shiplog.config.canonical_url || request.base_url}#{value}" if value.match?(%r{\A/(?![/\\])})
94
+ uri = URI.parse(value)
95
+ value if uri.is_a?(URI::HTTP) && uri.host.present?
96
+ rescue URI::InvalidURIError
97
+ nil
61
98
  end
62
99
 
63
100
  # 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,13 +34,21 @@ 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
40
47
  # the newest entry. Nil by default. Not part of the ETag: it doesn't change the markup.
41
48
  attr_reader :on_changelog_seen
42
- # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path):
43
- # callables cannot be part of the ETag, so this is how cached public pages learn about it.
49
+ # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path,
50
+ # og_image): callables cannot be part of the ETag, so this is how cached public pages learn about it. Also
51
+ # bump it after changing the layout's head or a shiplog/head/* partial: ETags don't cover layouts.
44
52
  attr_accessor :cache_version
45
53
  attr_reader :extra_pages, :repository_labels, :roadmap_shipped_count
46
54
  # How many product areas the AI may create (default 8). Past it, entries can only join existing areas.
@@ -70,7 +78,7 @@ module Shiplog
70
78
  stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
71
79
  extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
72
80
  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
81
+ stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image)
74
82
  ]
75
83
  Digest::SHA256.hexdigest(JSON.generate(data))
76
84
  end
@@ -156,6 +164,11 @@ module Shiplog
156
164
  @canonical_url = normalize_canonical_url(value)
157
165
  end
158
166
 
167
+ def og_image=(value)
168
+ raise ArgumentError, "og_image must be nil or a callable like ->(entry, locale) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
169
+ @og_image = value
170
+ end
171
+
159
172
  def on_changelog_seen=(value)
160
173
  raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
161
174
  @on_changelog_seen = value
@@ -227,6 +240,7 @@ module Shiplog
227
240
  @repository_labels = {}.freeze
228
241
  @roadmap_shipped_count = 4
229
242
  @changelog_search = true
243
+ @structured_data = true
230
244
  @max_areas = 8
231
245
  @images = true
232
246
  @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
@@ -0,0 +1,34 @@
1
+ module Shiplog
2
+ # Constants of shiplog_head_tags. Not included anywhere, so views of the host cannot see them.
3
+ module HeadTags
4
+ EXCEPT_KEYS = %i[description canonical robots open_graph twitter feed structured_data].freeze
5
+ end
6
+
7
+ # The one helper added to every view of the host (like a pagination gem's `paginate`), so a layout shared
8
+ # with the host's own pages can call `shiplog_head_tags`. The rest lives in Shiplog::HeadHelper, which only
9
+ # the engine's controllers include.
10
+ #
11
+ # This file is required by lib/shiplog.rb rather than autoloaded: the engine includes the module into
12
+ # ActionView::Base from an on_load hook, which can run during boot before the autoloaders are set up, and
13
+ # ActionView::Base never reloads, so the module must not either.
14
+ module HeadTagsHelper
15
+ # The head tags of the current Shiplog page; an empty string on any other page. `except:` leaves groups
16
+ # out, for tags your layout already writes: :description, :canonical, :robots, :open_graph, :twitter,
17
+ # :feed, :structured_data. An unknown key raises ArgumentError.
18
+ #
19
+ # Call it in the layout, not inside a component (ViewComponent, Phlex): there it gives an empty string.
20
+ # Use it OR the older `content_for :shiplog_*` blocks in a layout, not both: both would emit the tags twice.
21
+ def shiplog_head_tags(except: [])
22
+ keys = Array(except).map { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
23
+ unknown = keys - HeadTags::EXCEPT_KEYS
24
+ if unknown.any?
25
+ raise ArgumentError, "Unknown shiplog_head_tags except: key(s) #{unknown.map(&:inspect).join(", ")}. Valid keys: #{HeadTags::EXCEPT_KEYS.map(&:inspect).join(", ")}"
26
+ end
27
+ # A marker, not is_a?(Shiplog::PublicController): that would autoload the controller (and the host's
28
+ # parent controller) on every host page. Views that don't have the engine's helper (components) skip too.
29
+ return "".html_safe unless controller.respond_to?(:shiplog_public_page?) && respond_to?(:shiplog_render_head_tags, true)
30
+
31
+ shiplog_render_head_tags(keys)
32
+ end
33
+ end
34
+ end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.2"
3
3
  end
data/lib/shiplog.rb CHANGED
@@ -4,6 +4,7 @@ require "shiplog/configuration"
4
4
  require "shiplog/github/errors"
5
5
  require "shiplog/github/queries"
6
6
  require "shiplog/github/client"
7
+ require "shiplog/head_tags_helper"
7
8
  require "shiplog/engine"
8
9
  require "shiplog/admin/engine"
9
10
 
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Trackberry
@@ -72,9 +72,11 @@ 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
75
76
  - app/helpers/shiplog/host_routes_helper.rb
76
77
  - app/helpers/shiplog/permalink_helper.rb
77
78
  - app/helpers/shiplog/roadmap_helper.rb
79
+ - app/helpers/shiplog/structured_data_helper.rb
78
80
  - app/helpers/shiplog/styling_helper.rb
79
81
  - app/helpers/shiplog/switcher_helper.rb
80
82
  - app/helpers/shiplog/unseen_helper.rb
@@ -125,6 +127,7 @@ files:
125
127
  - app/views/layouts/shiplog/admin.html.erb
126
128
  - app/views/layouts/shiplog/application.html.erb
127
129
  - app/views/shiplog/_empty.html.erb
130
+ - app/views/shiplog/_head_tags.html.erb
128
131
  - app/views/shiplog/_page.html.erb
129
132
  - app/views/shiplog/_page_header.html.erb
130
133
  - app/views/shiplog/_pagination.html.erb
@@ -151,6 +154,10 @@ files:
151
154
  - app/views/shiplog/changelogs/_month.html.erb
152
155
  - app/views/shiplog/changelogs/_search.html.erb
153
156
  - app/views/shiplog/changelogs/show.html.erb
157
+ - app/views/shiplog/head/_feed.html.erb
158
+ - app/views/shiplog/head/_meta.html.erb
159
+ - app/views/shiplog/head/_open_graph.html.erb
160
+ - app/views/shiplog/head/_structured_data.html.erb
154
161
  - app/views/shiplog/roadmaps/_card.html.erb
155
162
  - app/views/shiplog/roadmaps/_column.html.erb
156
163
  - app/views/shiplog/roadmaps/_shipped.html.erb
@@ -173,6 +180,7 @@ files:
173
180
  - lib/shiplog/github/client.rb
174
181
  - lib/shiplog/github/errors.rb
175
182
  - lib/shiplog/github/queries.rb
183
+ - lib/shiplog/head_tags_helper.rb
176
184
  - lib/shiplog/version.rb
177
185
  - lib/tasks/shiplog.rake
178
186
  homepage: https://github.com/Trackberry/shiplog