shiplog 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +172 -7
  4. data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
  5. data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
  6. data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
  7. data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
  8. data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
  9. data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
  10. data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
  11. data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
  12. data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
  13. data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
  14. data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
  15. data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
  16. data/app/controllers/shiplog/changelogs_controller.rb +21 -5
  17. data/app/controllers/shiplog/public_controller.rb +57 -0
  18. data/app/controllers/shiplog/roadmaps_controller.rb +3 -1
  19. data/app/frontend/shiplog/admin.css +9 -0
  20. data/app/frontend/shiplog/shiplog.css +47 -2
  21. data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
  22. data/app/helpers/shiplog/head_helper.rb +95 -0
  23. data/app/helpers/shiplog/head_tags_helper.rb +24 -0
  24. data/app/helpers/shiplog/permalink_helper.rb +149 -0
  25. data/app/helpers/shiplog/roadmap_helper.rb +9 -0
  26. data/app/helpers/shiplog/structured_data_helper.rb +74 -0
  27. data/app/helpers/shiplog/unseen_helper.rb +9 -0
  28. data/app/jobs/shiplog/detect_areas_job.rb +18 -0
  29. data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
  30. data/app/jobs/shiplog/translate_area_job.rb +25 -0
  31. data/app/jobs/shiplog/translate_entry_job.rb +25 -0
  32. data/app/models/shiplog/area.rb +156 -0
  33. data/app/models/shiplog/area_detector.rb +146 -0
  34. data/app/models/shiplog/area_translator.rb +77 -0
  35. data/app/models/shiplog/entry/images.rb +127 -0
  36. data/app/models/shiplog/entry.rb +204 -5
  37. data/app/models/shiplog/entry_translation.rb +15 -0
  38. data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
  39. data/app/models/shiplog/image_fetcher/sources.rb +41 -0
  40. data/app/models/shiplog/image_fetcher.rb +72 -0
  41. data/app/models/shiplog/image_type.rb +111 -0
  42. data/app/models/shiplog/rewriter/prompt.rb +31 -7
  43. data/app/models/shiplog/rewriter.rb +41 -23
  44. data/app/models/shiplog/setting.rb +19 -0
  45. data/app/models/shiplog/translator/prompt.rb +40 -0
  46. data/app/models/shiplog/translator.rb +99 -0
  47. data/app/views/layouts/shiplog/application.html.erb +1 -1
  48. data/app/views/shiplog/_empty.html.erb +1 -1
  49. data/app/views/shiplog/_head_tags.html.erb +5 -0
  50. data/app/views/shiplog/_pagination.html.erb +3 -3
  51. data/app/views/shiplog/admin/_nav.html.erb +2 -1
  52. data/app/views/shiplog/admin/areas/index.html.erb +78 -0
  53. data/app/views/shiplog/admin/entries/_entry_row.html.erb +3 -0
  54. data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
  55. data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
  56. data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
  57. data/app/views/shiplog/changelog_entries/show.html.erb +23 -0
  58. data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
  59. data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
  60. data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
  61. data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
  62. data/app/views/shiplog/changelogs/_search.html.erb +4 -2
  63. data/app/views/shiplog/changelogs/show.html.erb +13 -7
  64. data/app/views/shiplog/head/_feed.html.erb +1 -0
  65. data/app/views/shiplog/head/_meta.html.erb +3 -0
  66. data/app/views/shiplog/head/_open_graph.html.erb +6 -0
  67. data/app/views/shiplog/head/_structured_data.html.erb +1 -0
  68. data/app/views/shiplog/roadmaps/_card.html.erb +2 -2
  69. data/app/views/shiplog/roadmaps/_column.html.erb +3 -3
  70. data/app/views/shiplog/roadmaps/_shipped.html.erb +1 -1
  71. data/app/views/shiplog/roadmaps/show.html.erb +5 -2
  72. data/config/admin_routes.rb +16 -0
  73. data/config/locales/en.yml +85 -0
  74. data/config/locales/es.yml +223 -0
  75. data/config/routes.rb +5 -0
  76. data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
  77. data/lib/generators/shiplog/install/install_generator.rb +10 -1
  78. data/lib/generators/shiplog/install/templates/initializer.rb.tt +58 -1
  79. data/lib/shiplog/classes.rb +10 -1
  80. data/lib/shiplog/configuration.rb +105 -3
  81. data/lib/shiplog/engine.rb +8 -0
  82. data/lib/shiplog/github/queries.rb +13 -1
  83. data/lib/shiplog/version.rb +1 -1
  84. data/lib/shiplog.rb +73 -0
  85. data/lib/tasks/shiplog.rake +29 -0
  86. metadata +47 -1
