alchemy_cms 8.3.10 → 8.4.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.
Files changed (370) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +58 -10
  4. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  5. data/app/assets/builds/alchemy/admin/print.css +1 -1
  6. data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
  7. data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
  8. data/app/assets/builds/alchemy/admin.css +4 -4
  9. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  10. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  11. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  12. data/app/assets/builds/alchemy/light-theme.css +1 -1
  13. data/app/assets/builds/alchemy/preview.min.js +1 -1
  14. data/app/assets/builds/alchemy/theme.css +1 -1
  15. data/app/assets/builds/alchemy/welcome.css +1 -1
  16. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  17. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  18. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  19. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  20. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
  22. data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
  25. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
  26. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
  27. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
  28. data/app/components/alchemy/admin/element_editor.html.erb +1 -1
  29. data/app/components/alchemy/admin/element_select.rb +30 -20
  30. data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
  31. data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
  32. data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
  33. data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
  34. data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
  35. data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
  36. data/app/components/alchemy/admin/page_node.html.erb +1 -1
  37. data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
  38. data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
  39. data/app/components/alchemy/admin/page_select.rb +18 -8
  40. data/app/components/alchemy/admin/preview_time_select.rb +1 -1
  41. data/app/components/alchemy/admin/toolbar_button.rb +43 -20
  42. data/app/components/alchemy/admin/uploader_button.rb +66 -0
  43. data/app/components/alchemy/ingredients/base_editor.rb +10 -1
  44. data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
  45. data/app/components/alchemy/ingredients/color_editor.rb +6 -3
  46. data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
  47. data/app/components/alchemy/ingredients/picture_view.rb +2 -4
  48. data/app/components/alchemy/menubar.html.erb +157 -0
  49. data/app/components/alchemy/menubar.rb +15 -0
  50. data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
  51. data/app/controllers/alchemy/admin/base_controller.rb +34 -21
  52. data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
  53. data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
  54. data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
  55. data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
  56. data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
  57. data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
  58. data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
  59. data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
  60. data/app/controllers/alchemy/base_controller.rb +24 -16
  61. data/app/controllers/alchemy/pages_controller.rb +29 -8
  62. data/app/controllers/alchemy/sitemap_controller.rb +58 -0
  63. data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
  64. data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
  65. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
  66. data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
  67. data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
  68. data/app/helpers/alchemy/admin/base_helper.rb +4 -1
  69. data/app/helpers/alchemy/admin/form_helper.rb +0 -2
  70. data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
  71. data/app/helpers/alchemy/elements_block_helper.rb +14 -0
  72. data/app/helpers/alchemy/resources_helper.rb +15 -1
  73. data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
  74. data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
  75. data/app/javascript/alchemy_admin/components/button.js +0 -6
  76. data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
  77. data/app/javascript/alchemy_admin/components/color_select.js +50 -45
  78. data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
  79. data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
  80. data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
  81. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
  82. data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
  83. data/app/javascript/alchemy_admin/components/element_select.js +26 -66
  84. data/app/javascript/alchemy_admin/components/icon.js +9 -4
  85. data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
  86. data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
  87. data/app/javascript/alchemy_admin/components/index.js +12 -1
  88. data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
  89. data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
  90. data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
  91. data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
  92. data/app/javascript/alchemy_admin/components/node_form.js +34 -0
  93. data/app/javascript/alchemy_admin/components/node_select.js +25 -30
  94. data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
  95. data/app/javascript/alchemy_admin/components/page_node.js +2 -1
  96. data/app/javascript/alchemy_admin/components/page_select.js +16 -34
  97. data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
  98. data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
  99. data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
  100. data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
  101. data/app/javascript/alchemy_admin/components/select.js +26 -88
  102. data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
  103. data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
  104. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
  105. data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
  106. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
  107. data/app/javascript/alchemy_admin/components/uploader.js +19 -3
  108. data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
  109. data/app/javascript/alchemy_admin/dialog.js +182 -110
  110. data/app/javascript/alchemy_admin/hotkeys.js +0 -5
  111. data/app/javascript/alchemy_admin/i18n.js +3 -9
  112. data/app/javascript/alchemy_admin/image_overlay.js +58 -42
  113. data/app/javascript/alchemy_admin/initializer.js +2 -2
  114. data/app/javascript/alchemy_admin/link_dialog.js +15 -2
  115. data/app/javascript/alchemy_admin/routes.js +18 -0
  116. data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
  117. data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
  118. data/app/javascript/alchemy_admin/utils/events.js +5 -0
  119. data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
  120. data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
  121. data/app/javascript/alchemy_admin.js +2 -19
  122. data/app/javascript/preview.js +131 -113
  123. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
  124. data/app/models/alchemy/admin/content_security_policy.rb +143 -0
  125. data/app/models/alchemy/admin/filters/base.rb +7 -0
  126. data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
  127. data/app/models/alchemy/admin/filters/select.rb +20 -7
  128. data/app/models/alchemy/attachment.rb +14 -7
  129. data/app/models/alchemy/cache_control.rb +104 -0
  130. data/app/models/alchemy/current.rb +1 -1
  131. data/app/models/alchemy/eager_loading.rb +0 -2
  132. data/app/models/alchemy/element.rb +23 -8
  133. data/app/models/alchemy/element_definition.rb +1 -0
  134. data/app/models/alchemy/elements_repository.rb +11 -0
  135. data/app/models/alchemy/folded_page.rb +5 -1
  136. data/app/models/alchemy/ingredient.rb +26 -0
  137. data/app/models/alchemy/ingredients/datetime.rb +1 -1
  138. data/app/models/alchemy/ingredients/link.rb +2 -0
  139. data/app/models/alchemy/ingredients/picture.rb +3 -0
  140. data/app/models/alchemy/ingredients/text.rb +2 -0
  141. data/app/models/alchemy/language.rb +19 -7
  142. data/app/models/alchemy/legacy_page_url.rb +6 -1
  143. data/app/models/alchemy/node.rb +51 -0
  144. data/app/models/alchemy/page/page_natures.rb +41 -8
  145. data/app/models/alchemy/page/page_scopes.rb +23 -0
  146. data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
  147. data/app/models/alchemy/page.rb +88 -32
  148. data/app/models/alchemy/page_definition.rb +1 -1
  149. data/app/models/alchemy/page_mutex.rb +16 -0
  150. data/app/models/alchemy/page_version.rb +24 -1
  151. data/app/models/alchemy/permissions.rb +6 -5
  152. data/app/models/alchemy/picture.rb +15 -7
  153. data/app/models/alchemy/picture_description.rb +22 -0
  154. data/app/models/alchemy/picture_thumb.rb +18 -0
  155. data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
  156. data/app/models/alchemy/resource.rb +1 -1
  157. data/app/models/alchemy/safe_url_validator.rb +34 -0
  158. data/app/models/alchemy/site.rb +8 -3
  159. data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
  160. data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
  161. data/app/models/alchemy/tag.rb +10 -4
  162. data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
  163. data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
  164. data/app/models/concerns/alchemy/publishable.rb +14 -29
  165. data/app/models/concerns/alchemy/taggable.rb +92 -0
  166. data/app/models/concerns/alchemy/user_methods.rb +95 -0
  167. data/app/services/alchemy/duplicate_element.rb +5 -2
  168. data/app/services/alchemy/element_preloader.rb +1 -2
  169. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  170. data/app/stylesheets/alchemy/_extends.scss +0 -8
  171. data/app/stylesheets/alchemy/_themes.scss +10 -9
  172. data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
  173. data/app/stylesheets/alchemy/admin/_help.scss +10 -0
  174. data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
  175. data/app/stylesheets/alchemy/admin/archive.scss +56 -11
  176. data/app/stylesheets/alchemy/admin/base.scss +4 -5
  177. data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
  178. data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
  179. data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
  180. data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
  181. data/app/stylesheets/alchemy/admin/elements.scss +8 -24
  182. data/app/stylesheets/alchemy/admin/flash.scss +2 -1
  183. data/app/stylesheets/alchemy/admin/forms.scss +60 -24
  184. data/app/stylesheets/alchemy/admin/frame.scss +40 -4
  185. data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
  186. data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
  187. data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
  188. data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
  189. data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
  190. data/app/stylesheets/alchemy/admin/print.scss +0 -4
  191. data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
  192. data/app/stylesheets/alchemy/admin/search.scss +2 -3
  193. data/app/stylesheets/alchemy/admin/selects.scss +1 -294
  194. data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
  195. data/app/stylesheets/alchemy/admin/tables.scss +7 -1
  196. data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
  197. data/app/stylesheets/alchemy/admin/tags.scss +0 -13
  198. data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
  199. data/app/stylesheets/alchemy/admin/upload.scss +61 -1
  200. data/app/stylesheets/alchemy/admin.scss +4 -1
  201. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +19 -0
  202. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +19 -0
  203. data/app/types/alchemy/cache_control_type.rb +84 -0
  204. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  205. data/app/views/alchemy/_menubar.html.erb +2 -154
  206. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  207. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  208. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  209. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  210. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  211. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  212. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  213. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  214. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  215. data/app/views/alchemy/admin/crop.html.erb +20 -20
  216. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  217. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  218. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  219. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  220. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  221. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  222. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  223. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  224. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  225. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  227. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  228. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  229. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  230. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  231. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  232. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  233. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  234. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  235. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  236. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  237. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  238. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  239. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  240. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  241. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  242. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  243. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  244. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  245. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  246. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  247. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  248. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  249. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  250. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  251. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  252. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  253. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  254. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  255. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  256. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  257. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  258. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  259. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  260. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  261. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  262. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  263. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  264. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  265. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  266. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  267. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  268. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  269. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  270. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  271. data/app/views/alchemy/admin/translations/_en.js +3 -0
  272. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  273. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  274. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  275. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  277. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  279. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  280. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  281. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  282. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  283. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  284. data/config/brakeman.ignore +23 -1
  285. data/config/importmap.rb +10 -8
  286. data/config/initializers/dragonfly.rb +21 -17
  287. data/config/locales/alchemy.en.yml +53 -12
  288. data/config/routes.rb +3 -3
  289. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  290. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  291. data/lib/alchemy/configuration/number_option.rb +13 -0
  292. data/lib/alchemy/configuration/proc_option.rb +13 -0
  293. data/lib/alchemy/configuration.rb +2 -0
  294. data/lib/alchemy/configurations/admin_components.rb +30 -0
  295. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  296. data/lib/alchemy/configurations/main.rb +103 -4
  297. data/lib/alchemy/configurations/menu.rb +91 -0
  298. data/lib/alchemy/configurations/menu_item.rb +123 -0
  299. data/lib/alchemy/configurations/sitemap.rb +1 -0
  300. data/lib/alchemy/configurations/uploader.rb +15 -1
  301. data/lib/alchemy/engine.rb +0 -6
  302. data/lib/alchemy/i18n.rb +26 -0
  303. data/lib/alchemy/modules.rb +52 -50
  304. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  305. data/lib/alchemy/tasks/tidy.rb +1 -1
  306. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  307. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  308. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  309. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  310. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  311. data/lib/alchemy/test_support/shared_publishable_examples.rb +39 -4
  312. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  313. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  314. data/lib/alchemy/upgrader.rb +2 -1
  315. data/lib/alchemy/version.rb +1 -1
  316. data/lib/alchemy.rb +0 -1
  317. data/lib/alchemy_cms.rb +0 -13
  318. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  319. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  320. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  321. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  322. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  323. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  324. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  325. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  326. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  327. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  328. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  329. data/lib/tasks/alchemy/upgrade.rake +45 -3
  330. data/vendor/javascript/cropperjs.min.js +3 -3
  331. data/vendor/javascript/floating-ui.min.js +1 -1
  332. data/vendor/javascript/tinymce.min.js +5 -5
  333. data/vendor/javascript/tom-select.min.js +1 -1
  334. metadata +85 -65
  335. data/app/javascript/alchemy_admin/components/action.js +0 -50
  336. data/app/javascript/alchemy_admin/dirty.js +0 -55
  337. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  338. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  339. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  340. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  341. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  342. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  343. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  344. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  345. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  346. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  347. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  348. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  349. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  350. data/app/views/alchemy/base/redirect.js.erb +0 -11
  351. data/config/alchemy/config.yml +0 -217
  352. data/config/alchemy/modules.yml +0 -82
  353. data/config/initializers/rails_live_reload.rb +0 -13
  354. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  355. data/lib/alchemy/taggable.rb +0 -49
  356. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  357. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  358. data/vendor/javascript/handlebars.min.js +0 -29
  359. data/vendor/javascript/jquery.min.js +0 -2
  360. data/vendor/javascript/rails-ujs.min.js +0 -1
  361. data/vendor/javascript/select2.min.js +0 -23
  362. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  365. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  366. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  367. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  368. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  369. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  370. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Pre-generates picture thumbnails so they do not have to be rendered
