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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +47 -14
- data/app/controllers/shiplog/public_controller.rb +6 -0
- data/app/helpers/shiplog/head_helper.rb +95 -0
- data/app/helpers/shiplog/permalink_helper.rb +44 -7
- data/app/helpers/shiplog/structured_data_helper.rb +74 -0
- data/app/views/layouts/shiplog/application.html.erb +1 -4
- data/app/views/shiplog/_head_tags.html.erb +5 -0
- data/app/views/shiplog/changelog_entries/show.html.erb +1 -3
- data/app/views/shiplog/changelogs/show.html.erb +4 -5
- data/app/views/shiplog/head/_feed.html.erb +1 -0
- data/app/views/shiplog/head/_meta.html.erb +3 -0
- data/app/views/shiplog/head/_open_graph.html.erb +6 -0
- data/app/views/shiplog/head/_structured_data.html.erb +1 -0
- data/app/views/shiplog/roadmaps/show.html.erb +4 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +16 -3
- data/lib/shiplog/configuration.rb +17 -3
- data/lib/shiplog/engine.rb +5 -0
- data/lib/shiplog/head_tags_helper.rb +34 -0
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +1 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a624887b92e388e7234f1c9051437c7c8732b624f448ae4ed6a50834668835b3
|
|
4
|
+
data.tar.gz: 1a0f18b1f55b919bf9fcf04da75379666428a49fd312c75ea55c6d064e613959
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
<
|
|
399
|
-
|
|
400
|
-
<%=
|
|
401
|
-
|
|
412
|
+
<head>
|
|
413
|
+
<title><%= content_for(:title) %></title>
|
|
414
|
+
<%= shiplog_head_tags %>
|
|
415
|
+
</head>
|
|
402
416
|
```
|
|
403
417
|
|
|
404
|
-
|
|
405
|
-
|
|
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">` (`
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
<%
|
|
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
|
-
<%
|
|
2
|
-
<%
|
|
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 @@
|
|
|
1
|
+
<%= json_ld if json_ld.present? %>
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
<%
|
|
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.
|
|
139
|
-
|
|
140
|
-
#
|
|
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
|
data/lib/shiplog/engine.rb
CHANGED
|
@@ -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
|
data/lib/shiplog/version.rb
CHANGED
data/lib/shiplog.rb
CHANGED
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.
|
|
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
|