shiplog 0.1.1 → 0.3.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 +97 -0
- data/README.md +266 -9
- data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
- data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
- data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
- data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
- data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
- data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
- data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
- data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
- data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
- data/app/controllers/shiplog/admin/syncs_controller.rb +3 -2
- data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
- data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
- data/app/controllers/shiplog/changelogs_controller.rb +30 -4
- data/app/controllers/shiplog/public_controller.rb +58 -1
- data/app/controllers/shiplog/roadmaps_controller.rb +27 -2
- data/app/frontend/shiplog/admin.css +9 -0
- data/app/frontend/shiplog/shiplog.css +168 -8
- data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
- data/app/helpers/shiplog/callable_helper.rb +12 -0
- data/app/helpers/shiplog/frontend_helper.rb +1 -0
- data/app/helpers/shiplog/permalink_helper.rb +113 -0
- data/app/helpers/shiplog/roadmap_helper.rb +24 -0
- data/app/helpers/shiplog/switcher_helper.rb +101 -0
- data/app/helpers/shiplog/unseen_helper.rb +9 -0
- data/app/jobs/shiplog/detect_areas_job.rb +18 -0
- data/app/jobs/shiplog/refresh_job.rb +1 -0
- data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
- data/app/jobs/shiplog/scan_changes_job.rb +19 -5
- data/app/jobs/shiplog/sync_job.rb +1 -1
- data/app/jobs/shiplog/translate_area_job.rb +25 -0
- data/app/jobs/shiplog/translate_entry_job.rb +25 -0
- data/app/models/shiplog/area.rb +156 -0
- data/app/models/shiplog/area_detector.rb +146 -0
- data/app/models/shiplog/area_translator.rb +77 -0
- data/app/models/shiplog/change_candidates.rb +39 -4
- data/app/models/shiplog/entry/images.rb +127 -0
- data/app/models/shiplog/entry.rb +218 -2
- data/app/models/shiplog/entry_translation.rb +15 -0
- data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
- data/app/models/shiplog/image_fetcher/sources.rb +41 -0
- data/app/models/shiplog/image_fetcher.rb +72 -0
- data/app/models/shiplog/image_type.rb +111 -0
- data/app/models/shiplog/pull_request.rb +4 -0
- data/app/models/shiplog/pull_request_upgrade.rb +5 -0
- data/app/models/shiplog/rewriter/prompt.rb +57 -9
- data/app/models/shiplog/rewriter.rb +50 -13
- data/app/models/shiplog/setting.rb +19 -0
- 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/models/shiplog/translator/prompt.rb +40 -0
- data/app/models/shiplog/translator.rb +99 -0
- data/app/views/layouts/shiplog/application.html.erb +4 -0
- data/app/views/shiplog/_empty.html.erb +1 -1
- data/app/views/shiplog/_page_header.html.erb +2 -1
- data/app/views/shiplog/_pagination.html.erb +3 -3
- data/app/views/shiplog/_switcher.html.erb +13 -0
- data/app/views/shiplog/admin/_nav.html.erb +2 -1
- data/app/views/shiplog/admin/areas/index.html.erb +78 -0
- data/app/views/shiplog/admin/entries/_entry_row.html.erb +4 -0
- data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
- data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
- data/app/views/shiplog/changelog_entries/show.html.erb +25 -0
- data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
- data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
- data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
- data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
- data/app/views/shiplog/changelogs/_search.html.erb +18 -0
- data/app/views/shiplog/changelogs/show.html.erb +20 -5
- data/app/views/shiplog/roadmaps/_card.html.erb +14 -3
- data/app/views/shiplog/roadmaps/_column.html.erb +4 -4
- data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
- data/app/views/shiplog/roadmaps/show.html.erb +11 -2
- data/config/admin_routes.rb +16 -0
- data/config/locales/en.yml +105 -0
- data/config/locales/es.yml +223 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
- data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
- data/lib/generators/shiplog/install/install_generator.rb +29 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +82 -1
- data/lib/shiplog/classes.rb +29 -1
- data/lib/shiplog/configuration.rb +245 -3
- data/lib/shiplog/engine.rb +3 -0
- data/lib/shiplog/github/client.rb +42 -9
- data/lib/shiplog/github/errors.rb +3 -0
- data/lib/shiplog/github/queries.rb +44 -1
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +73 -0
- data/lib/tasks/shiplog.rake +29 -0
- metadata +48 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2826f599a903db09dc27e45e8b8b0e1b4b643bce51b36a995d913addec96abe
|
|
4
|
+
data.tar.gz: d27c0d107c0dc01e1e0509eb6754cbebd5a210b1a5f049f0f3379c5f7dc63faa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27a0a478a59b2770fee353de8b5305d6674fc485d9fe3cb79ab5e84f46ef4bbe590d68e3a063275c1de6f83e7603e32822b9e696da83f7c20b9ed3a200c3b7f8
|
|
7
|
+
data.tar.gz: 6f5fca4e30d1511e38d3272cbe997a8232d6c5b14c80ea4263925a05c5b7378eb10dfb0f48d0e65ebf6069334f01f725cb24d4ffbd4baa50ee105018a19e4225
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-10-26)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Entry permalinks: every approved feature and change has a page at `/changelog/:slug`, with a "Back to the changelog" link to its month, and links to the newer and older entries. The changelog list links each title to it. The slug is made from the title when the entry is first approved (`a-z`, `0-9` and dashes only, at most 80 characters, `-2`, `-3` for duplicates, `entry-<id>` when the title leaves nothing) and never changes afterwards. Drafts, discarded and roadmap entries and unknown slugs answer 404. The page sets `content_for :title`, `:shiplog_description` and `:shiplog_og` (Open Graph `og:title`, `og:description`, `og:type`, `og:url`); Shiplog's layout renders them, host layouts should too (see the README). The entry markup is the new `shiplog/changelogs/_entry_content` partial.
|
|
8
|
+
- An Atom feed of the latest 50 features and changes at `/changelog.atom`, with stable tag-URI ids, absolute URLs, an ETag, a `<link rel="alternate">` through `content_for :shiplog_feed` and a "Subscribe (Atom)" link on the changelog.
|
|
9
|
+
- `Shiplog.unseen_count(since:)` (approved features and changes approved after `since`, all of them when nil, at most 99), the `Shiplog::UnseenHelper#shiplog_unseen_count(since)` view helper, and `config.on_changelog_seen = ->(controller) { ... }`, called before each changelog index or permalink view renders (also when the answer is a 304), so a badge in the same response is current. It may write, so keep it cheap (the README shows a write guard). Shiplog doesn't know your users, so the host supplies and stores the time.
|
|
10
|
+
- `config.canonical_url` (nil by default): the origin (`"https://trackberry.com"`) used for the feed's ids, links and the other absolute URLs instead of the request's host. It and `config.product_name` are part of the pages' ETag.
|
|
11
|
+
- An unknown permalink slug answers 404 with `X-Cascade: pass`, so host routes declared after the engine mount below the changelog path (`/changelog/archive`) are no longer shadowed.
|
|
12
|
+
- `config.changelog_path` is validated leniently: surrounding slashes are stripped (`"/changelog"` works as before), segments may use letters, digits, `-` and `_` and may be nested (`"docs/whats_new"`). Blank values and anything unsafe (spaces, dots, `?`, `#`, accented letters) raise `ArgumentError`.
|
|
13
|
+
- `bin/rails shiplog:backfill_slugs`, which gives entries approved before 0.3.0 their permalink slug. The 0.3.0 migration already runs the same generation for every approved feature and change (oldest first), so the task is only needed for entries approved by older code during a deploy window. The public pages never write (a GET may hit a read replica): an approved entry without a slug is listed without a link and has no permalink page until the task runs.
|
|
14
|
+
- New `config.classes` keys: `permalink`, `permalink_back`, `permalink_nav`, `permalink_nav_link`. The Atom link reuses `footnote` and `footnote_link` (`shiplog-footnote__link--feed`).
|
|
15
|
+
- Product areas, detected by AI. The rewriter's reply may include `"area"`: an existing area (matched ignoring case, then by slug) or a new name of 1-3 words, created while fewer than `config.max_areas` (default 8) exist and the name is valid (at most 40 characters; letters, digits, spaces, `&`, `-`). The prompt lists the existing areas; a reply without `area`, or with a name that can't be used (cap reached, invalid), keeps the entry's current area. An area an admin chose on the entry edit page (`area_set_by_admin`) is never changed by a rewrite or by detection.
|
|
16
|
+
- An admin Areas page (in the nav): areas with their entry counts, rename (the slug follows), move up/down, merge (moves the entries, deletes the source) and delete (entries get no area), and a "Detect areas" button that enqueues `Shiplog::DetectAreasJob`. With no areas the job asks your `config.rewriter` to propose up to `max_areas` from up to 200 approved titles, then classifies the entries without an area in batches of 50 titles (at most 20 batches a run, idempotent, logged). The entry edit page gets an Area select (with "None") and the inbox shows an area badge.
|
|
17
|
+
- Area detection stamps every entry it asked the AI about (`area_detected_at`), so entries the AI declines are not asked again and later runs reach the rest; the Areas page has a "Re-check unplaced entries" checkbox to ask again. Only one detection runs at a time (a Postgres advisory lock).
|
|
18
|
+
- Area slugs for names that `parameterize` empties (Cyrillic, CJK) are the lowercased letters and digits joined by dashes (`Оплата` is `оплата`; `?area=` takes it URL-encoded). Area creation checks `config.max_areas` under a Postgres advisory lock, so concurrent rewrites cannot exceed it. Merging and deleting an area bump the entries' `updated_at`.
|
|
19
|
+
- The Areas page shows "Last detection: <time>, assigned N" or the last error. `Shiplog::DetectAreasJob` never retries or raises: failures are logged and recorded, and the Detect button refuses to enqueue without `config.rewriter`.
|
|
20
|
+
- On the public changelog, an area filter (`?area=slug`, an "All" link) when at least 2 areas have published entries; it combines with search and pagination and is part of the ETag. An unknown slug answers 404. Entries show their area as a tag (list and permalink), and the Atom feed adds `<category term="slug" label="Name"/>` and honours `?area=`.
|
|
21
|
+
- New `config.classes` keys: `areas`, `area_link` (with a `--current` modifier) and `entry_area`; new `config.max_areas`.
|
|
22
|
+
- Screenshots in entries (needs Active Storage). `Shiplog::Entry` gets `has_many_attached :images`, with the order in `image_order` and per-image alt texts in `image_alts` (both jsonb, keyed by blob id; the alt text defaults to the entry's title). When a draft is written (or regenerated), `Shiplog::RewriteEntryJob` fetches the issue's or pull request's `bodyHTML` (a separate small GraphQL query, `Queries::BODY_HTML`, so sync payloads don't grow) and downloads up to `config.max_images_per_entry` (4) images from GitHub's image hosts, only for a draft that has none: https only and exact host match (`private-user-images.githubusercontent.com`, `user-images.githubusercontent.com`, `camo.githubusercontent.com`, `raw.githubusercontent.com`, `objects.githubusercontent.com`, and `github.com/user-attachments/assets/<id>` with the id made of letters, digits and dashes only), at most 3 redirects (re-checked, their bodies read under the size cap), 5 second timeouts, 15 seconds per download and 45 per entry (monotonic clock; the remaining images are skipped and logged), 5 MB of raw bytes (`Accept-Encoding: identity`), PNG/JPEG/GIF/WebP by magic bytes (SVG and HTML are refused). The size is parsed from the header (PNG, GIF, WebP and JPEG): over 10,000 pixels on a side, over 40 megapixels or unreadable is refused, for admin uploads too, and the width and height go into the blob's metadata. The GitHub token is sent to `github.com` only. Failures are logged and skipped and never fail the draft.
|
|
23
|
+
- The admin entry edit page lists the images with alt text, Remove and Up/Down buttons, and an upload form (several files, checked by magic bytes and size on the server). The changelog list shows an entry's first image under its text, the permalink page all of them (`loading="lazy"`, alt text, width and height when Active Storage has analysed the image); the Atom feed and `og:image` use absolute URLs (`config.canonical_url` when set). The ETags include the blob ids, order and alt texts.
|
|
24
|
+
- `config.images` (default `true`; effective only with Active Storage) and `config.max_images_per_entry` (default 4), both validated and part of the pages' ETag. New `config.classes` keys: `entry_images` and `entry_image`. Without Active Storage (or with `config.images = false`) the feature is off, nothing crashes, and Shiplog logs once at boot when Active Storage is the reason.
|
|
25
|
+
- Translations of entries. `config.locales = [:es]` (default `[]`; an Array of Symbols or Strings, stored as symbols, the default locale dropped, part of the ETag) translates entries with your `config.rewriter` and publishes the translation with the English text, with no second approval. `Shiplog::TranslateEntryJob(entry, locale, force: false)` is enqueued per locale when an entry (roadmap entries too) is approved and when the English title or body of an approved entry changes. The prompt keeps meaning, tone, product names, numbers, links and formatting, puts the source inside `<source>` (neutralised) and asks for `{"title", "body"}`; the reply is validated and limited like the English kinds. Translations live in the new `shiplog_entry_translations` table (unique on `[entry_id, locale]`, `source_digest` of the English text, `edited_by_admin`). A translation made from the current English text is not made again (no LLM call), an admin's edit is kept until the English text changes (then replaced and logged), and the job writes nothing for an entry that is no longer approved. Failures are logged, retried twice with a growing wait, then dropped.
|
|
26
|
+
- Public pages show `Entry#display_title` and `#display_body` for a translated `I18n.locale`: the translation when it exists and is current, English otherwise, on the roadmap cards, the changelog list, the permalink page (title, body, meta description, Open Graph), the Atom feed and "Recently shipped". Translations are preloaded for the current locale in one query (`Entry.preload_translations`). Search matches current translations as well as English under a translated locale. Slugs stay English. The ETags include the translations shown; the feed's `xml:lang` follows the request locale.
|
|
27
|
+
- The entry edit page gets a Translations section per locale: title and body to edit (saving marks the translation as edited), a Missing, Stale, Up to date and Edited marker, and a Retranslate button (`force: true`).
|
|
28
|
+
- `config/locales/es.yml`: the gem's own public and admin texts in Spanish (and Spanish month names for its date formats), checked for completeness against `en.yml` by a test.
|
|
29
|
+
|
|
30
|
+
- `bin/rails shiplog:translate_approved`: enqueues `Shiplog::TranslateEntryJob` for every approved entry and configured locale that has no current translation, in batches (`BATCH_SIZE`, default 100), and prints the counts. Safe to repeat: current translations are not enqueued, and the job skips them anyway (digest).
|
|
31
|
+
|
|
32
|
+
- Translated area names. `shiplog_areas.name_translations` (jsonb, default `{}`, keyed by locale string; part of the 0.3.0 migration) holds the name in each of `config.locales`, and `Area#display_name(locale = I18n.locale)` returns the translation for a configured locale and the name otherwise. The public changelog uses it for the filter pills, the entry tags and the permalink tag, and the Atom feed for `<category label>`; slugs and the category `term` stay English, so `?area=` URLs are shared across locales. The pages' ETags include the translated names.
|
|
33
|
+
- `Shiplog::TranslateAreaJob(area, force: false)` makes one `config.rewriter` call for all locales: a short prompt (the name inside `<source>`, neutralised like the other prompts) asking for JSON `{"es": "..."}`. Every requested locale must come back as a valid area name (at most 40 characters; letters, digits, spaces, `&`, `-`), or the reply is rejected; failures are retried twice with a growing wait, then logged and dropped. It is enqueued when an area is created (by a rewrite, by detection) and when it is renamed, only with `config.locales` and `config.rewriter` set. Without `force` it fills the locales that have no name and never touches one that has (an admin's edit included), and makes no call when none is missing; a rename drops the stored translations and translates the new name.
|
|
34
|
+
- The admin Areas page shows a small field per configured locale on each area's form: saving without a rename keeps what the admin typed (an empty field removes that translation), and a rename discards translations sent with it. A "Translate names" button enqueues the job for the areas missing a translation in some locale; it only fills in the missing names, so edits are kept. `bin/rails shiplog:translate_approved` also enqueues the job (without force) for areas missing a translation, and prints how many.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- The changelog's entry markup moved to `shiplog/changelogs/_entry_content.html.erb`; entry titles on the list are now links.
|
|
39
|
+
|
|
40
|
+
### Upgrade notes
|
|
41
|
+
|
|
42
|
+
- Copy and run the new migration: `bin/rails shiplog:install:migrations`, then `bin/rails db:migrate`. It adds `shiplog_entries.slug` (unique index on non-null values), an index on `shiplog_entries (status, approved_at)`, the `shiplog_areas` table and `shiplog_entries.area_id` (nullable foreign key, `ON DELETE SET NULL`, indexed) `shiplog_entries.area_set_by_admin` (boolean, default false) and `shiplog_entries.area_detected_at` (datetime), and the small key-value table `shiplog_settings` (the last area detection's result). The migration also gives the entries approved so far their slug. If old code approves entries during your deploy window, run `bin/rails shiplog:backfill_slugs` afterwards.
|
|
43
|
+
- `config.changelog_path` now raises `ArgumentError` for blank values and for characters outside `A-Z`, `a-z`, `0-9`, `-`, `_` and `/` (spaces, dots, `?`, `#`). Surrounding slashes are stripped, so `"/changelog"` and `"whats_new"` from a 0.2.0 initializer keep working.
|
|
44
|
+
- If you override `shiplog/changelogs/_entry.html.erb`, titles are not links in your copy; render `shiplog/changelogs/_entry_content` (locals `entry:`, `heading:`, `linked: true`) or link `changelog_entry_path(entry.slug)` yourself.
|
|
45
|
+
- If your `config.public_layout` is your own layout, render `content_for(:shiplog_og)`, `content_for(:shiplog_feed)` and the `:shiplog_description` meta tag in its `<head>` to get Open Graph tags and feed discovery on the permalink pages.
|
|
46
|
+
- New I18n keys, all with English defaults: `shiplog.permalink.title`, `.back`, `.nav_label`, `.newer`, `.older` (with `title`); `shiplog.feed.title_with_product` (with `product`), `.link_title`, `.subscribe`.
|
|
47
|
+
- Areas appear as you rewrite entries. To fill in existing ones, open the admin's Areas page and click "Detect areas" (it needs `config.rewriter`). Set `config.max_areas` to change the cap of 8 (`0` stops the AI creating areas).
|
|
48
|
+
- If you use a custom `config.prompt_builder`, ask your model for `"area"` in its JSON reply (an existing name, or a new one of 1-3 words), or entries get no area from rewrites. List the existing areas in your prompt with `Shiplog::Area.ordered.pluck(:name)`.
|
|
49
|
+
- If you override `shiplog/changelogs/_entry_content.html.erb` or `show.html.erb`, add the area tag (`entry.area`, class `entry_area`) and render `shiplog/changelogs/_areas` (locals `areas:`, `current:`, `query:`) to get the tag and the filter. The search partial reads the selected area from `@area` (its locals are still `query:` and `count:`, so a 0.2.0 copy keeps rendering, but its form and "Clear search" link don't carry the area: add `<input type="hidden" name="area" value="<%= @area.slug %>">` when `@area` is set, and `area: @area&.slug` to the clear link).
|
|
50
|
+
- New I18n keys, all with English defaults: `shiplog.changelog.areas.label` and `.all`; `shiplog.admin.areas.*`, `shiplog.admin.nav.areas`, `shiplog.admin.entries.no_area` and `shiplog.admin.entries.fields.area`.
|
|
51
|
+
- Every translation key of the public pages now has an English default (including `shiplog.roadmap.title` and `.lede`, `shiplog.changelog.title`, `.lede` and `.pagination.*`, `shiplog.columns.*`, `shiplog.empty`), so a host locale without Shiplog translations shows English instead of translation-missing spans.
|
|
52
|
+
- Screenshots need Active Storage: load it in the host (`require "active_storage/engine"` in `config/application.rb`, or `rails/all`) and install its tables (`bin/rails active_storage:install`). Without it everything else works and images stay off. The migration adds `shiplog_entries.image_order` (jsonb, default `[]`) and `shiplog_entries.image_alts` (jsonb, default `{}`) either way. The GitHub token needs no extra scope: the images come from signed URLs in the body's HTML (fetched with the token you already use for GraphQL) and from public GitHub image hosts. Entries drafted before the upgrade get images only by upload (or by "Regenerate" while they have none). Add `config.images = false` to opt out. Thumbnails in the admin are variants when `image_processing` is in your bundle and a processor is available (`ruby-vips` loads, or `mini_magick` is in the bundle); otherwise the original image is shown. Shiplog adds no dependency.
|
|
53
|
+
- If you override `shiplog/changelogs/_entry_content.html.erb`, render `shiplog_entry_images(entry, limit: 1)` (list) or `shiplog_entry_images(entry)` (permalink) under the body to show images. New I18n keys, all with English defaults: `shiplog.admin.entries.images.*`.
|
|
54
|
+
- Translations of entries approved before the upgrade: nothing translates them by itself (the job runs on approval and on English edits). After setting `config.locales` and `config.rewriter`, run `bin/rails shiplog:translate_approved` once; it enqueues one job per missing entry and locale, prints how many, and can be repeated (it only enqueues what has no current translation). Each job is one LLM call.
|
|
55
|
+
- Translated area names: the same migration adds `shiplog_areas.name_translations` (jsonb, not null, default `{}`). With `config.locales` and `config.rewriter` set, every new or renamed area costs one LLM call (areas are at most `config.max_areas`); run `bin/rails shiplog:translate_approved` (or click "Translate names" on the Areas page) once to translate the existing areas. New I18n keys, all with English defaults: `shiplog.admin.areas.translate_names`, `.translate_names_help`, `.no_locales`, `.no_rewriter_translate`, `.translation_queued`. If you override `shiplog/changelogs/_areas.html.erb`, `_entry_content.html.erb` or the feed template, use `area.display_name` instead of `area.name`.
|
|
56
|
+
- Translations: copy and run the new migration (see above): it also creates `shiplog_entry_translations` (`entry_id` foreign key with `ON DELETE CASCADE`, `locale`, `title`, `body`, `source_digest`, `edited_by_admin`, timestamps; unique index on `[entry_id, locale]`). Nothing changes until you set `config.locales`. Then every approval and every English edit of an approved entry costs one LLM call per locale, and entries approved before the upgrade have no translation until you run `bin/rails shiplog:translate_approved` (see above): they show in English meanwhile. Area names are translated with the entries (see above). Your routes decide which URLs set `I18n.locale` (a `/es` scope, a middleware); Shiplog adds none, and the locale must be in `I18n.available_locales` (the gem now ships `es`, so a host with `config.i18n.available_locales = [:en]` has to add it).
|
|
57
|
+
- Hosts with Spanish pages: the gem's `es.yml` now supplies `shiplog.*` and the month names (`date.month_names`); override any key in your own `es.yml`, or add `rails-i18n`, whose values for the months are the same. If you override `shiplog/roadmaps/_card.html.erb`, `_shipped.html.erb`, `shiplog/changelogs/_entry_content.html.erb`, `changelog_entries/show.html.erb` or the feed template, replace `entry.title` and `entry.body` with `entry.display_title` and `entry.display_body` to show translations. If you call `Entry.search(query)` yourself, pass `locale: I18n.locale` to search translations too. A custom `config.prompt_builder` does not apply to translations.
|
|
58
|
+
- Shipping `es.yml` adds `:es` to the derived `I18n.available_locales` for hosts that don't set it explicitly (Rails derives it from the loaded locale files), so a host that switches locale from the request now accepts `:es`. Hosts that set `config.locales = [:es]` and also set `I18n.available_locales` (or `config.i18n.available_locales`) explicitly must list `:es` in it.
|
|
59
|
+
- If you use `config.classes` with `config.stylesheet = false`, style the new elements, or they render unstyled (a full-size screenshot overflows the list): the permalink keys, `areas`, `area_link`, `entry_area`, `entry_images` and `entry_image`. The `--tailwind` install generator (`lib/generators/shiplog/install`) has Tailwind values for all of them to copy.
|
|
60
|
+
- If your layout shows the unseen count, it renders per-user content: declare `etag { current_user&.changelog_seen_at }` on your `public_parent_controller`.
|
|
61
|
+
|
|
62
|
+
## 0.2.0 (2026-10-26)
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- 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`.
|
|
67
|
+
- 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`).
|
|
68
|
+
- 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`.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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.
|
|
71
|
+
- `config.cache_version`, to refresh cached public pages when the output of a configured callable changes.
|
|
72
|
+
- 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).
|
|
73
|
+
- 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.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- Roadmap drafts are shorter (about two sentences) and lead with what the customer gets, instead of opening with "We're working on".
|
|
78
|
+
- The roadmap's ETag also covers its recently shipped entries and each card's repository and sub-issues, so those changes reach cached pages.
|
|
79
|
+
- The migration `create_shiplog_pull_request_upgrades` also adds an index on `shiplog_entries (status, released_on)`.
|
|
80
|
+
- 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.
|
|
81
|
+
- Only the last commit's lockfile change is seen for a rebase merge; Dependabot's single-commit pull requests are unaffected.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- 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.
|
|
86
|
+
- Hosts that mount the engine with another name (`as: :updates`) no longer get a `NameError` from the switcher.
|
|
87
|
+
- 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.
|
|
88
|
+
|
|
89
|
+
### Upgrade notes
|
|
90
|
+
|
|
91
|
+
- 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.
|
|
92
|
+
- 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`).
|
|
93
|
+
- 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.
|
|
94
|
+
- 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.
|
|
95
|
+
- Style the new elements with the new class keys above if you use `config.classes` with `config.stylesheet = false`.
|
|
96
|
+
- `extra_pages` paths must be absolute (`/…`), `http(s)://` or `mailto:`. A `feedback_path` that is not one of these renders no link.
|
|
97
|
+
- Repository names are validated: anything other than `owner/name` raises `ArgumentError` when `config.repositories` is assigned.
|
|
98
|
+
- 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.
|
|
99
|
+
|
|
3
100
|
## 0.1.1 (2026-10-26)
|
|
4
101
|
|
|
5
102
|
### Added
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A mountable Rails engine that turns your GitHub issues, pull requests and commits into a public roadmap and changelog written for customers, not developers. An LLM that you supply drafts the plain-language text. Nothing is public until an admin approves it.
|
|
4
4
|
|
|
5
5
|
- **Roadmap**: issues labelled `public` appear in three columns (In progress, Coming soon, Later) once approved.
|
|
6
|
-
- **Changelog**: shipped features and small changes, grouped by month.
|
|
6
|
+
- **Changelog**: shipped features and small changes, grouped by month, each with its own permalink page, plus an Atom feed.
|
|
7
7
|
- **Admin**: drafts inbox, entry editing, a drag-and-drop roadmap board and a sync status page.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
@@ -12,6 +12,7 @@ A mountable Rails engine that turns your GitHub issues, pull requests and commit
|
|
|
12
12
|
- Rails 8.1.4 or newer (< 9)
|
|
13
13
|
- PostgreSQL (tables use `jsonb`)
|
|
14
14
|
- A job backend for Active Job (GoodJob, Solid Queue, ...)
|
|
15
|
+
- Active Storage, only for [screenshots in entries](#screenshots) (the rest works without it)
|
|
15
16
|
|
|
16
17
|
## Installation
|
|
17
18
|
|
|
@@ -82,7 +83,8 @@ All settings live in `config/initializers/shiplog.rb`:
|
|
|
82
83
|
|
|
83
84
|
| Key | Default | Description |
|
|
84
85
|
|---|---|---|
|
|
85
|
-
| `repositories` | `[]` | GitHub repositories to sync, as `"owner/name"
|
|
86
|
+
| `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). |
|
|
87
|
+
| `lockfile_path` | `"Gemfile.lock"` | Lockfile read in merged pull requests to detect [gem upgrades](#gem-upgrades). Change it for a monorepo. |
|
|
86
88
|
| `github_token` | `nil` | A String, or a callable evaluated on every GitHub request. |
|
|
87
89
|
| `webhook_secret` | `nil` | Secret of the GitHub webhook. While blank, the webhook endpoint answers 503. |
|
|
88
90
|
| `public_label` | `"public"` | Only issues with this label reach the roadmap. |
|
|
@@ -94,7 +96,7 @@ All settings live in `config/initializers/shiplog.rb`:
|
|
|
94
96
|
| `product_name` | `nil` | Your product's name, used in the prompts. |
|
|
95
97
|
| `audience` | `nil` | Who your customers are, used in the prompts. |
|
|
96
98
|
| `tone` | `"Friendly, concise, no jargon. Address the customer as 'you'."` | Writing style used in the prompts. |
|
|
97
|
-
| `prompt_builder` | `nil` | Optional `->(kind:, context:) { String }` replacing the built-in prompts. `kind` is `"roadmap"`, `"feature"` or `"change"`. |
|
|
99
|
+
| `prompt_builder` | `nil` | Optional `->(kind:, context:) { String }` replacing the built-in prompts. `kind` is `"roadmap"`, `"feature"` or `"change"`. The built-in prompts ask for the product area; a custom builder must ask for `"area"` in the JSON reply itself (an existing name, or a new one of 1-3 words), and can list the current areas with `Shiplog::Area.ordered.pluck(:name)`. Without `"area"` in the reply, rewrites leave areas alone. |
|
|
98
100
|
| `public_parent_controller` | `"ActionController::Base"` | Parent class of the public controllers. Read once when they load, so set it in the initializer; assigning it after the controllers loaded raises `ArgumentError`. |
|
|
99
101
|
| `public_layout` | `"shiplog/application"` | Layout of the public pages. |
|
|
100
102
|
| `admin_parent_controller` | `"ActionController::Base"` | Parent class of the admin controllers. Read once when they load, so set it in the initializer; assigning it after the controllers loaded raises `ArgumentError`. |
|
|
@@ -106,7 +108,19 @@ All settings live in `config/initializers/shiplog.rb`:
|
|
|
106
108
|
| `authorize_admin` | `->(controller) { false }` | Admin check; return false for a 403. Denies everyone until set; the generated initializer opens it, relying on your route constraint. |
|
|
107
109
|
| `current_admin` | `->(controller) { controller.try(:current_user)&.try(:email) }` | Stored as the approver of an entry. |
|
|
108
110
|
| `roadmap_path` | `"roadmap"` | Path of the public roadmap. |
|
|
109
|
-
| `changelog_path` | `"changelog"` | Path of the public changelog. |
|
|
111
|
+
| `changelog_path` | `"changelog"` | Path of the public changelog: segments of letters, digits, `-` and `_`, nested with `/` if you like (`"docs/whats_new"`). Surrounding slashes are stripped (`"/changelog"` is `"changelog"`); a blank value, spaces, dots, `?` or `#` raise `ArgumentError`. Permalinks live below it and the feed is `<path>.atom`. |
|
|
112
|
+
| `canonical_url` | `nil` | The site's canonical origin, e.g. `"https://trackberry.com"`. See [Canonical URL](#canonical-url). |
|
|
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
|
+
| `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
|
+
| `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. |
|
|
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
|
+
| `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
|
+
| `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
|
+
| `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
|
+
| `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). |
|
|
122
|
+
| `locales` | `[]` | Locales entries are translated into when approved, e.g. `[:es]`. An Array of Symbols or Strings (stored as symbols; the default locale `I18n.default_locale` is dropped); anything else raises `ArgumentError`. See [Translations](#translations). Part of the ETag. |
|
|
123
|
+
| `max_areas` | `8` | How many [product areas](#product-areas) the AI may create. Past it, entries can only join existing areas. A non-negative Integer; `0` stops the AI creating areas. |
|
|
110
124
|
|
|
111
125
|
Example:
|
|
112
126
|
|
|
@@ -121,6 +135,50 @@ Shiplog.configure do |config|
|
|
|
121
135
|
end
|
|
122
136
|
```
|
|
123
137
|
|
|
138
|
+
## Repositories
|
|
139
|
+
|
|
140
|
+
Each entry of `config.repositories` is a String (`"owner/name"`) or a Hash:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
config.repositories = [
|
|
144
|
+
"acme/app",
|
|
145
|
+
"acme/app-ios",
|
|
146
|
+
{ name: "acme/market_data", changelog: false, gem: "market_data" },
|
|
147
|
+
{ name: "acme/produce", changelog: false, gem: "acme-produce" }
|
|
148
|
+
]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
| Option | Default | Description |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `name` | required | `"owner/name"`. |
|
|
154
|
+
| `changelog` | `true` | `false` makes the repository roadmap-only: its issues still feed the roadmap, but its pull requests and commits never become changelog entries. |
|
|
155
|
+
| `gem` | `nil` | The gem's name in your `Gemfile.lock`, for [gem upgrades](#gem-upgrades). |
|
|
156
|
+
|
|
157
|
+
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`.
|
|
158
|
+
|
|
159
|
+
`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.
|
|
160
|
+
|
|
161
|
+
`config.lockfile_path` (default `"Gemfile.lock"`) is the lockfile Shiplog reads in the repositories where `changelog` is true.
|
|
162
|
+
|
|
163
|
+
## Gem upgrades
|
|
164
|
+
|
|
165
|
+
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:`.
|
|
166
|
+
|
|
167
|
+
For each merged pull request in a repository with `changelog: true` that changes the lockfile:
|
|
168
|
+
|
|
169
|
+
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`.
|
|
170
|
+
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.
|
|
171
|
+
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.
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
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.
|
|
177
|
+
|
|
178
|
+
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.
|
|
179
|
+
|
|
180
|
+
**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.
|
|
181
|
+
|
|
124
182
|
## GitHub token
|
|
125
183
|
|
|
126
184
|
Shiplog talks to the GitHub GraphQL API. Provide either:
|
|
@@ -179,11 +237,15 @@ You can also trigger a sync from the admin Sync page. Rewriting always runs in `
|
|
|
179
237
|
`config.rewriter` is a callable that receives the prompt (a String) and returns a String containing a JSON object:
|
|
180
238
|
|
|
181
239
|
```json
|
|
182
|
-
{"title": "...", "body": "...", "customer_facing": true}
|
|
240
|
+
{"title": "...", "body": "...", "customer_facing": true, "area": "Billing"}
|
|
183
241
|
```
|
|
184
242
|
|
|
243
|
+
`area` is optional (see [Product areas](#product-areas)); a reply without it works as before.
|
|
244
|
+
|
|
185
245
|
Shiplog extracts the first valid JSON object from the reply, so models that wrap it in prose or code fences still work. `customer_facing: false` means the model thinks the change is irrelevant for customers; such small changes are stored as discarded and can be restored by an admin. If no rewriter is configured, the reply can't be parsed or the call raises, the entry keeps the original GitHub text and the error is shown in the admin, where you can edit the text or regenerate.
|
|
186
246
|
|
|
247
|
+
The same callable also receives the translation, area-name translation and area-detection prompts, whose replies are other JSON shapes (`{"title", "body"}` for a translation, `{"areas": [...]}` or `{"assignments": [...]}` for areas). It must handle free-form prompts: pass the prompt to your model as is, and return its text reply, without assuming the rewrite schema above.
|
|
248
|
+
|
|
187
249
|
With [RubyLLM](https://rubyllm.com):
|
|
188
250
|
|
|
189
251
|
```ruby
|
|
@@ -226,6 +288,172 @@ Approved changelog entries do not react to later GitHub changes (a reverted pull
|
|
|
226
288
|
|
|
227
289
|
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
290
|
|
|
291
|
+
## Product updates navigation
|
|
292
|
+
|
|
293
|
+
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:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
config.extra_pages = [
|
|
297
|
+
{ label: "News", path: "/learn/news" },
|
|
298
|
+
# Callables are evaluated in the view, so route helpers and I18n work:
|
|
299
|
+
{ label: -> { t("pages.docs") }, path: -> { main_app.docs_path } }
|
|
300
|
+
]
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
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:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
class NewsController < ApplicationController
|
|
307
|
+
helper Shiplog::SwitcherHelper
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```erb
|
|
312
|
+
<%= shiplog_switcher %>
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
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.
|
|
316
|
+
|
|
317
|
+
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.
|
|
318
|
+
|
|
319
|
+
### Roadmap details
|
|
320
|
+
|
|
321
|
+
- **Status:** each column has a rule on top and a marker (`shiplog-column__marker--in-progress` / `--coming-soon` / `--later`).
|
|
322
|
+
- **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.
|
|
323
|
+
- **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.
|
|
324
|
+
- **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`).
|
|
325
|
+
|
|
326
|
+
## Changelog search
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
330
|
+
- Turn it off with `config.changelog_search = false`: no form, and `q` is ignored.
|
|
331
|
+
- 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).
|
|
332
|
+
- Texts: `shiplog.changelog.search.label`, `.submit`, `.summary` (`one` / `other`, with `count` and `query`), `.clear` and `.empty` (with `query`); all have English defaults.
|
|
333
|
+
- The ETag includes the normalised query.
|
|
334
|
+
- The model scope is `Shiplog::Entry.search(query)`.
|
|
335
|
+
|
|
336
|
+
**Keeping search pages out of search engines.** A page with a query sets `content_for :shiplog_robots, "noindex"`. Shiplog's own layout turns that into `<meta name="robots" content="noindex">`. A host layout (`config.public_layout`) controls its own `<head>`, so emit it there:
|
|
337
|
+
|
|
338
|
+
```erb
|
|
339
|
+
<%= tag.meta name: "robots", content: content_for(:shiplog_robots) if content_for?(:shiplog_robots) %>
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Product areas
|
|
343
|
+
|
|
344
|
+
The rewriter groups entries into product areas ("Billing", "Reports"), and the changelog lets visitors filter by area.
|
|
345
|
+
|
|
346
|
+
- **Detection while rewriting.** The prompt lists the existing areas (as data, before the `<source>` block) and asks the model for `"area"` in its JSON reply: an existing name, or a new name of 1-3 words when none fits. Shiplog matches the name against the existing areas ignoring case, then by slug, and creates a new area only while fewer than `config.max_areas` (default 8) exist and the name is valid: at most 40 characters, letters, digits, spaces, `&` and `-`. Any other name leaves the entry's current area as it is (a rewrite never erases an area). A reply without `"area"` (or with one that is not a String) changes nothing. Roadmap entries get areas too, but only features and changes show them publicly. A custom `config.prompt_builder` builds its own prompt, so it has to ask for `"area"` itself and can read the existing areas with `Shiplog::Area.ordered.pluck(:name)`.
|
|
347
|
+
- **Detect areas.** The admin has an Areas page with a "Detect areas" button, which enqueues `Shiplog::DetectAreasJob`. With no areas yet, the job asks your `config.rewriter` (same callable, a dedicated prompt, reply `{"areas": [...]}`) to propose up to `max_areas` areas from the titles of up to 200 approved features and changes. Then it classifies the draft and approved entries that have no area, 50 titles per prompt (reply `{"assignments": [{"id": 1, "area": "Billing"}]}`), using only existing areas; unknown ids and areas are ignored. It is idempotent (it only touches entries without an area), bounded (at most 20 classification prompts, 1000 entries, per run; click again for more) and logs what it did (`[shiplog] Detect areas: ...`). Each entry it sent is stamped (`area_detected_at`), placed or not, so entries the AI declined are not asked again and the next run moves on to the rest; tick "Re-check unplaced entries" next to the button (for example after renaming, merging or adding areas) to ask about them again. Only one detection runs at a time (a database advisory lock); a second one logs and returns. The button needs `config.rewriter` (without it nothing is enqueued and a flash says so). The job never retries: a failure is logged (class, message, first 5 backtrace lines) and the Areas page shows "Last detection: <time>, assigned N" or the error from the last run (stored in the `shiplog_settings` table). After clicking, refresh to see the result. The titles are sent, never the bodies.
|
|
348
|
+
- **Admin.** The Areas page lists areas with their entry counts. You can rename (the slug follows, translated names are dropped and translated again), move up/down, merge into another area (moves the entries, deletes the source) and delete (the entries have no area). The entry edit page has an Area select with "None"; changing it marks the entry as set by an admin (`area_set_by_admin`), and neither rewrites nor detection ever change such an entry's area. The inbox shows the area as a badge.
|
|
349
|
+
- **Public.** With at least 2 areas that have published features or changes, the changelog shows an "All" link and one link per area above the list (`?area=slug`, the `areas` / `area_link` keys, `--current` on the selected one). It combines with search and pagination (the links keep both) and is part of the ETag, with the names, slugs and order of all areas. An unknown `?area=` slug answers 404; a blank one is ignored. Every entry shows its area as a tag (`entry_area`), in the list and on the permalink page. The Atom feed adds `<category term="slug" label="Name"/>` to entries with an area and honours `?area=` (404 for an unknown one). Roadmap cards don't change.
|
|
350
|
+
- **Translated names.** With `config.locales` set, area names are translated too; see [Translations](#translations). Public pages show `area.display_name`.
|
|
351
|
+
- **Texts:** `shiplog.changelog.areas.label` and `.all`, and `shiplog.admin.areas.*`, `shiplog.admin.nav.areas`, `shiplog.admin.entries.no_area` and `.fields.area`; all have English defaults.
|
|
352
|
+
- **Schema:** the `shiplog_areas` table (`name`, `slug` unique, `position`) and `shiplog_entries.area_id` (nullable foreign key, `ON DELETE SET NULL`) and `area_set_by_admin`. The cap is checked under a Postgres advisory lock, so two rewrites racing for the last slot cannot go past it. Also `shiplog_entries.area_detected_at` (entries already sent to detection) and `shiplog_settings` (the last detection run).
|
|
353
|
+
|
|
354
|
+
## Translations
|
|
355
|
+
|
|
356
|
+
Set `config.locales = [:es]` and entries are translated into Spanish by your `config.rewriter`, then published with the English text: **there is no second approval**.
|
|
357
|
+
|
|
358
|
+
- **When.** `Shiplog::TranslateEntryJob` is enqueued for each configured locale when an entry is approved (roadmap entries too), and when the English title or body of an approved entry changes (an admin edit). Editing anything else (the release date, the area) does not translate. Drafts, discarded entries and the unpublishing "Regenerate" (which returns an entry to draft) do not either.
|
|
359
|
+
- **How.** One LLM call per entry, per locale and per change. The prompt asks for the title and body in the language of the locale (Shiplog names about 40 languages; any other locale code is named by its code), keeping the meaning, the tone, product names, numbers, links and formatting, with your `tone`, `audience` and `product_name` as context, and for a JSON reply `{"title": "...", "body": "..."}`. The English text goes inside `<source>` and is neutralised like the rewriting prompts (a `<source>` or `</source>` in it becomes `[source]`). The reply is validated like the English kinds: strings, a non-blank title, at most 120 characters of title and 2000 of body (a roadmap body is cut at the roadmap limit, about 220 characters, so a translation longer than that loses its last sentence). A custom `config.prompt_builder` is for rewriting GitHub text and is not used here.
|
|
360
|
+
- **Stored in** `shiplog_entry_translations` (`entry_id`, `locale`, `title`, `body`, `source_digest`, `edited_by_admin`; unique on `[entry_id, locale]`, deleted with the entry). `source_digest` is the SHA-256 of the English title and body the translation was made from.
|
|
361
|
+
- **The stale rule.** A translation is current while its `source_digest` matches the entry's English text now. Pages show it only then; a missing or stale one shows the English text. A job whose translation is current does nothing and makes no LLM call, so repeated approvals and double clicks cost nothing. The job checks again, under the entry's row lock, before it writes: if the entry was unpublished or its English text changed meanwhile, the result is dropped (the change enqueued its own job).
|
|
362
|
+
- **Admin edits.** The entry edit page has a Translations section per locale with the title and body, editable. Saving marks the translation as edited by an admin and as made from the current English text (so it shows at once, even when it was stale). An edited translation is kept as long as the English text stays the same; when the English text changes, the new translation replaces it and clears the mark (the replacement is logged). The section marks each locale as Missing, Stale, Up to date, and Edited by an admin. **Retranslate** enqueues the job with `force: true`, which ignores the digest and an admin's edit.
|
|
363
|
+
- **Entries approved before 0.3.0** (or before you set `config.locales`) have no translations: run `bin/rails shiplog:translate_approved`. It enqueues `TranslateEntryJob` for every approved entry and configured locale that has no current translation, reading entries in batches (`BATCH_SIZE=100` by default), and prints the counts per locale (and enqueues `TranslateAreaJob` for areas missing a name in some locale). It is idempotent: what is current is not enqueued, and the job's digest check skips it anyway. It needs `config.locales` and `config.rewriter`, and costs one LLM call per job.
|
|
364
|
+
- **Errors.** A failing rewriter (or an unusable reply) is logged and retried twice with a growing wait (`retry_on StandardError, attempts: 3, wait: :polynomially_longer`), then logged and dropped: the English text stays shown and Retranslate tries again. Without `config.rewriter` nothing is translated. An entry deleted since the job was queued is ignored.
|
|
365
|
+
- **Area names.** `areas.name_translations` (jsonb, keyed by locale string) holds each area's name per configured locale, and `Area#display_name(locale = I18n.locale)` returns the translation for a locale in `config.locales`, else the name. The filter pills, the entry tags, the permalink tag and the Atom `<category label>` use it; slugs and the category `term` stay English, so `?area=` URLs are the same in every locale. `Shiplog::TranslateAreaJob(area, force: false)` is enqueued when an area is created (a rewrite, detection) or renamed, if `config.locales` and `config.rewriter` are set. It makes one call for all locales (the name inside `<source>`, neutralised; reply `{"es": "..."}`); every locale asked for must come back as a valid area name (at most 40 characters, letters, digits, spaces, `&`, `-`) or the reply is rejected, retried twice and dropped like entry translations. Without `force` it only fills locales that have no name and never touches an existing one, so a repeat costs no call. A rename drops the stored translations and translates the new name. The Areas page shows a field per locale on each area: what an admin types is kept (an empty field removes it) until the area is renamed or forced. **Translate names** enqueues the job for the areas missing a name in some locale; it only fills in the missing names, so edits are kept. `bin/rails shiplog:translate_approved` enqueues the job (no force) for those areas too. The ETags include the translated names.
|
|
366
|
+
- **Unpublishing.** The job writes nothing for an entry that is not approved. Discarding keeps the rows, but they are never shown: every public query lists approved entries only.
|
|
367
|
+
- **Public pages.** When `I18n.locale` is one of `config.locales`, the roadmap cards, the changelog list, the permalink page (title, body, `<title>`, meta description and Open Graph tags), the Atom feed and the "Recently shipped" list show `entry.display_title` and `entry.display_body` (the translation, else English); call them in your own views. Translations are loaded for the current locale only, in one query per page. Slugs stay English and are shared by all locales. Under the default locale or any other, nothing is queried and English shows. Screenshots' default alt text follows the title shown.
|
|
368
|
+
- **Search** under a translated locale matches the translated title or body of a current translation, or the English text, with the same escaping.
|
|
369
|
+
- **Feed.** `/changelog.atom` follows the request locale (`xml:lang`); an entry's `updated` is the later of its own and its translation's.
|
|
370
|
+
- **Caching.** The ETag includes the locale, `config.locales` (through `render_digest`) and the id and `updated_at` of the translations shown.
|
|
371
|
+
- **Routing is yours.** Shiplog renders under whatever `I18n.locale` your app sets; it adds no locale routes. For `/es/roadmap` and `/es/changelog`, scope your engine mount or set the locale in a controller concern or middleware (the test app uses a tiny middleware that moves `/es` into `SCRIPT_NAME`, so links keep the prefix). A host that redirects `/es/...` to English has to stop doing so. The locale must be in `I18n.available_locales`.
|
|
372
|
+
- **The gem's own texts.** `config/locales/es.yml` translates every `shiplog.*` key (public and admin) into neutral Spanish, and the month names its date formats use. Override any key in your own locale files; `test/shiplog/es_locale_test.rb` keeps `es.yml` complete, and you can copy it for another language.
|
|
373
|
+
- **Cost.** Each approval and each English edit of an approved entry costs one LLM call per locale; a Retranslate costs one.
|
|
374
|
+
|
|
375
|
+
## Screenshots
|
|
376
|
+
|
|
377
|
+
Entries can show screenshots: the first one on the changelog list, all of them on the entry's permalink page, and in the Atom feed and the `og:image` tag.
|
|
378
|
+
|
|
379
|
+
- **Needs Active Storage.** The host must load it (`require "active_storage/engine"` in `config/application.rb`, or `rails/all`) and have its tables (`bin/rails active_storage:install`, then `db:migrate`). Without it, or when its tables (`active_storage_attachments`, `active_storage_blobs`) don't exist yet, screenshots are off: nothing is fetched, uploaded, queried or rendered, nothing crashes, and Shiplog logs the reason once (`[shiplog] Screenshots are off: Active Storage is not loaded...` at boot, or `...tables are missing...` the first time a page checks; the check runs once per process and is not repeated per request). `config.images = false` turns them off too. Shiplog declares `has_many_attached :images` on `Shiplog::Entry` only when Active Storage is loaded. Active Storage's routes belong to your app: Shiplog's views reach them through `main_app`.
|
|
380
|
+
- **From GitHub.** When a draft is written (and when it is regenerated), the job fetches the issue's or pull request's rendered body (`bodyHTML`, one small GraphQL query per draft, so the sync payloads don't grow) and downloads the `<img>` images in it, at most `config.max_images_per_entry` (4). It only does so for a draft that has no images yet: images of an approved entry are never changed automatically, and nothing is fetched twice. Only these hosts are fetched, https only, exact host match: `private-user-images.githubusercontent.com`, `user-images.githubusercontent.com`, `camo.githubusercontent.com`, `raw.githubusercontent.com`, `objects.githubusercontent.com` and `github.com` for `/user-attachments/assets/` paths. `github.com` paths must be exactly `/user-attachments/assets/<letters, digits and dashes>` (an encoded `..` or `%2f` is refused). `raw.` and `objects.githubusercontent.com` are on the list because GitHub serves attachments, and redirects them, from there. Redirects are followed up to 3 times, each checked against the list again, and the body of a redirect is read under the same size cap. Timeouts are 5 seconds (connect and read), a download gets 15 seconds in total (checked while streaming and between redirects, on a monotonic clock) and all the images of one entry 45 seconds (the rest are skipped and logged), the size limit is 5 MB of raw bytes (requests send `Accept-Encoding: identity`; the download stops past the limit), and the type is decided by the file's first bytes: PNG, JPEG, GIF or WebP only (SVG and HTML are refused, whatever the extension says). The pixel size is read from the header too (no decoding): an image over 10,000 pixels on a side or 40 megapixels, or one whose size can't be read, is refused, for downloads and for admin uploads alike. The width and height are stored in the blob's metadata at once, so the pages carry them without waiting for Active Storage's analysis. The GitHub token is sent to `github.com` only, never to the other hosts; signed `private-user-images` URLs need none. Any failure is logged and skipped, and never fails the draft. The token needs no extra permission.
|
|
381
|
+
- **Admin.** The entry edit page lists the images in order, each with an alt text field, "Remove" and "Up"/"Down" buttons, plus an upload form (several files; same type and size checks, done on the file's bytes on the server). Thumbnails are 200px variants when the `image_processing` gem is in your bundle and a processor is available (`ruby-vips` loads, or `mini_magick` is in the bundle), else the original, sized by CSS.
|
|
382
|
+
- **Order and alt text.** Stored on the entry: `image_order` (jsonb, blob ids in order) and `image_alts` (jsonb, blob id => text). An image without alt text uses the entry's title. `Shiplog::Entry#ordered_images` returns the attachments in order.
|
|
383
|
+
- **Public.** `<img loading="lazy">` with the alt text, `width` and `height` (taken from the image's header when it is attached, so they don't wait for the analysis). The URLs come from your routes (`url_for` on the attachment, so `resolve_model_to_route` applies). The `og:image` and the feed use the absolute URL (`config.canonical_url` when set). The ETags include the images' blob ids, order, alt texts and sizes.
|
|
384
|
+
- **Texts:** `shiplog.admin.entries.images.*`, all with English defaults.
|
|
385
|
+
- **Schema:** `shiplog_entries.image_order` and `.image_alts`, in the 0.3.0 migration, plus Active Storage's own tables.
|
|
386
|
+
|
|
387
|
+
## Entry permalinks
|
|
388
|
+
|
|
389
|
+
Every approved feature and change has its own page at `GET /changelog/:slug` (under `config.changelog_path`). The changelog list links each title to it. Roadmap entries have no permalink.
|
|
390
|
+
|
|
391
|
+
- **Slug:** made when the entry is first approved, from the title (`parameterize` with underscores turned into dashes, so `a-z`, `0-9` and single dashes only; at most 80 characters, `entry-<id>` when nothing is left) and made unique with `-2`, `-3`, ... A later title edit keeps the slug, so links never break; restoring and re-approving an entry keeps it too. A unique index (partial, on non-null slugs) guards against two approvals racing for one slug, and the loser retries.
|
|
392
|
+
- **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
|
+
- **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
|
+
- **Caching:** the ETag covers the entry and its neighbours' ids and `updated_at`.
|
|
395
|
+
- **Head tags:** the page sets `content_for :title`, `content_for :shiplog_description` (the body, cut at 160 characters) and `content_for :shiplog_og` (Open Graph: `og:title`, `og:description`, `og:type` = `article`, `og:url`). Shiplog's own layout renders them. A host layout (`config.public_layout`) controls its own `<head>`, so render them there, as for `:shiplog_robots`:
|
|
396
|
+
|
|
397
|
+
```erb
|
|
398
|
+
<title><%= content_for(:title) %></title>
|
|
399
|
+
<%= tag.meta name: "description", content: content_for(:shiplog_description) if content_for?(:shiplog_description) %>
|
|
400
|
+
<%= content_for(:shiplog_og) %>
|
|
401
|
+
<%= content_for(:shiplog_feed) %>
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
- **Styling:** the `permalink` (the `article`), `permalink_back`, `permalink_nav` and `permalink_nav_link` keys of [`config.classes`](#3-configclasses-tailwind-and-other-utility-frameworks). Override `shiplog/changelogs/_entry_content.html.erb` (locals `entry:`, `heading:`, `linked:`) to change what an entry shows in both the list and the permalink page.
|
|
405
|
+
- **Texts:** `shiplog.permalink.title`, `.back`, `.nav_label`, `.newer` and `.older` (with `title`); all have English defaults.
|
|
406
|
+
|
|
407
|
+
## Atom feed
|
|
408
|
+
|
|
409
|
+
`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
|
+
|
|
411
|
+
The changelog and permalink pages advertise it with a `<link rel="alternate" type="application/atom+xml">` (`content_for :shiplog_feed`, rendered by Shiplog's layout; render it in a host layout too, see above) and the changelog shows a visible "Subscribe (Atom)" link (the `footnote_link` key). The feed has an `<author>` (the product name, or "Changelog"), which RFC 4287 requires when entries carry none. The feed is titled "Changelog", or "<product_name> changelog" when `config.product_name` is set, and follows the request locale. Texts: `shiplog.feed.title_with_product`, `.link_title` and `.subscribe`.
|
|
412
|
+
|
|
413
|
+
### Canonical URL
|
|
414
|
+
|
|
415
|
+
By default the feed's ids and every absolute URL (the feed's self and alternate links, entry links, `og:url`, the feed discovery link) are built from the request's host, which varies: apex and `www`, or a forged `X-Forwarded-Host`. A feed reader treats a changed id as a new entry, so set the origin once:
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
config.canonical_url = "https://trackberry.com"
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
It must be an `http(s)` origin: scheme, host and an optional port, no path, query or credentials (a trailing slash is dropped; anything else raises `ArgumentError`). A path prefix is not needed: if you mount the engine under one (`mount Shiplog::Engine => "/updates"`), the routes already add it, so the feed lives at `https://trackberry.com/updates/changelog.atom`. The feed's tag URIs use the canonical host. It is part of `render_digest`, so changing it refreshes cached pages, as does `product_name`. Leave it `nil` to keep the request's host.
|
|
422
|
+
|
|
423
|
+
Behind a proxy or load balancer, the scheme of the URLs built from the request (when `canonical_url` is `nil`) comes from `X-Forwarded-Proto`: set `config.assume_ssl = true` in production (or have your proxy send the header), or the URLs say `http://`.
|
|
424
|
+
|
|
425
|
+
## What's new: the unseen count
|
|
426
|
+
|
|
427
|
+
Shiplog doesn't know your users, so you supply the "last seen" time:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
# config/initializers/shiplog.rb
|
|
431
|
+
config.on_changelog_seen = lambda do |controller|
|
|
432
|
+
user = controller.send(:current_user) # current_user is often private; a Current attribute (Current.user) works too
|
|
433
|
+
# Write only when something is new: the callback runs on every visit, before the page renders.
|
|
434
|
+
user.update_column(:changelog_seen_at, Time.current) if user && Shiplog.unseen_count(since: user.changelog_seen_at).positive?
|
|
435
|
+
end
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
Shiplog.unseen_count(since: user.changelog_seen_at) # approved features and changes approved after it, at most 99
|
|
440
|
+
Shiplog.unseen_count(since: nil) # all of them, at most 99
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
class ApplicationController < ActionController::Base
|
|
445
|
+
helper Shiplog::UnseenHelper
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
```erb
|
|
450
|
+
<%= shiplog_unseen_count(current_user.changelog_seen_at) %>
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
The count is one `COUNT` query over the `(status, approved_at)` index. It filters on `approved_at` (when the entry was approved), not on the release date the list is ordered by: "new since you last looked" is about when an entry appeared, so an entry approved today with an earlier `released_on` still counts as new.
|
|
454
|
+
|
|
455
|
+
`on_changelog_seen` runs **before the page renders**, on the changelog index and on permalink pages whose entry exists, for 200 and 304 answers alike (a visitor whose browser revalidates its cached page has still seen the changelog). Running first means a navbar badge rendered in the same response shows the count after your update. It does not run for 404s (an unknown permalink, a page past the last one), the feed, the roadmap or non-HTML formats. Because it runs on every visit and **may write**, keep it cheap: write only when the stored time is older than the newest entry, as in the example above (one `COUNT`, and a write only when there is something new). The response is not sent yet, so the callback can still set a cookie. An exception in the callback is logged with its class, message and the first five backtrace lines, never shown. A host layout that shows the unseen count renders per-user content, so declare `etag { current_user&.changelog_seen_at }` on your `public_parent_controller`, or a cached page keeps showing the old count.
|
|
456
|
+
|
|
229
457
|
## Styling
|
|
230
458
|
|
|
231
459
|
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 +528,12 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
|
|
|
300
528
|
| `page_header` | `shiplog-page__header` | Header with title and lede |
|
|
301
529
|
| `page_title` | `shiplog-page__title` | `h1` |
|
|
302
530
|
| `page_lede` | `shiplog-page__lede` | Intro paragraph |
|
|
531
|
+
| `switcher` | `shiplog-switcher` | Switcher `nav` (Roadmap, Changelog, extra pages) |
|
|
532
|
+
| `switcher_link` | `shiplog-switcher__link` (+ `--current`) | One switcher link |
|
|
303
533
|
| `columns` | `shiplog-columns` | Roadmap column grid |
|
|
304
534
|
| `column` | `shiplog-column` (+ `--in-progress` / `--coming-soon` / `--later`) | One roadmap column |
|
|
305
535
|
| `column_header` | `shiplog-column__header` | Column heading block |
|
|
536
|
+
| `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
537
|
| `column_title` | `shiplog-column__title` | Column `h2` |
|
|
307
538
|
| `column_count` | `shiplog-column__count` | Number of items |
|
|
308
539
|
| `column_description` | `shiplog-column__description` | One-line description |
|
|
@@ -311,6 +542,16 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
|
|
|
311
542
|
| `card` | `shiplog-card` | Roadmap card |
|
|
312
543
|
| `card_title` | `shiplog-card__title` | Card `h3` |
|
|
313
544
|
| `card_body` | `shiplog-card__body` | Card text |
|
|
545
|
+
| `card_meta` | `shiplog-card__meta` | Line under the card text (omitted when empty) |
|
|
546
|
+
| `card_tag` | `shiplog-card__tag` | Repository label |
|
|
547
|
+
| `shipped` | `shiplog-shipped` | "Recently shipped" section |
|
|
548
|
+
| `shipped_title` | `shiplog-shipped__title` | Its `h2` |
|
|
549
|
+
| `shipped_list` | `shiplog-shipped__list` | Its list |
|
|
550
|
+
| `shipped_item` | `shiplog-shipped__item` | One entry |
|
|
551
|
+
| `shipped_date` | `shiplog-shipped__date` | Release date of the entry |
|
|
552
|
+
| `shipped_link` | `shiplog-shipped__link` (+ `--all` on the changelog link) | Links to the changelog |
|
|
553
|
+
| `footnote` | `shiplog-footnote` | Footnote paragraph |
|
|
554
|
+
| `footnote_link` | `shiplog-footnote__link` (+ `--feed` on the Atom link) | "Suggest a feature" and "Subscribe (Atom)" links |
|
|
314
555
|
| `months` | `shiplog-months` | Changelog months wrapper |
|
|
315
556
|
| `month` | `shiplog-month` | One month section |
|
|
316
557
|
| `month_title` | `shiplog-month__title` | Month `h2` |
|
|
@@ -324,16 +565,32 @@ Without it, `shiplog` is first seen when `shiplog.css` loads, which can put it a
|
|
|
324
565
|
| `pagination` | `shiplog-pagination` | Pagination `nav` |
|
|
325
566
|
| `pagination_link` | `shiplog-pagination__link` (+ `--newer` / `--older`) | Newer/older links |
|
|
326
567
|
| `empty` | `shiplog-empty` | "Nothing here yet" notice |
|
|
327
|
-
|
|
328
|
-
|
|
568
|
+
| `search` | `shiplog-search` | Changelog search `form` |
|
|
569
|
+
| `search_input` | `shiplog-search__input` | The `type="search"` input |
|
|
570
|
+
| `search_button` | `shiplog-search__button` | Submit button |
|
|
571
|
+
| `search_summary` | `shiplog-search__summary` | "N results for ..." line |
|
|
572
|
+
| `search_clear` | `shiplog-search__clear` | "Clear search" link |
|
|
573
|
+
| `search_empty` | `shiplog-search__empty` | "No entries match" notice |
|
|
574
|
+
| `permalink` | `shiplog-permalink` (+ `--feature` / `--change`) | The `article` of a permalink page |
|
|
575
|
+
| `permalink_back` | `shiplog-permalink__back` | "Back to the changelog" link |
|
|
576
|
+
| `permalink_nav` | `shiplog-permalink__nav` | Newer / older `nav` |
|
|
577
|
+
| `permalink_nav_link` | `shiplog-permalink__nav-link` (+ `--newer` / `--older`) | One neighbour link |
|
|
578
|
+
| `areas` | `shiplog-areas` | The area filter `nav` |
|
|
579
|
+
| `area_link` | `shiplog-areas__link` (+ `--current`) | One filter link, "All" included |
|
|
580
|
+
| `entry_area` | `shiplog-entry__area` | An entry's area tag |
|
|
581
|
+
| `entry_images` | `shiplog-entry__images` | The container of an entry's screenshots |
|
|
582
|
+
| `entry_image` | `shiplog-entry__image` | One screenshot (`img`) |
|
|
583
|
+
|
|
584
|
+
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
585
|
|
|
330
586
|
### 4. Override the partials
|
|
331
587
|
|
|
332
588
|
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
589
|
|
|
334
590
|
- `shiplog/_page.html.erb`, `shiplog/_page_header.html.erb`, `shiplog/_pagination.html.erb`, `shiplog/_empty.html.erb`
|
|
335
|
-
- `shiplog/
|
|
336
|
-
- `shiplog/
|
|
591
|
+
- `shiplog/_switcher.html.erb`
|
|
592
|
+
- `shiplog/roadmaps/_column.html.erb`, `shiplog/roadmaps/_card.html.erb`, `shiplog/roadmaps/_shipped.html.erb`
|
|
593
|
+
- `shiplog/changelogs/_month.html.erb`, `shiplog/changelogs/_entry.html.erb`, `shiplog/changelogs/_search.html.erb`
|
|
337
594
|
|
|
338
595
|
`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
596
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
# "Detect areas": enqueues the backfill. The page shows no job state, only the flash.
|
|
4
|
+
class AreaDetectionsController < ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
unless Shiplog.config.rewriter
|
|
7
|
+
return redirect_to areas_path, alert: t("shiplog.admin.areas.no_rewriter", default: "Detection needs config.rewriter, which is not set.")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
DetectAreasJob.perform_later(recheck: params[:recheck] == "1")
|
|
11
|
+
redirect_to areas_path, notice: t("shiplog.admin.areas.detection_queued", default: "Detection queued. Refresh in a moment.")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
# "Translate names": enqueues a TranslateAreaJob, which only fills in missing names (edits are kept), for every area that has no name in some configured
|
|
4
|
+
# locale. The page shows no job state, only the flash.
|
|
5
|
+
class AreaTranslationsController < ApplicationController
|
|
6
|
+
def create
|
|
7
|
+
if Shiplog.config.locales.empty?
|
|
8
|
+
return redirect_to areas_path, alert: t("shiplog.admin.areas.no_locales", default: "Translating names needs config.locales, which is empty.")
|
|
9
|
+
end
|
|
10
|
+
unless Shiplog.config.rewriter
|
|
11
|
+
return redirect_to areas_path, alert: t("shiplog.admin.areas.no_rewriter_translate", default: "Translating names needs config.rewriter, which is not set.")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
count = Area.enqueue_missing_translations(force: false)
|
|
15
|
+
redirect_to areas_path, notice: t("shiplog.admin.areas.translation_queued", count:, default: { one: "Translation queued for %{count} area. Refresh in a moment.", other: "Translation queued for %{count} areas. Refresh in a moment." })
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
module Areas
|
|
4
|
+
# Moves every entry of an area to another one and deletes the first.
|
|
5
|
+
class MergesController < ApplicationController
|
|
6
|
+
def create
|
|
7
|
+
area = Area.find(params[:area_id])
|
|
8
|
+
target = Area.find_by(id: params[:target_id])
|
|
9
|
+
if target.nil? || target == area
|
|
10
|
+
redirect_to areas_path, alert: t("shiplog.admin.areas.merge_target", default: "Choose another area to merge into.")
|
|
11
|
+
else
|
|
12
|
+
area.merge_into!(target)
|
|
13
|
+
redirect_to areas_path, notice: t("shiplog.admin.areas.merged", from: area.name, into: target.name, default: "%{from} merged into %{into}.")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|