5
+ # on the first request.
6
+ #
7
+ # Used by the +alchemy:generate:thumbnails+ rake tasks. Works with both
8
+ # the +active_storage+ and the +dragonfly+ storage adapter.
9
+ #
10
+ # Both methods yield each processed record, so callers (like the rake task)
11
+ # can report progress without this service having to know about the shell.
12
+ #
13
+ # With +async: true+ the +active_storage+ variants are enqueued as
14
+ # +ActiveStorage::TransformJob+s instead of being processed inline. The
15
+ # +dragonfly+ adapter has no background processor and always runs inline.
16
+ class GenerateThumbnails
17
+ class << self
18
+ # Pre-generates the archive thumbnails (+Alchemy::Picture::THUMBNAIL_SIZES+)
19
+ # for every picture. These are the thumbnails used by the picture archive.
20
+ #
21
+ # @param async [Boolean] Enqueue active_storage variants as background jobs
22
+ # @yield [Alchemy::Picture] each picture after it was processed / enqueued
23
+ def pictures(async: false)
24
+ Alchemy::Picture.find_each do |picture|
25
+ generate_for_picture(picture, async: async)
26
+ yield picture if block_given?
27
+ end
28
+ end
29
+
30
+ # Pre-generates the thumbnails rendered for published picture ingredients.
31
+ #
32
+ # Takes the ingredients crop and srcset settings into account, so all
33
+ # variants that the frontend requests are generated upfront.
34
+ #
35
+ # @param element_names [Array<String>, nil] Restrict to these element names
36
+ # @param async [Boolean] Enqueue active_storage variants as background jobs
37
+ # @yield [Alchemy::Ingredients::Picture] each ingredient after it was processed / enqueued
38
+ def ingredients(element_names: nil, async: false)
39
+ ingredient_scope(element_names).find_each do |ingredient|
40
+ generate_for_ingredient(ingredient, async: async)
41
+ yield ingredient if block_given?
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def generate_for_picture(picture, async:)
48
+ Alchemy::Picture::THUMBNAIL_SIZES.each_value do |size|
49
+ preprocess(picture, {size: size, flatten: true}, async: async)
50
+ end
51
+ end
52
+
53
+ def generate_for_ingredient(ingredient, async:)
54
+ picture = ingredient.picture
55
+ return if picture.nil?
56
+
57
+ preprocess(picture, ingredient.picture_url_options, async: async)
58
+ preprocess(picture, ingredient.thumbnail_url_options, async: async)
59
+ ingredient.picture_srcset_variant_options.each do |options|
60
+ preprocess(picture, options, async: async)
61
+ end
62
+ end
63
+
64
+ # Materializes a single picture variant for the given render options
65
+ # using the configured storage adapter.
66
+ #
67
+ # Under +active_storage+ this processes and stores the variant inline, or
68
+ # enqueues a native +ActiveStorage::TransformJob+ when +async+ is true.
69
+ # Under +dragonfly+ requesting the url persists the +Alchemy::PictureThumb+.
70
+ def preprocess(picture, options, async: false)
71
+ return unless picture&.has_convertible_format?
72
+
73
+ if Alchemy.storage_adapter.active_storage?
74
+ transformations = Alchemy::DragonflyToImageProcessing.call(options)
75
+ if async
76
+ ActiveStorage::TransformJob.perform_later(picture.image_file.blob, transformations)
77
+ else
78
+ picture.image_file.variant(transformations).processed
79
+ end
80
+ else
81
+ picture.url(options)
82
+ end
83
+ rescue => error
84
+ Alchemy::ErrorTracking.notification_handler.call(error)
85
+ nil
86
+ end
87
+
88
+ def ingredient_scope(element_names)
89
+ scope = Alchemy::Ingredients::Picture
90
+ .joins(:element)
91
+ .preload(:related_object)
92
+ .merge(Alchemy::Element.published)
93
+ return scope if element_names.blank?
94
+
95
+ scope.merge(Alchemy::Element.named(element_names))
96
+ end
97
+ end
98
+ end
99
+ end
@@ -64,8 +64,8 @@ module Alchemy
64
64
  def remove_duplicate_legacy_urls