@@ -0,0 +1,223 @@
1
+ # Textos de Shiplog en español. Los hosts pueden sobrescribir cualquier clave en sus propios archivos de idioma.
2
+ es:
3
+ activerecord:
4
+ attributes:
5
+ shiplog/entry:
6
+ changelog_kind: Categoría
7
+ # Los nombres de los meses que usa el formato de fecha de Shiplog (rails-i18n los trae también; son los mismos).
8
+ date:
9
+ month_names: [~, enero, febrero, marzo, abril, mayo, junio, julio, agosto, septiembre, octubre, noviembre, diciembre]
10
+ shiplog:
11
+ columns:
12
+ in_progress: En desarrollo
13
+ coming_soon: Próximamente
14
+ later: Más adelante
15
+ in_progress_description: Lo que estamos construyendo ahora
16
+ coming_soon_description: Lo siguiente en la lista
17
+ later_description: En nuestro radar
18
+ categories:
19
+ feature: Nuevo
20
+ change: Mejorado
21
+ empty: Todavía no hay nada
22
+ date_format: "%-d de %B de %Y"
23
+ roadmap:
24
+ title: Hoja de ruta
25
+ lede: En qué estamos trabajando y qué viene después.
26
+ parts:
27
+ one: Incluye %{count} parte
28
+ other: Incluye %{count} partes
29
+ shipped_heading: Lanzado recientemente
30
+ shipped_date_format: "%-d de %B"
31
+ full_changelog: Ver el registro de cambios completo
32
+ footnote: ""
33
+ suggest: Sugerir una función
34
+ switcher:
35
+ label: Actualizaciones del producto
36
+ roadmap: Hoja de ruta
37
+ changelog: Registro de cambios
38
+ changelog:
39
+ title: Registro de cambios
40
+ lede: Nuevas funciones y mejoras, a medida que se lanzan.
41
+ pagination:
42
+ label: Paginación
43
+ older: Anteriores
44
+ newer: Más recientes
45
+ search:
46
+ label: Buscar en el registro de cambios
47
+ submit: Buscar
48
+ summary:
49
+ one: "%{count} resultado para “%{query}”"
50
+ other: "%{count} resultados para “%{query}”"
51
+ clear: Borrar búsqueda
52
+ empty: "Ninguna entrada coincide con “%{query}”."
53
+ areas:
54
+ label: Filtrar por área
55
+ all: Todas
56
+ permalink:
57
+ title: "%{title} · %{changelog}"
58
+ back: "← Volver al registro de cambios"
59
+ nav_label: Más cambios
60
+ newer: "← Más reciente: %{title}"
61
+ older: "Anterior: %{title} →"
62
+ feed:
63
+ title_with_product: "Registro de cambios de %{product}"
64
+ link_title: Registro de cambios (Atom)
65
+ subscribe: Suscribirse (Atom)
66
+ admin:
67
+ title: Administración de Shiplog
68
+ nav:
69
+ drafts: Borradores
70
+ board: Tablero de la hoja de ruta
71
+ sync: Sincronización
72
+ areas: Áreas
73
+ areas:
74
+ title: Áreas del producto
75
+ help: La IA agrupa las entradas en áreas (como máximo %{max}) mientras las reescribe. Los visitantes pueden filtrar el registro de cambios por área cuando hay dos áreas con entradas publicadas.
76
+ detect: Detectar áreas
77
+ recheck: Revisar de nuevo las entradas sin área
78
+ recheck_help: Vuelve a preguntar a la IA por las entradas que dejó sin área, por ejemplo después de renombrar, fusionar o añadir áreas.
79
+ no_rewriter: La detección necesita config.rewriter, que no está configurado.
80
+ last_detection: "Última detección: %{time}, %{count} asignadas"
81
+ last_error: "La última detección falló (%{time}): %{error}"
82
+ detection_queued: Detección en cola. Actualiza en un momento.
83
+ translate_names: Traducir nombres
84
+ translate_names_help: Pide a la IA los nombres que faltan en algún idioma. Los nombres que ya existen, incluidos los que editaste, se conservan.
85
+ no_locales: Traducir los nombres necesita config.locales, que está vacío.
86
+ no_rewriter_translate: Traducir los nombres necesita config.rewriter, que no está configurado.
87
+ translation_queued:
88
+ one: Traducción en cola para %{count} área. Actualiza en un momento.
89
+ other: Traducción en cola para %{count} áreas. Actualiza en un momento.
90
+ empty: Todavía no hay áreas. Detecta áreas para que se propongan a partir de tus entradas.
91
+ columns:
92
+ name: Nombre
93
+ entries: Entradas
94
+ order: Orden
95
+ merge: Fusionar con
96
+ name_label: Nombre
97
+ rename: Renombrar
98
+ renamed: Área renombrada.
99
+ move_up: Subir
100
+ move_down: Bajar
101
+ merge: Fusionar
102
+ merge_into: Fusionar con
103
+ merged: "%{from} se fusionó con %{into}."
104
+ merge_target: Elige otra área con la que fusionar.
105
+ merge_help: Al fusionar, las entradas pasan a la otra área y esta se elimina. Si eliminas un área, sus entradas se quedan sin área.
106
+ delete: Eliminar
107
+ deleted: Área eliminada. Sus entradas ya no tienen área.
108
+ entries:
109
+ title: Entradas
110
+ filters: Filtrar entradas
111
+ all_kinds: Todos los tipos
112
+ empty: Ninguna entrada coincide.
113
+ pagination: Paginación
114
+ older: Anteriores
115
+ newer: Más recientes
116
+ saved: Entrada guardada.
117
+ approved: Entrada aprobada.
118
+ discarded: Entrada descartada.
119
+ restored: Entrada devuelta a borrador.
120
+ invalid_transition: Esa acción no está disponible para esta entrada en su estado actual.
121
+ rewrite_queued: Reescritura en cola. Actualiza en un momento.
122
+ edit: Editar
123
+ edit_title: "Editar %{kind}"
124
+ approve: Aprobar
125
+ discard: Descartar
126
+ restore: Restaurar
127
+ save: Guardar
128
+ regenerate: Regenerar
129
+ no_area: Ninguna
130
+ area_gone: ya no existe
131
+ regenerate_warning: Al regenerar, esta entrada deja de estar publicada hasta que se apruebe de nuevo.
132
+ source_changed: El texto de GitHub cambió desde este borrador
133
+ rewrite_error: Falló la reescritura
134
+ not_rewritten: Todavía sin reescribir
135
+ public_text: Texto público
136
+ source: Origen en GitHub
137
+ back: Volver a los borradores
138
+ images:
139
+ title: Capturas de pantalla
140
+ help: Se muestran bajo el texto en el registro de cambios (la primera en la lista, todas en la página de la entrada). Se toman de la incidencia o del pull request de GitHub al escribir el borrador; también puedes subir las tuyas.
141
+ empty: Todavía no hay capturas.
142
+ alt: Texto alternativo
143
+ alt_placeholder: Describe la imagen (por defecto, el título)
144
+ save_alt: Guardar texto alternativo
145
+ remove: Quitar
146
+ move_up: Subir
147
+ move_down: Bajar
148
+ upload: Subir imágenes
149
+ upload_label: Imágenes (PNG, JPEG, GIF o WebP, hasta 5 MB cada una)
150
+ none_selected: Elige al menos una imagen.
151
+ rejected: "No se añadieron; solo se aceptan imágenes PNG, JPEG, GIF y WebP de hasta 5 MB y 10.000 píxeles por lado: %{names}"
152
+ added:
153
+ one: Imagen añadida.
154
+ other: "%{count} imágenes añadidas."
155
+ alt_saved: Texto alternativo guardado.
156
+ removed: Imagen quitada.
157
+ moved: Imagen movida.
158
+ translations:
159
+ title: Traducciones
160
+ help: "Al aprobar una entrada, esta se traduce automáticamente, y la traducción se publica junto con el texto en inglés. Una traducción que edites se conserva hasta que cambie el texto en inglés; entonces se vuelve a traducir. Una traducción desactualizada o ausente no se muestra: los visitantes ven el texto en inglés."
161
+ not_approved: Las traducciones se escriben cuando se aprueba la entrada.
162
+ missing: Falta
163
+ stale: Desactualizada
164
+ current: Al día
165
+ edited: Editada por un administrador
166
+ retranslate: Traducir de nuevo
167
+ saved: Traducción guardada.
168
+ queued: Traducción en cola. Actualiza en un momento.
169
+ only_approved: Solo se traducen las entradas aprobadas.
170
+ no_rewriter: Para traducir hace falta config.rewriter, que no está configurado.
171
+ category_options:
172
+ feature: Nueva función
173
+ change: Mejora
174
+ statuses:
175
+ draft: Borradores
176
+ approved: Aprobadas
177
+ discarded: Descartadas
178
+ kinds:
179
+ roadmap: Hoja de ruta
180
+ feature: Función
181
+ change: Cambio
182
+ columns:
183
+ entry: Entrada
184
+ kind: Tipo
185
+ source: Origen
186
+ actions: Acciones
187
+ fields:
188
+ title: Título
189
+ body: Texto
190
+ released_on: Fecha de lanzamiento
191
+ category: Categoría
192
+ area: Área
193
+ reason: Motivo
194
+ board:
195
+ title: Tablero de la hoja de ruta
196
+ help: Arrastra las tarjetas entre columnas para fijarlas y ordenarlas. Sin JavaScript, usa el botón Mover de cada tarjeta.
197
+ updated: Tablero actualizado.
198
+ not_approved: Sin aprobar
199
+ pinned: Fijada
200
+ move_to: Mover a
201
+ move: Mover
202
+ unpin: Quitar de fijadas
203
+ sync:
204
+ title: Estado de la sincronización
205
+ queued: Sincronización en cola.
206
+ webhook_secret_missing: El secreto del webhook no está configurado, así que se rechazan los webhooks de GitHub.
207
+ github_token_missing: El token de GitHub no está configurado, así que no se puede sincronizar.
208
+ sync_now: Sincronizar ahora
209
+ sync_and_scan: Sincronizar y buscar cambios
210
+ repositories: Repositorios
211
+ no_repositories: No hay repositorios configurados.
212
+ repository: Repositorio
213
+ synced_at: Última sincronización
214
+ changes_scanned_at: Cambios revisados
215
+ last_error: Último error
216
+ never: Nunca
217
+ deliveries: Últimas entregas de webhook
218
+ no_deliveries: Todavía no hay entregas.
219
+ event: Evento
220
+ action: Acción
221
+ received_at: Recibida
222
+ processed_at: Procesada
223
+ error: Error
data/config/routes.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  Shiplog::Engine.routes.draw do
2
2
  get Shiplog.config.roadmap_path, to: "roadmaps#show", as: :roadmap
