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,142 @@
1
+ module Postnhost
2
+ class Article < ApplicationRecord
3
+ include ExcerptOptimizable
4
+ include PrimaryLanguageGuard
5
+ include Postnhost::Article::ImageUploadable
6
+
7
+ STATUSES = %w[published draft scheduled].freeze
8
+ SORT_OPTIONS = {
9
+ "created_at_desc" => { column: "created_at", direction: :desc },
10
+ "created_at_asc" => { column: "created_at", direction: :asc }
11
+ }.freeze
12
+
13
+ DEFAULT_SORT = "created_at_desc"
14
+
15
+ has_paper_trail versions: { class_name: "Postnhost::Version" },
16
+ only: %i[title title_tag og_title schema_headline schema_article_type meta_description custom_excerpt
17
+ use_excerpt_as_meta_description content slug cover_image_alt cover_image],
18
+ on: []
19
+
20
+ mount_uploader :cover_image, CoverImageUploader
21
+
22
+ belongs_to :user, class_name: "Postnhost::User", optional: true
23
+ belongs_to :language, optional: true, counter_cache: true
24
+
25
+ has_many :article_authors, dependent: :destroy
26
+ has_many :authors, -> { order("postnhost_article_authors.position ASC") }, through: :article_authors, source: :user
27
+ has_many :article_categories, dependent: :destroy
28
+ has_many :categories, through: :article_categories
29
+ has_many :article_variants, dependent: :destroy
30
+ has_many :article_suggestions, dependent: :destroy
31
+ has_many :suggested_articles, through: :article_suggestions
32
+ has_one :article_snapshot, class_name: "Postnhost::Snapshot::Article", dependent: :destroy
33
+ has_many :article_variant_snapshots, through: :article_variants, source: :article_variant_snapshot
34
+
35
+ validates :slug, uniqueness: true,
36
+ format: { with: /\A[a-z0-9-]+\z/, message: "can only contain lowercase letters, numbers, and hyphens" },
37
+ allow_blank: true
38
+ validate :slug_not_used_by_page, if: -> { slug.present? }
39
+
40
+ before_validation :generate_slug, if: -> { slug.blank? && title.present? }
41
+ before_validation :normalize_blank_slug
42
+ after_create :assign_creator_as_default_author
43
+
44
+ scope :published, -> { joins(:article_snapshot) }
45
+ scope :draft, -> { where.missing(:article_snapshot) }
46
+ scope :scheduled, -> { draft.where.not(scheduled_at: nil) }
47
+ scope :top_picks, -> { where(top_pick: true) }
48
+ scope :with_status, lambda { |status|
49
+ case status
50
+ when "published" then published
51
+ when "draft" then draft
52
+ when "scheduled" then scheduled
53
+ else all
54
+ end
55
+ }
56
+ scope :sorted_by, lambda { |sort_key|
57
+ option = SORT_OPTIONS.fetch(sort_key) { SORT_OPTIONS.fetch(DEFAULT_SORT) }
58
+ order(option[:column] => option[:direction])
59
+ }
60
+
61
+ def self.normalize_status(status)
62
+ status.to_s.presence_in(STATUSES)
63
+ end
64
+
65
+ def self.normalize_sort(sort)
66
+ sort.to_s.presence_in(SORT_OPTIONS.keys) || DEFAULT_SORT
67
+ end
68
+
69
+ def published?
70
+ article_snapshot.present?
71
+ end
72
+
73
+ def unpublished_changes?
74
+ article_snapshot&.differs_from?(self) || false
75
+ end
76
+
77
+ def schedule_publication!(scheduled_time)
78
+ with_lock do
79
+ cancel_scheduled_job
80
+ job = Postnhost::ScheduledArticleJob.set(wait_until: scheduled_time).perform_later(id)
81
+ update!(scheduled_at: scheduled_time, scheduled_job_id: job.job_id)
82
+ end
83
+ end
84
+
85
+ def unschedule_publication!
86
+ with_lock do
87
+ cancel_scheduled_job
88
+ update!(scheduled_at: nil, scheduled_job_id: nil)
89
+ end
90
+ end
91
+
92
+ def sync_publication_schedule!
93
+ return unless saved_change_to_scheduled_at?
94
+
95
+ if scheduled_at.present?
96
+ schedule_publication!(scheduled_at)
97
+ else
98
+ unschedule_publication!
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def normalize_blank_slug
105
+ self.slug = nil if slug.blank?
106
+ end
107
+
108
+ def generate_slug
109
+ base_slug = title.downcase.gsub(/[^a-z0-9\s-]/, "").gsub(/\s+/, "-").strip
110
+ self.slug = base_slug
111
+ counter = 1
112
+ while self.class.where(slug: slug).where.not(id: id).exists? || Postnhost::Page.exists?(slug: slug)
113
+ self.slug = "#{base_slug}-#{counter}"
114
+ counter += 1
115
+ end
116
+ end
117
+
118
+ def slug_not_used_by_page
119
+ return unless Postnhost::Page.exists?(slug: slug)
120
+
121
+ errors.add(:slug, "has already been taken")
122
+ end
123
+
124
+ def translation_variants
125
+ article_variants
126
+ end
127
+
128
+ def cancel_scheduled_job
129
+ return if scheduled_job_id.blank?
130
+ return unless defined?(SolidQueue::Job)
131
+
132
+ SolidQueue::Job.find_by(active_job_id: scheduled_job_id)&.destroy
133
+ end
134
+
135
+ def assign_creator_as_default_author
136
+ return if user.blank?
137
+ return if article_authors.exists?(user_id: user_id)
138
+
139
+ article_authors.create!(user:, position: article_authors.maximum(:position).to_i + 1)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,20 @@
1
+ module Postnhost
2
+ class ArticleAuthor < ApplicationRecord
3
+ belongs_to :article, touch: true
4
+ belongs_to :user, class_name: "Postnhost::User"
5
+
6
+ before_validation :assign_position, on: :create
7
+
8
+ validates :user_id, uniqueness: { scope: :article_id }
9
+
10
+ default_scope { order(:position) }
11
+
12
+ private
13
+
14
+ def assign_position
15
+ return if position.present? && position.positive?
16
+
17
+ self.position = article&.article_authors&.maximum(:position).to_i + 1
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ module Postnhost
2
+ class ArticleCategory < ApplicationRecord
3
+ belongs_to :article, touch: true
4
+ belongs_to :category, counter_cache: :articles_count, touch: true
5
+
6
+ validates :article_id, uniqueness: { scope: :category_id }
7
+ end
8
+ end
@@ -0,0 +1,19 @@
1
+ module Postnhost
2
+ class ArticleSuggestion < ApplicationRecord
3
+ belongs_to :article, touch: true
4
+ belongs_to :suggested_article, class_name: "Postnhost::Article"
5
+
6
+ validates :suggested_article_id, uniqueness: { scope: :article_id }
7
+ validate :cannot_suggest_self
8
+
9
+ default_scope { order(:position) }
10
+
11
+ private
12
+
13
+ def cannot_suggest_self
14
+ return unless article_id == suggested_article_id
15
+
16
+ errors.add(:suggested_article, "can't be the same as the article")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ module Postnhost
2
+ class ArticleVariant < ApplicationRecord
3
+ include ExcerptOptimizable
4
+
5
+ has_paper_trail versions: { class_name: "Postnhost::Version" },
6
+ only: %i[title title_tag og_title schema_headline meta_description custom_excerpt use_excerpt_as_meta_description content],
7
+ on: []
8
+
9
+ belongs_to :article, counter_cache: true, touch: true
10
+ belongs_to :language, counter_cache: true
11
+ has_one :user, through: :article
12
+ has_one :article_variant_snapshot, class_name: "Postnhost::Snapshot::ArticleVariant", dependent: :destroy
13
+
14
+ broadcasts_refreshes_to ->(r) { "article_variants_#{r.article_id}" }
15
+
16
+ validates :article_id, uniqueness: { scope: :language_id }
17
+
18
+ scope :published, -> { joins(:article_variant_snapshot) }
19
+
20
+ def published?
21
+ article_variant_snapshot.present?
22
+ end
23
+
24
+ def unpublished_changes?
25
+ article_variant_snapshot&.differs_from?(self) || false
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,34 @@
1
+ module Postnhost
2
+ class Category < ApplicationRecord
3
+ include Postnhost::PublicRevisionTouch
4
+
5
+ has_many :article_categories, dependent: :destroy
6
+ has_many :articles, through: :article_categories
7
+ has_many :category_variants, dependent: :destroy
8
+ has_many :article_snapshot_categories, class_name: "Postnhost::Snapshot::ArticleCategory",
9
+ dependent: :restrict_with_error
10
+ has_many :article_snapshots, through: :article_snapshot_categories
11
+
12
+ scope :with_category_variants_for_language, lambda { |language|
13
+ language&.default? ? all : includes(:category_variants)
14
+ }
15
+ scope :with_published_articles, -> { joins(:article_snapshot_categories).distinct }
16
+ scope :alphabetical, -> { order(:name) }
17
+
18
+ validates :name, presence: true, uniqueness: true
19
+ validates :slug, presence: true, uniqueness: true
20
+
21
+ after_destroy :touch_articles
22
+ after_save :touch_articles, if: :saved_change_to_name_or_slug?
23
+
24
+ private
25
+
26
+ def saved_change_to_name_or_slug?
27
+ saved_change_to_name? || saved_change_to_slug?
28
+ end
29
+
30
+ def touch_articles
31
+ articles.find_each(&:touch)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,24 @@
1
+ module Postnhost
2
+ class CategoryVariant < ApplicationRecord
3
+ belongs_to :category, touch: true
4
+ belongs_to :language
5
+
6
+ broadcasts_refreshes_to ->(r) { "category_variants_#{r.category_id}" }
7
+
8
+ validates :category_id, uniqueness: { scope: :language_id }
9
+ validates :name, presence: true, unless: :generating?
10
+ validate :language_must_not_be_blog_default
11
+
12
+ private
13
+
14
+ def language_must_not_be_blog_default
15
+ return if language_id.blank?
16
+
17
+ default = Postnhost::Language.blog_default
18
+ return if default.nil?
19
+ return unless language_id == default.id
20
+
21
+ errors.add(:language_id, "cannot be the site default language; edit the category for that locale")
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,38 @@
1
+ module Postnhost
2
+ class Language < ApplicationRecord
3
+ include Postnhost::PublicRevisionTouch
4
+
5
+ has_many :articles, dependent: :nullify
6
+ has_many :article_variants, dependent: :destroy
7
+ has_many :category_variants, dependent: :destroy
8
+ has_many :pages, dependent: :nullify
9
+ has_many :page_variants, dependent: :destroy
10
+ has_many :article_snapshots, class_name: "Postnhost::Snapshot::Article", dependent: :restrict_with_error
11
+ has_many :article_variant_snapshots, class_name: "Postnhost::Snapshot::ArticleVariant", dependent: :restrict_with_error
12
+ has_many :page_snapshots, class_name: "Postnhost::Snapshot::Page", dependent: :restrict_with_error
13
+ has_many :page_variant_snapshots, class_name: "Postnhost::Snapshot::PageVariant", dependent: :restrict_with_error
14
+
15
+ validates :name, presence: true, uniqueness: true
16
+ validates :html_lang, presence: true, uniqueness: true
17
+
18
+ after_destroy :touch_articles_and_variants
19
+ after_save :touch_articles_and_variants, if: :saved_change_to_name_or_html_lang?
20
+
21
+ def self.blog_default
22
+ find_by(default: true)
23
+ end
24
+
25
+ private
26
+
27
+ def saved_change_to_name_or_html_lang?
28
+ saved_change_to_name? || saved_change_to_html_lang?
29
+ end
30
+
31
+ def touch_articles_and_variants
32
+ articles.find_each(&:touch)
33
+ article_variants.find_each(&:touch)
34
+ pages.find_each(&:touch)
35
+ page_variants.find_each(&:touch)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,76 @@
1
+ module Postnhost
2
+ class Navigation < ApplicationRecord
3
+ self.table_name = "postnhost_navigations"
4
+
5
+ belongs_to :setting, class_name: "Postnhost::Setting", touch: true
6
+ has_many :navigation_items,
7
+ -> { order(:container_kind, :parent_id, :position, :id) },
8
+ class_name: "Postnhost::NavigationItem",
9
+ dependent: :destroy,
10
+ inverse_of: :navigation
11
+
12
+ validates :setting_id, uniqueness: true
13
+
14
+ def replace_tree!(tree:, locale_key:)
15
+ locale = locale_key.to_s
16
+
17
+ transaction do
18
+ old_items = navigation_items.index_by(&:id)
19
+ navigation_items.delete_all
20
+ create_container_items!("header", tree.fetch("header", []), locale, old_items)
21
+ create_container_items!("footer", tree.fetch("footer", []), locale, old_items)
22
+ setting.touch
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def create_container_items!(container_kind, items, locale, old_items)
29
+ items.each_with_index do |item_payload, position|
30
+ create_item!(parent: nil, payload: item_payload, context: {
31
+ container_kind: container_kind,
32
+ locale: locale,
33
+ position: position,
34
+ old_items: old_items
35
+ })
36
+ end
37
+ end
38
+
39
+ def create_item!(parent:, payload:, context:)
40
+ container_kind = context[:container_kind]
41
+ locale = context[:locale]
42
+ position = context[:position]
43
+ old_items = context[:old_items]
44
+ original_item = old_items[payload["id"].to_i]
45
+ labels = (original_item&.label_translations || {}).deep_stringify_keys
46
+ new_label = payload["label"].to_s.strip
47
+ if new_label.blank?
48
+ labels.delete(locale)
49
+ else
50
+ labels[locale] = new_label
51
+ end
52
+
53
+ item = navigation_items.create!(
54
+ parent:,
55
+ container_kind:,
56
+ kind: payload["kind"],
57
+ target_kind: payload["target_kind"].presence,
58
+ target_id: payload["target_id"].presence,
59
+ target_slug: payload["target_slug"].presence,
60
+ url: payload["url"].presence,
61
+ nofollow: ActiveModel::Type::Boolean.new.cast(payload["nofollow"]) || false,
62
+ label_translations: labels,
63
+ position:
64
+ )
65
+
66
+ payload["children"].to_a.each_with_index do |child_payload, child_position|
67
+ create_item!(parent: item, payload: child_payload, context: {
68
+ container_kind: container_kind,
69
+ locale: locale,
70
+ position: child_position,
71
+ old_items: old_items
72
+ })
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,100 @@
1
+ module Postnhost
2
+ class NavigationItem < ApplicationRecord
3
+ self.table_name = "postnhost_navigation_items"
4
+
5
+ enum :container_kind, {
6
+ header: 0,
7
+ footer: 1
8
+ }, validate: true
9
+
10
+ enum :kind, {
11
+ link: 0,
12
+ dropdown: 1,
13
+ column: 2
14
+ }, validate: true
15
+
16
+ enum :target_kind, {
17
+ article: 0,
18
+ page: 1,
19
+ category: 2,
20
+ static_page: 3,
21
+ external: 4,
22
+ text: 5
23
+ }, validate: { allow_nil: true }
24
+
25
+ belongs_to :navigation, class_name: "Postnhost::Navigation", inverse_of: :navigation_items
26
+ belongs_to :parent,
27
+ class_name: "Postnhost::NavigationItem",
28
+ optional: true,
29
+ inverse_of: :children
30
+ has_many :children,
31
+ -> { order(:position, :id) },
32
+ class_name: "Postnhost::NavigationItem",
33
+ foreign_key: :parent_id,
34
+ dependent: :destroy,
35
+ inverse_of: :parent
36
+
37
+ scope :roots, -> { where(parent_id: nil) }
38
+ scope :with_children, -> { includes(:children) }
39
+
40
+ validates :position, numericality: { greater_than_or_equal_to: 0 }
41
+
42
+ validate :validate_structure
43
+ validate :validate_target_requirements
44
+
45
+ def label_for(locale_key:, default_locale_key:)
46
+ labels = label_translations.deep_stringify_keys
47
+ labels[locale_key.to_s].presence || labels[default_locale_key.to_s].presence
48
+ end
49
+
50
+ private
51
+
52
+ def validate_structure
53
+ errors.add(:parent_id, "must belong to same navigation") if parent_id.present? && parent&.navigation_id != navigation_id
54
+
55
+ if parent_id.blank?
56
+ errors.add(:kind, "must be link or dropdown for header root") if header? && !link? && !dropdown?
57
+ errors.add(:kind, "must be column for footer root") if footer? && !column?
58
+ return
59
+ end
60
+
61
+ return if parent.blank?
62
+
63
+ if parent.dropdown? && !link?
64
+ errors.add(:kind, "must be link inside dropdown")
65
+ elsif parent.column? && !link?
66
+ errors.add(:kind, "must be link inside column")
67
+ elsif parent.link?
68
+ errors.add(:parent_id, "link items cannot have children")
69
+ end
70
+ end
71
+
72
+ def validate_target_requirements
73
+ if dropdown? || column?
74
+ errors.add(:target_kind, "must be blank for grouping items") if target_kind.present?
75
+ errors.add(:target_id, "must be blank for grouping items") if target_id.present?
76
+ errors.add(:target_slug, "must be blank for grouping items") if target_slug.present?
77
+ errors.add(:url, "must be blank for grouping items") if url.present?
78
+ return
79
+ end
80
+
81
+ if target_kind.blank?
82
+ errors.add(:target_kind, "is required for link items")
83
+ return
84
+ end
85
+
86
+ case target_kind
87
+ when "text"
88
+ errors.add(:target_id, "must be blank for text links") if target_id.present?
89
+ errors.add(:target_slug, "must be blank for text links") if target_slug.present?
90
+ errors.add(:url, "must be blank for text links") if url.present?
91
+ when "external"
92
+ errors.add(:url, "is required") if url.blank?
93
+ when "static_page"
94
+ errors.add(:target_slug, "is required") if target_slug.blank?
95
+ else
96
+ errors.add(:target_id, "is required") if target_id.blank?
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,63 @@
1
+ module Postnhost
2
+ class Page < ApplicationRecord
3
+ include PrimaryLanguageGuard
4
+ include Postnhost::Page::ImageUploadable
5
+
6
+ has_paper_trail versions: { class_name: "Postnhost::Version" },
7
+ only: %i[title title_tag og_title meta_description content slug], on: []
8
+
9
+ belongs_to :user, class_name: "Postnhost::User"
10
+ belongs_to :language, optional: true
11
+
12
+ has_many :page_variants, dependent: :destroy
13
+ has_one :page_snapshot, class_name: "Postnhost::Snapshot::Page", dependent: :destroy
14
+ has_many :page_variant_snapshots, through: :page_variants, source: :page_variant_snapshot
15
+
16
+ validates :slug, uniqueness: true,
17
+ format: { with: /\A[a-z0-9-]+\z/, message: "can only contain lowercase letters, numbers, and hyphens" },
18
+ allow_blank: true
19
+ validate :slug_not_used_by_article, if: -> { slug.present? }
20
+
21
+ before_validation :generate_slug, if: -> { slug.blank? && title.present? }
22
+ before_validation :normalize_blank_slug
23
+ scope :published, -> { joins(:page_snapshot) }
24
+
25
+ def published?
26
+ page_snapshot.present?
27
+ end
28
+
29
+ def unpublished_changes?
30
+ page_snapshot&.differs_from?(self) || false
31
+ end
32
+
33
+ def title_for_meta
34
+ title_tag.presence || title
35
+ end
36
+
37
+ private
38
+
39
+ def normalize_blank_slug
40
+ self.slug = nil if slug.blank?
41
+ end
42
+
43
+ def generate_slug
44
+ base_slug = title.downcase.gsub(/[^a-z0-9\s-]/, "").gsub(/\s+/, "-").strip
45
+ self.slug = base_slug
46
+ counter = 1
47
+ while self.class.where(slug: slug).where.not(id: id).exists? || Postnhost::Article.exists?(slug: slug)
48
+ self.slug = "#{base_slug}-#{counter}"
49
+ counter += 1
50
+ end
51
+ end
52
+
53
+ def slug_not_used_by_article
54
+ return unless Postnhost::Article.exists?(slug: slug)
55
+
56
+ errors.add(:slug, "has already been taken")
57
+ end
58
+
59
+ def translation_variants
60
+ page_variants
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,26 @@
1
+ module Postnhost
2
+ class PageVariant < ApplicationRecord
3
+ has_paper_trail versions: { class_name: "Postnhost::Version" },
4
+ only: %i[title title_tag og_title meta_description content],
5
+ on: []
6
+
7
+ belongs_to :page, counter_cache: true, touch: true
8
+ belongs_to :language
9
+ has_one :user, through: :page
10
+ has_one :page_variant_snapshot, class_name: "Postnhost::Snapshot::PageVariant", dependent: :destroy
11
+
12
+ broadcasts_refreshes_to ->(r) { "page_variants_#{r.page_id}" }
13
+
14
+ validates :page_id, uniqueness: { scope: :language_id }
15
+
16
+ scope :published, -> { joins(:page_variant_snapshot) }
17
+
18
+ def published?
19
+ page_variant_snapshot.present?
20
+ end
21
+
22
+ def unpublished_changes?
23
+ page_variant_snapshot&.differs_from?(self) || false
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ module Postnhost
2
+ class PublicSiteRevision < ApplicationRecord
3
+ SINGLETON_ID = 1
4
+
5
+ def self.current
6
+ find_or_create_by!(id: SINGLETON_ID)
7
+ end
8
+
9
+ def self.locked
10
+ current
11
+ lock.find(SINGLETON_ID)
12
+ end
13
+
14
+ def self.bump!
15
+ transaction do
16
+ locked.bump!
17
+ end
18
+ end
19
+
20
+ def bump!
21
+ update!(revision: revision + 1)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ module Postnhost
2
+ class PublishedCoverImage
3
+ attr_reader :identifier
4
+
5
+ def initialize(article_id:, identifier:)
6
+ @article_id = article_id
7
+ @identifier = identifier.presence
8
+ end
9
+
10
+ delegate :present?, to: :identifier
11
+
12
+ delegate :url, to: :uploader
13
+
14
+ delegate :thumbnail, to: :uploader
15
+
16
+ delegate :medium, to: :uploader
17
+
18
+ private
19
+
20
+ attr_reader :article_id
21
+
22
+ def uploader
23
+ @uploader ||= CoverImageUploader.new(Article.new(id: article_id), :cover_image).tap do |mounted_uploader|
24
+ mounted_uploader.retrieve_from_store!(identifier) if identifier.present?
25
+ end
26
+ end
27
+ end
28
+ end