65
65
  puts "\n## Removing duplicate legacy URLs"
66
66
  keep_ids = Alchemy::LegacyPageUrl
67
- .select("MAX(id)")
68
67
  .group(:page_id, :urlname)
68
+ .pluck(Arel.sql("MAX(id)"))
69
69
  count = Alchemy::LegacyPageUrl
70
70
  .where.not(id: keep_ids)
71
71
  .delete_all
@@ -3,26 +3,12 @@
3
3
  module Alchemy
4
4
  module TestSupport
5
5
  module CapybaraHelpers
6
- # Select2 capybara helper
6
+ # @deprecated Use {#tom_select} instead.
7
7
  def select2(value, options)
8
- label = find_label_by_text(options[:from])
9
-
10
- select2_anchor_selector = ".select2-container a"
11
-
12
- if label.has_css?(select2_anchor_selector)
13
- label.find(select2_anchor_selector).click
14
- else
15
- within label.find(:xpath, "..") do
16
- find(select2_anchor_selector).click
17
- end
18
- end
19
-
20
- within_entire_page do
21
- page.find(
22
- "div.select2-result-label",
23
- text: /#{Regexp.escape(value)}/i, match: :prefer_exact
24
- ).click
25
- end
8
+ Alchemy::Deprecation.warn(
9
+ "Alchemy::TestSupport::CapybaraHelpers#select2 is deprecated. Use #tom_select instead."
10
+ )
11
+ tom_select(value, options)
26
12
  end
