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,61 @@
1
+ module Postnhost
2
+ class Settings::SiteScriptsController < ApplicationController
3
+ before_action :authenticate_user!
4
+ before_action :load_setting
5
+ before_action :set_active_section
6
+ before_action :load_site_script, only: %i[update destroy]
7
+
8
+ layout "postnhost/product"
9
+
10
+ def create
11
+ @site_script = @setting.site_scripts.build(site_script_params)
12
+
13
+ if @site_script.save
14
+ redirect_to edit_settings_path(section: "scripts")
15
+ else
16
+ @new_site_script = @site_script
17
+ render_edit_with_errors
18
+ end
19
+ end
20
+
21
+ def update
22
+ if @site_script.update(site_script_params)
23
+ redirect_to edit_settings_path(section: "scripts")
24
+ else
25
+ @site_scripts = @site_scripts.map do |site_script|
26
+ site_script.id == @site_script.id ? @site_script : site_script
27
+ end
28
+ render_edit_with_errors
29
+ end
30
+ end
31
+
32
+ def destroy
33
+ @site_script.destroy!
34
+ redirect_to edit_settings_path(section: "scripts")
35
+ end
36
+
37
+ private
38
+
39
+ def load_setting
40
+ @setting = Postnhost::Setting.current
41
+ end
42
+
43
+ def set_active_section
44
+ @active_section = "scripts"
45
+ @new_site_script = Postnhost::SiteScript.new(setting: @setting)
46
+ @site_scripts = @setting.site_scripts.order(:id)
47
+ end
48
+
49
+ def load_site_script
50
+ @site_script = @setting.site_scripts.find(params[:id])
51
+ end
52
+
53
+ def render_edit_with_errors
54
+ render "postnhost/settings/edit", status: :unprocessable_content
55
+ end
56
+
57
+ def site_script_params
58
+ params.require(:site_script).permit(:placement, :script)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,63 @@
1
+ module Postnhost
2
+ class SettingsController < ApplicationController
3
+ include Postnhost::Settings::I18nManageable
4
+ include Postnhost::Settings::NavigationManageable
5
+ include Postnhost::Settings::SchemaManageable
6
+
7
+ before_action :authenticate_user!
8
+ before_action :load_setting
9
+ before_action :set_active_section
10
+
11
+ layout "postnhost/product"
12
+
13
+ def edit
14
+ set_i18n_editor_state if @active_section == "i18n"
15
+ set_navigation_editor_state if @active_section == "navigation"
16
+ set_schema_editor_state if @active_section == "schema"
17
+ set_scripts_editor_state if @active_section == "scripts"
18
+ end
19
+
20
+ def update
21
+ if @active_section == "i18n"
22
+ update_i18n
23
+ elsif @active_section == "navigation"
24
+ update_navigation
25
+ elsif @active_section == "schema"
26
+ update_schema
27
+ elsif @setting.update(setting_params)
28
+ redirect_to edit_settings_path(section: @active_section), notice: "Settings were successfully updated."
29
+ else
30
+ render :edit, status: :unprocessable_content
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def load_setting
37
+ @setting = Postnhost::Setting.current
38
+ end
39
+
40
+ def setting_params
41
+ params.require(:setting).permit(
42
+ :site_url,
43
+ :site_indexing,
44
+ :public_page_size,
45
+ :site_logo,
46
+ :og_image,
47
+ :timezone,
48
+ :author_pages_enabled,
49
+ :search_enabled,
50
+ :show_powered_by
51
+ )
52
+ end
53
+
54
+ def set_scripts_editor_state
55
+ @site_scripts = @setting.site_scripts.order(:id)
56
+ @new_site_script = Postnhost::SiteScript.new(setting: @setting)
57
+ end
58
+
59
+ def set_active_section
60
+ @active_section = params[:section].presence || "site"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,17 @@
1
+ module Postnhost
2
+ class SitemapController < ApplicationController
3
+ def show
4
+ sitemap_builder = Postnhost::SitemapBuilder.new(request:, site_url: current_setting.effective_site_url.presence || request.base_url)
5
+ sitemap_xml = Rails.cache.fetch(sitemap_builder.cache_key, expires_in: 1.hour) do
6
+ sitemap_builder.call
7
+ end
8
+
9
+ render xml: sitemap_xml
10
+ end
11
+
12
+ def stylesheet
13
+ path = Postnhost::Engine.root.join("app/assets/xsl/sitemap.xsl")
14
+ send_file path, type: "application/xslt+xml", disposition: "inline"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ module Postnhost
2
+ class TemplatesController < ApplicationController
3
+ before_action :authenticate_user!
4
+ layout "postnhost/product"
5
+
6
+ def edit
7
+ @template = Postnhost::Template.current
8
+ end
9
+
10
+ def update
11
+ @template = Postnhost::Template.current
12
+
13
+ if @template.update(template_params)
14
+ redirect_to edit_template_path, notice: "Template settings were successfully updated."
15
+ else
16
+ render :edit, status: :unprocessable_content
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def template_params
23
+ params.require(:template).permit(:name)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,112 @@
1
+ module Postnhost
2
+ class UsersController < ApplicationController
3
+ include Postnhost::Users::SchemaManageable
4
+
5
+ before_action :authenticate_user!
6
+ before_action :load_user, only: %i[edit update destroy schema_edit schema_update]
7
+ layout "postnhost/product"
8
+
9
+ def index
10
+ @users = Postnhost::User.order(:name, :email)
11
+ end
12
+
13
+ def new
14
+ @user = Postnhost::User.new
15
+ end
16
+
17
+ def edit; end
18
+
19
+ def schema_edit
20
+ set_user_schema_editor_state
21
+ end
22
+
23
+ def create
24
+ @user = Postnhost::User.new(user_params)
25
+
26
+ if @user.save
27
+ redirect_to users_path
28
+ else
29
+ render :new, status: :unprocessable_content
30
+ end
31
+ end
32
+
33
+ def update
34
+ if @user.update(user_params)
35
+ redirect_to edit_user_path(@user), notice: "Author was successfully updated."
36
+ else
37
+ render :edit, status: :unprocessable_content
38
+ end
39
+ end
40
+
41
+ def schema_update
42
+ @schema_locale_keys = user_schema_locale_keys
43
+ locale_key = selected_user_schema_locale_key
44
+ merged_profile = @user.schema_profile_hash.merge(user_schema_profile_params)
45
+ merged_overrides = merge_user_schema_locale_overrides(
46
+ @user.schema_locale_overrides_hash,
47
+ locale_key,
48
+ user_schema_translation_params
49
+ )
50
+
51
+ if @user.update(schema_profile: merged_profile, schema_locale_overrides: merged_overrides)
52
+ redirect_to schema_edit_user_path(@user, locale: locale_key), notice: "Author schema was successfully updated."
53
+ else
54
+ set_user_schema_editor_state
55
+ @schema_profile_values = merged_profile
56
+ @schema_translation_entries = @schema_translation_entries.merge(user_schema_translation_params.stringify_keys)
57
+ render :schema_edit, status: :unprocessable_content
58
+ end
59
+ end
60
+
61
+ def destroy
62
+ if @user == current_user
63
+ redirect_to users_path, alert: "You cannot remove your own account while signed in."
64
+ return
65
+ end
66
+
67
+ if @user.pages.exists?
68
+ redirect_to users_path, alert: "Author cannot be removed while they own pages. Reassign or remove their pages first."
69
+ return
70
+ end
71
+
72
+ Postnhost::User.transaction do
73
+ Postnhost::Article.where(user_id: @user.id).update_all(user_id: nil, updated_at: Time.current)
74
+ @user.article_authors.delete_all
75
+ @user.destroy!
76
+ end
77
+
78
+ redirect_to users_path, notice: "Author was successfully removed. Their articles were kept."
79
+ end
80
+
81
+ private
82
+
83
+ def load_user
84
+ @user = Postnhost::User.find(params[:id])
85
+ end
86
+
87
+ def user_params
88
+ attrs = params.require(:user).permit(
89
+ :email,
90
+ :name,
91
+ :slug,
92
+ :position,
93
+ :bio,
94
+ :website_url,
95
+ :x_url,
96
+ :linkedin_url,
97
+ :threads_url,
98
+ :tiktok_url,
99
+ :youtube_url,
100
+ :facebook_url,
101
+ :instagram_url,
102
+ :bluesky_url,
103
+ :mastodon_url,
104
+ :avatar_file,
105
+ :password,
106
+ :password_confirmation
107
+ )
108
+ attrs = attrs.except(:password, :password_confirmation) if attrs[:password].blank?
109
+ attrs
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,247 @@
1
+ module Postnhost
2
+ module ApplicationHelper
3
+ AUTHOR_SOCIAL_FIELDS = [
4
+ { field: :website_url, label: "Website", icon: "external-link" },
5
+ { field: :x_url, label: "X", icon: "x" },
6
+ { field: :linkedin_url, label: "LinkedIn", icon: "linkedin" },
7
+ { field: :facebook_url, label: "Facebook", icon: "facebook" },
8
+ { field: :youtube_url, label: "YouTube", icon: "youtube" },
9
+ { field: :instagram_url, label: "Instagram", icon: "instagram" },
10
+ { field: :threads_url, label: "Threads", icon: "threads" },
11
+ { field: :tiktok_url, label: "TikTok", icon: "tiktok" },
12
+ { field: :mastodon_url, label: "Mastodon", icon: "mastodon" },
13
+ { field: :bluesky_url, label: "Bluesky", icon: "bluesky" }
14
+ ].freeze
15
+
16
+ def postnhost_stylesheet_assets
17
+ assets = ["postnhost/application"]
18
+ assets << "postnhost/host" if postnhost_host_tailwind_enabled?
19
+ assets
20
+ end
21
+
22
+ def postnhost_host_tailwind_enabled?
23
+ Rails.root.join("postnhost.tailwind.config.js").exist? &&
24
+ Rails.root.join("app/assets/stylesheets/postnhost/host.tailwind.css").exist?
25
+ end
26
+
27
+ def postnhost_openai_api_key_configured?
28
+ Postnhost.config.openai_api_key.present?
29
+ end
30
+
31
+ def postnhost_public_locale_file_present_for?(html_lang)
32
+ locale = html_lang.to_s.strip
33
+ return true if locale.blank?
34
+
35
+ Postnhost::Engine.root.join("config/locales/#{locale}.yml").exist? ||
36
+ Rails.root.join("config/locales/#{locale}.yml").exist?
37
+ end
38
+
39
+ def flash_class(level)
40
+ case level.to_sym
41
+ when :notice then "bg-blue-50 border border-blue-200 text-blue-800 rounded-2xl"
42
+ when :success then "bg-green-50 border border-green-200 text-green-800 rounded-2xl"
43
+ when :error then "bg-red-50 border border-red-200 text-red-800 rounded-2xl"
44
+ when :alert then "bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-2xl"
45
+ when :payment_required then "hidden"
46
+ else "bg-gray-50 border border-gray-200 text-gray-800 rounded-2xl"
47
+ end
48
+ end
49
+
50
+ def admin_datetime(value, format: "%B %d, %Y at %l:%M %p %Z")
51
+ return if value.blank?
52
+
53
+ l(value, format:)
54
+ end
55
+
56
+ def admin_datetime_local_value(value)
57
+ return if value.blank?
58
+
59
+ l(value, format: "%Y-%m-%dT%H:%M")
60
+ end
61
+
62
+ # Author helpers rely on the CMS user profile only.
63
+ def author_name(user = nil)
64
+ user ||= current_user if respond_to?(:current_user)
65
+ return unless user.respond_to?(:name)
66
+
67
+ user.name.presence
68
+ end
69
+
70
+ def author_avatar(user = nil)
71
+ user ||= current_user if respond_to?(:current_user)
72
+ return unless user.respond_to?(:avatar_file) && user.avatar_file?
73
+
74
+ user.avatar_file.url
75
+ end
76
+
77
+ def author_initials(user = nil)
78
+ name = author_name(user)
79
+ return if name.blank?
80
+
81
+ name.split.map(&:first).join.upcase[0, 2]
82
+ end
83
+
84
+ def author_pages_enabled?
85
+ return @author_pages_enabled if defined?(@author_pages_enabled)
86
+
87
+ @author_pages_enabled = current_setting.author_pages_enabled?
88
+ end
89
+
90
+ delegate :search_enabled?, to: :current_setting
91
+
92
+ def article_authors(article)
93
+ article = article_record_for_author_data(article)
94
+ return [] unless article
95
+
96
+ authors = article_author_records(article).map(&:user)
97
+ authors.select { |author| author_name(author).present? }
98
+ end
99
+
100
+ def article_authors_cache_key(article)
101
+ article = article_record_for_author_data(article)
102
+ return [] unless article.respond_to?(:article_authors) || article.respond_to?(:article_snapshot_authors)
103
+
104
+ [
105
+ "author_pages_enabled",
106
+ author_pages_enabled?,
107
+ *article_author_records(article).map do |article_author|
108
+ author = article_author.user
109
+ [
110
+ article_author.user_id,
111
+ article_author.position,
112
+ article_author.updated_at&.to_i,
113
+ author&.updated_at&.to_i
114
+ ]
115
+ end
116
+ ]
117
+ end
118
+
119
+ def article_author_label(author)
120
+ author&.position.presence || t("postnhost.public.blog.author_label")
121
+ end
122
+
123
+ def author_social_links(author)
124
+ return [] unless author
125
+
126
+ AUTHOR_SOCIAL_FIELDS.filter_map do |social|
127
+ url = normalize_external_url(author.public_send(social[:field]))
128
+ next unless url
129
+
130
+ social.merge(url:)
131
+ end
132
+ end
133
+
134
+ # Language switcher helpers
135
+ def current_path_without_locale
136
+ base_path = request.path_info.to_s
137
+ base_path = "/" if base_path.blank?
138
+
139
+ locale = params[:locale].presence
140
+ stripped_path = locale.present? ? base_path.sub(%r{\A/#{Regexp.escape(locale)}(?=/|$)}, "") : base_path
141
+
142
+ stripped_path.presence || "/"
143
+ end
144
+
145
+ def localized_path(language)
146
+ base_path = current_path_without_locale
147
+ base_path = "/" if base_path.empty?
148
+
149
+ locale_path = if language.default
150
+ base_path
151
+ else
152
+ base_path == "/" ? "/#{language.html_lang}" : "/#{language.html_lang}#{base_path}"
153
+ end
154
+
155
+ "#{request.script_name}#{locale_path}"
156
+ end
157
+
158
+ # Language-aware navigation helpers for public pages
159
+ def language_root_path(language)
160
+ return root_path unless language
161
+
162
+ if language.default
163
+ root_path
164
+ else
165
+ localized_root_path(locale: language.html_lang)
166
+ end
167
+ end
168
+
169
+ def language_category_path(category_slug, language)
170
+ return public_category_path(category_slug) unless language
171
+
172
+ if language.default
173
+ public_category_path(category_slug)
174
+ else
175
+ localized_public_category_path(language.html_lang, category_slug)
176
+ end
177
+ end
178
+
179
+ def language_author_path(author_slug, language)
180
+ return unless author_pages_enabled?
181
+ return public_author_path(author_slug) unless language
182
+
183
+ if language.default
184
+ public_author_path(author_slug)
185
+ else
186
+ localized_public_author_path(language.html_lang, author_slug)
187
+ end
188
+ end
189
+
190
+ def language_author_url(author_slug, language)
191
+ return unless author_pages_enabled?
192
+ return public_author_url(author_slug) unless language
193
+
194
+ if language.default
195
+ public_author_url(author_slug)
196
+ else
197
+ localized_public_author_url(language.html_lang, author_slug)
198
+ end
199
+ end
200
+
201
+ def article_record_for_author_data(record)
202
+ return unless record
203
+
204
+ case record
205
+ when Postnhost::Snapshot::ArticleVariant
206
+ record.article_snapshot
207
+ when Postnhost::ArticleVariant
208
+ record.article
209
+ else
210
+ record
211
+ end
212
+ end
213
+
214
+ def article_author_records(article)
215
+ association_name = article.is_a?(Postnhost::Snapshot::Article) ? :article_snapshot_authors : :article_authors
216
+ return [] unless article.respond_to?(association_name)
217
+
218
+ @article_author_records_cache ||= {}
219
+ cache_key = [article.class.name, article.id || article.object_id]
220
+ return @article_author_records_cache[cache_key] if @article_author_records_cache.key?(cache_key)
221
+
222
+ records = if article.association(association_name).loaded?
223
+ article.public_send(association_name).to_a
224
+ else
225
+ article.public_send(association_name).order(:position).to_a
226
+ end
227
+
228
+ ActiveRecord::Associations::Preloader.new(records:, associations: :user).call if records.any?
229
+
230
+ @article_author_records_cache[cache_key] = records
231
+ end
232
+
233
+ def normalize_external_url(value)
234
+ raw_url = value.to_s.strip
235
+ return if raw_url.blank?
236
+
237
+ normalized_url = raw_url.match?(%r{\Ahttps?://}i) ? raw_url : "https://#{raw_url}"
238
+ uri = URI.parse(normalized_url)
239
+ return if uri.host.blank?
240
+ return if uri.userinfo.present?
241
+
242
+ normalized_url
243
+ rescue URI::InvalidURIError
244
+ nil
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,41 @@
1
+ require "rouge"
2
+
3
+ module Postnhost
4
+ module ArticleHelper
5
+ def render_article_content(content)
6
+ # Add <br> to empty <p> tags to match editor behavior
7
+ content = content&.gsub("<p></p>", "<p><br></p>")
8
+ content = content&.gsub(%r{<p>\s*</p>}, "<p><br></p>")
9
+
10
+ sanitized_content = sanitize(
11
+ content,
12
+ tags: %w[p h1 h2 h3 h4 h5 h6 strong em u s sup sub mark code pre span blockquote ul ol li a img table colgroup col thead tbody tr th td br
13
+ hr div iframe],
14
+ attributes: %w[href src alt class style target rel width height colspan rowspan frameborder allow allowfullscreen title]
15
+ )
16
+
17
+ highlight_code_blocks(sanitized_content)
18
+ end
19
+
20
+ def article_excerpt(article)
21
+ article.custom_excerpt.presence || article.auto_excerpt.presence
22
+ end
23
+
24
+ private
25
+
26
+ def highlight_code_blocks(content)
27
+ fragment = Nokogiri::HTML5.fragment(content)
28
+ formatter = Rouge::Formatters::HTML.new
29
+
30
+ fragment.css("pre > code[class]").each do |code_block|
31
+ language = code_block["class"].to_s.split.filter_map { |class_name| class_name.delete_prefix("language-") if class_name.start_with?("language-") }.first
32
+ lexer = Rouge::Lexer.find(language)&.new
33
+ next unless lexer
34
+
35
+ code_block.inner_html = formatter.format(lexer.lex(code_block.text))
36
+ end
37
+
38
+ fragment.to_html.html_safe
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,52 @@
1
+ module Postnhost
2
+ module CategoryHelper
3
+ def category_articles
4
+ @article_snapshots || @article_variant_snapshots || []
5
+ end
6
+
7
+ def top_pick_articles
8
+ @top_pick_article_snapshots || @top_pick_article_variant_snapshots || []
9
+ end
10
+
11
+ def localized_category_name(category, language)
12
+ return category.name if language.nil? || language.default?
13
+
14
+ category_variant_for_language(category, language)&.name || category.name
15
+ end
16
+
17
+ def localized_category_meta_description(category, language)
18
+ return category.meta_description if language.nil? || language.default?
19
+
20
+ category_variant_for_language(category, language)&.meta_description || category.meta_description
21
+ end
22
+
23
+ def category_article(item)
24
+ item.is_a?(Postnhost::Snapshot::ArticleVariant) ? item.article_snapshot : item
25
+ end
26
+
27
+ def category_article_author_user(item)
28
+ @category_article_author_cache ||= {}
29
+ cache_key = [item.class.name, item.id || item.object_id]
30
+ return @category_article_author_cache[cache_key] if @category_article_author_cache.key?(cache_key)
31
+
32
+ @category_article_author_cache[cache_key] = article_authors(category_article(item)).first
33
+ end
34
+
35
+ def category_article_url(item)
36
+ snapshot = category_article(item)
37
+ return public_article_path(snapshot.slug) if item.is_a?(Postnhost::Snapshot::Article)
38
+
39
+ localized_public_article_path(@current_language.html_lang, snapshot.slug)
40
+ end
41
+
42
+ private
43
+
44
+ def category_variant_for_language(category, language)
45
+ if category.association(:category_variants).loaded?
46
+ category.category_variants.find { |v| v.language_id == language.id }
47
+ else
48
+ category.category_variants.find_by(language: language)
49
+ end
50
+ end
51
+ end
52
+ end