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,395 @@
1
+ module Postnhost
2
+ class SitemapBuilder
3
+ include Postnhost::Engine.routes.url_helpers
4
+
5
+ SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
6
+ XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
7
+ CACHE_VERSION = "sitemap_xml_v12"
8
+
9
+ def initialize(request:, site_url:)
10
+ @request = request
11
+ @site_url = site_url
12
+ end
13
+
14
+ def cache_key
15
+ [
16
+ CACHE_VERSION,
17
+ site_url,
18
+ request.script_name.presence,
19
+ Postnhost::PublicSiteRevision.current.revision,
20
+ static_pages_signature
21
+ ].compact.join(":")
22
+ end
23
+
24
+ def call
25
+ doc = Nokogiri::XML(build_xml_string)
26
+ pretty = doc.to_xml(indent: 2, indent_text: " ")
27
+ insert_stylesheet_pi(pretty)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :request, :site_url
33
+
34
+ def build_xml_string
35
+ Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
36
+ xml.urlset("xmlns" => SITEMAP_NAMESPACE, "xmlns:xhtml" => XHTML_NAMESPACE) do
37
+ sitemap_entries.each do |entry|
38
+ xml.url do
39
+ xml.loc entry[:loc]
40
+ xml.lastmod entry[:lastmod].utc.iso8601 if entry[:lastmod].present?
41
+
42
+ entry[:alternates].each do |alternate|
43
+ xml["xhtml"].link(rel: "alternate", hreflang: alternate[:hreflang], href: alternate[:href])
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end.to_xml
49
+ end
50
+
51
+ # Browsers may use this PI for presentation; crawlers fetch the same XML bytes and ignore styling.
52
+ def insert_stylesheet_pi(xml)
53
+ xml.sub(/\A<\?xml[^?]*\?>/m) do |decl|
54
+ %(#{decl}\n<?xml-stylesheet type="text/xsl" href="#{stylesheet_href}"?>)
55
+ end
56
+ end
57
+
58
+ def stylesheet_href
59
+ script = request.script_name.to_s.chomp("/")
60
+ return "/sitemap.xsl" if script.blank?
61
+
62
+ "#{script}/sitemap.xsl"
63
+ end
64
+
65
+ def sitemap_entries
66
+ @sitemap_entries ||= [
67
+ *blog_index_entries,
68
+ *category_page_entries,
69
+ *article_page_entries,
70
+ *static_page_entries
71
+ ]
72
+ end
73
+
74
+ def blog_index_entries
75
+ urls = []
76
+ default_href = nil
77
+
78
+ if default_language_present_for_root?
79
+ default_href = absolute_url(root_path(script_name: request.script_name))
80
+ urls << { hreflang: default_language_code, href: default_href }
81
+ end
82
+
83
+ localized_root_languages.each do |language|
84
+ urls << {
85
+ hreflang: language.html_lang,
86
+ href: absolute_url(localized_root_path(locale: language.html_lang, script_name: request.script_name))
87
+ }
88
+ end
89
+
90
+ build_cluster_entries(urls:, lastmod: latest_public_content_updated_at, x_default_href: default_href)
91
+ end
92
+
93
+ def category_page_entries
94
+ public_categories.flat_map do |category|
95
+ urls = []
96
+ default_href = nil
97
+
98
+ if category_present_for_default_language?(category)
99
+ default_href = absolute_url(public_category_path(category.slug, script_name: request.script_name))
100
+ urls << { hreflang: default_language_code, href: default_href }
101
+ end
102
+
103
+ localized_category_languages(category).each do |language|
104
+ urls << {
105
+ hreflang: language.html_lang,
106
+ href: absolute_url(
107
+ localized_public_category_path(language.html_lang, category.slug, script_name: request.script_name)
108
+ )
109
+ }
110
+ end
111
+
112
+ build_cluster_entries(
113
+ urls:,
114
+ lastmod: category_last_modified_at(category),
115
+ x_default_href: default_href
116
+ )
117
+ end
118
+ end
119
+
120
+ def article_page_entries
121
+ public_articles.order(:slug).flat_map do |article|
122
+ next [] if category_slugs.include?(article.slug)
123
+
124
+ urls = []
125
+ article_language_code = article.language&.html_lang || default_language_code
126
+
127
+ urls << {
128
+ hreflang: article_language_code,
129
+ href: absolute_url(public_article_path(article.slug, script_name: request.script_name))
130
+ }
131
+
132
+ public_variants_by_article_id.fetch(article.article_id, []).each do |variant|
133
+ next if variant.language.html_lang == article_language_code
134
+
135
+ urls << {
136
+ hreflang: variant.language.html_lang,
137
+ href: absolute_url(
138
+ localized_public_article_path(locale: variant.language.html_lang, slug: article.slug, script_name: request.script_name)
139
+ )
140
+ }
141
+ end
142
+
143
+ build_cluster_entries(urls:, lastmod: article_last_modified_at(article), x_default_href: urls.first&.dig(:href))
144
+ end
145
+ end
146
+
147
+ def static_page_entries
148
+ [
149
+ *static_template_entries,
150
+ *dynamic_page_entries
151
+ ]
152
+ end
153
+
154
+ def static_template_entries
155
+ static_page_templates.filter_map do |slug, lastmod|
156
+ next if reserved_static_slug?(slug)
157
+
158
+ href = absolute_url(public_static_page_path(slug:, script_name: request.script_name))
159
+ build_cluster_entries(
160
+ urls: [{ hreflang: default_language_code, href: }],
161
+ lastmod:,
162
+ x_default_href: href
163
+ )
164
+ end.flatten
165
+ end
166
+
167
+ def dynamic_page_entries
168
+ published_pages.filter_map do |page|
169
+ slug = page.slug
170
+ next if slug.blank? || reserved_static_slug?(slug)
171
+
172
+ urls = []
173
+ page_language_code = page.language&.html_lang || default_language_code
174
+
175
+ urls << {
176
+ hreflang: page_language_code,
177
+ href: absolute_url(public_static_page_path(slug:, script_name: request.script_name))
178
+ }
179
+
180
+ public_page_variants_by_page_id.fetch(page.page_id, []).each do |variant|
181
+ next if variant.language.html_lang == page_language_code
182
+
183
+ urls << {
184
+ hreflang: variant.language.html_lang,
185
+ href: absolute_url(
186
+ localized_public_static_page_path(locale: variant.language.html_lang, slug:, script_name: request.script_name)
187
+ )
188
+ }
189
+ end
190
+
191
+ build_cluster_entries(urls:, lastmod: page_last_modified_at(page), x_default_href: urls.first&.dig(:href))
192
+ end.flatten
193
+ end
194
+
195
+ def page_last_modified_at(page)
196
+ [page.updated_at, public_page_variants_by_page_id.fetch(page.page_id, []).map(&:updated_at).max].compact.max
197
+ end
198
+
199
+ def build_cluster_entries(urls:, lastmod:, x_default_href: nil)
200
+ return [] if urls.blank?
201
+
202
+ unique_urls = urls.uniq { |url| url[:hreflang] }
203
+ alternates = unique_urls.dup
204
+ alternates << { hreflang: "x-default", href: x_default_href } if x_default_href.present?
205
+
206
+ unique_urls.map do |url|
207
+ {
208
+ loc: url[:href],
209
+ lastmod:,
210
+ alternates:
211
+ }
212
+ end
213
+ end
214
+
215
+ def absolute_url(path)
216
+ "#{site_url}#{path}"
217
+ end
218
+
219
+ def latest_public_content_updated_at
220
+ [public_articles.maximum(:updated_at), public_variants.maximum(:updated_at)].compact.max
221
+ end
222
+
223
+ def article_last_modified_at(article)
224
+ [article.updated_at, public_variants_by_article_id.fetch(article.article_id, []).map(&:updated_at).max].compact.max
225
+ end
226
+
227
+ def category_last_modified_at(category)
228
+ [category.updated_at, latest_default_category_article_update(category), latest_localized_category_variant_update(category)].compact.max
229
+ end
230
+
231
+ def latest_default_category_article_update(category)
232
+ default_category_article_updated_at_by_category_id[category.id]
233
+ end
234
+
235
+ def latest_localized_category_variant_update(category)
236
+ localized_category_variant_updated_at_by_category_id[category.id]
237
+ end
238
+
239
+ def default_language_present_for_root?
240
+ default_public_articles.exists?
241
+ end
242
+
243
+ def category_present_for_default_language?(category)
244
+ default_category_ids.include?(category.id)
245
+ end
246
+
247
+ def localized_root_languages
248
+ all_non_default_languages.select { |language| localized_root_language_ids.include?(language.id) }
249
+ end
250
+
251
+ def localized_category_languages(category)
252
+ language_ids = localized_category_language_ids_by_category_id[category.id] || Set.new
253
+ all_non_default_languages.select { |language| language_ids.include?(language.id) }
254
+ end
255
+
256
+ def public_articles
257
+ @public_articles ||= Postnhost::Snapshot::Article.includes(:language)
258
+ end
259
+
260
+ def public_variants
261
+ @public_variants ||= Postnhost::Snapshot::ArticleVariant
262
+ .joins(:article_snapshot)
263
+ .includes(:language)
264
+ end
265
+
266
+ def default_public_articles
267
+ @default_public_articles ||= if default_language.present?
268
+ public_articles.where(language: default_language)
269
+ else
270
+ Postnhost::Article.none
271
+ end
272
+ end
273
+
274
+ def public_categories
275
+ @public_categories ||= Postnhost::Category
276
+ .joins(:article_snapshot_categories)
277
+ .distinct
278
+ .order(:slug)
279
+ end
280
+
281
+ def public_variants_by_article_id
282
+ @public_variants_by_article_id ||= public_variants.group_by(&:article_id)
283
+ end
284
+
285
+ def all_languages
286
+ @all_languages ||= begin
287
+ languages = Postnhost::Language.order(:name).to_a
288
+ default = default_language
289
+ default.present? ? [default, *languages.reject { |language| language.id == default.id }] : languages
290
+ end
291
+ end
292
+
293
+ def all_non_default_languages
294
+ all_languages.reject { |language| language.id == default_language&.id }
295
+ end
296
+
297
+ def default_language
298
+ @default_language ||= Postnhost::Language.blog_default
299
+ end
300
+
301
+ def default_language_code
302
+ default_language&.html_lang || "en"
303
+ end
304
+
305
+ def static_page_templates
306
+ @static_page_templates ||= begin
307
+ templates = {}
308
+
309
+ static_page_template_paths.each do |path|
310
+ slug = File.basename(path, ".html.erb")
311
+ templates[slug] = [templates[slug], File.mtime(path)].compact.max
312
+ end
313
+
314
+ templates.sort.to_h
315
+ end
316
+ end
317
+
318
+ def static_page_template_paths
319
+ [
320
+ Rails.root.join("app/views/postnhost/static_pages/*.html.erb"),
321
+ Postnhost::Engine.root.join("app/views/postnhost/static_pages/*.html.erb")
322
+ ].flat_map { |pattern| Dir[pattern.to_s] }
323
+ end
324
+
325
+ def static_pages_signature
326
+ static_page_templates.map { |slug, mtime| "#{slug}-#{mtime.utc.iso8601}" }.join(":")
327
+ end
328
+
329
+ def published_pages
330
+ @published_pages ||= Postnhost::Snapshot::Page.where.not(slug: nil).includes(:language)
331
+ end
332
+
333
+ def public_page_variants
334
+ @public_page_variants ||= Postnhost::Snapshot::PageVariant
335
+ .joins(:page_snapshot)
336
+ .includes(:language)
337
+ end
338
+
339
+ def public_page_variants_by_page_id
340
+ @public_page_variants_by_page_id ||= public_page_variants.group_by(&:page_id)
341
+ end
342
+
343
+ def reserved_static_slug?(slug)
344
+ category_slugs.include?(slug) || article_slugs.include?(slug)
345
+ end
346
+
347
+ def default_category_ids
348
+ @default_category_ids ||= default_public_articles
349
+ .joins(:article_snapshot_categories)
350
+ .distinct
351
+ .pluck("postnhost_article_snapshot_categories.category_id")
352
+ .to_set
353
+ end
354
+
355
+ def localized_root_language_ids
356
+ @localized_root_language_ids ||= public_variants.distinct.pluck(:language_id).to_set
357
+ end
358
+
359
+ def localized_category_language_ids_by_category_id
360
+ @localized_category_language_ids_by_category_id ||= begin
361
+ pairs = public_variants
362
+ .joins(article_snapshot: :article_snapshot_categories)
363
+ .distinct
364
+ .pluck("postnhost_article_snapshot_categories.category_id", "postnhost_article_variant_snapshots.language_id")
365
+
366
+ pairs.each_with_object({}) do |(category_id, language_id), hash|
367
+ hash[category_id] ||= Set.new
368
+ hash[category_id] << language_id
369
+ end
370
+ end
371
+ end
372
+
373
+ def default_category_article_updated_at_by_category_id
374
+ @default_category_article_updated_at_by_category_id ||= default_public_articles
375
+ .joins(:article_snapshot_categories)
376
+ .group("postnhost_article_snapshot_categories.category_id")
377
+ .maximum(:updated_at)
378
+ end
379
+
380
+ def localized_category_variant_updated_at_by_category_id
381
+ @localized_category_variant_updated_at_by_category_id ||= public_variants
382
+ .joins(article_snapshot: :article_snapshot_categories)
383
+ .group("postnhost_article_snapshot_categories.category_id")
384
+ .maximum("postnhost_article_variant_snapshots.updated_at")
385
+ end
386
+
387
+ def category_slugs
388
+ @category_slugs ||= Postnhost::Category.where.not(slug: nil).distinct.pluck(:slug).to_set
389
+ end
390
+
391
+ def article_slugs
392
+ @article_slugs ||= Postnhost::Snapshot::Article.where.not(slug: nil).distinct.pluck(:slug).to_set
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,102 @@
1
+ module Postnhost
2
+ module Translation
3
+ class ArticleVariantTranslator < BaseService
4
+ def initialize(article_id:, language_id:, llm_client: OpenaiLlmClient.new)
5
+ @article_id = article_id
6
+ @language_id = language_id
7
+ @llm_client = llm_client
8
+ end
9
+
10
+ def call
11
+ load_records
12
+ return failure("Translation source or language not found") unless article && language
13
+ return failure("Published translation source not found") unless source
14
+
15
+ load_variant
16
+ return failure("Translation variant not found") unless variant
17
+
18
+ attributes = translated_attributes
19
+ return finish_with_failure("Translation failed") if attributes[:title].blank? || attributes[:content].blank?
20
+
21
+ variant.update!(attributes.merge(generating: false))
22
+ success(variant)
23
+ rescue StandardError => e
24
+ mark_generation_finished
25
+ failure(e.message)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :article_id, :language_id, :llm_client, :article, :language, :variant
31
+
32
+ def load_records
33
+ @article = Postnhost::Article.find_by(id: article_id)
34
+ @language = Postnhost::Language.find_by(id: language_id)
35
+ end
36
+
37
+ def load_variant
38
+ @variant = Postnhost::ArticleVariant.find_by(article:, language:)
39
+ end
40
+
41
+ def translated_attributes
42
+ ContentTranslator.call(source:, language:, llm_client:).merge(
43
+ schema_headline: translate_schema_headline,
44
+ custom_excerpt: translate_custom_excerpt,
45
+ use_excerpt_as_meta_description: source.use_excerpt_as_meta_description
46
+ )
47
+ end
48
+
49
+ def source
50
+ @source ||= article.article_snapshot
51
+ end
52
+
53
+ def translate_schema_headline
54
+ return if source.schema_headline.blank?
55
+
56
+ prompt = <<~PROMPT
57
+ Translate this schema headline into #{language.name} (#{language.html_lang}).
58
+
59
+ Requirements:
60
+ - Keep it accurate and natural for #{language.name} speakers
61
+ - Preserve meaning
62
+ - Keep it suitable for structured data headline usage
63
+
64
+ Schema headline to translate: #{source.schema_headline}
65
+
66
+ Respond with only the translated schema headline, no additional text.
67
+ PROMPT
68
+
69
+ llm_client.text_response(prompt)
70
+ end
71
+
72
+ def translate_custom_excerpt
73
+ return if source.custom_excerpt.blank?
74
+
75
+ prompt = <<~PROMPT
76
+ Translate this article excerpt into #{language.name} (#{language.html_lang}).
77
+
78
+ Requirements:
79
+ - Keep it concise and click-oriented
80
+ - Preserve the original intent and tone
81
+ - Make it natural for #{language.name} readers
82
+ - Keep punctuation and sentence flow clean
83
+
84
+ Excerpt to translate: #{source.custom_excerpt}
85
+
86
+ Respond with only the translated excerpt, no additional text.
87
+ PROMPT
88
+
89
+ llm_client.text_response(prompt)
90
+ end
91
+
92
+ def finish_with_failure(message)
93
+ mark_generation_finished
94
+ failure(message)
95
+ end
96
+
97
+ def mark_generation_finished
98
+ variant&.update!(generating: false)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,92 @@
1
+ module Postnhost
2
+ module Translation
3
+ class CategoryVariantTranslator < BaseService
4
+ def initialize(category_id:, language_id:, llm_client: OpenaiLlmClient.new)
5
+ @category_id = category_id
6
+ @language_id = language_id
7
+ @llm_client = llm_client
8
+ end
9
+
10
+ def call
11
+ load_records
12
+ return failure("Category or language not found") unless category && language
13
+
14
+ load_variant
15
+ return failure("Category variant not found") unless variant
16
+
17
+ attributes = translated_attributes
18
+ return finish_with_failure("Translation failed") if attributes[:name].blank?
19
+
20
+ variant.update!(attributes.merge(generating: false))
21
+ success(variant)
22
+ rescue StandardError => e
23
+ mark_generation_finished
24
+ failure(e.message)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :category_id, :language_id, :llm_client, :category, :language, :variant
30
+
31
+ def load_records
32
+ @category = Postnhost::Category.find_by(id: category_id)
33
+ @language = Postnhost::Language.find_by(id: language_id)
34
+ end
35
+
36
+ def load_variant
37
+ @variant = Postnhost::CategoryVariant.find_by(category:, language:)
38
+ end
39
+
40
+ def translated_attributes
41
+ {
42
+ name: translate_name,
43
+ meta_description: translate_meta_description
44
+ }
45
+ end
46
+
47
+ def translate_name
48
+ prompt = <<~PROMPT
49
+ Translate this category name into #{language.name} (#{language.html_lang}).
50
+
51
+ Requirements:
52
+ - Keep the same tone and style
53
+ - Make it natural for #{language.name} speakers
54
+ - Preserve the meaning
55
+
56
+ Category name to translate: #{category.name}
57
+
58
+ Respond with only the translated name, no additional text.
59
+ PROMPT
60
+
61
+ llm_client.text_response(prompt)
62
+ end
63
+
64
+ def translate_meta_description
65
+ return if category.meta_description.blank?
66
+
67
+ prompt = <<~PROMPT
68
+ Translate this meta description into #{language.name} (#{language.html_lang}).
69
+
70
+ Requirements:
71
+ - Keep it concise and SEO-friendly
72
+ - Make it natural for #{language.name} speakers
73
+
74
+ Meta description to translate: #{category.meta_description}
75
+
76
+ Respond with only the translated meta description, no additional text.
77
+ PROMPT
78
+
79
+ llm_client.text_response(prompt)
80
+ end
81
+
82
+ def finish_with_failure(message)
83
+ mark_generation_finished
84
+ failure(message)
85
+ end
86
+
87
+ def mark_generation_finished
88
+ variant&.update!(generating: false)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,119 @@
1
+ module Postnhost
2
+ module Translation
3
+ class ContentTranslator < BaseService
4
+ def initialize(source:, language:, llm_client:)
5
+ @source = source
6
+ @language = language
7
+ @llm_client = llm_client
8
+ end
9
+
10
+ def call
11
+ {
12
+ title: translate_title,
13
+ title_tag: translate_title_tag,
14
+ og_title: translate_og_title,
15
+ meta_description: translate_meta_description,
16
+ content: translate_body
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :source, :language, :llm_client
23
+
24
+ def translate_title
25
+ prompt = <<~PROMPT
26
+ Translate this title into #{language.name} (#{language.html_lang}).
27
+
28
+ Requirements:
29
+ - Keep the same tone and style
30
+ - Make it natural for #{language.name} speakers
31
+ - Preserve the meaning and impact
32
+
33
+ Title to translate: #{source.title}
34
+
35
+ Respond with only the translated title, no additional text.
36
+ PROMPT
37
+
38
+ llm_client.text_response(prompt)
39
+ end
40
+
41
+ def translate_og_title
42
+ return if source.og_title.blank?
43
+
44
+ prompt = <<~PROMPT
45
+ Translate this Open Graph title into #{language.name} (#{language.html_lang}).
46
+
47
+ Requirements:
48
+ - Keep it concise and compelling for social sharing
49
+ - Preserve the original meaning and click intent
50
+ - Make it natural for #{language.name} speakers
51
+
52
+ OG title to translate: #{source.og_title}
53
+
54
+ Respond with only the translated OG title, no additional text.
55
+ PROMPT
56
+
57
+ llm_client.text_response(prompt)
58
+ end
59
+
60
+ def translate_title_tag
61
+ return if source.title_tag.blank?
62
+
63
+ prompt = <<~PROMPT
64
+ Translate this title tag into #{language.name} (#{language.html_lang}).
65
+
66
+ Requirements:
67
+ - Keep it SEO-optimized and concise
68
+ - Make it natural for #{language.name} speakers
69
+ - Preserve the meaning and marketing impact
70
+ - Keep it suitable for HTML <title> tag
71
+
72
+ Title tag to translate: #{source.title_tag}
73
+
74
+ Respond with only the translated title tag, no additional text.
75
+ PROMPT
76
+
77
+ llm_client.text_response(prompt)
78
+ end
79
+
80
+ def translate_meta_description
81
+ return if source.meta_description.blank?
82
+
83
+ prompt = <<~PROMPT
84
+ Translate this meta description into #{language.name} (#{language.html_lang}).
85
+
86
+ Requirements:
87
+ - Keep it concise and SEO-friendly
88
+ - Maintain the marketing tone
89
+ - Make it natural for #{language.name} speakers
90
+
91
+ Meta description to translate: #{source.meta_description}
92
+
93
+ Respond with only the translated meta description, no additional text.
94
+ PROMPT
95
+
96
+ llm_client.text_response(prompt)
97
+ end
98
+
99
+ def translate_body
100
+ prompt = <<~PROMPT
101
+ Translate this content into #{language.name} (#{language.html_lang}).
102
+
103
+ Requirements:
104
+ - Maintain the exact HTML structure and formatting
105
+ - Preserve the tone while using natural #{language.name}
106
+ - Translate naturally while preserving meaning
107
+ - Keep all HTML tags exactly as they are
108
+
109
+ Content to translate:
110
+ #{source.content}
111
+
112
+ Respond with only the translated content, maintaining all HTML structure.
113
+ PROMPT
114
+
115
+ llm_client.text_response(prompt)
116
+ end
117
+ end
118
+ end
119
+ end