postnhost 0.0.0 → 0.1.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 (497) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/{LICENSE.txt → LICENSE} +6 -6
  4. data/README.md +341 -15
  5. data/Rakefile +38 -6
  6. data/app/assets/builds/postnhost/application.css +2 -0
  7. data/app/assets/builds/postnhost/application.js +50190 -0
  8. data/app/assets/config/postnhost_manifest.js +2 -0
  9. data/app/assets/images/postnhost/demo.webp +0 -0
  10. data/app/assets/images/postnhost/icons/alert-circle.svg +3 -0
  11. data/app/assets/images/postnhost/icons/arrow-left-long.svg +3 -0
  12. data/app/assets/images/postnhost/icons/arrow-left.svg +3 -0
  13. data/app/assets/images/postnhost/icons/arrow-right-end-on-rectangle.svg +3 -0
  14. data/app/assets/images/postnhost/icons/arrow-right-long.svg +3 -0
  15. data/app/assets/images/postnhost/icons/arrow-right.svg +3 -0
  16. data/app/assets/images/postnhost/icons/bars.svg +3 -0
  17. data/app/assets/images/postnhost/icons/bluesky.svg +3 -0
  18. data/app/assets/images/postnhost/icons/check-circle.svg +3 -0
  19. data/app/assets/images/postnhost/icons/chevron-down-small.svg +3 -0
  20. data/app/assets/images/postnhost/icons/chevron-down.svg +3 -0
  21. data/app/assets/images/postnhost/icons/chevron-left.svg +3 -0
  22. data/app/assets/images/postnhost/icons/chevron-right.svg +3 -0
  23. data/app/assets/images/postnhost/icons/clock.svg +3 -0
  24. data/app/assets/images/postnhost/icons/close.svg +3 -0
  25. data/app/assets/images/postnhost/icons/code-bracket.svg +3 -0
  26. data/app/assets/images/postnhost/icons/document-text.svg +3 -0
  27. data/app/assets/images/postnhost/icons/drag-handle.svg +8 -0
  28. data/app/assets/images/postnhost/icons/edit.svg +3 -0
  29. data/app/assets/images/postnhost/icons/external-link.svg +3 -0
  30. data/app/assets/images/postnhost/icons/eye.svg +4 -0
  31. data/app/assets/images/postnhost/icons/facebook.svg +3 -0
  32. data/app/assets/images/postnhost/icons/folder.svg +3 -0
  33. data/app/assets/images/postnhost/icons/image.svg +3 -0
  34. data/app/assets/images/postnhost/icons/info-circle.svg +3 -0
  35. data/app/assets/images/postnhost/icons/instagram.svg +3 -0
  36. data/app/assets/images/postnhost/icons/language.svg +3 -0
  37. data/app/assets/images/postnhost/icons/layers.svg +3 -0
  38. data/app/assets/images/postnhost/icons/lightning.svg +3 -0
  39. data/app/assets/images/postnhost/icons/linkedin.svg +3 -0
  40. data/app/assets/images/postnhost/icons/logout.svg +3 -0
  41. data/app/assets/images/postnhost/icons/magnifying-glass.svg +3 -0
  42. data/app/assets/images/postnhost/icons/mastodon.svg +3 -0
  43. data/app/assets/images/postnhost/icons/menu.svg +3 -0
  44. data/app/assets/images/postnhost/icons/plus.svg +3 -0
  45. data/app/assets/images/postnhost/icons/schedule.svg +3 -0
  46. data/app/assets/images/postnhost/icons/settings.svg +4 -0
  47. data/app/assets/images/postnhost/icons/sparkles.svg +3 -0
  48. data/app/assets/images/postnhost/icons/spinner.svg +4 -0
  49. data/app/assets/images/postnhost/icons/threads.svg +3 -0
  50. data/app/assets/images/postnhost/icons/tiktok.svg +3 -0
  51. data/app/assets/images/postnhost/icons/tiptap/align-center.svg +3 -0
  52. data/app/assets/images/postnhost/icons/tiptap/align-left.svg +3 -0
  53. data/app/assets/images/postnhost/icons/tiptap/align-right.svg +3 -0
  54. data/app/assets/images/postnhost/icons/tiptap/apply-link.svg +3 -0
  55. data/app/assets/images/postnhost/icons/tiptap/blockquote.svg +3 -0
  56. data/app/assets/images/postnhost/icons/tiptap/bold.svg +3 -0
  57. data/app/assets/images/postnhost/icons/tiptap/bullet-list.svg +3 -0
  58. data/app/assets/images/postnhost/icons/tiptap/code-block.svg +3 -0
  59. data/app/assets/images/postnhost/icons/tiptap/code.svg +3 -0
  60. data/app/assets/images/postnhost/icons/tiptap/heading-2.svg +3 -0
  61. data/app/assets/images/postnhost/icons/tiptap/heading-3.svg +3 -0
  62. data/app/assets/images/postnhost/icons/tiptap/image-add.svg +3 -0
  63. data/app/assets/images/postnhost/icons/tiptap/italic.svg +3 -0
  64. data/app/assets/images/postnhost/icons/tiptap/link.svg +3 -0
  65. data/app/assets/images/postnhost/icons/tiptap/ordered-list.svg +3 -0
  66. data/app/assets/images/postnhost/icons/tiptap/redo.svg +3 -0
  67. data/app/assets/images/postnhost/icons/tiptap/remove-link.svg +3 -0
  68. data/app/assets/images/postnhost/icons/tiptap/strike.svg +3 -0
  69. data/app/assets/images/postnhost/icons/tiptap/table.svg +3 -0
  70. data/app/assets/images/postnhost/icons/tiptap/underline.svg +3 -0
  71. data/app/assets/images/postnhost/icons/tiptap/undo.svg +3 -0
  72. data/app/assets/images/postnhost/icons/tiptap/youtube.svg +4 -0
  73. data/app/assets/images/postnhost/icons/translate.svg +3 -0
  74. data/app/assets/images/postnhost/icons/trash.svg +3 -0
  75. data/app/assets/images/postnhost/icons/user.svg +4 -0
  76. data/app/assets/images/postnhost/icons/users.svg +3 -0
  77. data/app/assets/images/postnhost/icons/warning.svg +3 -0
  78. data/app/assets/images/postnhost/icons/x.svg +3 -0
  79. data/app/assets/images/postnhost/icons/youtube.svg +3 -0
  80. data/app/assets/images/postnhost/logo.webp +0 -0
  81. data/app/assets/images/postnhost/og-image.webp +0 -0
  82. data/app/assets/images/postnhost/sample-docs/08-how-to-control-suggested-articles-and-top-picks-01-discovery-controls.webp +0 -0
  83. data/app/assets/images/postnhost/sample-docs/09-all-about-authors-in-postnhost-01-author-profile.webp +0 -0
  84. data/app/assets/images/postnhost/sample-docs/09-all-about-authors-in-postnhost-02-multiple-authors.webp +0 -0
  85. data/app/assets/images/postnhost/sample-docs/11-how-to-schedule-an-article-for-publication-01-timezone.webp +0 -0
  86. data/app/assets/images/postnhost/sample-docs/11-how-to-schedule-an-article-for-publication-02-scheduled-article.webp +0 -0
  87. data/app/assets/images/postnhost/sample-docs/12-how-article-version-history-and-restores-work-01-version-history.webp +0 -0
  88. data/app/assets/images/postnhost/sample-docs/13-how-to-add-and-manage-languages-01-language-management.webp +0 -0
  89. data/app/assets/images/postnhost/sample-docs/14-how-to-translate-articles-pages-and-categories-01-translation-options.webp +0 -0
  90. data/app/assets/images/postnhost/sample-docs/14-how-to-translate-articles-pages-and-categories-02-ai-language-selection.webp +0 -0
  91. data/app/assets/images/postnhost/sample-docs/16-how-to-add-navigation-links-01-navigation-builder.webp +0 -0
  92. data/app/assets/images/postnhost/sample-docs/16-how-to-add-navigation-links-02-external-link-settings.webp +0 -0
  93. data/app/assets/images/postnhost/sample-docs/17-how-to-add-a-logo-and-default-og-image-01-assets-settings.webp +0 -0
  94. data/app/assets/images/postnhost/sample-docs/19-how-to-customize-public-text-for-each-language-01-i18n-overrides.webp +0 -0
  95. data/app/assets/images/postnhost/sample-docs/20-how-to-configure-structured-data-and-sitemap-seo-01-site-schema.webp +0 -0
  96. data/app/assets/images/postnhost/sample-docs/20-how-to-configure-structured-data-and-sitemap-seo-02-author-schema-defaults.webp +0 -0
  97. data/app/assets/images/postnhost/sample-docs/20-how-to-configure-structured-data-and-sitemap-seo-03-author-schema-advanced-fields.webp +0 -0
  98. data/app/assets/images/postnhost/sample-docs/covers/08-suggested-articles-and-top-picks.webp +0 -0
  99. data/app/assets/images/postnhost/sample-docs/covers/09-authors.webp +0 -0
  100. data/app/assets/images/postnhost/sample-docs/covers/11-scheduling.webp +0 -0
  101. data/app/assets/images/postnhost/sample-docs/covers/12-version-history.webp +0 -0
  102. data/app/assets/images/postnhost/sample-docs/covers/13-languages.webp +0 -0
  103. data/app/assets/images/postnhost/sample-docs/covers/14-translations.webp +0 -0
  104. data/app/assets/images/postnhost/sample-docs/covers/16-navigation-links.webp +0 -0
  105. data/app/assets/images/postnhost/sample-docs/covers/17-logo-and-default-og-image.webp +0 -0
  106. data/app/assets/images/postnhost/sample-docs/covers/19-public-text-for-each-language.webp +0 -0
  107. data/app/assets/images/postnhost/sample-docs/covers/20-structured-data-and-sitemap-seo.webp +0 -0
  108. data/app/assets/stylesheets/application.tailwind.css +343 -0
  109. data/app/assets/stylesheets/postnhost/application.css +15 -0
  110. data/app/assets/xsl/sitemap.xsl +64 -0
  111. data/app/controllers/concerns/postnhost/authentication.rb +59 -0
  112. data/app/controllers/concerns/postnhost/canonical_first_page_redirect.rb +14 -0
  113. data/app/controllers/concerns/postnhost/language_switcher.rb +42 -0
  114. data/app/controllers/concerns/postnhost/onboarding/access_control.rb +57 -0
  115. data/app/controllers/concerns/postnhost/onboarding/step_assignment.rb +55 -0
  116. data/app/controllers/concerns/postnhost/public_footer_data.rb +103 -0
  117. data/app/controllers/concerns/postnhost/public_http_caching.rb +35 -0
  118. data/app/controllers/concerns/postnhost/public_page_size.rb +17 -0
  119. data/app/controllers/concerns/postnhost/public_template_resolvable.rb +44 -0
  120. data/app/controllers/concerns/postnhost/settings/i18n_manageable.rb +61 -0
  121. data/app/controllers/concerns/postnhost/settings/navigation_manageable.rb +118 -0
  122. data/app/controllers/concerns/postnhost/settings/schema_manageable.rb +140 -0
  123. data/app/controllers/concerns/postnhost/users/schema_manageable.rb +119 -0
  124. data/app/controllers/postnhost/application_controller.rb +37 -0
  125. data/app/controllers/postnhost/article_variants_controller.rb +138 -0
  126. data/app/controllers/postnhost/articles/cover_images_controller.rb +35 -0
  127. data/app/controllers/postnhost/articles/images_controller.rb +28 -0
  128. data/app/controllers/postnhost/articles/translations_controller.rb +26 -0
  129. data/app/controllers/postnhost/articles_controller.rb +130 -0
  130. data/app/controllers/postnhost/categories/translations_controller.rb +24 -0
  131. data/app/controllers/postnhost/categories_controller.rb +59 -0
  132. data/app/controllers/postnhost/category_variants_controller.rb +69 -0
  133. data/app/controllers/postnhost/languages_controller.rb +66 -0
  134. data/app/controllers/postnhost/onboarding_controller.rb +70 -0
  135. data/app/controllers/postnhost/page_variants_controller.rb +136 -0
  136. data/app/controllers/postnhost/pages/images_controller.rb +28 -0
  137. data/app/controllers/postnhost/pages/translations_controller.rb +26 -0
  138. data/app/controllers/postnhost/pages_controller.rb +95 -0
  139. data/app/controllers/postnhost/public/articles_controller.rb +135 -0
  140. data/app/controllers/postnhost/public/authors_controller.rb +57 -0
  141. data/app/controllers/postnhost/public/categories_controller.rb +61 -0
  142. data/app/controllers/postnhost/public/static_pages_controller.rb +77 -0
  143. data/app/controllers/postnhost/public_controller.rb +32 -0
  144. data/app/controllers/postnhost/sessions_controller.rb +37 -0
  145. data/app/controllers/postnhost/settings/site_scripts_controller.rb +61 -0
  146. data/app/controllers/postnhost/settings_controller.rb +63 -0
  147. data/app/controllers/postnhost/sitemap_controller.rb +17 -0
  148. data/app/controllers/postnhost/templates_controller.rb +26 -0
  149. data/app/controllers/postnhost/users_controller.rb +112 -0
  150. data/app/helpers/postnhost/application_helper.rb +247 -0
  151. data/app/helpers/postnhost/article_helper.rb +41 -0
  152. data/app/helpers/postnhost/category_helper.rb +52 -0
  153. data/app/helpers/postnhost/common_css_helper.rb +97 -0
  154. data/app/helpers/postnhost/navigation_helper.rb +148 -0
  155. data/app/helpers/postnhost/pagy_helper.rb +103 -0
  156. data/app/helpers/postnhost/public_pages_helper.rb +287 -0
  157. data/app/helpers/postnhost/schema_helper.rb +102 -0
  158. data/app/helpers/postnhost/seo_helper.rb +428 -0
  159. data/app/helpers/postnhost/settings_helper.rb +79 -0
  160. data/app/javascript/application.js +3 -0
  161. data/app/javascript/controllers/ai_translation_controller.js +42 -0
  162. data/app/javascript/controllers/application.js +9 -0
  163. data/app/javascript/controllers/author_manager_controller.js +96 -0
  164. data/app/javascript/controllers/auto_save_controller.js +244 -0
  165. data/app/javascript/controllers/bulk_selection_controller.js +74 -0
  166. data/app/javascript/controllers/burger_controller.js +20 -0
  167. data/app/javascript/controllers/category_manager_controller.js +99 -0
  168. data/app/javascript/controllers/collapsible_sidebar_controller.js +87 -0
  169. data/app/javascript/controllers/cover_image_upload_controller.js +164 -0
  170. data/app/javascript/controllers/dropdown_controller.js +38 -0
  171. data/app/javascript/controllers/editor_mobile_sidebars_controller.js +36 -0
  172. data/app/javascript/controllers/file_auto_submit_controller.js +23 -0
  173. data/app/javascript/controllers/flash_message_controller.js +40 -0
  174. data/app/javascript/controllers/index.js +71 -0
  175. data/app/javascript/controllers/navigation_builder_controller.js +463 -0
  176. data/app/javascript/controllers/public_mobile_nav_controller.js +26 -0
  177. data/app/javascript/controllers/publish_guard_controller.js +47 -0
  178. data/app/javascript/controllers/reading_progress_controller.js +38 -0
  179. data/app/javascript/controllers/schedule_manager_controller.js +87 -0
  180. data/app/javascript/controllers/suggestion_manager_controller.js +99 -0
  181. data/app/javascript/controllers/template_preview_controller.js +34 -0
  182. data/app/javascript/controllers/tiptap_editor_controller.js +812 -0
  183. data/app/javascript/controllers/title_field_controller.js +17 -0
  184. data/app/javascript/helpers/auto_save_state.js +47 -0
  185. data/app/javascript/helpers/code_highlighting.js +48 -0
  186. data/app/javascript/helpers/dom_helpers.js +109 -0
  187. data/app/javascript/helpers/style_helpers.js +168 -0
  188. data/app/jobs/postnhost/application_job.rb +9 -0
  189. data/app/jobs/postnhost/scheduled_article_job.rb +13 -0
  190. data/app/jobs/postnhost/translation/article_variant_job.rb +17 -0
  191. data/app/jobs/postnhost/translation/category_variant_job.rb +17 -0
  192. data/app/jobs/postnhost/translation/page_variant_job.rb +17 -0
  193. data/app/models/concerns/postnhost/article/image_uploadable.rb +11 -0
  194. data/app/models/concerns/postnhost/excerpt_optimizable.rb +57 -0
  195. data/app/models/concerns/postnhost/inline_image_uploadable.rb +40 -0
  196. data/app/models/concerns/postnhost/page/image_uploadable.rb +11 -0
  197. data/app/models/concerns/postnhost/primary_language_guard.rb +18 -0
  198. data/app/models/concerns/postnhost/public_revision_touch.rb +15 -0
  199. data/app/models/concerns/postnhost/schema_localizable.rb +28 -0
  200. data/app/models/postnhost/application_record.rb +5 -0
  201. data/app/models/postnhost/article.rb +142 -0
  202. data/app/models/postnhost/article_author.rb +20 -0
  203. data/app/models/postnhost/article_category.rb +8 -0
  204. data/app/models/postnhost/article_suggestion.rb +19 -0
  205. data/app/models/postnhost/article_variant.rb +28 -0
  206. data/app/models/postnhost/category.rb +34 -0
  207. data/app/models/postnhost/category_variant.rb +24 -0
  208. data/app/models/postnhost/language.rb +38 -0
  209. data/app/models/postnhost/navigation.rb +76 -0
  210. data/app/models/postnhost/navigation_item.rb +100 -0
  211. data/app/models/postnhost/page.rb +63 -0
  212. data/app/models/postnhost/page_variant.rb +26 -0
  213. data/app/models/postnhost/public_site_revision.rb +24 -0
  214. data/app/models/postnhost/published_cover_image.rb +28 -0
  215. data/app/models/postnhost/setting.rb +116 -0
  216. data/app/models/postnhost/site_script.rb +11 -0
  217. data/app/models/postnhost/snapshot/article.rb +142 -0
  218. data/app/models/postnhost/snapshot/article_author.rb +11 -0
  219. data/app/models/postnhost/snapshot/article_category.rb +10 -0
  220. data/app/models/postnhost/snapshot/article_suggestion.rb +11 -0
  221. data/app/models/postnhost/snapshot/article_variant.rb +60 -0
  222. data/app/models/postnhost/snapshot/page.rb +33 -0
  223. data/app/models/postnhost/snapshot/page_variant.rb +41 -0
  224. data/app/models/postnhost/template.rb +20 -0
  225. data/app/models/postnhost/user.rb +63 -0
  226. data/app/models/postnhost/version.rb +7 -0
  227. data/app/services/postnhost/base_service.rb +17 -0
  228. data/app/services/postnhost/constraints/public_locale_constraint.rb +9 -0
  229. data/app/services/postnhost/constraints/public_route_constraint.rb +19 -0
  230. data/app/services/postnhost/onboarding/step_processor.rb +27 -0
  231. data/app/services/postnhost/onboarding/steps/base.rb +52 -0
  232. data/app/services/postnhost/onboarding/steps/choose_languages.rb +66 -0
  233. data/app/services/postnhost/onboarding/steps/complete_post_install_checklist.rb +12 -0
  234. data/app/services/postnhost/onboarding/steps/create_admin_account.rb +28 -0
  235. data/app/services/postnhost/onboarding/steps/finish_setup.rb +27 -0
  236. data/app/services/postnhost/onboarding/steps/set_site_metadata.rb +70 -0
  237. data/app/services/postnhost/public_navigation_targets.rb +67 -0
  238. data/app/services/postnhost/public_request_context.rb +101 -0
  239. data/app/services/postnhost/publishing/article_variants/publish.rb +26 -0
  240. data/app/services/postnhost/publishing/article_variants/publish_many.rb +23 -0
  241. data/app/services/postnhost/publishing/article_variants/snapshot_writer.rb +44 -0
  242. data/app/services/postnhost/publishing/article_variants/unpublish.rb +27 -0
  243. data/app/services/postnhost/publishing/article_variants/unpublish_many.rb +23 -0
  244. data/app/services/postnhost/publishing/articles/publish.rb +25 -0
  245. data/app/services/postnhost/publishing/articles/publish_scheduled.rb +42 -0
  246. data/app/services/postnhost/publishing/articles/snapshot_writer.rb +84 -0
  247. data/app/services/postnhost/publishing/articles/unpublish.rb +26 -0
  248. data/app/services/postnhost/publishing/bulk_transition.rb +63 -0
  249. data/app/services/postnhost/publishing/error.rb +12 -0
  250. data/app/services/postnhost/publishing/page_variants/publish.rb +26 -0
  251. data/app/services/postnhost/publishing/page_variants/publish_many.rb +23 -0
  252. data/app/services/postnhost/publishing/page_variants/snapshot_writer.rb +41 -0
  253. data/app/services/postnhost/publishing/page_variants/unpublish.rb +27 -0
  254. data/app/services/postnhost/publishing/page_variants/unpublish_many.rb +23 -0
  255. data/app/services/postnhost/publishing/pages/publish.rb +25 -0
  256. data/app/services/postnhost/publishing/pages/snapshot_writer.rb +38 -0
  257. data/app/services/postnhost/publishing/pages/unpublish.rb +26 -0
  258. data/app/services/postnhost/publishing/revision.rb +37 -0
  259. data/app/services/postnhost/publishing/route_validator.rb +41 -0
  260. data/app/services/postnhost/publishing/versions/restore_draft.rb +64 -0
  261. data/app/services/postnhost/service_result.rb +7 -0
  262. data/app/services/postnhost/sitemap_builder.rb +395 -0
  263. data/app/services/postnhost/translation/article_variant_translator.rb +102 -0
  264. data/app/services/postnhost/translation/category_variant_translator.rb +92 -0
  265. data/app/services/postnhost/translation/content_translator.rb +119 -0
  266. data/app/services/postnhost/translation/openai_llm_client.rb +30 -0
  267. data/app/services/postnhost/translation/page_variant_translator.rb +55 -0
  268. data/app/uploaders/base_uploader.rb +26 -0
  269. data/app/uploaders/cover_image_uploader.rb +31 -0
  270. data/app/uploaders/postnhost/avatar_uploader.rb +17 -0
  271. data/app/uploaders/postnhost/inline_article_image_uploader.rb +38 -0
  272. data/app/uploaders/postnhost/inline_page_image_uploader.rb +38 -0
  273. data/app/uploaders/postnhost/setting_asset_uploader.rb +18 -0
  274. data/app/views/layouts/postnhost/_favicon.html.erb +6 -0
  275. data/app/views/layouts/postnhost/_footer.html.erb +67 -0
  276. data/app/views/layouts/postnhost/_header.html.erb +89 -0
  277. data/app/views/layouts/postnhost/_sidebar.html.erb +72 -0
  278. data/app/views/layouts/postnhost/application.html.erb +23 -0
  279. data/app/views/layouts/postnhost/articles.html.erb +34 -0
  280. data/app/views/layouts/postnhost/product.html.erb +36 -0
  281. data/app/views/layouts/postnhost/public/templates/default.html.erb +110 -0
  282. data/app/views/layouts/postnhost/public/templates/swiss-editorial.html.erb +213 -0
  283. data/app/views/layouts/postnhost/public/templates/workspace-journal.html.erb +231 -0
  284. data/app/views/layouts/postnhost/public_header/_dashboard_sign_in.html.erb +14 -0
  285. data/app/views/layouts/postnhost/public_header/_language_switcher.html.erb +43 -0
  286. data/app/views/layouts/postnhost/public_header/_logo.html.erb +9 -0
  287. data/app/views/postnhost/article_variants/_variants_list.html.erb +82 -0
  288. data/app/views/postnhost/article_variants/edit.html.erb +48 -0
  289. data/app/views/postnhost/article_variants/index.html.erb +128 -0
  290. data/app/views/postnhost/article_variants/publish.turbo_stream.erb +3 -0
  291. data/app/views/postnhost/article_variants/unpublish.turbo_stream.erb +3 -0
  292. data/app/views/postnhost/article_variants/update.turbo_stream.erb +3 -0
  293. data/app/views/postnhost/articles/_author_manager.html.erb +38 -0
  294. data/app/views/postnhost/articles/_category_manager.html.erb +42 -0
  295. data/app/views/postnhost/articles/_cover_image_upload.html.erb +82 -0
  296. data/app/views/postnhost/articles/_suggestion_manager.html.erb +43 -0
  297. data/app/views/postnhost/articles/_version_snapshot.html.erb +10 -0
  298. data/app/views/postnhost/articles/edit.html.erb +51 -0
  299. data/app/views/postnhost/articles/index.html.erb +132 -0
  300. data/app/views/postnhost/articles/publish.turbo_stream.erb +3 -0
  301. data/app/views/postnhost/articles/unpublish.turbo_stream.erb +3 -0
  302. data/app/views/postnhost/articles/update.turbo_stream.erb +3 -0
  303. data/app/views/postnhost/articles/version_preview.html.erb +17 -0
  304. data/app/views/postnhost/articles/versions.html.erb +68 -0
  305. data/app/views/postnhost/categories/edit.html.erb +77 -0
  306. data/app/views/postnhost/categories/index.html.erb +80 -0
  307. data/app/views/postnhost/categories/new.html.erb +50 -0
  308. data/app/views/postnhost/categories/show.html.erb +99 -0
  309. data/app/views/postnhost/category_variants/_category_variants_list.html.erb +67 -0
  310. data/app/views/postnhost/category_variants/_form.html.erb +29 -0
  311. data/app/views/postnhost/category_variants/edit.html.erb +21 -0
  312. data/app/views/postnhost/category_variants/index.html.erb +82 -0
  313. data/app/views/postnhost/category_variants/new.html.erb +21 -0
  314. data/app/views/postnhost/languages/edit.html.erb +45 -0
  315. data/app/views/postnhost/languages/index.html.erb +86 -0
  316. data/app/views/postnhost/languages/new.html.erb +45 -0
  317. data/app/views/postnhost/languages/show.html.erb +156 -0
  318. data/app/views/postnhost/onboarding/_post_install_checklist.html.erb +40 -0
  319. data/app/views/postnhost/onboarding/_step_1.html.erb +42 -0
  320. data/app/views/postnhost/onboarding/_step_2.html.erb +38 -0
  321. data/app/views/postnhost/onboarding/_step_3.html.erb +62 -0
  322. data/app/views/postnhost/onboarding/_step_4.html.erb +26 -0
  323. data/app/views/postnhost/onboarding/show.html.erb +51 -0
  324. data/app/views/postnhost/page_variants/_variants_list.html.erb +82 -0
  325. data/app/views/postnhost/page_variants/edit.html.erb +50 -0
  326. data/app/views/postnhost/page_variants/index.html.erb +128 -0
  327. data/app/views/postnhost/page_variants/publish.turbo_stream.erb +3 -0
  328. data/app/views/postnhost/page_variants/unpublish.turbo_stream.erb +3 -0
  329. data/app/views/postnhost/page_variants/update.turbo_stream.erb +3 -0
  330. data/app/views/postnhost/pages/edit.html.erb +50 -0
  331. data/app/views/postnhost/pages/index.html.erb +110 -0
  332. data/app/views/postnhost/pages/publish.turbo_stream.erb +3 -0
  333. data/app/views/postnhost/pages/unpublish.turbo_stream.erb +3 -0
  334. data/app/views/postnhost/public/articles/content_for/_index.html.erb +30 -0
  335. data/app/views/postnhost/public/articles/content_for/_preview.html.erb +9 -0
  336. data/app/views/postnhost/public/articles/content_for/_show.html.erb +24 -0
  337. data/app/views/postnhost/public/authors/content_for/_show.html.erb +24 -0
  338. data/app/views/postnhost/public/categories/content_for/_index.html.erb +29 -0
  339. data/app/views/postnhost/public/static_pages/content_for/_show.html.erb +19 -0
  340. data/app/views/postnhost/public/templates/default/articles/_article_card.html.erb +37 -0
  341. data/app/views/postnhost/public/templates/default/articles/_search_form.html.erb +12 -0
  342. data/app/views/postnhost/public/templates/default/articles/_suggestions.html.erb +44 -0
  343. data/app/views/postnhost/public/templates/default/articles/_top_picks.html.erb +42 -0
  344. data/app/views/postnhost/public/templates/default/articles/index.html.erb +53 -0
  345. data/app/views/postnhost/public/templates/default/articles/preview.html.erb +102 -0
  346. data/app/views/postnhost/public/templates/default/articles/show.html.erb +111 -0
  347. data/app/views/postnhost/public/templates/default/authors/_article_card.html.erb +28 -0
  348. data/app/views/postnhost/public/templates/default/authors/_profile_block.html.erb +29 -0
  349. data/app/views/postnhost/public/templates/default/authors/show.html.erb +36 -0
  350. data/app/views/postnhost/public/templates/default/categories/_article_card.html.erb +34 -0
  351. data/app/views/postnhost/public/templates/default/categories/index.html.erb +48 -0
  352. data/app/views/postnhost/public/templates/default/shared/_article_authors.html.erb +24 -0
  353. data/app/views/postnhost/public/templates/default/shared/_author_avatar.html.erb +9 -0
  354. data/app/views/postnhost/public/templates/default/shared/_hero_cover_image.html.erb +11 -0
  355. data/app/views/postnhost/public/templates/default/shared/_pagination.html.erb +35 -0
  356. data/app/views/postnhost/public/templates/default/shared/_thumbnail_cover_image.html.erb +9 -0
  357. data/app/views/postnhost/public/templates/default/static_pages/show.html.erb +22 -0
  358. data/app/views/postnhost/public/templates/swiss-editorial/articles/_article_card.html.erb +28 -0
  359. data/app/views/postnhost/public/templates/swiss-editorial/articles/_search_form.html.erb +19 -0
  360. data/app/views/postnhost/public/templates/swiss-editorial/articles/_suggestions.html.erb +42 -0
  361. data/app/views/postnhost/public/templates/swiss-editorial/articles/_top_picks.html.erb +38 -0
  362. data/app/views/postnhost/public/templates/swiss-editorial/articles/index.html.erb +133 -0
  363. data/app/views/postnhost/public/templates/swiss-editorial/articles/preview.html.erb +42 -0
  364. data/app/views/postnhost/public/templates/swiss-editorial/articles/show.html.erb +100 -0
  365. data/app/views/postnhost/public/templates/swiss-editorial/authors/_article_card.html.erb +27 -0
  366. data/app/views/postnhost/public/templates/swiss-editorial/authors/_profile_block.html.erb +32 -0
  367. data/app/views/postnhost/public/templates/swiss-editorial/authors/show.html.erb +31 -0
  368. data/app/views/postnhost/public/templates/swiss-editorial/categories/_article_card.html.erb +28 -0
  369. data/app/views/postnhost/public/templates/swiss-editorial/categories/index.html.erb +88 -0
  370. data/app/views/postnhost/public/templates/swiss-editorial/shared/_article_authors.html.erb +26 -0
  371. data/app/views/postnhost/public/templates/swiss-editorial/shared/_author_avatar.html.erb +9 -0
  372. data/app/views/postnhost/public/templates/swiss-editorial/shared/_hero_cover_image.html.erb +11 -0
  373. data/app/views/postnhost/public/templates/swiss-editorial/shared/_pagination.html.erb +35 -0
  374. data/app/views/postnhost/public/templates/swiss-editorial/shared/_thumbnail_cover_image.html.erb +9 -0
  375. data/app/views/postnhost/public/templates/swiss-editorial/static_pages/show.html.erb +17 -0
  376. data/app/views/postnhost/public/templates/workspace-journal/articles/_article_card.html.erb +44 -0
  377. data/app/views/postnhost/public/templates/workspace-journal/articles/_search_form.html.erb +19 -0
  378. data/app/views/postnhost/public/templates/workspace-journal/articles/_suggestions.html.erb +49 -0
  379. data/app/views/postnhost/public/templates/workspace-journal/articles/_top_picks.html.erb +34 -0
  380. data/app/views/postnhost/public/templates/workspace-journal/articles/index.html.erb +69 -0
  381. data/app/views/postnhost/public/templates/workspace-journal/articles/preview.html.erb +113 -0
  382. data/app/views/postnhost/public/templates/workspace-journal/articles/show.html.erb +120 -0
  383. data/app/views/postnhost/public/templates/workspace-journal/authors/_article_card.html.erb +1 -0
  384. data/app/views/postnhost/public/templates/workspace-journal/authors/_profile_block.html.erb +32 -0
  385. data/app/views/postnhost/public/templates/workspace-journal/authors/show.html.erb +33 -0
  386. data/app/views/postnhost/public/templates/workspace-journal/categories/_article_card.html.erb +1 -0
  387. data/app/views/postnhost/public/templates/workspace-journal/categories/index.html.erb +58 -0
  388. data/app/views/postnhost/public/templates/workspace-journal/shared/_article_authors.html.erb +27 -0
  389. data/app/views/postnhost/public/templates/workspace-journal/shared/_article_card.html.erb +44 -0
  390. data/app/views/postnhost/public/templates/workspace-journal/shared/_author_avatar.html.erb +9 -0
  391. data/app/views/postnhost/public/templates/workspace-journal/shared/_category_navigation.html.erb +10 -0
  392. data/app/views/postnhost/public/templates/workspace-journal/shared/_hero_cover_image.html.erb +11 -0
  393. data/app/views/postnhost/public/templates/workspace-journal/shared/_pagination.html.erb +35 -0
  394. data/app/views/postnhost/public/templates/workspace-journal/shared/_thumbnail_cover_image.html.erb +9 -0
  395. data/app/views/postnhost/public/templates/workspace-journal/static_pages/show.html.erb +17 -0
  396. data/app/views/postnhost/sessions/new.html.erb +59 -0
  397. data/app/views/postnhost/settings/edit.html.erb +68 -0
  398. data/app/views/postnhost/settings/sections/_assets.html.erb +40 -0
  399. data/app/views/postnhost/settings/sections/_feature_flags.html.erb +43 -0
  400. data/app/views/postnhost/settings/sections/_i18n.html.erb +57 -0
  401. data/app/views/postnhost/settings/sections/_navigation.html.erb +98 -0
  402. data/app/views/postnhost/settings/sections/_schema.html.erb +253 -0
  403. data/app/views/postnhost/settings/sections/_scripts.html.erb +79 -0
  404. data/app/views/postnhost/settings/sections/_site.html.erb +41 -0
  405. data/app/views/postnhost/settings/sections/_timezone.html.erb +19 -0
  406. data/app/views/postnhost/shared/_ai_translation_modal.html.erb +85 -0
  407. data/app/views/postnhost/shared/_flash_messages.html.erb +27 -0
  408. data/app/views/postnhost/shared/_form_errors.html.erb +22 -0
  409. data/app/views/postnhost/shared/_seo_meta_tags.html.erb +70 -0
  410. data/app/views/postnhost/shared/_structured_data.html.erb +5 -0
  411. data/app/views/postnhost/shared/articles/_action_buttons.html.erb +87 -0
  412. data/app/views/postnhost/shared/articles/_cover_image_preview.html.erb +30 -0
  413. data/app/views/postnhost/shared/articles/_editor_column.html.erb +15 -0
  414. data/app/views/postnhost/shared/articles/_editor_column_preview.html.erb +33 -0
  415. data/app/views/postnhost/shared/articles/_error_messages.html.erb +23 -0
  416. data/app/views/postnhost/shared/articles/_meta_sidebar.html.erb +322 -0
  417. data/app/views/postnhost/shared/articles/_meta_sidebar_preview.html.erb +29 -0
  418. data/app/views/postnhost/shared/articles/_post_actions.html.erb +105 -0
  419. data/app/views/postnhost/shared/articles/_schedule_actions.html.erb +51 -0
  420. data/app/views/postnhost/shared/articles/_tiptap_editor.html.erb +225 -0
  421. data/app/views/postnhost/shared/articles/_title_field.html.erb +9 -0
  422. data/app/views/postnhost/shared/articles/_version_action_buttons.html.erb +35 -0
  423. data/app/views/postnhost/shared/elements/_button.html.erb +20 -0
  424. data/app/views/postnhost/shared/elements/_button_link.html.erb +24 -0
  425. data/app/views/postnhost/shared/elements/_empty_state.html.erb +22 -0
  426. data/app/views/postnhost/shared/elements/_error_messages.html.erb +28 -0
  427. data/app/views/postnhost/shared/elements/_form_card.html.erb +10 -0
  428. data/app/views/postnhost/shared/elements/_form_page_header.html.erb +21 -0
  429. data/app/views/postnhost/shared/elements/_icon_button.html.erb +17 -0
  430. data/app/views/postnhost/shared/elements/_page_header.html.erb +40 -0
  431. data/app/views/postnhost/shared/elements/_table_container.html.erb +23 -0
  432. data/app/views/postnhost/shared/pages/_action_buttons.html.erb +84 -0
  433. data/app/views/postnhost/shared/pages/_meta_sidebar.html.erb +132 -0
  434. data/app/views/postnhost/shared/pages/_post_actions.html.erb +69 -0
  435. data/app/views/postnhost/templates/edit.html.erb +83 -0
  436. data/app/views/postnhost/users/_avatar.html.erb +9 -0
  437. data/app/views/postnhost/users/_form.html.erb +160 -0
  438. data/app/views/postnhost/users/edit.html.erb +26 -0
  439. data/app/views/postnhost/users/index.html.erb +91 -0
  440. data/app/views/postnhost/users/new.html.erb +9 -0
  441. data/app/views/postnhost/users/schema_edit.html.erb +175 -0
  442. data/config/locales/de.yml +55 -0
  443. data/config/locales/en.yml +88 -0
  444. data/config/locales/es.yml +58 -0
  445. data/config/locales/fr.yml +55 -0
  446. data/config/locales/ja.yml +55 -0
  447. data/config/locales/ko.yml +55 -0
  448. data/config/locales/pl.yml +55 -0
  449. data/config/locales/pt.yml +55 -0
  450. data/config/locales/ru.yml +55 -0
  451. data/config/routes.rb +112 -0
  452. data/db/migrate/20250822213820_create_users.rb +29 -0
  453. data/db/migrate/20250822214046_create_categories.rb +15 -0
  454. data/db/migrate/20250822214106_create_languages.rb +16 -0
  455. data/db/migrate/20250822214148_create_articles.rb +34 -0
  456. data/db/migrate/20250822214217_create_article_variants.rb +24 -0
  457. data/db/migrate/20250929091137_create_article_categories.rb +12 -0
  458. data/db/migrate/20251013204200_create_versions.rb +40 -0
  459. data/db/migrate/20260106074532_create_article_suggestions.rb +13 -0
  460. data/db/migrate/20260108110506_create_category_variants.rb +15 -0
  461. data/db/migrate/20260308130000_create_settings.rb +23 -0
  462. data/db/migrate/20260323113000_create_pages.rb +19 -0
  463. data/db/migrate/20260414100000_create_article_authors.rb +14 -0
  464. data/db/migrate/20260512100000_create_templates.rb +9 -0
  465. data/db/migrate/20260520110001_create_navigations.rb +9 -0
  466. data/db/migrate/20260520110002_create_navigation_items.rb +22 -0
  467. data/db/migrate/20260528000002_create_page_variants.rb +20 -0
  468. data/db/migrate/20260709090000_create_snapshots.rb +154 -0
  469. data/db/migrate/20260726130000_create_site_scripts.rb +11 -0
  470. data/lib/generators/postnhost/install/install_generator.rb +56 -0
  471. data/lib/generators/postnhost/install/templates/carrierwave.rb +17 -0
  472. data/lib/generators/postnhost/install/templates/postnhost.rb +36 -0
  473. data/lib/generators/postnhost/locale/locale_generator.rb +36 -0
  474. data/lib/generators/postnhost/static_pages/static_pages_generator.rb +47 -0
  475. data/lib/generators/postnhost/tailwindcss/install_generator.rb +97 -0
  476. data/lib/generators/postnhost/tailwindcss/templates/host.tailwind.css +7 -0
  477. data/lib/generators/postnhost/tailwindcss/templates/tailwind.config.js +8 -0
  478. data/lib/generators/postnhost/user/user_generator.rb +36 -0
  479. data/lib/generators/postnhost/views/views_generator.rb +142 -0
  480. data/lib/postnhost/configuration.rb +64 -0
  481. data/lib/postnhost/engine.rb +57 -0
  482. data/lib/postnhost/sample_data.rb +865 -0
  483. data/lib/postnhost/settings/i18n_overrides.rb +137 -0
  484. data/lib/postnhost/settings/i18n_patch.rb +18 -0
  485. data/lib/postnhost/version.rb +1 -3
  486. data/lib/postnhost.rb +26 -3
  487. data/lib/tasks/postnhost_snapshots.rake +77 -0
  488. data/lib/tasks/postnhost_tasks.rake +96 -0
  489. metadata +662 -21
  490. data/.github/workflows/main.yml +0 -27
  491. data/.gitignore +0 -11
  492. data/.rspec +0 -3
  493. data/.rubocop.yml +0 -8
  494. data/Gemfile +0 -13
  495. data/bin/console +0 -11
  496. data/bin/setup +0 -8
  497. data/postnhost.gemspec +0 -31