3
+ # The feed and the permalinks come before the plain changelog route, whose optional (.:format) would
4
+ # otherwise take /changelog.atom. A slug has no dot, and the permalink takes no format.
5
+ get "#{Shiplog.config.changelog_path}.atom", to: "changelog_feeds#show", as: :changelog_feed, defaults: { format: "atom" }
6
+ get "#{Shiplog.config.changelog_path}/:slug", to: "changelog_entries#show", as: :changelog_entry,
7
+ format: false, constraints: { slug: /[a-z0-9-]+/ }
3
8
  get Shiplog.config.changelog_path, to: "changelogs#show", as: :changelog
4
9
  post "shiplog/github/webhook", to: "webhooks#create", as: :github_webhook
5
10
  get "shiplog/assets/:version/:name", to: "frontends#show", as: :frontend, constraints: { version: /[^\/]+/, name: /[a-z_]+\.(css|js)/ }
@@ -0,0 +1,105 @@
1
+ # Everything 0.3.0 adds to the schema, one section per feature. Additive and reversible.
2
+ class Shiplog030 < ActiveRecord::Migration[8.1]
3
+ # A migration must not depend on model code that changes later: this is a bare table-backed class.
4
+ class MigrationEntry < ActiveRecord::Base
5
+ self.table_name = "shiplog_entries"
6
+ self.inheritance_column = nil
7
+ end
8
+
9
+ SLUG_MAX_LENGTH = 80
10
+
11
+ def change
12
+ # --- Entry permalinks (/changelog/:slug) ---
13
+ # Set when an entry is first approved, never changed afterwards, so links never break. Nil for roadmap
14
+ # entries and drafts. Entries approved before 0.3.0 get theirs here (oldest first, so the oldest keeps the
15
+ # plain slug on duplicate titles): the public pages never write, so nothing else fills them in.
16
+ add_column :shiplog_entries, :slug, :string
17
+ add_index :shiplog_entries, :slug, unique: true, where: "slug IS NOT NULL"
18
+ reversible do |direction|
19
+ direction.up do
20
+ MigrationEntry.reset_column_information
21
+ backfill_slugs
22
+ end
23
+ end
24
+
25
+ # --- "What's new" unseen count ---
26
+ # `Shiplog.unseen_count` counts approved feature and change entries with `approved_at > since`:
27
+ # an equality column first, then the range column.
28
+ add_index :shiplog_entries, [ :status, :approved_at ]
29
+
30
+ # --- Product areas (detected by AI) ---
31
+ # `entries.area_id` is nullable; deleting an area nullifies it (merging moves the entries first).
32
+ # `area_set_by_admin` marks an area chosen by hand, which detection never overwrites.
33
+ create_table :shiplog_areas do |t|
34
+ t.string :name, null: false
35
+ t.string :slug, null: false
36
+ t.integer :position, null: false, default: 0
37
+ # The name in each of config.locales, keyed by locale string ({ "es" => "Listas de embalaje" }); empty
38
+ # until translated. Slugs stay English, so ?area= URLs are shared across locales.
39
+ t.jsonb :name_translations, null: false, default: {}
40
+ t.timestamps
41
+ end
42
+ add_index :shiplog_areas, :slug, unique: true
43
+ add_reference :shiplog_entries, :area, null: true, index: true, foreign_key: { to_table: :shiplog_areas, on_delete: :nullify }
44
+ add_column :shiplog_entries, :area_set_by_admin, :boolean, null: false, default: false
45
+ # When detection last asked the AI about the entry (placed or not): unplaced entries are not asked again
46
+ # until an admin asks for a re-check.
47
+ add_column :shiplog_entries, :area_detected_at, :datetime
48
+ # A small key-value store for state that belongs to no record, e.g. the result of the last area detection
49
+ # (shown on the admin's Areas page).
50
+ create_table :shiplog_settings do |t|
51
+ t.string :key, null: false
52
+ t.jsonb :value, null: false, default: {}
53
+ t.timestamps
54
+ end
55
+ add_index :shiplog_settings, :key, unique: true
56
+
57
+ # --- Screenshots (needs Active Storage in the host; the columns are harmless without it) ---
58
+ # `image_order` is the ordered list of the entry's image blob ids, `image_alts` the alt text per blob id
59
+ # ({ "12" => "The dark theme" }); a missing alt text falls back to the entry's title.
60
+ add_column :shiplog_entries, :image_order, :jsonb, null: false, default: []
61
+ add_column :shiplog_entries, :image_alts, :jsonb, null: false, default: {}
62
+
63
+ # --- Translations of entries (config.locales) ---
64
+ # One row per entry and locale, published with the English text (no second approval). `source_digest` is the
65
+ # digest of the English title and body it was made from: a row whose digest differs from the entry's current
66
+ # text is stale and never shown. `edited_by_admin` keeps an admin's edit until the English text changes.
67
+ create_table :shiplog_entry_translations do |t|
68
+ t.references :entry, null: false, index: false, foreign_key: { to_table: :shiplog_entries, on_delete: :cascade }
69
+ t.string :locale, null: false
70
+ t.string :title, null: false
71
+ t.text :body, null: false, default: ""
72
+ t.string :source_digest, null: false
73
+ t.boolean :edited_by_admin, null: false, default: false
74
+ t.timestamps
75
+ end
76
+ add_index :shiplog_entry_translations, [ :entry_id, :locale ], unique: true
77
+ end
78
+
79
+ # Gives every approved feature and change with a release date, and no slug, one: the title as [a-z0-9-] only
80
+ # (cut at 80 characters), made unique with -2, -3, ...; entry-<id> when nothing is left. The same rules as
81
+ # Shiplog::Entry#generate_slug at the time of 0.3.0.
82
+ def backfill_slugs
83
+ taken = MigrationEntry.where.not(slug: nil).pluck(:slug).to_set
84
+ MigrationEntry.where(status: "approved", kind: %w[feature change], slug: nil).where.not(released_on: nil)
85
+ .order(:released_on, :approved_at, :id).to_a.each do |entry|
86
+ slug = slug_for(entry, taken)
87
+ taken << slug
88
+ MigrationEntry.where(id: entry.id, slug: nil).update_all(slug:)
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def slug_for(entry, taken)
95
+ base = entry.title.to_s.parameterize.tr("_", "-").squeeze("-").first(SLUG_MAX_LENGTH).gsub(/\A-+|-+\z/, "").presence || "entry-#{entry.id}"
96
+ candidate = base
97
+ suffix = 2
98
+ while taken.include?(candidate)
99
+ tail = "-#{suffix}"
100
+ candidate = "#{base.first(SLUG_MAX_LENGTH - tail.length).delete_suffix("-")}#{tail}"
101
+ suffix += 1
102
+ end
103
+ candidate
104
+ end
105
+ end
@@ -59,7 +59,16 @@ module Shiplog
59
59
  search_button: "rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500",
