shiplog 0.1.1 → 0.2.0
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 +38 -0
- data/README.md +126 -4
- data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
- data/app/controllers/shiplog/changelogs_controller.rb +11 -1
- data/app/controllers/shiplog/public_controller.rb +3 -1
- data/app/controllers/shiplog/roadmaps_controller.rb +25 -2
- data/app/frontend/shiplog/shiplog.css +121 -6
- data/app/helpers/shiplog/callable_helper.rb +12 -0
- data/app/helpers/shiplog/frontend_helper.rb +1 -0
- data/app/helpers/shiplog/roadmap_helper.rb +15 -0
- data/app/helpers/shiplog/switcher_helper.rb +101 -0
- data/app/jobs/shiplog/refresh_job.rb +1 -0
- data/app/jobs/shiplog/scan_changes_job.rb +19 -5
- data/app/jobs/shiplog/sync_job.rb +1 -1
- data/app/models/shiplog/change_candidates.rb +39 -4
- data/app/models/shiplog/entry.rb +17 -0
- data/app/models/shiplog/pull_request.rb +4 -0
- data/app/models/shiplog/pull_request_upgrade.rb +5 -0
- data/app/models/shiplog/rewriter/prompt.rb +27 -3
- data/app/models/shiplog/rewriter.rb +20 -1
- data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
- data/app/models/shiplog/sync/repository_sync.rb +2 -0
- data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
- data/app/views/layouts/shiplog/application.html.erb +1 -0
- data/app/views/shiplog/_page_header.html.erb +2 -1
- data/app/views/shiplog/_switcher.html.erb +13 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +1 -0
- data/app/views/shiplog/changelogs/_search.html.erb +16 -0
- data/app/views/shiplog/changelogs/show.html.erb +11 -3
- data/app/views/shiplog/roadmaps/_card.html.erb +12 -1
- data/app/views/shiplog/roadmaps/_column.html.erb +2 -2
- data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
- data/app/views/shiplog/roadmaps/show.html.erb +9 -0
- data/config/locales/en.yml +20 -0
- data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
- data/lib/generators/shiplog/install/install_generator.rb +20 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +37 -0
- data/lib/shiplog/classes.rb +20 -1
- data/lib/shiplog/configuration.rb +155 -2
- data/lib/shiplog/github/client.rb +42 -9
- data/lib/shiplog/github/errors.rb +3 -0
- data/lib/shiplog/github/queries.rb +32 -1
- data/lib/shiplog/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 429880b5cc28e18781af480d464269bbd82a117a76b23746ef1c2e9c2309a6e2
|
|
4
|
+
data.tar.gz: 418bd44307404a7f9404ae7aed1b444b9c738333b09490cd64027b85a455e231
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7eacb4b715e25cd9899852abef7d4a8e08dc9b18b9ca1e5f9085193e0447158472a82f049d66d66f8da18d12dbed092609248e0b5f5320ac9b07e78174163ac7
|
|
7
|
+
data.tar.gz: 4760676c3cfc7fde8d11ad80423c5e9626a581624913c5950986ca8b8ff1708568c4af179f4b06e7ddd9150425cb26fbe41ed29b70d171112c0df9021347ea7d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- A Roadmap / Changelog switcher in the default page header. Add your own pages with `config.extra_pages` (`[{ label:, path: }]`, each a String or a callable evaluated in the view), and render the same switcher on host pages with `helper Shiplog::SwitcherHelper` and `shiplog_switcher`.
|
|
8
|
+
- Roadmap status by default: a rule and marker per column, a card tag with the issue's repository (`config.repository_labels`), "Includes N parts" on cards, a "Recently shipped" section (`config.roadmap_shipped_count`, default 4, `0` hides it) and an optional footnote with a "Suggest a feature" link (`shiplog.roadmap.footnote`, `config.feedback_path`).
|
|
9
|
+
- New `config.classes` keys: `switcher`, `switcher_link`, `column_marker`, `card_meta`, `card_tag`, `shipped`, `shipped_title`, `shipped_list`, `shipped_item`, `shipped_date`, `shipped_link`, `footnote`, `footnote_link`.
|
|
10
|
+
- Per-repository options in `config.repositories`: `{ name:, changelog: false, gem: }`. `changelog: false` keeps a repository on the roadmap only. `gem:` names the gem in your lockfile so that upgrading it produces a single changelog entry, written from the gem's own merged pull requests, instead of one entry per repository. `config.lockfile_path` changes the lockfile that is read.
|
|
11
|
+
- A Dependabot author, a `chore(deps)` title or the `dependencies` label no longer excludes a pull request that carries gem changes; other excluded labels, such as `internal`, still do.
|
|
12
|
+
- `config.cache_version`, to refresh cached public pages when the output of a configured callable changes.
|
|
13
|
+
- Search on the public changelog: a GET form (`/changelog?q=csv`) that lists approved features and changes whose title or body contain every word of the query, with a result count, a "Clear search" link and pagination that keeps `q`. Turn it off with `config.changelog_search = false`; override `shiplog/changelogs/_search.html.erb` to change it. New `config.classes` keys: `search`, `search_input`, `search_button`, `search_summary`, `search_clear`, `search_empty`. Search pages set `content_for :shiplog_robots, "noindex"` (emitted as a robots meta tag by Shiplog's own layout; add it to your layout, see the README).
|
|
14
|
+
- A Discard button on draft rows of the admin inbox, next to Edit and Approve. It updates the list in place; give a reason from the entry's page.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Roadmap drafts are shorter (about two sentences) and lead with what the customer gets, instead of opening with "We're working on".
|
|
19
|
+
- The roadmap's ETag also covers its recently shipped entries and each card's repository and sub-issues, so those changes reach cached pages.
|
|
20
|
+
- The migration `create_shiplog_pull_request_upgrades` also adds an index on `shiplog_entries (status, released_on)`.
|
|
21
|
+
- Gem upgrade detection: a failure on one pull request no longer blocks the others or keeps the scan window from advancing. Temporary GitHub errors are retried and given up after three attempts; other errors give up at once, with a warning. Pull requests merged before the next scan's start are marked checked without being fetched.
|
|
22
|
+
- Only the last commit's lockfile change is seen for a rebase merge; Dependabot's single-commit pull requests are unaffected.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The scan window could move back to the oldest pending lockfile pull request after a first sync of a long history, drafting every old pull request and commit in that span. It now never ends before its start.
|
|
27
|
+
- Hosts that mount the engine with another name (`as: :updates`) no longer get a `NameError` from the switcher.
|
|
28
|
+
- The public pages' ETag used a per-process `Hash#hash`, so workers of a multi-process host answered with different ETags for the same page. It is now a stable digest.
|
|
29
|
+
|
|
30
|
+
### Upgrade notes
|
|
31
|
+
|
|
32
|
+
- Copy and run the new migration: `bin/rails shiplog:install:migrations`, then `bin/rails db:migrate`. Run it before the job workers start on the new code, because the importer writes the new columns.
|
|
33
|
+
- New I18n keys, all with English defaults, so a locale without translations shows English: `shiplog.switcher.label`, `.roadmap`, `.changelog`; `shiplog.roadmap.parts` (with `count`), `.shipped_heading`, `.shipped_date_format`, `.full_changelog`, `.suggest`; `shiplog.changelog.search.label`, `.submit`, `.summary` (with `count` and `query`), `.clear`, `.empty` (with `query`).
|
|
34
|
+
- The default `shiplog/_page_header` now renders the switcher. Hosts that override `_page_header` keep theirs; render `shiplog_switcher` (or the `shiplog/switcher` partial) in it to get the links.
|
|
35
|
+
- If your app overrides `roadmaps/show`, `_column` or `_card` only to add column status, card tags or recently shipped, delete them and use the new defaults and `config.classes` keys.
|
|
36
|
+
- Style the new elements with the new class keys above if you use `config.classes` with `config.stylesheet = false`.
|
|
37
|
+
- `extra_pages` paths must be absolute (`/…`), `http(s)://` or `mailto:`. A `feedback_path` that is not one of these renders no link.
|
|
38
|
+
- Repository names are validated: anything other than `owner/name` raises `ArgumentError` when `config.repositories` is assigned.
|
|
39
|
+
- For gem upgrades the GitHub token needs read access to **Contents** and **Pull requests** on the gem repositories. Only pull requests synced by 0.2.0 or later are inspected; earlier merges are not backfilled.
|
|
40
|
+
|
|
3
41
|
## 0.1.1 (2026-10-26)
|
|
4
42
|
|
|
5
43
|
### Added
|
data/README.md
CHANGED
|
@@ -82,7 +82,8 @@ All settings live in `config/initializers/shiplog.rb`:
|
|
|
82
82
|
|
|
83
83
|
| Key | Default | Description |
|
|
84
84
|
|---|---|---|
|
|
85
|
-
| `repositories` | `[]` | GitHub repositories to sync, as `"owner/name"
|
|
85
|
+
| `repositories` | `[]` | GitHub repositories to sync, as `"owner/name"` or a Hash with options. Sub-issues may cross repositories of the same owner. See [Repositories](#repositories). |
|
|
86
|
+
| `lockfile_path` | `"Gemfile.lock"` | Lockfile read in merged pull requests to detect [gem upgrades](#gem-upgrades). Change it for a monorepo. |
|
|
86
87
|
| `github_token` | `nil` | A String, or a callable evaluated on every GitHub request. |
|
|
87
88
|
| `webhook_secret` | `nil` | Secret of the GitHub webhook. While blank, the webhook endpoint answers 503. |
|
|
88
89
|
| `public_label` | `"public"` | Only issues with this label reach the roadmap. |
|
|
@@ -107,6 +108,12 @@ All settings live in `config/initializers/shiplog.rb`:
|
|
|
107
108
|
| `current_admin` | `->(controller) { controller.try(:current_user)&.try(:email) }` | Stored as the approver of an entry. |
|
|
108
109
|
| `roadmap_path` | `"roadmap"` | Path of the public roadmap. |
|
|
109
110
|
| `changelog_path` | `"changelog"` | Path of the public changelog. |
|
|
111
|
+
| `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`. |
|
|
112
|
+
| `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. |
|
|
113
|
+
| `roadmap_shipped_count` | `4` | How many recently shipped changelog entries (features and changes) the roadmap lists below its columns. `0` hides the section. |
|
|
114
|
+
| `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. |
|
|
115
|
+
| `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`. |
|
|
116
|
+
| `changelog_search` | `true` | Search box on the public changelog and `?q=` filtering. `false` hides the form and ignores `q`. See [Changelog search](#changelog-search). |
|
|
110
117
|
|
|
111
118
|
Example:
|
|
112
119
|
|
|
@@ -121,6 +128,50 @@ Shiplog.configure do |config|
|
|
|
121
128
|
end
|
|
122
129
|
```
|
|
123
130
|
|
|
131
|
+
## Repositories
|
|
132
|
+
|
|
133
|
+
Each entry of `config.repositories` is a String (`"owner/name"`) or a Hash:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
config.repositories = [
|
|
137
|
+
"acme/app",
|
|
138
|
+
"acme/app-ios",
|
|
139
|
+
{ name: "acme/market_data", changelog: false, gem: "market_data" },
|
|
140
|
+
{ name: "acme/produce", changelog: false, gem: "acme-produce" }
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Option | Default | Description |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `name` | required | `"owner/name"`. |
|
|
147
|
+
| `changelog` | `true` | `false` makes the repository roadmap-only: its issues still feed the roadmap, but its pull requests and commits never become changelog entries. |
|
|
148
|
+
| `gem` | `nil` | The gem's name in your `Gemfile.lock`, for [gem upgrades](#gem-upgrades). |
|
|
149
|
+
|
|
150
|
+
Unknown options and invalid values raise `ArgumentError` when you assign `repositories`, and so does a name that is not `owner/name` (letters, digits, `.`, `_` and `-`; a segment cannot be only dots). A repository with both `gem:` and `changelog: true` is accepted, but a warning is logged: its changes would be drafted twice, from its own pull requests and from the upgrade. Set `changelog: false`.
|
|
151
|
+
|
|
152
|
+
`gem:` must be set explicitly because gem names and repository names differ (`acme-produce` lives in `acme/produce`), and a public gem can share a name with one of your repositories without being it: the `holidays` gem from RubyGems is not `acme/holidays`. Matching on names would attach someone else's changes to your changelog, so only gems listed with `gem:` are tracked.
|
|
153
|
+
|
|
154
|
+
`config.lockfile_path` (default `"Gemfile.lock"`) is the lockfile Shiplog reads in the repositories where `changelog` is true.
|
|
155
|
+
|
|
156
|
+
## Gem upgrades
|
|
157
|
+
|
|
158
|
+
A change made in a gem you depend on should appear in the changelog once, when your customers get it: the day your app upgraded the gem. Shiplog does this with the repositories you listed with `gem:`.
|
|
159
|
+
|
|
160
|
+
For each merged pull request in a repository with `changelog: true` that changes the lockfile:
|
|
161
|
+
|
|
162
|
+
1. Shiplog reads the lockfile at the merge commit's first parent and at the merge commit itself, and compares the tracked gems with `Bundler::LockfileParser`.
|
|
163
|
+
2. For a gem whose version changed it resolves the tag in the gem's repository (`v<version>` and `<version>`, trying the matching styles first and then the mixed ones). For a git source it uses the locked revision. Shiplog then compares the two refs.
|
|
164
|
+
3. It lists the pull requests merged between the two refs (GitHub's compare API, then the pull requests associated with those commits, the newest 50) and stores them with the upgrade.
|
|
165
|
+
4. The pull request gets one change draft, dated at its merge. The gem's changes (up to 20 titles with excerpts) go into the rewrite prompt, and the rewriter writes one customer-facing entry, or marks it not customer-facing.
|
|
166
|
+
|
|
167
|
+
A Dependabot bump, a `chore(deps):` title or the `dependencies` label does not exclude a pull request that carries an upgrade with changes; without one it is skipped as usual. Other excluded labels (such as `internal`) still exclude it. If a tag cannot be found, or the gem's repository is not readable by the token, the upgrade is recorded without changes and a warning is logged; the rewriter then works from the pull request alone.
|
|
168
|
+
|
|
169
|
+
The GitHub token needs read access to the gem repositories as well (**Contents** and **Pull requests**). Detection is capped per sync (50 pull requests), so a large repository's first sync finishes over several syncs.
|
|
170
|
+
|
|
171
|
+
Only pull requests synced by 0.2.0 or later are inspected: merges imported earlier are not backfilled, and their gem changes are not turned into entries. Pull requests merged before the next scan's start (the last scan, or seven days ago for a repository never scanned) are marked checked without being fetched, since nothing that old would be drafted. A pull request whose inspection fails with a temporary GitHub error is retried on the next syncs and given up after three attempts; any other error gives it up at once, with a warning.
|
|
172
|
+
|
|
173
|
+
**Rebase merges.** Shiplog reads the lockfile at the merge commit and at its first parent. For a rebase merge, GitHub creates no merge commit: the pull request's "merge commit" is the last rebased commit, so only that commit's lockfile change is seen. Dependabot's pull requests are a single commit, so they are unaffected; a rebased branch that bumps a gem in an earlier commit is missed.
|
|
174
|
+
|
|
124
175
|
## GitHub token
|
|
125
176
|
|
|
126
177
|
Shiplog talks to the GitHub GraphQL API. Provide either:
|
|
@@ -226,6 +277,57 @@ Approved changelog entries do not react to later GitHub changes (a reverted pull
|
|
|
226
277
|
|
|
227
278
|
Drafts whose rewrite job was lost (no result and no error after an hour) are queued again by the next sync, and the admin marks them "Not rewritten yet" until then.
|
|
228
279
|
|
|
280
|
+
## Product updates navigation
|
|
281
|
+
|
|
282
|
+
The default page header shows a switcher under the lede: **Roadmap**, **Changelog**, then `config.extra_pages`. The current page carries `aria-current="page"`: of the pages whose path equals or contains the request path, the one with the longest path is current. Add your own pages, for example a news section:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
config.extra_pages = [
|
|
286
|
+
{ label: "News", path: "/learn/news" },
|
|
287
|
+
# Callables are evaluated in the view, so route helpers and I18n work:
|
|
288
|
+
{ label: -> { t("pages.docs") }, path: -> { main_app.docs_path } }
|
|
289
|
+
]
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
To show the same switcher on your own pages, add the helper to the controller and call it in the view. It links the stylesheet (unless `config.stylesheet` is `false`) and finds the engine's route proxy in your routes whatever name you gave the mount (`as: :updates`), so it works in main-app views. It raises an error if `Shiplog::Engine` is not mounted:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
class NewsController < ApplicationController
|
|
296
|
+
helper Shiplog::SwitcherHelper
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```erb
|
|
301
|
+
<%= shiplog_switcher %>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
A `path` String must start with `/` (not `//`, `/\` or `/` followed by whitespace), `http://`, `https://` or `mailto:`, and contain no control characters; anything else, such as `"news"`, raises `ArgumentError` when you assign `extra_pages`. An absolute `http(s)` page is marked current only on its own host (`request.host`). A callable that returns another scheme renders the label as text, without a link, and logs a warning. `feedback_path` follows the same rule at render time: an unsafe value shows no link.
|
|
305
|
+
|
|
306
|
+
Labels come from `shiplog.switcher.roadmap`, `.changelog` and `.label` (the `nav` label); every Shiplog key has an English default, so a locale without translations shows English. Style it with the `switcher` and `switcher_link` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks); the current link also has `shiplog-switcher__link--current`. Hosts that override `shiplog/_page_header` keep their own header; render `shiplog_switcher` or the `shiplog/switcher` partial in it to get the links.
|
|
307
|
+
|
|
308
|
+
### Roadmap details
|
|
309
|
+
|
|
310
|
+
- **Status:** each column has a rule on top and a marker (`shiplog-column__marker--in-progress` / `--coming-soon` / `--later`).
|
|
311
|
+
- **Card meta:** the label of the issue's repository (`config.repository_labels`) and "Includes N parts" when the issue has more than one sub-issue (`shiplog.roadmap.parts`). A card with neither has no meta element.
|
|
312
|
+
- **Recently shipped:** the last `config.roadmap_shipped_count` approved feature and change entries (default 4), each linking to its month on the changelog, plus a "See the full changelog" link. The roadmap's ETag follows changelog approvals.
|
|
313
|
+
- **Footnote:** set the `shiplog.roadmap.footnote` translation (empty by default, nothing renders while it is empty) and `config.feedback_path` for a "Suggest a feature" link (`shiplog.roadmap.suggest`).
|
|
314
|
+
|
|
315
|
+
## Changelog search
|
|
316
|
+
|
|
317
|
+
The public changelog has a search form above its months: `GET /changelog?q=csv` lists the approved features and changes whose title or body contain every word of the query (case-insensitive `ILIKE`, so PostgreSQL; `%`, `_` and `\` are matched literally). Only the first 5 words of the first 100 characters count. Results keep the changelog's order and month grouping and are paginated; the pagination links keep `q`. With a query the page shows "N results for “csv”" and a "Clear search" link, or a "no entries match" message. A blank `q` is the same as no `q`. The form is a plain GET form (`role="search"`, a `type="search"` input named `q` with an `aria-label`), so it needs no JavaScript.
|
|
318
|
+
|
|
319
|
+
- Turn it off with `config.changelog_search = false`: no form, and `q` is ignored.
|
|
320
|
+
- Override `shiplog/changelogs/_search.html.erb` (locals `query:` and `count:`, both `nil` without a query) to change the markup. Style it with the `search*` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks).
|
|
321
|
+
- Texts: `shiplog.changelog.search.label`, `.submit`, `.summary` (`one` / `other`, with `count` and `query`), `.clear` and `.empty` (with `query`); all have English defaults.
|
|
322
|
+
- The ETag includes the normalised query.
|
|
323
|
+
- The model scope is `Shiplog::Entry.search(query)`.
|
|
324
|
+
|
|
325
|
+
**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:
|
|
326
|
+
|
|
327
|
+
```erb
|
|
328
|
+
<%= tag.meta name: "robots", content: content_for(:shiplog_robots) if content_for?(:shiplog_robots) %>
|
|
329
|
+
```
|
|
330
|
+
|
|
229
331
|
## Styling
|
|
230
332
|
|
|
231
333
|
The public pages ship plain semantic HTML with stable [BEM](https://getbem.com) classes (`shiplog-card`, `shiplog-entry__title`, ...) and a small, framework-free stylesheet. Pick the level that fits your app; the same views work in all of them.
|
|
@@ -300,9 +402,12 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
|
|
|
300
402
|
| `page_header` | `shiplog-page__header` | Header with title and lede |
|
|
301
403
|
| `page_title` | `shiplog-page__title` | `h1` |
|
|
302
404
|
| `page_lede` | `shiplog-page__lede` | Intro paragraph |
|
|
405
|
+
| `switcher` | `shiplog-switcher` | Switcher `nav` (Roadmap, Changelog, extra pages) |
|
|
406
|
+
| `switcher_link` | `shiplog-switcher__link` (+ `--current`) | One switcher link |
|
|
303
407
|
| `columns` | `shiplog-columns` | Roadmap column grid |
|
|
304
408
|
| `column` | `shiplog-column` (+ `--in-progress` / `--coming-soon` / `--later`) | One roadmap column |
|
|
305
409
|
| `column_header` | `shiplog-column__header` | Column heading block |
|
|
410
|
+
| `column_marker` | `shiplog-column__marker` (+ `--in-progress` / `--coming-soon` / `--later`) | Status dot inside the column `h2`. The column's rule is its `border-top`, set by the column modifier (`shiplog-column--later`); with utility classes, target it with a variant such as `in-[.shiplog-column--later]:bg-gray-300`. |
|
|
306
411
|
| `column_title` | `shiplog-column__title` | Column `h2` |
|
|
307
412
|
| `column_count` | `shiplog-column__count` | Number of items |
|
|
308
413
|
| `column_description` | `shiplog-column__description` | One-line description |
|
|
@@ -311,6 +416,16 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
|
|
|
311
416
|
| `card` | `shiplog-card` | Roadmap card |
|
|
312
417
|
| `card_title` | `shiplog-card__title` | Card `h3` |
|
|
313
418
|
| `card_body` | `shiplog-card__body` | Card text |
|
|
419
|
+
| `card_meta` | `shiplog-card__meta` | Line under the card text (omitted when empty) |
|
|
420
|
+
| `card_tag` | `shiplog-card__tag` | Repository label |
|
|
421
|
+
| `shipped` | `shiplog-shipped` | "Recently shipped" section |
|
|
422
|
+
| `shipped_title` | `shiplog-shipped__title` | Its `h2` |
|
|
423
|
+
| `shipped_list` | `shiplog-shipped__list` | Its list |
|
|
424
|
+
| `shipped_item` | `shiplog-shipped__item` | One entry |
|
|
425
|
+
| `shipped_date` | `shiplog-shipped__date` | Release date of the entry |
|
|
426
|
+
| `shipped_link` | `shiplog-shipped__link` (+ `--all` on the changelog link) | Links to the changelog |
|
|
427
|
+
| `footnote` | `shiplog-footnote` | Footnote paragraph |
|
|
428
|
+
| `footnote_link` | `shiplog-footnote__link` | "Suggest a feature" link |
|
|
314
429
|
| `months` | `shiplog-months` | Changelog months wrapper |
|
|
315
430
|
| `month` | `shiplog-month` | One month section |
|
|
316
431
|
| `month_title` | `shiplog-month__title` | Month `h2` |
|
|
@@ -324,16 +439,23 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
|
|
|
324
439
|
| `pagination` | `shiplog-pagination` | Pagination `nav` |
|
|
325
440
|
| `pagination_link` | `shiplog-pagination__link` (+ `--newer` / `--older`) | Newer/older links |
|
|
326
441
|
| `empty` | `shiplog-empty` | "Nothing here yet" notice |
|
|
442
|
+
| `search` | `shiplog-search` | Changelog search `form` |
|
|
443
|
+
| `search_input` | `shiplog-search__input` | The `type="search"` input |
|
|
444
|
+
| `search_button` | `shiplog-search__button` | Submit button |
|
|
445
|
+
| `search_summary` | `shiplog-search__summary` | "N results for ..." line |
|
|
446
|
+
| `search_clear` | `shiplog-search__clear` | "Clear search" link |
|
|
447
|
+
| `search_empty` | `shiplog-search__empty` | "No entries match" notice |
|
|
327
448
|
|
|
328
|
-
Values may be strings or arrays. The list is `Shiplog::Classes::KEYS`; assigning `config.classes` raises `ArgumentError` (listing the valid keys) for any other key, in every environment. String keys are accepted and normalised to symbols, and the hash is stored frozen. Texts come from I18n (`shiplog.roadmap.title`, `shiplog.roadmap.lede`, `shiplog.changelog.*`, `shiplog.categories.feature` / `change`, `shiplog.date_format` (default `%-d %B %Y`, used for entry dates), `shiplog.columns.<column>_description`), so override them in your locale files.
|
|
449
|
+
Values may be strings or arrays. The list is `Shiplog::Classes::KEYS`; assigning `config.classes` raises `ArgumentError` (listing the valid keys) for any other key, in every environment. String keys are accepted and normalised to symbols, and the hash is stored frozen. Texts come from I18n (`shiplog.roadmap.title`, `shiplog.roadmap.lede`, `shiplog.roadmap.*` (`parts`, `shipped_heading`, `full_changelog`, `footnote`, `suggest`), `shiplog.switcher.*`, `shiplog.changelog.*`, `shiplog.categories.feature` / `change`, `shiplog.date_format` (default `%-d %B %Y`, used for entry dates), `shiplog.columns.<column>_description`), so override them in your locale files.
|
|
329
450
|
|
|
330
451
|
### 4. Override the partials
|
|
331
452
|
|
|
332
453
|
Copy any view from the engine's `app/views/shiplog/` into your app with the same path and edit it. Each partial documents its locals in a comment on its first lines:
|
|
333
454
|
|
|
334
455
|
- `shiplog/_page.html.erb`, `shiplog/_page_header.html.erb`, `shiplog/_pagination.html.erb`, `shiplog/_empty.html.erb`
|
|
335
|
-
- `shiplog/
|
|
336
|
-
- `shiplog/
|
|
456
|
+
- `shiplog/_switcher.html.erb`
|
|
457
|
+
- `shiplog/roadmaps/_column.html.erb`, `shiplog/roadmaps/_card.html.erb`, `shiplog/roadmaps/_shipped.html.erb`
|
|
458
|
+
- `shiplog/changelogs/_month.html.erb`, `shiplog/changelogs/_entry.html.erb`, `shiplog/changelogs/_search.html.erb`
|
|
337
459
|
|
|
338
460
|
`shiplog/_page.html.erb` is what links the stylesheet (so it also works in layouts you don't control); keep that line if you copy it and still want the bundled CSS.
|
|
339
461
|
|
|
@@ -4,8 +4,9 @@ module Shiplog
|
|
|
4
4
|
DELIVERIES_SHOWN = 20
|
|
5
5
|
|
|
6
6
|
def show
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
names = Shiplog.config.repository_names
|
|
8
|
+
repositories = Repository.where(full_name: names).index_by(&:full_name)
|
|
9
|
+
@repositories = names.map { |full_name| [ full_name, repositories[full_name] ] }
|
|
9
10
|
@deliveries = WebhookDelivery.order(received_at: :desc, id: :desc).limit(DELIVERIES_SHOWN)
|
|
10
11
|
@webhook_secret_missing = Shiplog.config.webhook_secret.blank?
|
|
11
12
|
@github_token_missing = Shiplog.config.resolved_github_token.blank?
|
|
@@ -7,16 +7,26 @@ module Shiplog
|
|
|
7
7
|
@page = page_number
|
|
8
8
|
scope = Entry.approved.where(kind: %w[feature change]).where.not(released_on: nil)
|
|
9
9
|
.order(released_on: :desc, approved_at: :desc, id: :desc)
|
|
10
|
+
@query = search_query
|
|
11
|
+
scope = scope.search(@query) if @query
|
|
10
12
|
records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
11
13
|
return head(:not_found) if @page > 1 && records.empty?
|
|
12
14
|
|
|
13
15
|
@older = records.size > PER_PAGE
|
|
14
16
|
@months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
|
|
15
|
-
|
|
17
|
+
@result_count = scope.count if @query
|
|
18
|
+
fresh_when etag: [ *shiplog_etag_parts, @query, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
private
|
|
19
22
|
|
|
23
|
+
# The normalised search query (words joined by one space), or nil when search is off or the query is blank.
|
|
24
|
+
def search_query
|
|
25
|
+
return unless Shiplog.config.changelog_search && params[:q].is_a?(String)
|
|
26
|
+
|
|
27
|
+
Entry.search_terms(params[:q]).join(" ").presence
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
def page_number
|
|
21
31
|
Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
|
|
22
32
|
end
|
|
@@ -5,6 +5,8 @@ module Shiplog
|
|
|
5
5
|
class PublicController < Shiplog.config.public_parent_controller.constantize
|
|
6
6
|
helper Shiplog::FrontendHelper
|
|
7
7
|
helper Shiplog::StylingHelper
|
|
8
|
+
helper Shiplog::SwitcherHelper
|
|
9
|
+
helper Shiplog::RoadmapHelper
|
|
8
10
|
helper Shiplog::HostRoutesHelper
|
|
9
11
|
layout -> { Shiplog.config.public_layout }
|
|
10
12
|
|
|
@@ -13,7 +15,7 @@ module Shiplog
|
|
|
13
15
|
# What the rendered markup depends on besides the data: the locale, the gem version and the styling
|
|
14
16
|
# config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
|
|
15
17
|
def shiplog_etag_parts
|
|
16
|
-
[ I18n.locale, Shiplog::VERSION, Shiplog.config.
|
|
18
|
+
[ I18n.locale, Shiplog::VERSION, Shiplog.config.render_digest ]
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -2,13 +2,36 @@ module Shiplog
|
|
|
2
2
|
class RoadmapsController < PublicController
|
|
3
3
|
def show
|
|
4
4
|
@columns = Roadmap.new(published_only: true).columns
|
|
5
|
-
|
|
5
|
+
preload_cards(@columns)
|
|
6
|
+
@shipped = recently_shipped
|
|
7
|
+
fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped) ]
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
private
|
|
9
11
|
|
|
12
|
+
# The cards read each entry's issue, its repository (label) and its sub-issues (parts).
|
|
13
|
+
def preload_cards(columns)
|
|
14
|
+
entries = columns.values.flatten.map(&:entry)
|
|
15
|
+
ActiveRecord::Associations::Preloader.new(records: entries, associations: { subject: [ :repository, :children ] }).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def recently_shipped
|
|
19
|
+
count = Shiplog.config.roadmap_shipped_count
|
|
20
|
+
return Entry.none if count.zero?
|
|
21
|
+
|
|
22
|
+
Entry.approved.where(kind: Entry::CHANGELOG_KINDS).where.not(released_on: nil)
|
|
23
|
+
.order(released_on: :desc, approved_at: :desc, id: :desc).limit(count).load
|
|
24
|
+
end
|
|
25
|
+
|
|
10
26
|
def etag_for(columns)
|
|
11
|
-
columns.transform_values
|
|
27
|
+
columns.transform_values do |items|
|
|
28
|
+
items.map do |item|
|
|
29
|
+
issue = item.entry.subject
|
|
30
|
+
[ item.entry.id, item.entry.updated_at, issue.updated_at, issue.children.size, issue.repository.full_name ]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
12
33
|
end
|
|
34
|
+
|
|
35
|
+
def shipped_etag(entries) = entries.map { |entry| [ entry.id, entry.updated_at ] }
|
|
13
36
|
end
|
|
14
37
|
end
|
|
@@ -67,6 +67,37 @@
|
|
|
67
67
|
.shiplog-page__title { font-size: 1.875rem; line-height: 1.2; margin: 0; }
|
|
68
68
|
.shiplog-page__lede { color: var(--sl-muted); margin: 0.5rem 0 0; }
|
|
69
69
|
|
|
70
|
+
/* Switcher: Roadmap / Changelog / host pages. It can sit outside .shiplog-page (host pages), so it
|
|
71
|
+
falls back to the host-safe palette when the page variables are not inherited. */
|
|
72
|
+
.shiplog-switcher {
|
|
73
|
+
--sw-border: var(--sl-border, var(--shiplog-border, color-mix(in srgb, currentColor 22%, transparent)));
|
|
74
|
+
--sw-card: var(--sl-card, var(--shiplog-card, color-mix(in srgb, currentColor 5%, transparent)));
|
|
75
|
+
--sw-muted: var(--sl-muted, var(--shiplog-muted, color-mix(in srgb, currentColor 65%, transparent)));
|
|
76
|
+
--sw-accent: var(--sl-accent, var(--shiplog-accent, #2f81f7));
|
|
77
|
+
background: var(--sw-card);
|
|
78
|
+
border: 1px solid var(--sw-border);
|
|
79
|
+
border-radius: 999px;
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
flex-wrap: wrap;
|
|
82
|
+
font-size: 0.875rem;
|
|
83
|
+
font-weight: 500;
|
|
84
|
+
gap: 0.25rem;
|
|
85
|
+
margin-top: var(--sl-space, 1rem);
|
|
86
|
+
padding: 0.25rem;
|
|
87
|
+
}
|
|
88
|
+
.shiplog-switcher__link {
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
color: var(--sw-muted);
|
|
91
|
+
padding: 0.25rem 1rem;
|
|
92
|
+
text-decoration: none;
|
|
93
|
+
transition: background-color 0.15s, color 0.15s;
|
|
94
|
+
}
|
|
95
|
+
.shiplog-switcher__link:hover { color: inherit; }
|
|
96
|
+
.shiplog-switcher__link:focus-visible { outline: 2px solid var(--sw-accent); outline-offset: 2px; }
|
|
97
|
+
.shiplog-switcher__link--current,
|
|
98
|
+
.shiplog-switcher__link--current:hover { background: var(--sw-accent); color: #fff; }
|
|
99
|
+
@media (prefers-reduced-motion: reduce) { .shiplog-switcher__link { transition: none; } }
|
|
100
|
+
|
|
70
101
|
.shiplog-empty,
|
|
71
102
|
.shiplog-column__empty { color: var(--sl-muted); margin: 0; }
|
|
72
103
|
|
|
@@ -77,17 +108,21 @@
|
|
|
77
108
|
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
|
|
78
109
|
}
|
|
79
110
|
|
|
111
|
+
/* A rule on top and a marker carry the column's place in time: in progress is solid, coming soon
|
|
112
|
+
is the accent, later is neutral. */
|
|
80
113
|
.shiplog-column {
|
|
81
114
|
--sl-dot: var(--sl-muted);
|
|
115
|
+
--sl-rule: var(--sl-border);
|
|
116
|
+
border-top: 2px solid var(--sl-rule);
|
|
82
117
|
min-width: 0;
|
|
118
|
+
padding-top: 1rem;
|
|
83
119
|
}
|
|
84
|
-
.shiplog-column--in-progress { --sl-dot: #1a7f37; }
|
|
85
|
-
.shiplog-column--coming-soon { --sl-dot: var(--sl-accent); }
|
|
86
|
-
.shiplog-column--later { --sl-dot: var(--sl-muted); }
|
|
120
|
+
.shiplog-column--in-progress { --sl-dot: #1a7f37; --sl-rule: var(--sl-dot); }
|
|
121
|
+
.shiplog-column--coming-soon { --sl-dot: var(--sl-accent); --sl-rule: var(--sl-dot); }
|
|
122
|
+
.shiplog-column--later { --sl-dot: var(--sl-muted); --sl-rule: var(--sl-border); }
|
|
87
123
|
|
|
88
124
|
.shiplog-column__header {
|
|
89
125
|
align-items: baseline;
|
|
90
|
-
border-bottom: 1px solid var(--sl-border);
|
|
91
126
|
display: flex;
|
|
92
127
|
flex-wrap: wrap;
|
|
93
128
|
gap: 0 0.5rem;
|
|
@@ -95,13 +130,14 @@
|
|
|
95
130
|
padding-bottom: 0.75rem;
|
|
96
131
|
}
|
|
97
132
|
.shiplog-column__title { align-items: center; display: flex; font-size: 1.125rem; gap: 0.5rem; margin: 0; }
|
|
98
|
-
.shiplog-
|
|
133
|
+
.shiplog-column__marker {
|
|
99
134
|
background: var(--sl-dot);
|
|
100
135
|
border-radius: 50%;
|
|
101
|
-
|
|
136
|
+
flex-shrink: 0;
|
|
102
137
|
height: 0.625rem;
|
|
103
138
|
width: 0.625rem;
|
|
104
139
|
}
|
|
140
|
+
.shiplog-column__marker--in-progress { box-shadow: 0 0 0 4px color-mix(in srgb, var(--sl-dot) 20%, transparent); }
|
|
105
141
|
.shiplog-column__count {
|
|
106
142
|
background: var(--sl-card);
|
|
107
143
|
border: 1px solid var(--sl-border);
|
|
@@ -132,6 +168,58 @@
|
|
|
132
168
|
.shiplog-entry__body p { margin: 0.25rem 0; }
|
|
133
169
|
.shiplog-card__body { color: var(--sl-muted); font-size: 0.9375rem; }
|
|
134
170
|
|
|
171
|
+
.shiplog-card__meta {
|
|
172
|
+
align-items: center;
|
|
173
|
+
color: var(--sl-muted);
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-wrap: wrap;
|
|
176
|
+
font-size: 0.75rem;
|
|
177
|
+
font-weight: 500;
|
|
178
|
+
gap: 0.5rem;
|
|
179
|
+
margin: 0.75rem 0 0;
|
|
180
|
+
}
|
|
181
|
+
.shiplog-card__tag {
|
|
182
|
+
border: 1px solid var(--sl-border);
|
|
183
|
+
border-radius: 0.375rem;
|
|
184
|
+
padding: 0 0.5rem;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Recently shipped: a title, a link to the full changelog, then a grid of the latest entries */
|
|
188
|
+
.shiplog-shipped {
|
|
189
|
+
align-items: baseline;
|
|
190
|
+
border-top: 1px solid var(--sl-border);
|
|
191
|
+
display: grid;
|
|
192
|
+
gap: 0.5rem 1rem;
|
|
193
|
+
grid-template-columns: 1fr auto;
|
|
194
|
+
margin-top: calc(var(--sl-space) * 2.5);
|
|
195
|
+
padding-top: calc(var(--sl-space) * 1.5);
|
|
196
|
+
}
|
|
197
|
+
.shiplog-shipped__title { font-size: 1.125rem; margin: 0; }
|
|
198
|
+
.shiplog-shipped__list {
|
|
199
|
+
display: grid;
|
|
200
|
+
gap: var(--sl-space) calc(var(--sl-space) * 1.5);
|
|
201
|
+
grid-column: 1 / -1;
|
|
202
|
+
grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
|
|
203
|
+
list-style: none;
|
|
204
|
+
margin: 0;
|
|
205
|
+
padding: 0;
|
|
206
|
+
}
|
|
207
|
+
.shiplog-shipped__item { display: grid; gap: 0.25rem; align-content: start; }
|
|
208
|
+
.shiplog-shipped__date { color: var(--sl-muted); font-size: 0.75rem; }
|
|
209
|
+
.shiplog-shipped__link { color: inherit; font-weight: 600; text-decoration: none; }
|
|
210
|
+
.shiplog-shipped__link:hover { color: var(--sl-accent); }
|
|
211
|
+
.shiplog-shipped__link--all { color: var(--sl-accent); font-size: 0.875rem; font-weight: 500; }
|
|
212
|
+
|
|
213
|
+
.shiplog-footnote {
|
|
214
|
+
border-top: 1px solid var(--sl-border);
|
|
215
|
+
color: var(--sl-muted);
|
|
216
|
+
font-size: 0.875rem;
|
|
217
|
+
margin: calc(var(--sl-space) * 2) 0 0;
|
|
218
|
+
padding-top: var(--sl-space);
|
|
219
|
+
text-align: center;
|
|
220
|
+
}
|
|
221
|
+
.shiplog-footnote__link { color: var(--sl-accent); font-weight: 500; }
|
|
222
|
+
|
|
135
223
|
/* Changelog: months hang off a vertical line */
|
|
136
224
|
.shiplog-months { display: grid; gap: calc(var(--sl-space) * 2); }
|
|
137
225
|
|
|
@@ -176,6 +264,33 @@
|
|
|
176
264
|
.shiplog-pagination__link { color: var(--sl-accent); }
|
|
177
265
|
.shiplog-pagination__link--older { margin-left: auto; }
|
|
178
266
|
|
|
267
|
+
.shiplog-search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
|
|
268
|
+
.shiplog-search__input {
|
|
269
|
+
flex: 1 1 14rem;
|
|
270
|
+
min-width: 0;
|
|
271
|
+
font: inherit;
|
|
272
|
+
color: inherit;
|
|
273
|
+
background: var(--sl-card);
|
|
274
|
+
border: 1px solid var(--sl-border);
|
|
275
|
+
border-radius: var(--sl-radius);
|
|
276
|
+
padding: 0.4rem 0.75rem;
|
|
277
|
+
}
|
|
278
|
+
.shiplog-search__input:focus-visible,
|
|
279
|
+
.shiplog-search__button:focus-visible,
|
|
280
|
+
.shiplog-search__clear:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
|
|
281
|
+
.shiplog-search__button {
|
|
282
|
+
font: inherit;
|
|
283
|
+
color: #fff;
|
|
284
|
+
background: var(--sl-accent);
|
|
285
|
+
border: 1px solid var(--sl-accent);
|
|
286
|
+
border-radius: var(--sl-radius);
|
|
287
|
+
padding: 0.4rem 1rem;
|
|
288
|
+
cursor: pointer;
|
|
289
|
+
}
|
|
290
|
+
.shiplog-search__summary { color: var(--sl-muted); margin: 0 0 var(--sl-space); }
|
|
291
|
+
.shiplog-search__clear { color: var(--sl-accent); margin-left: 0.5rem; }
|
|
292
|
+
.shiplog-search__empty { color: var(--sl-muted); margin: 0; }
|
|
293
|
+
|
|
179
294
|
@media (max-width: 40rem) {
|
|
180
295
|
.shiplog-page { padding: var(--sl-space) 0.75rem; }
|
|
181
296
|
.shiplog-page__title { font-size: 1.5rem; }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module CallableHelper
|
|
3
|
+
# A config value that is a String, or a callable evaluated in the view so it can use route helpers
|
|
4
|
+
# and I18n (`-> { main_app.news_path }`, `-> { t("...") }`).
|
|
5
|
+
def shiplog_resolve(value)
|
|
6
|
+
case value
|
|
7
|
+
when Proc then instance_exec(&value)
|
|
8
|
+
else value.respond_to?(:call) ? value.call : value
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Shiplog
|
|
2
2
|
module FrontendHelper
|
|
3
3
|
def shiplog_stylesheet_tag(name)
|
|
4
|
+
@_shiplog_stylesheet_emitted = true if name == "shiplog" # shiplog_switcher then skips its own link
|
|
4
5
|
tag.link rel: "stylesheet", href: shiplog_asset_path("#{name}.css"), nonce: content_security_policy_nonce
|
|
5
6
|
end
|
|
6
7
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module RoadmapHelper
|
|
3
|
+
include Shiplog::SwitcherHelper
|
|
4
|
+
|
|
5
|
+
# The configured label of a repository (config.repository_labels, matched case-insensitively), or nil.
|
|
6
|
+
def shiplog_repository_label(repository)
|
|
7
|
+
labels = Shiplog.config.repository_labels
|
|
8
|
+
label = labels[repository.full_name] || labels.find { |name, _| name.casecmp?(repository.full_name) }&.last
|
|
9
|
+
shiplog_resolve(label).presence
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The "suggest a feature" target (String or callable), or nil.
|
|
13
|
+
def shiplog_feedback_path = shiplog_safe_url(shiplog_resolve(Shiplog.config.feedback_path))
|
|
14
|
+
end
|
|
15
|
+
end
|