27
13
 
28
14
  # Tom Select capybara helper
@@ -42,30 +28,55 @@ module Alchemy
42
28
  end
43
29
  end
44
30
 
45
- def select2_search(value, options)
46
- if options[:from]
47
- label = find_label_by_text(options[:from])
48
- within label.first(:xpath, ".//..") do
49
- options[:from] = "##{find(".select2-container")["id"]}"
50
- end
51
- elsif options[:element_id] && options[:ingredient_role]
52
- container_id = find("#element_#{options[:element_id]} [data-ingredient-role='#{options[:ingredient_role]}'] .select2-container")["id"]
53
- options[:from] = "##{container_id}"
31
+ # Tom Select capybara helper for adding a tag to an
32
+ # alchemy-tags-autocomplete field.
33
+ def add_tom_select_tag(value, options)
34
+ label = find_label_by_text(options[:from])
35
+
36
+ within label.find(:xpath, "..") do
37
+ find(".ts-control").click
38
+ find(".ts-control input").send_keys(value, :return)
54
39
  end
40
+ end
55
41
 
56
- find("#{options[:from]}:not(.select2-container-disabled):not(.select2-offscreen)").click
42
+ # Tom Select capybara helper for remote (ajax) selects. Types the given
43
+ # value into the search field to trigger the server request and selects the
44
+ # matching option. Pass `select: false` to only search without selecting.
45
+ def tom_select_search(value, options)
46
+ scope =
47
+ if options[:from]
48
+ find_label_by_text(options[:from]).find(:xpath, "..")
49
+ elsif options[:element_id] && options[:ingredient_role]
50
+ find("#element_#{options[:element_id]} [data-ingredient-role='#{options[:ingredient_role]}']")
51
+ else
52
+ page
53
+ end
57
54
 