60
60
  search_summary: "mb-4 text-sm text-gray-500 dark:text-gray-400",
61
61
  search_clear: "ml-2 font-medium text-indigo-600 hover:underline dark:text-indigo-400",
62
- search_empty: "text-gray-500 dark:text-gray-400"
62
+ search_empty: "text-gray-500 dark:text-gray-400",
63
+ permalink: "mx-auto max-w-2xl rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900",
64
+ permalink_back: "mt-6 inline-block text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400",
65
+ permalink_nav: "mt-8 flex flex-wrap justify-between gap-4 border-t border-gray-200 pt-4 dark:border-gray-800",
66
+ permalink_nav_link: "text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400",
67
+ areas: "my-4 flex flex-wrap gap-2",
68
+ area_link: "rounded-full border border-gray-300 px-3 py-0.5 text-sm hover:border-indigo-500 dark:border-gray-700 [&.shiplog-areas__link--current]:border-indigo-600 [&.shiplog-areas__link--current]:bg-indigo-600 [&.shiplog-areas__link--current]:text-white",
69
+ entry_area: "text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
70
+ entry_images: "mt-3 grid w-full gap-3",
71
+ entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800"
63
72
  }.freeze
64
73
 
65
74
  def copy_initializer
@@ -97,7 +97,12 @@ Shiplog.configure do |config|
97
97
 