@@ -0,0 +1,66 @@
1
+ module Postnhost
2
+ module Onboarding
3
+ module Steps
4
+ class ChooseLanguages < Base
5
+ def call
6
+ return restart_at_admin_account unless pending_user
7
+
8
+ options_by_locale = LANGUAGE_OPTIONS.index_by { |option| option[:html_lang] }
9
+ locales = selected_locales
10
+ default_locale = selected_default_locale
11
+ payload = language_selection_payload(locales:, default_locale:)
12
+
13
+ return render_show(**payload, alert: "Select at least one language.") if locales.empty?
14
+ return render_show(**payload, alert: "Choose valid languages.") if locales.any? { |locale| !options_by_locale.key?(locale) }
15
+
16
+ return render_show(**payload, alert: "Default language must be one of the selected languages.") unless locales.include?(default_locale)
17
+
18
+ replace_languages!(locales:, default_locale:, options_by_locale:)
19
+ advance_session(locales:, default_locale:)
20
+ redirect_onboarding
21
+ end
22
+
23
+ private
24
+
25
+ def replace_languages!(locales:, default_locale:, options_by_locale:)
26
+ Postnhost::Language.transaction do
27
+ Postnhost::Language.destroy_all
28
+ locales.each do |locale|
29
+ option = options_by_locale.fetch(locale)
30
+ Postnhost::Language.create!(
31
+ name: option[:name],
32
+ html_lang: option[:html_lang],
33
+ default: option[:html_lang] == default_locale
34
+ )
35
+ end
36
+ end
37
+ end
38
+
39
+ def advance_session(locales:, default_locale:)
40
+ session[:onboarding_locale] = default_locale
41
+ session[:onboarding_language_locales] = locales
42
+ session[:onboarding_step] = 3
43
+ end
44
+
45
+ def language_selection_payload(locales:, default_locale:)
46
+ {
47
+ step: 2,
48
+ language_options: LANGUAGE_OPTIONS,
49
+ selected_language_locales: locales,
50
+ selected_default_locale: default_locale
51
+ }
52
+ end
53
+
54
+ def selected_locales
55
+ locales = Array(params[:language_locales]).map(&:to_s).compact_blank.uniq
56
+ locales = [params[:html_lang].to_s] if locales.empty? && params[:html_lang].present?
57
+ locales
58
+ end
59
+
60
+ def selected_default_locale
61
+ params[:default_locale].presence || params[:html_lang].presence || "en"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,12 @@
1
+ module Postnhost
2
+ module Onboarding
3
+ module Steps
4
+ class CompletePostInstallChecklist < Base
5
+ def call
6
+ session.delete(:onboarding_post_install_checklist)
7
+ success(status: :redirect_articles)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,28 @@
1
+ module Postnhost
2
+ module Onboarding
3
+ module Steps
4
+ class CreateAdminAccount < Base
5
+ def call
6
+ user = Postnhost::User.new(admin_user_params)
7
+
8
+ return success({ alert: "An account already exists." }, status: :redirect_new_session) unless Postnhost::User.none?
9
+
10
+ if user.save
11
+ session[:onboarding_pending_user_id] = user.id
12
+ session[:onboarding_step] = 2
13
+ redirect_onboarding
14
+ else
15
+ render_show(step: 1, user:)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def admin_user_params
22
+ raw = params[:postnhost_user].presence || params[:user].presence || ActionController::Parameters.new
23
+ raw.permit(:name, :email, :password)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ module Postnhost
2
+ module Onboarding
3
+ module Steps
4
+ class FinishSetup < Base
5
+ def call
6
+ user = pending_user
7
+
8
+ unless user
9
+ reset_onboarding_session!
10
+ return redirect_onboarding(alert: "Continue setup from the beginning.")
11
+ end
12
+
13
+ Postnhost::SampleData.seed!(user:) if generate_sample?
14
+
15
+ reset_onboarding_session!
16
+ success({ user: }, status: :finish_setup)
17
+ end
18
+
19
+ private
20
+
21
+ def generate_sample?
22
+ %w[1 true yes on].include?(params[:generate_sample].to_s.downcase)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,70 @@
1
+ module Postnhost
2
+ module Onboarding
3
+ module Steps
4
+ class SetSiteMetadata < Base
5
+ def call
6
+ return restart_at_admin_account unless pending_user
7
+
8
+ locale = session[:onboarding_locale].presence || I18n.default_locale.to_s
9
+ translations = translation_params
10
+ setting = Postnhost::Setting.current
11
+ merged = Postnhost::Settings::I18nOverrides.merge_locale_overrides(
12
+ setting.locale_overrides,
13
+ locale,
14
+ translations
15
+ )
16
+
17
+ unless setting.update(
18
+ locale_overrides: merged,
19
+ site_url: params[:site_url],
20
+ site_indexing: params[:site_indexing]
21
+ )
22
+ return render_show(
23
+ **site_metadata_payload(
24
+ values: translations,
25
+ locale:,
26
+ site_url: params[:site_url],
27
+ site_indexing: params[:site_indexing]
28
+ ),
29
+ alert: setting_error_message(setting)
30
+ )
31
+ end
32
+
33
+ session[:onboarding_step] = 4
34
+ redirect_onboarding
35
+ end
36
+
37
+ private
38
+
39
+ def setting_error_message(setting)
40
+ site_url_error = setting.errors[:site_url].to_sentence
41
+ return "Site URL #{site_url_error}" if site_url_error.present?
42
+
43
+ setting.errors.full_messages.to_sentence.presence || "Could not save site settings."
44
+ end
45
+
46
+ def site_metadata_payload(values:, locale:, site_url:, site_indexing:)
47
+ {
48
+ step: 3,
49
+ locale:,
50
+ site_url: site_url.to_s,
51
+ site_indexing: site_indexing.to_s,
52
+ site_copy: SITE_COPY_KEYS.index_with { |key| values[key].to_s }
53
+ }
54
+ end
55
+
56
+ def translation_params
57
+ raw = params[:translations]
58
+ permitted =
59
+ if raw.is_a?(ActionController::Parameters)
60
+ raw.permit(*SITE_COPY_KEYS)
61
+ else
62
+ {}
63
+ end
64
+
65
+ SITE_COPY_KEYS.index_with { |key| permitted[key].to_s }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,67 @@
1
+ module Postnhost
2
+ class PublicNavigationTargets < BaseService
3
+ Targets = Data.define(:records, :variants)
4
+
5
+ def initialize(items:, language:)
6
+ @items = items
7
+ @language = language
8
+ end
9
+
10
+ def call
11
+ records = {
12
+ "article" => article_snapshots,
13
+ "page" => page_snapshots,
14
+ "category" => categories
15
+ }
16
+
17
+ success(Targets.new(records:, variants: variants_for(records)))
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :items, :language
23
+
24
+ def target_ids(kind)
25
+ items.filter_map { |item| item.target_id if item.kind == "link" && item.target_kind == kind }.uniq
26
+ end
27
+
28
+ def article_snapshots
29
+ Postnhost::Snapshot::Article.where(article_id: target_ids("article")).index_by(&:article_id)
30
+ end
31
+
32
+ def page_snapshots
33
+ Postnhost::Snapshot::Page.where(page_id: target_ids("page")).index_by(&:page_id)
34
+ end
35
+
36
+ def categories
37
+ scope = category_scope
38
+ scope = scope.includes(:category_variants) unless language.default?
39
+ scope.distinct.index_by(&:id)
40
+ end
41
+
42
+ def category_scope
43
+ scope = Postnhost::Category.where(id: target_ids("category"))
44
+ return scope.joins(:article_snapshot_categories) if language.default?
45
+
46
+ scope.joins(article_snapshot_categories: { article_snapshot: :article_variant_snapshots })
47
+ .where(postnhost_article_variant_snapshots: { language_id: language.id })
48
+ end
49
+
50
+ def variants_for(records)
51
+ return {} if language.default?
52
+
53
+ {
54
+ Postnhost::Snapshot::Article.name => article_variants(records.fetch("article")),
55
+ Postnhost::Snapshot::Page.name => page_variants(records.fetch("page"))
56
+ }
57
+ end
58
+
59
+ def article_variants(articles)
60
+ Postnhost::Snapshot::ArticleVariant.where(article_id: articles.keys, language:).index_by(&:article_id)
61
+ end
62
+
63
+ def page_variants(pages)
64
+ Postnhost::Snapshot::PageVariant.where(page_id: pages.keys, language:).index_by(&:page_id)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,101 @@
1
+ module Postnhost
2
+ class PublicRequestContext
3
+ ENV_KEY = "postnhost.public_request_context"
4
+ Resolution = Data.define(:kind, :record) do
5
+ delegate :id, to: :record, prefix: true
6
+ end
7
+
8
+ def self.for(request)
9
+ request.env[ENV_KEY] ||= new(request)
10
+ end
11
+
12
+ def initialize(request)
13
+ @request = request
14
+ end
15
+
16
+ def setting
17
+ @setting ||= Postnhost::Setting.current
18
+ end
19
+
20
+ def template_name
21
+ @template_name ||= Postnhost::Template.active_name
22
+ end
23
+
24
+ def public_site_revision
25
+ @public_site_revision ||= Postnhost::PublicSiteRevision.current
26
+ end
27
+
28
+ def requested_language
29
+ return @requested_language if defined?(@requested_language)
30
+
31
+ @requested_language = Postnhost::Language.find_by(html_lang: requested_locale) if requested_locale.present?
32
+ end
33
+
34
+ def default_language
35
+ return @default_language if defined?(@default_language)
36
+
37
+ @default_language = Postnhost::Language.blog_default
38
+ end
39
+
40
+ attr_writer :default_language
41
+
42
+ def localized_language?
43
+ requested_language.present? && !requested_language.default?
44
+ end
45
+
46
+ def resolve(slug)
47
+ %i[category article page].each do |kind|
48
+ resolution = resolve_kind(kind, slug)
49
+ return resolution if resolution
50
+ end
51
+
52
+ nil
53
+ end
54
+
55
+ def resolve_kind(kind, slug)
56
+ @resolutions ||= {}
57
+ key = [kind, slug, requested_language&.id]
58
+ return @resolutions[key] if @resolutions.key?(key)
59
+
60
+ record = route_scope(kind).find_by(slug:)
61
+ @resolutions[key] = record ? Resolution.new(kind:, record:) : nil
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :request
67
+
68
+ def requested_locale
69
+ request.path_parameters[:locale].presence || request.params[:locale].presence
70
+ end
71
+
72
+ def route_scope(kind)
73
+ case kind
74
+ when :category then category_scope
75
+ when :article then article_scope
76
+ when :page then page_scope
77
+ else raise ArgumentError, "Unknown public route kind: #{kind}"
78
+ end
79
+ end
80
+
81
+ def category_scope
82
+ Postnhost::Category.joins(:article_snapshot_categories)
83
+ end
84
+
85
+ def article_scope
86
+ return Postnhost::Snapshot::Article.all unless localized_language?
87
+
88
+ Postnhost::Snapshot::Article
89
+ .joins(:article_variant_snapshots)
90
+ .where(postnhost_article_variant_snapshots: { language_id: requested_language.id })
91
+ end
92
+
93
+ def page_scope
94
+ return Postnhost::Snapshot::Page.all unless localized_language?
95
+
96
+ Postnhost::Snapshot::Page
97
+ .joins(:page_variant_snapshots)
98
+ .where(postnhost_page_variant_snapshots: { language_id: requested_language.id })
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,26 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class ArticleVariants::Publish < BaseService
4
+ def initialize(article_variant:)
5
+ @article_variant = article_variant
6
+ end
7
+
8
+ def call
9
+ snapshot = Revision.hold do
10
+ article_variant.article.lock!
11
+ article_variant.lock!
12
+ ArticleVariants::SnapshotWriter.new(variant: article_variant).call
13
+ end
14
+ success(snapshot, status: :ok)
15
+ rescue Error => e
16
+ failure(e.messages)
17
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
18
+ failure(e.message)
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :article_variant
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class ArticleVariants::PublishMany < BaseService
4
+ def initialize(article:, ids:)
5
+ @article = article
6
+ @ids = ids
7
+ end
8
+
9
+ def call
10
+ BulkTransition.call(
11
+ scope: article.article_variants,
12
+ ids:,
13
+ service_class: ArticleVariants::Publish,
14
+ argument_name: :article_variant
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :article, :ids
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,44 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class ArticleVariants::SnapshotWriter
4
+ ATTRIBUTES = %i[
5
+ language_id title title_tag og_title schema_headline meta_description custom_excerpt auto_excerpt
6
+ use_excerpt_as_meta_description content
7
+ ].freeze
8
+
9
+ def initialize(variant:)
10
+ @variant = variant
11
+ end
12
+
13
+ def call
14
+ validate!
15
+ version = variant.paper_trail.save_with_version
16
+ raise Error, "could not capture the snapshot source version" unless version&.persisted?
17
+
18
+ snapshot = variant.article_variant_snapshot || variant.build_article_variant_snapshot
19
+ snapshot.assign_attributes(
20
+ variant.attributes.symbolize_keys.slice(*ATTRIBUTES).merge(
21
+ article_id: variant.article_id,
22
+ paper_trail_version: version
23
+ )
24
+ )
25
+ snapshot.published_at ||= Time.current
26
+ snapshot.save!
27
+ snapshot
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :variant
33
+
34
+ def validate!
35
+ errors = []
36
+ errors << "base article must be published" unless variant.article.article_snapshot
37
+ errors << "title can't be blank" if variant.title.blank?
38
+ errors << "content can't be blank" if variant.content.blank?
39
+ errors << "translation is still being generated" if variant.generating?
40
+ raise Error, errors if errors.any?
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class ArticleVariants::Unpublish < BaseService
4
+ def initialize(article_variant:)
5
+ @article_variant = article_variant
6
+ end
7
+
8
+ def call
9
+ snapshot = article_variant.article_variant_snapshot
10
+ return failure("Article variant snapshot was not found", status: :not_found) unless snapshot
11
+
12
+ Revision.hold do
13
+ article_variant.article.lock!
14
+ article_variant.lock!
15
+ snapshot.destroy!
16
+ end
17
+ success(true, status: :ok)
18
+ rescue ActiveRecord::RecordInvalid => e
19
+ failure(e.message)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :article_variant
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class ArticleVariants::UnpublishMany < BaseService
4
+ def initialize(article:, ids:)
5
+ @article = article
6
+ @ids = ids
7
+ end
8
+
9
+ def call
10
+ BulkTransition.call(
11
+ scope: article.article_variants,
12
+ ids:,
13
+ service_class: ArticleVariants::Unpublish,
14
+ argument_name: :article_variant
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :article, :ids
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class Articles::Publish < BaseService
4
+ def initialize(article:)
5
+ @article = article
6
+ end
7
+
8
+ def call
9
+ snapshot = Revision.hold do
10
+ article.lock!
11
+ Articles::SnapshotWriter.new(article:).call
12
+ end
13
+ success(snapshot, status: :ok)
14
+ rescue Error => e
15
+ failure(e.messages)
16
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
17
+ failure(e.message)
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :article
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class Articles::PublishScheduled < BaseService
4
+ def initialize(article_id:, job_id:)
5
+ @article_id = article_id
6
+ @job_id = job_id
7
+ end
8
+
9
+ def call
10
+ article = Article.find_by(id: article_id)
11
+ return failure("Article was not found", status: :not_found) unless article
12
+ return failure("Article is not due for this job") unless due_for_this_job?(article)
13
+
14
+ snapshot = Revision.hold do
15
+ article.lock!
16
+ Articles::SnapshotWriter.new(article:).call
17
+ end
18
+ success(snapshot, status: :ok)
19
+ rescue Error => e
20
+ record_publication_error(article, e.message)
21
+ failure(e.messages)
22
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
23
+ record_publication_error(article, e.message)
24
+ failure(e.message)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :article_id, :job_id
30
+
31
+ def due_for_this_job?(article)
32
+ article.scheduled_at.present? &&
33
+ article.scheduled_at <= Time.current &&
34
+ article.scheduled_job_id == job_id
35
+ end
36
+
37
+ def record_publication_error(article, message)
38
+ article.update_columns(publication_error: message, updated_at: Time.current)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,84 @@
1
+ module Postnhost
2
+ module Publishing
3
+ class Articles::SnapshotWriter
4
+ ATTRIBUTES = %i[
5
+ language_id title title_tag og_title schema_headline schema_article_type meta_description custom_excerpt
6
+ auto_excerpt use_excerpt_as_meta_description content slug cover_image_alt top_pick
7
+ ].freeze
8
+
9
+ def initialize(article:)
10
+ @article = article
11
+ end
12
+
13
+ def call
14
+ validate!
15
+ version = article.paper_trail.save_with_version
16
+ raise Error, "could not capture the snapshot source version" unless version&.persisted?
17
+
18
+ snapshot = article.article_snapshot || article.build_article_snapshot
19
+ snapshot.assign_attributes(snapshot_attributes.merge(paper_trail_version: version))
20
+ snapshot.published_at ||= Time.current
21
+ snapshot.save!
22
+ replace_categories!(snapshot)
23
+ replace_authors!(snapshot)
24
+ replace_suggestions!(snapshot)
25
+ clear_schedule!
26
+ snapshot
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :article
32
+
33
+ def validate!
34
+ errors = []
35
+ errors << "title can't be blank" if article.title.blank?
36
+ errors << "content can't be blank" if article.content.blank?
37
+ errors << "slug can't be blank" if article.slug.blank?
38
+ errors << "language must exist" if article.language.blank?
39
+ raise Error, errors if errors.any?
40
+
41
+ RouteValidator.validate!(slug: article.slug, article_id: article.id)
42
+ end
43
+
44
+ def snapshot_attributes
45
+ article.attributes.symbolize_keys.slice(*ATTRIBUTES).merge(
46
+ cover_image_identifier: article.cover_image.identifier.presence
47
+ )
48
+ end
49
+
50
+ def replace_categories!(snapshot)
51
+ category_ids = article.article_categories.order(:id).pluck(:category_id)
52
+ replace_joins!(snapshot.article_snapshot_categories, category_ids.map { |id| { category_id: id } })
53
+ end
54
+
55
+ def replace_authors!(snapshot)
56
+ user_ids = article.article_authors.order(:position, :id).pluck(:user_id)
57
+ rows = user_ids.each_with_index.map { |id, position| { user_id: id, position: } }
58
+ replace_joins!(snapshot.article_snapshot_authors, rows)
59
+ end
60
+
61
+ def replace_suggestions!(snapshot)
62
+ suggested_ids = article.article_suggestions.order(:position, :id).pluck(:suggested_article_id)
63
+ rows = suggested_ids.each_with_index.map { |id, position| { suggested_article_id: id, position: } }
64
+ replace_joins!(snapshot.article_snapshot_suggestions, rows)
65
+ end
66
+
67
+ # Deleting before inserting keeps the (snapshot, position) unique indexes satisfied without
68
+ # per-row validation round trips; the indexes and check constraints enforce the same invariants.
69
+ def replace_joins!(association, rows)
70
+ association.delete_all
71
+ association.insert_all(rows) if rows.any?
72
+ association.reset
73
+ end
74
+
75
+ def clear_schedule!
76
+ SolidQueue::Job.find_by(active_job_id: article.scheduled_job_id)&.destroy! if article.scheduled_job_id.present? && defined?(SolidQueue::Job)
77
+
78
+ return if article.scheduled_at.blank? && article.scheduled_job_id.blank? && article.publication_error.blank?
79
+
80
+ article.update!(scheduled_at: nil, scheduled_job_id: nil, publication_error: nil)
81
+ end
82
+ end
83
+ end
84
+ end