58
- within_entire_page do
59
- find("input.select2-input.select2-focused").set(value)
60
- expect(page).to_not have_selector(".select2-searching")
61
- unless options[:select] == false
62
- expect(page).to have_selector(".select2-result-label", visible: true)
63
- find("div.select2-result-label", text: /#{Regexp.escape(value)}/i, match: :prefer_exact).click
64
- expect(page).not_to have_selector(".select2-result-label")
55
+ within scope do
56
+ find(".ts-control").click
57
+ find(".ts-control input").send_keys(value)
58
+ end
59
+
60
+ # The dropdown is appended to the body, so search the whole page for it.
61
+ # `find` waits for the debounced remote request to deliver the option.
62
+ unless options[:select] == false
63
+ within_entire_page do
64
+ find(
65
+ ".ts-dropdown .option",
66
+ text: /#{Regexp.escape(value)}/i, match: :prefer_exact
67
+ ).click
65
68
  end
66
69
  end
67
70
  end
68
71
 
72
+ # @deprecated Use {#tom_select_search} instead.
73
+ def select2_search(value, options)
74
+ Alchemy::Deprecation.warn(
75
+ "Alchemy::TestSupport::CapybaraHelpers#select2_search is deprecated. Use #tom_select_search instead."
76
+ )
77
+ tom_select_search(value, options)
78
+ end
79
+
69
80
  def click_button_with_tooltip(content)
70
81
  find(%([content="#{content}"] button)).click
71
82
  end
@@ -85,7 +96,7 @@ module Alchemy
85
96
  end
86
97
 
87
98
  def find_label_by_text(text)
88
- find "label:not(.select2-offscreen)",
99
+ find "label",
89
100
  text: /#{Regexp.escape(text)}/i,
90
101
  match: :one
91
102
  rescue Capybara::ElementNotFound
@@ -24,7 +24,7 @@ RSpec.shared_examples_for "a controller that loads current language" do |args|
24
24
  it "shows flash warning with redirect" do
25
25
  Alchemy::Language.destroy_all
26
26
  get :index, params: {site_id: site_1.id}
27
- expect(flash[:warning]).to eq Alchemy.t("Please create a language first.")
27
+ expect(flash[:info]).to eq Alchemy.t("Please create a language first.")
28
28
  expect(response).to redirect_to admin_languages_path
29
29
  end
30
30
  end
@@ -4,7 +4,10 @@ FactoryBot.define do
4
4
  factory :alchemy_dummy_user, class: "DummyUser" do
5
5
  sequence(:email) { |n| "john.#{n}@doe.com" }
6
6
  password { "s3cr3t" }
7
- alchemy_roles { ["member"] }
7
+
8
+ trait :as_member do
9
+ alchemy_roles { ["member"] }
10
+ end
8
11
 
9
12
  trait :as_admin do
10
13
  alchemy_roles { ["admin"] }
@@ -183,6 +183,61 @@ RSpec.shared_examples_for "having picture thumbnails" do
183
183
  end
184
184
  end
185
185
 
186
+ context "having format setting" do
187
+ before do
188
+ allow(record).to receive(:settings) { {format: "webp"} }
189
+ end
190
+
191
+ context "with picture having a convertible format" do
192
+ before do
193
+ allow(picture).to receive(:has_convertible_format?) { true }
194
+ end
195
+
196
+ it "includes this format" do
197
+ expect(picture_url_options[:format]).to eq "webp"
198
+ end
199
+
200
+ context "with format option given" do
201
+ subject(:picture_url_options) { record.picture_url_options(format: "png") }
202
+
203
+ it "prefers the option" do
204
+ expect(picture_url_options[:format]).to eq "png"
205
+ end
206
+ end
207
+ end
208
+
209
+ context "with picture not having a convertible format" do
210
+ before do
211
+ allow(picture).to receive(:has_convertible_format?) { false }
212
+ allow(picture).to receive(:default_render_format) { "svg" }
213
+ end
214
+
215
+ it "includes the pictures default render format" do
216
+ expect(picture_url_options[:format]).to eq "svg"
217
+ end
218
+
219
+ context "with format option given" do
220
+ subject(:picture_url_options) { record.picture_url_options(format: "png") }
221
+
222
+ it "still includes the pictures default render format" do
223
+ expect(picture_url_options[:format]).to eq "svg"
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ context "with size option given" do
230
+ subject(:picture_url_options) { record.picture_url_options(size: "100x100") }
231
+
232
+ before do
233
+ allow(record).to receive(:settings) { {size: "30x70"} }
234
+ end
235
+
236
+ it "prefers the option" do
237
+ expect(picture_url_options[:size]).to eq "100x100"
238
+ end
239
+ end
240
+
186
241
  context "without picture assigned" do
187
242
  let(:picture) { nil }
188
243
 
@@ -190,6 +245,83 @@ RSpec.shared_examples_for "having picture thumbnails" do
190
245
  end
191
246
  end
192
247
 
248
+ describe "#picture_srcset_variant_options" do
249
+ subject(:picture_srcset_variant_options) { record.picture_srcset_variant_options }
250
+
251
+ let(:picture) do
252
+ build(:alchemy_picture).tap do |picture|
253
+ allow(picture).to receive(:has_convertible_format?) { true }
254
+ allow(picture).to receive(:default_render_format) { "jpg" }
255
+ end
256
+ end
257
+
258
+ before do
259
+ allow(record).to receive(:settings) do
260
+ {
261
+ size: "1200x800",
262
+ srcset: ["360x360", {size: "360x360", format: "webp"}]
263
+ }.with_indifferent_access
264
+ end
265
+ end
266
+
267
+ it "returns url options for each srcset entry" do
268
+ expect(picture_srcset_variant_options.map { |options| options.slice(:size, :format) }).to eq([
269
+ {"size" => "360x360", "format" => "jpg"},
270
+ {"size" => "360x360", "format" => "webp"}
271
+ ])
272
+ end
273
+
274
+ context "with format setting" do
275
+ before do
276
+ allow(record).to receive(:settings) do
277
+ {format: "png", srcset: ["360x360", {size: "360x360", format: "webp"}]}.with_indifferent_access
278
+ end
279
+ end
280
+
281
+ it "uses the format setting for entries without format" do
282
+ expect(picture_srcset_variant_options.map { |options| options[:format] }).to eq(["png", "webp"])
283
+ end
284
+ end
285
+
286
+ context "without picture assigned" do
287
+ let(:picture) { nil }
288
+
289
+ it { is_expected.to eq([]) }
290
+ end
291
+ end
292
+
293
+ describe "#picture_srcset_options" do
294
+ subject(:picture_srcset_options) { record.picture_srcset_options }
295
+
296
+ let(:picture) do
297
+ create(:alchemy_picture).tap do |picture|
298
+ allow(picture).to receive(:has_convertible_format?) { true }
299
+ allow(picture).to receive(:default_render_format) { "jpg" }
300
+ end
301
+ end
302
+
303
+ before do
304
+ allow(record).to receive(:settings) do
305
+ {srcset: ["360x240", {size: "360x240", format: "webp"}]}.with_indifferent_access
306
+ end
307
+ end
308
+
309
+ it "returns url, dimensions and mime type for each srcset entry", :aggregate_failures do
310
+ expect(picture_srcset_options.map { |src| src.except(:url) }).to eq([
311
+ {desc: "360w", width: "360", height: "240", type: "image/jpeg"},
312
+ {desc: "360w", width: "360", height: "240", type: "image/webp"}
313
+ ])
314
+ expect(picture_srcset_options[0][:url]).to match(/\.jpg/)
315
+ expect(picture_srcset_options[1][:url]).to match(/\.webp/)
316
+ end
317
+
318
+ context "without picture assigned" do
319
+ let(:picture) { nil }
320
+
321
+ it { is_expected.to eq([]) }
322
+ end
323
+ end
324
+
193
325
  describe "#thumbnail_url" do
194
326
  subject(:thumbnail_url) { record.thumbnail_url }
195
327
 
@@ -80,3 +80,55 @@ RSpec.shared_examples_for "an alchemy ingredient" do
80
80
  it { is_expected.to be_a("#{described_class}View".constantize) }
81
81
  end
82
82
  end
83
+
84
+ # Shared by the ingredients that store an editor supplied url.
85
+ #
86
+ # it_behaves_like "a linkable alchemy ingredient", :link
87
+ #
88
+ RSpec.shared_examples_for "a linkable alchemy ingredient" do |url_attribute|
89
+ describe "url scheme validation" do
90
+ let(:element) { build(:alchemy_element, name: "article") }
91
+
92
+ subject(:ingredient) do
93
+ described_class.new(element: element, role: "headline")
94
+ end
95
+
96
+ # captured in a let, a block parameter is not in scope inside a def
97
+ let(:attribute) { url_attribute }
98
+
99
+ def errors_for(url)
100
+ ingredient.public_send(:"#{attribute}=", url)
101
+ ingredient.valid?
102
+ ingredient.errors[attribute]
103
+ end
104
+
105
+ it "allows a blank url" do
106
+ expect(errors_for(nil)).to be_empty
107
+ end
108
+
109
+ it "allows an allowed scheme" do
110
+ expect(errors_for("https://example.com")).to be_empty
111
+ expect(errors_for("mailto:jane@example.com")).to be_empty
112
+ end
113
+
114
+ it "allows a relative url" do
115
+ expect(errors_for("/a/path")).to be_empty
116
+ expect(errors_for("#an-anchor")).to be_empty
117
+ end
118
+
119
+ it "rejects an executable scheme" do
120
+ expect(errors_for("javascript:alert(document.domain)")).to be_present
121
+ expect(errors_for("data:text/html;base64,PHN2Zz4=")).to be_present
122
+ expect(errors_for("vbscript:msgbox(1)")).to be_present
123
+ end
124
+
125
+ it "rejects an executable scheme obfuscated by whitespace" do
126
+ expect(errors_for(" javascript:alert(1)")).to be_present
127
+ expect(errors_for("java\tscript:alert(1)")).to be_present
128
+ end
129
+
130
+ it "rejects a javascript url disguised as an authority" do
131
+ expect(errors_for("javascript://%0aalert(1)")).to be_present
132
+ end
133
+ end
134
+ end
@@ -33,6 +33,11 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
33
33
  it "only includes records without public_on date" do
34
34
  expect(subject).to eq [nil]
35
35
  end
36
+
37
+ it "delegates to the resolver" do
38
+ expect(Alchemy.config.publishable_resolver).to receive(:draft)
39
+ described_class.draft
40
+ end
36
41
  end
37
42
 
38
43
  describe ".scheduled" do
@@ -50,6 +55,11 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
50
55
  public_two
51
56
  ])