98
98
  # Public paths, read when routes are drawn.
99
99
  # config.roadmap_path = "roadmap"
100
- # config.changelog_path = "changelog"
100
+ # config.changelog_path = "changelog" # a-z, 0-9, - and _; "docs/news" nests; outer slashes are stripped
101
+
102
+ # The site's canonical origin (scheme, host, optional port; no path). The Atom feed's ids and links, og:url
103
+ # and the other absolute URLs use it instead of the request's host, which varies (apex vs www, a forged
104
+ # X-Forwarded-Host). Behind a proxy, also set config.assume_ssl = true (or send X-Forwarded-Proto) in Rails.
105
+ # config.canonical_url = "https://example.com"
101
106
 
102
107
  # Pages added to the Roadmap / Changelog switcher. label and path are Strings, or callables evaluated in the
103
108
  # view (route helpers, I18n). Render the same switcher on your own pages with `helper Shiplog::SwitcherHelper`
@@ -117,6 +122,58 @@ Shiplog.configure do |config|
117
122
  # The public changelog's search box and ?q= filtering. Set to false to turn search off.
118
123
  # config.changelog_search = true
119
124
 
125
+ # schema.org structured data (JSON-LD) on the public pages: an Article on each permalink, a CollectionPage on
126
+ # the changelog and the roadmap. Set to false to turn it off.
127
+ # config.structured_data = true
128
+
129
+ # "What's new". Shiplog doesn't know your users, so you supply the "last seen" time. This is called with the
130
+ # controller on every changelog index or permalink view, BEFORE the page renders (a 304 revisit included), so a
131
+ # navbar badge in the same response is current. It may write and runs on every visit: keep it cheap, and write
132
+ # only when something is new. Show `Shiplog.unseen_count(since: user.changelog_seen_at)` (at most 99, counted on
133
+ # approved_at; `helper Shiplog::UnseenHelper` and `shiplog_unseen_count(since)` in views) next to your Changelog
134
+ # link. current_user is often private, hence controller.send (or read a Current attribute).
135
+ # config.on_changelog_seen = lambda do |controller|
136
+ # user = controller.send(:current_user)
137
+ # user.update_column(:changelog_seen_at, Time.current) if user && Shiplog.unseen_count(since: user.changelog_seen_at).positive?
138
+ # end
139
+
140
+ # The 0.3.0 migration gives entries approved so far their permalink slug. The public pages never write (a GET
141
+ # may hit a read replica), so an entry approved by older code during a deploy window has no permalink until
142
+ # `bin/rails shiplog:backfill_slugs` runs.
143
+
144
+ # With your own config.public_layout, add `<%%= shiplog_head_tags %%>` to its <head>: it renders the description,
145
+ # canonical link, robots, Open Graph, Twitter, Atom discovery and JSON-LD tags of the Shiplog pages (and nothing
146
+ # on your other pages). `except: [:feed, ...]` leaves out what your layout writes itself. The older
147
+ # `content_for :shiplog_og`, `:shiplog_description`, `:shiplog_feed`, `:shiplog_robots` and
148
+ # `:shiplog_structured_data` blocks still work but are superseded: use one or the other, never both.
149
+
150
+ # The image shared with a permalink page that has no screenshot (og:image, twitter:image, JSON-LD image): a
151
+ # callable evaluated in the view, so route helpers work. Return an http(s) URL or a path (made absolute); an
152
+ # error or any other value is logged and the page gets no image. List pages and the roadmap get none from Shiplog.
153
+ # config.og_image = ->(entry, locale) { main_app.url_for(controller: "/og_images", action: :show, id: entry.slug) }
154
+
155
+ # Product areas. The rewriter's reply may name an area (an existing one, or a new name of 1-3 words); Shiplog
156
+ # creates it while fewer than max_areas exist, so the AI cannot sprawl. The changelog offers an area filter
157
+ # (?area=slug) once two areas have published entries. Manage areas in the admin ("Areas"): rename, reorder,
158
+ # merge, delete, and "Detect areas" to backfill entries that have none (it reuses config.rewriter).
159
+ # Entries you set by hand on the edit page are never changed by the AI.
160
+ # config.max_areas = 8
161
+
162
+ # Screenshots in entries. They need Active Storage in your app (require "active_storage/engine", and its tables);
163
+ # without it they stay off and Shiplog logs that once at boot. When a draft is written, the images in the GitHub
164
+ # issue or pull request body are attached to the entry (GitHub image hosts only, 5 MB and PNG, JPEG, GIF or
165
+ # WebP at most; the token needs no extra permission). You can also upload images on the entry edit page.
166
+ # config.images = true
167
+ # config.max_images_per_entry = 4
168
+
169
+ # Translations of entries. Entries are translated into each of these locales with config.rewriter when they are
170
+ # approved, and again when the English title or body of an approved entry changes; the translation is published
171
+ # at once, with no second approval. One LLM call per entry, locale and change. Admins can edit a translation on
172
+ # the entry page (kept until the English text changes) or retranslate it. Pages render the translation when
173
+ # I18n.locale is one of these and it matches the current English text, and English otherwise. Your routes
174
+ # decide which URLs set the locale (e.g. a /es scope). The default locale is dropped from the list.
175
+ # config.locales = [] # e.g. [:es]
176
+
120
177
  # Callables above cannot be part of the public pages' ETag. Bump this when the output of a callable you
121
178
  # configured changes (a new label, another path) so cached pages are refreshed.
122
179
  # config.cache_version = nil
@@ -49,7 +49,16 @@ module Shiplog
49
49
  search_button: "shiplog-search__button",
50
50
  search_summary: "shiplog-search__summary",
51
51
  search_clear: "shiplog-search__clear",
52
- search_empty: "shiplog-search__empty"
52
+ search_empty: "shiplog-search__empty",
53
+ permalink: "shiplog-permalink",
54
+ permalink_back: "shiplog-permalink__back",
55
+ permalink_nav: "shiplog-permalink__nav",
56
+ permalink_nav_link: "shiplog-permalink__nav-link",
57
+ areas: "shiplog-areas",
58
+ area_link: "shiplog-areas__link",
59
+ entry_area: "shiplog-entry__area",
60
+ entry_images: "shiplog-entry__images",
61
+ entry_image: "shiplog-entry__image"
53
62
  }.freeze
54
63
 
55
64
  KEYS = BEM.keys.freeze
@@ -5,7 +5,7 @@ module Shiplog
5
5
  :rewriter, :product_name, :audience, :tone, :prompt_builder,
6
6
  :public_layout, :admin_layout,
7
7
  :stylesheet, :admin_flash, :authorize_admin, :current_admin,