52
57
  end
58
+
59
+ it "delegates to the resolver" do
60
+ expect(Alchemy.config.publishable_resolver).to receive(:scheduled)
61
+ subject
62
+ end
53
63
  end
54
64
 
55
65
  describe ".published" do
@@ -57,6 +67,11 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
57
67
  let!(:public_two) { create(factory_name, public_on: Date.tomorrow) }
58
68
  let!(:non_public) { create(factory_name, public_on: nil) }
59
69
 
70
+ it "delegates to the resolver" do
71
+ expect(Alchemy.config.publishable_resolver).to receive(:published)
72
+ described_class.published
73
+ end
74
+
60
75
  context "without time given" do
61
76
  subject { described_class.published }
62
77
 
@@ -81,19 +96,32 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
81
96
  ])
82
97
  end
83
98
  end
99
+
100
+ context "with a publication window that has not started yet" do
101
+ let!(:future_window) do
102
+ create(factory_name, public_on: Date.tomorrow, public_until: Date.tomorrow + 2.days)
103
+ end
104
+
105
+ it "does not return the record" do
106
+ expect(described_class.published.where(id: future_window.id)).to be_empty
107
+ end
108
+ end
84
109
  end
85
110
 
86
111
  describe "#scheduled?" do
87
112
  subject { publishable.scheduled? }
88
113
 
89
114
  let(:publishable) { build(factory_name, public_on:, public_until:) }
115
+ let(:public_on) { nil }
116
+ let(:public_until) { nil }
90
117
 
91
- context "when public_on is nil" do
92
- let(:public_on) { nil }
118
+ it "delegates to the resolver" do
119
+ expect_any_instance_of(Alchemy.config.publishable_resolver).to receive(:scheduled?)
120
+ subject
121
+ end
93
122
 
123
+ context "when public_on is nil" do
94
124
  context "and public_until is nil" do
95
- let(:public_until) { nil }
96
-
97
125
  it { expect(subject).to be(false) }
98
126
  end
99
127
 
@@ -145,6 +173,13 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
145
173
  describe "#public?" do
146
174
  subject { publishable.public? }
147
175
 
176
+ let(:publishable) { build(factory_name) }
177
+
178
+ it "delegates to the resolver" do
179
+ expect_any_instance_of(Alchemy.config.publishable_resolver).to receive(:public?)
180
+ subject
181
+ end
182
+
148
183
  context "when public_on is not set" do
149
184
  let(:publishable) { build(factory_name, public_on: nil) }
150
185