8
- :roadmap_path, :changelog_path
8
+ :roadmap_path
9
9
 
10
10
  ADMIN_CHROMES = %i[standalone hosted].freeze
11
11
  # Segments of only dots ("." and "..") are path traversal, not names.
@@ -16,16 +16,47 @@ module Shiplog
16
16
  # Path of the lockfile read in merged pull requests to detect gem upgrades (see config.repositories `gem:`).
17
17
  attr_accessor :lockfile_path
18
18
 
19
+ # The public changelog's path: segments of a-z, 0-9, dashes and underscores, joined by slashes ("changelog",
20
+ # "docs/whats_new"); surrounding slashes are stripped. Permalinks live below it and the Atom feed is
21
+ # "<path>.atom". Read when routes are drawn.
22
+ attr_reader :changelog_path
23
+ CHANGELOG_PATH = %r{\A[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\z}
24
+
25
+ # The site's canonical origin, e.g. "https://trackberry.com" (nil by default). When set, the Atom feed's ids
26
+ # and links, entry links, og:url and the other absolute URLs use it instead of the request's host, which
27
+ # varies (apex and www, a forged X-Forwarded-Host). An origin only: scheme, host and optional port. Any path
28
+ # prefix of the engine's mount comes from the routes, so it needs no place here.
29
+ attr_reader :canonical_url
30
+
19
31
  attr_reader :admin_chrome, :classes, :public_parent_controller, :admin_parent_controller
20
32
 
21
33
  # Product updates navigation and roadmap options.
22
34
  attr_accessor :feedback_path
23
35
  # Whether the public changelog has a search box and honours ?q= (default true).
24
36
  attr_accessor :changelog_search
37
+ # Whether the public pages carry schema.org JSON-LD (rendered by shiplog_head_tags; default true).
38
+ attr_accessor :structured_data
39
+ # The image shared with a permalink page when the entry has no screenshot: nil (default) or a callable
40
+ # `->(entry, locale) { "https://cdn.example.com/card.png" }` evaluated in the view (instance_exec, so
41
+ # `main_app` route helpers work). A path is made absolute with canonical_url or the request's origin; an
42
+ # error or a non-http(s) value is logged and the page renders without the image. See shiplog_head_tags.
43
+ attr_reader :og_image
44
+ # Called with the controller on each changelog index or permalink view (200 or 304 answers), e.g. to stamp
45
+ # the visitor's "last seen" time. It runs BEFORE the page renders (so a badge in the same response is
46
+ # current), on every visit, and may write: keep it cheap, e.g. write only when the stored time is older than
47
+ # the newest entry. Nil by default. Not part of the ETag: it doesn't change the markup.
48
+ attr_reader :on_changelog_seen
25
49
  # Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path):
26
50
  # callables cannot be part of the ETag, so this is how cached public pages learn about it.
27
51
  attr_accessor :cache_version
28
52
  attr_reader :extra_pages, :repository_labels, :roadmap_shipped_count
53
+ # How many product areas the AI may create (default 8). Past it, entries can only join existing areas.
54
+ # Not part of render_digest: the markup depends on the areas themselves, which the ETag carries.
55
+ attr_reader :max_areas
56
+ # Screenshots in entries. `images` (default true) is effective only when Active Storage is loaded in the host
57
+ # (see Shiplog.images?). `max_images_per_entry` (default 4) caps the images fetched from GitHub per entry.
58
+ attr_reader :images, :max_images_per_entry
59
+ LOCALE_FORMAT = /\A[A-Za-z][A-Za-z0-9_-]*\z/
29
60
 
30
61
  EXTRA_PAGE_KEYS = %i[label path].freeze
31
62
 
@@ -43,10 +74,10 @@ module Shiplog
43
74
  stable = ->(value) { value.respond_to?(:call) ? "callable" : value }
44
75
  data = [
45
76
  classes.sort_by { |key, _| key.to_s }.map { |key, value| [ key.to_s, Array(value).map(&:to_s) ] },
46
- stylesheet, roadmap_shipped_count, cache_version,
77
+ stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
47
78
  extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
48
79
  repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
49
- stable.(feedback_path), changelog_search
80
+ stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image)
50
81
  ]
51
82
  Digest::SHA256.hexdigest(JSON.generate(data))
52
83
  end
@@ -92,6 +123,56 @@ module Shiplog
92
123
  @roadmap_shipped_count = value
93
124
  end
94
125
 
126
+ def max_areas=(value)
127
+ raise ArgumentError, "max_areas must be a non-negative Integer, got #{value.inspect}" unless value.is_a?(Integer) && value >= 0
128
+ @max_areas = value
129
+ end
130
+
131
+ def images=(value)
132
+ raise ArgumentError, "images must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
133
+ @images = value
134
+ end
135
+
136
+ def max_images_per_entry=(value)
137
+ raise ArgumentError, "max_images_per_entry must be a non-negative Integer, got #{value.inspect}" unless value.is_a?(Integer) && value >= 0
138
+ @max_images_per_entry = value
139
+ end
140
+
141
+ # Locales entries are translated into when approved, e.g. [:es] (default none), as symbols and without the
142
+ # default locale (I18n.default_locale, read each time so an initializer that sets it later still counts): it
143
+ # is the language entries are written in. See Shiplog::Translator.
144
+ def locales = @locales - [ I18n.default_locale.to_sym ]
145
+
146
+ # Sets the locales to translate into: an Array of locale Symbols or Strings (see #locales).
147
+ def locales=(value)
148
+ unless value.is_a?(Array) && value.all? { |item| (item.is_a?(Symbol) || item.is_a?(String)) && LOCALE_FORMAT.match?(item.to_s) }
149
+ raise ArgumentError, "locales must be an Array of locale Symbols or Strings like [:es, \"pt-BR\"], got #{value.inspect}"
150
+ end
151
+ @locales = value.map(&:to_sym).uniq.freeze
152
+ end
153
+
154
+ def changelog_path=(value)
155
+ path = value.gsub(%r{\A/+|/+\z}, "") if value.is_a?(String)
156
+ unless path && CHANGELOG_PATH.match?(path)
157
+ raise ArgumentError, "changelog_path must be a path of letters, digits, dashes and underscores, with / between segments (like \"changelog\"), got #{value.inspect}"
158
+ end
159
+ @changelog_path = path
160
+ end
161
+
162
+ def canonical_url=(value)
163
+ @canonical_url = normalize_canonical_url(value)
164
+ end
165
+
166
+ def og_image=(value)
167
+ raise ArgumentError, "og_image must be nil or a callable like ->(entry, locale) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
168
+ @og_image = value
169
+ end
170
+
171
+ def on_changelog_seen=(value)
172
+ raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
173
+ @on_changelog_seen = value
174
+ end
175
+
95
176
  # :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
96
177
  # layout of config.admin_layout (compact sub-nav, no page chrome of its own).
97
178
  def admin_chrome=(value)
@@ -158,6 +239,11 @@ module Shiplog
158
239
  @repository_labels = {}.freeze
159
240
  @roadmap_shipped_count = 4
160
241
  @changelog_search = true
242
+ @structured_data = true
243
+ @max_areas = 8
244
+ @images = true
245
+ @max_images_per_entry = 4
246
+ @locales = [].freeze
161
247
  end
162
248
 
163
249
  def resolved_github_token
@@ -196,6 +282,22 @@ module Shiplog
196
282
 
197
283
  private
198
284
 
285
+ def normalize_canonical_url(value)
286
+ return if value.nil? || (value.is_a?(String) && value.strip.empty?)
287
+ raise ArgumentError, "canonical_url must be a String like \"https://example.com\", got #{value.inspect}" unless value.is_a?(String)
288
+
289
+ uri = begin
290
+ URI.parse(value.strip)
291
+ rescue URI::InvalidURIError
292
+ nil
293
+ end
294
+ valid = uri.is_a?(URI::HTTP) && uri.host.present? && [ "", "/" ].include?(uri.path) && uri.query.nil? && uri.fragment.nil? && uri.userinfo.nil?
295
+ raise ArgumentError, "canonical_url must be an http(s) origin without path, query or credentials (\"https://example.com\"), got #{value.inspect}" unless valid
296
+
297
+ port = ":#{uri.port}" unless uri.port == uri.default_port
298
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
299
+ end
300
+
199
301
  def normalize_repositories(value)
200
302
  raise ArgumentError, "config.repositories must be an Array, got #{value.inspect}" unless value.is_a?(Array)
201
303
 
@@ -1,5 +1,13 @@
1
1
  module Shiplog
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace Shiplog
4
+
5
+ # shiplog_head_tags in every view, so a layout shared with the host's own pages can call it.
6
+ initializer "shiplog.head_tags_helper" do
7
+ ActiveSupport.on_load(:action_view) { include Shiplog::HeadTagsHelper }
8
+ end
9
+
10
+ # Once per boot, after the host's initializers have set config.images: say why screenshots are off.
11
+ config.after_initialize { Shiplog.log_images_status }
4
12
  end
5
13
  end
@@ -133,7 +133,19 @@ module Shiplog
133
133
  }
134
134
  GRAPHQL
135
135
 
136
- ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS:, BLOB_TEXT:, COMMIT_PULL_REQUESTS: }.freeze
136
+ # The rendered HTML of one issue or pull request body (`$id` is its node id). Kept out of the sync documents so
137
+ # their payloads don't grow: it is only asked for when a draft is written, to find screenshots. On private
138
+ # repositories its <img src> URLs are short-lived signed URLs.
139
+ BODY_HTML = <<~GRAPHQL.freeze
140
+ query($id: ID!) {
141
+ node(id: $id) {
142
+ ... on Issue { bodyHTML }
143
+ ... on PullRequest { bodyHTML }
144
+ }
145
+ }
146
+ GRAPHQL
147
+
148
+ ALL = { ISSUES:, PULL_REQUESTS:, ISSUE:, PULL_REQUEST:, COMMITS:, BLOB_TEXT:, COMMIT_PULL_REQUESTS:, BODY_HTML: }.freeze
137
149
  end
138
150
  end
139
151
  end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
3
3
  end