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,102 @@
1
+ module Postnhost
2
+ module SchemaHelper
3
+ ORGANIZATION_TYPES = {
4
+ "Organization" => "A general organization that does not fit a specific media type.",
5
+ "MediaOrganization" => "A company producing mixed media content.",
6
+ "OnlineBusiness" => "A web-only publication with no print or broadcast edition.",
7
+ "Periodical" => "A magazine, journal, or newsletter with recurring issues.",
8
+ "Brand" => "A publication that is a product or imprint.",
9
+ "NewsMediaOrganization" => "A news outlet producing journalism.",
10
+ "Corporation" => "A formally registered media company.",
11
+ "LocalBusiness" => "A local or regional publication with a physical office.",
12
+ "NGO" => "A nonprofit or donor-funded media organization.",
13
+ "GovernmentOrganization" => "A state-funded or public broadcaster.",
14
+ "EducationalOrganization" => "A university press, student paper, or academic journal."
15
+ }.freeze
16
+
17
+ ARTICLE_TYPES = {
18
+ "Article" => "A general-purpose article.",
19
+ "BlogPosting" => "A blog post, often opinion-driven or informal.",
20
+ "NewsArticle" => "A news story about a current event.",
21
+ "Report" => "A structured report of findings or investigation.",
22
+ "TechArticle" => "Technical guides, docs, or tutorials.",
23
+ "OpinionNewsArticle" => "News-oriented editorial opinion.",
24
+ "AnalysisNewsArticle" => "Analysis and interpretation of a news event.",
25
+ "ReviewNewsArticle" => "A review of a product, event, policy, or person.",
26
+ "BackgroundNewsArticle" => "Background context explaining a current story.",
27
+ "ReportageNewsArticle" => "On-the-ground field reporting.",
28
+ "SatiricalArticle" => "Satire using humor or irony.",
29
+ "ScholarlyArticle" => "Academic or peer-reviewed content.",
30
+ "MedicalWebPage" => "Health or medical content.",
31
+ "APIReference" => "Technical reference docs for an API."
32
+ }.freeze
33
+
34
+ POLICY_FIELDS = {
35
+ "publishing_principles" => "publishingPrinciples",
36
+ "corrections_policy" => "correctionsPolicy",
37
+ "ethics_policy" => "ethicsPolicy",
38
+ "ownership_funding_info" => "ownershipFundingInfo",
39
+ "actionable_feedback_policy" => "actionableFeedbackPolicy",
40
+ "verification_fact_checking_policy" => "verificationFactCheckingPolicy",
41
+ "masthead" => "masthead",
42
+ "diversity_policy" => "diversityPolicy",
43
+ "diversity_staffing_report" => "diversityStaffingReport",
44
+ "unnamed_sources_policy" => "unnamedSourcesPolicy",
45
+ "no_bylines_policy" => "noBylinesPolicy",
46
+ "mission_coverage_priorities_policy" => "missionCoveragePrioritiesPolicy"
47
+ }.freeze
48
+
49
+ CONTACT_TYPES = [
50
+ "editorial",
51
+ "customer service",
52
+ "press",
53
+ "technical support"
54
+ ].freeze
55
+
56
+ AUTHOR_ALUMNI_TYPES = %w[
57
+ EducationalOrganization
58
+ CollegeOrUniversity
59
+ School
60
+ HighSchool
61
+ MiddleSchool
62
+ ElementarySchool
63
+ Preschool
64
+ ].freeze
65
+
66
+ SETTINGS_TEXT_OVERRIDE_FIELDS = %w[
67
+ website_name
68
+ website_description
69
+ organization_name
70
+ organization_alternate_name
71
+ organization_description
72
+ ].freeze
73
+
74
+ def schema_organization_type_options
75
+ ORGANIZATION_TYPES.map { |name, description| ["#{name} - #{description}", name] }
76
+ end
77
+
78
+ def schema_article_type_options
79
+ ARTICLE_TYPES.map { |name, description| ["#{name} - #{description}", name] }
80
+ end
81
+
82
+ def schema_policy_fields
83
+ POLICY_FIELDS
84
+ end
85
+
86
+ def schema_contact_type_options
87
+ CONTACT_TYPES
88
+ end
89
+
90
+ def schema_author_alumni_type_options
91
+ AUTHOR_ALUMNI_TYPES
92
+ end
93
+
94
+ def schema_multiline_to_array(value)
95
+ value.to_s.lines.map(&:strip).compact_blank.uniq
96
+ end
97
+
98
+ def schema_array_to_multiline(value)
99
+ value.to_a.map(&:to_s).compact_blank.join("\n")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,428 @@
1
+ module Postnhost
2
+ module SeoHelper
3
+ include Postnhost::ApplicationHelper
4
+ include Postnhost::SettingsHelper
5
+ include Postnhost::SchemaHelper
6
+
7
+ SCHEMA_CONTEXT = "https://schema.org".freeze
8
+ DEFAULT_LANGUAGE = "en".freeze
9
+
10
+ def site_description
11
+ schema_website_description
12
+ end
13
+
14
+ def schema_site_name
15
+ schema_website_name
16
+ end
17
+
18
+ def page_title
19
+ fallback_title = I18n.t("postnhost.public.site.blog_meta_title")
20
+ candidate = content_for?(:title) ? content_for(:title) : fallback_title
21
+ normalize_meta_text(candidate).presence || fallback_title
22
+ end
23
+
24
+ def page_description
25
+ return nil if omit_description_for_paginated_public_index?
26
+
27
+ candidate = content_for?(:meta_description) ? content_for(:meta_description) : site_description
28
+ normalize_meta_text(candidate).presence || site_description
29
+ end
30
+
31
+ def og_title
32
+ candidate = content_for?(:og_title) ? content_for(:og_title) : page_title
33
+ normalize_meta_text(candidate).presence || page_title
34
+ end
35
+
36
+ def schema_headline
37
+ candidate = content_for?(:schema_headline) ? content_for(:schema_headline) : page_title
38
+ normalize_meta_text(candidate).presence || page_title
39
+ end
40
+
41
+ def meta_description_content
42
+ page_description
43
+ end
44
+
45
+ def render_description_meta_tags?
46
+ meta_description_content.present?
47
+ end
48
+
49
+ def page_url
50
+ @page_url ||= begin
51
+ raw_url = content_for?(:url) ? content_for(:url) : request.original_url
52
+ setting_current.canonical_url_for(raw_url)
53
+ end
54
+ end
55
+
56
+ def canonical_url
57
+ page_url.split("?").first.chomp("/")
58
+ end
59
+
60
+ def robots_content
61
+ return "noindex, nofollow" if setting_current.site_indexing == "noindex"
62
+
63
+ candidate = content_for?(:robots) ? content_for(:robots) : "index, follow"
64
+ normalize_meta_text(candidate).presence || "index, follow"
65
+ end
66
+
67
+ def article_cover_for_seo(article)
68
+ return unless article
69
+
70
+ article if article.cover_image?
71
+ end
72
+
73
+ def article_schema(article)
74
+ return unless article.respond_to?(:published_at) && article.published_at.present?
75
+
76
+ schema = {
77
+ "@context" => SCHEMA_CONTEXT,
78
+ "@type" => article_schema_type(article),
79
+ "headline" => schema_headline,
80
+ "description" => page_description,
81
+ "url" => page_url,
82
+ "datePublished" => article.published_at.iso8601,
83
+ "dateModified" => article.updated_at.iso8601,
84
+ "author" => article_author_schema_people(article),
85
+ "publisher" => {
86
+ "@id" => schema_organization_id
87
+ },
88
+ "mainEntityOfPage" => {
89
+ "@type" => "WebPage",
90
+ "@id" => page_url
91
+ },
92
+ "articleSection" => article.categories.first&.name,
93
+ "inLanguage" => schema_in_language
94
+ }
95
+
96
+ cover = article_cover_for_seo(article)
97
+ if cover
98
+ schema["image"] = {
99
+ "@type" => "ImageObject",
100
+ "url" => cover.cover_image.url,
101
+ "width" => 1200,
102
+ "height" => 630
103
+ }
104
+ end
105
+
106
+ schema.compact
107
+ end
108
+
109
+ def author_schema(author)
110
+ return unless author
111
+
112
+ author_schema_person(author, include_works_for: true).merge("@context" => SCHEMA_CONTEXT)
113
+ end
114
+
115
+ def website_schema
116
+ {
117
+ "@context" => SCHEMA_CONTEXT,
118
+ "@type" => "WebSite",
119
+ "name" => schema_website_name,
120
+ "url" => schema_site_root_url,
121
+ "description" => schema_website_description,
122
+ "inLanguage" => schema_in_language,
123
+ "publisher" => { "@id" => schema_organization_id }
124
+ }.compact
125
+ end
126
+
127
+ def organization_schema
128
+ schema_publisher
129
+ end
130
+
131
+ def structured_data_graph(article: nil, author: nil, category: nil)
132
+ graph = [website_schema, organization_schema]
133
+ graph << article_schema(article) if article
134
+ graph << author_schema(author) if author && !article
135
+ graph << breadcrumbs_schema(article, category)
136
+
137
+ {
138
+ "@context" => SCHEMA_CONTEXT,
139
+ "@graph" => graph.compact
140
+ }
141
+ end
142
+
143
+ def breadcrumbs_schema(article, category)
144
+ items = [{ name: I18n.t("postnhost.public.breadcrumbs.home"), url: schema_site_root_url }]
145
+
146
+ if article&.categories&.any?
147
+ items << { name: article.categories.first.name, url: postnhost.public_category_url(article.categories.first.slug) }
148
+ items << { name: page_title, url: page_url }
149
+ elsif category
150
+ items << { name: category.name, url: page_url }
151
+ elsif article
152
+ items << { name: page_title, url: page_url }
153
+ end
154
+
155
+ {
156
+ "@context" => SCHEMA_CONTEXT,
157
+ "@type" => "BreadcrumbList",
158
+ "itemListElement" => items.map.with_index do |item, index|
159
+ {
160
+ "@type" => "ListItem",
161
+ "position" => index + 1,
162
+ "name" => item[:name],
163
+ "item" => item[:url]
164
+ }
165
+ end
166
+ }
167
+ end
168
+
169
+ def structured_data_script(data, id: nil)
170
+ content_tag :script, type: "application/ld+json", "data-turbo-track": "reload", id: id do
171
+ data.to_json.html_safe
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def omit_description_for_paginated_public_index?
178
+ action_name == "index" &&
179
+ %w[articles categories].include?(controller_name) &&
180
+ params[:page].to_i > 1
181
+ end
182
+
183
+ def normalize_meta_text(value)
184
+ strip_tags(value.to_s).squish
185
+ end
186
+
187
+ def article_author_schema_people(article)
188
+ article_authors(article).map { |author| article_author_schema_person(author) }
189
+ end
190
+
191
+ def article_author_schema_person(author)
192
+ author_schema_person(author).compact
193
+ end
194
+
195
+ def author_schema_person(author, include_works_for: false)
196
+ schema = {
197
+ "@type" => "Person",
198
+ "@id" => author_profile_schema_id(author),
199
+ "name" => author_schema_text(author, "name", author_name(author)),
200
+ "jobTitle" => author_schema_text(author, "job_title", author.position.presence),
201
+ "description" => author_schema_text(author, "bio", author.bio.presence),
202
+ "url" => author_profile_url(author),
203
+ "image" => author_image_schema(author),
204
+ "sameAs" => author_same_as_links(author),
205
+ "knowsAbout" => author_schema_array(author, "knows_about"),
206
+ "knowsLanguage" => author_schema_knows_language(author),
207
+ "awards" => author_schema_array(author, "awards")
208
+ }
209
+
210
+ alumni_name = author_schema_text(author, "alumni_of", "")
211
+ if alumni_name.present?
212
+ schema["alumniOf"] = {
213
+ "@type" => author.schema_profile_value("alumni_type", default: "EducationalOrganization"),
214
+ "name" => alumni_name
215
+ }
216
+ end
217
+
218
+ schema["worksFor"] = { "@id" => schema_organization_id } if include_works_for
219
+ schema.compact
220
+ end
221
+
222
+ def author_image_schema(author)
223
+ avatar_url = author.schema_profile_value("image_url").presence || author_avatar(author)
224
+ return if avatar_url.blank?
225
+
226
+ {
227
+ "@type" => "ImageObject",
228
+ "url" => avatar_url,
229
+ "width" => 128,
230
+ "height" => 128
231
+ }
232
+ end
233
+
234
+ def author_profile_url(author)
235
+ return unless author_pages_enabled?
236
+
237
+ "#{postnhost.public_author_url(author.slug).chomp('/')}/"
238
+ end
239
+
240
+ def author_profile_schema_id(author)
241
+ profile_url = author_profile_url(author)
242
+ return if profile_url.blank?
243
+
244
+ "#{profile_url}#person"
245
+ end
246
+
247
+ def schema_publisher
248
+ schema_organization_node.merge(
249
+ "logo" => {
250
+ "@type" => "ImageObject",
251
+ "url" => setting_site_logo_absolute_url,
252
+ "width" => 960,
253
+ "height" => 198
254
+ }
255
+ ).compact
256
+ end
257
+
258
+ def schema_site_root_url
259
+ setting_current.effective_site_url.presence || request.base_url
260
+ end
261
+
262
+ def schema_organization_id
263
+ "#{schema_site_root_url}/#organization"
264
+ end
265
+
266
+ def schema_organization_node
267
+ node = {
268
+ "@type" => schema_settings_value("organization_type", "Organization"),
269
+ "@id" => schema_organization_id,
270
+ "name" => schema_organization_name,
271
+ "alternateName" => schema_organization_alternate_name,
272
+ "description" => schema_organization_description,
273
+ "foundingDate" => schema_settings_value("founding_date"),
274
+ "foundingLocation" => schema_settings_value("founding_location"),
275
+ "url" => schema_site_root_url
276
+ }
277
+
278
+ contact_point = schema_organization_contact_point
279
+ node["contactPoint"] = contact_point if contact_point.present?
280
+
281
+ address = schema_organization_address
282
+ node["address"] = address if address.present?
283
+
284
+ same_as = schema_organization_same_as
285
+ node["sameAs"] = same_as if same_as.any?
286
+
287
+ policies = schema_organization_policies
288
+ node.merge!(policies) if policies.present?
289
+
290
+ coverage_start = schema_settings_value("coverage_starts_at")
291
+ coverage_end = schema_settings_value("coverage_ends_at")
292
+ node["coverageStartTime"] = coverage_start if coverage_start.present?
293
+ node["coverageEndTime"] = coverage_end if coverage_end.present?
294
+
295
+ node.compact
296
+ end
297
+
298
+ def schema_website_name
299
+ schema_settings_object.schema_text_for(
300
+ locale: current_schema_locale,
301
+ key: "website_name",
302
+ fallback: I18n.t("postnhost.public.site.schema_site_name")
303
+ )
304
+ end
305
+
306
+ def schema_website_description
307
+ schema_settings_object.schema_text_for(
308
+ locale: current_schema_locale,
309
+ key: "website_description",
310
+ fallback: I18n.t("postnhost.public.site.blog_meta_description")
311
+ )
312
+ end
313
+
314
+ def schema_organization_name
315
+ schema_settings_object.schema_text_for(
316
+ locale: current_schema_locale,
317
+ key: "organization_name",
318
+ fallback: schema_website_name
319
+ )
320
+ end
321
+
322
+ def schema_organization_alternate_name
323
+ schema_settings_object.schema_text_for(
324
+ locale: current_schema_locale,
325
+ key: "organization_alternate_name",
326
+ fallback: nil
327
+ )
328
+ end
329
+
330
+ def schema_organization_description
331
+ schema_settings_object.schema_text_for(
332
+ locale: current_schema_locale,
333
+ key: "organization_description",
334
+ fallback: schema_website_description
335
+ )
336
+ end
337
+
338
+ def schema_organization_same_as
339
+ schema_settings_value("same_as").to_a.map(&:to_s).compact_blank.uniq
340
+ end
341
+
342
+ def schema_organization_policies
343
+ policies = schema_settings_object.schema_settings_hash.fetch("policies", {})
344
+ policies.each_with_object({}) do |(key, value), memo|
345
+ next if value.blank?
346
+
347
+ camelized_key = key.to_s.split("_").each_with_index.map { |segment, idx| idx.zero? ? segment : segment.capitalize }.join
348
+ memo[camelized_key] = value
349
+ end
350
+ end
351
+
352
+ def schema_organization_contact_point
353
+ email = schema_settings_value("contact_email")
354
+ contact_type = schema_settings_value("contact_type")
355
+ return if email.blank? && contact_type.blank?
356
+
357
+ {
358
+ "@type" => "ContactPoint",
359
+ "email" => email,
360
+ "contactType" => contact_type
361
+ }.compact
362
+ end
363
+
364
+ def schema_organization_address
365
+ street = schema_settings_value("address")
366
+ city = schema_settings_value("address_city")
367
+ country = schema_settings_value("address_country")
368
+ return if street.blank? && city.blank? && country.blank?
369
+
370
+ {
371
+ "@type" => "PostalAddress",
372
+ "streetAddress" => street,
373
+ "addressLocality" => city,
374
+ "addressCountry" => country
375
+ }.compact
376
+ end
377
+
378
+ def article_schema_type(article)
379
+ article_type = article.respond_to?(:schema_article_type) ? article.schema_article_type : nil
380
+ return article_type if article_type.present?
381
+
382
+ schema_settings_value("default_article_type", "BlogPosting")
383
+ end
384
+
385
+ def author_schema_text(author, key, fallback)
386
+ author.schema_text_for(locale: current_schema_locale, key:, fallback:)
387
+ end
388
+
389
+ def author_schema_array(author, key)
390
+ value = author_schema_text(author, key, "")
391
+ schema_multiline_to_array(value)
392
+ end
393
+
394
+ def author_same_as_links(author)
395
+ profile_links = author.schema_profile_value("same_as").to_a.map(&:to_s).compact_blank
396
+ social_links = author_social_links(author).pluck(:url)
397
+
398
+ (profile_links + social_links).uniq
399
+ end
400
+
401
+ def author_schema_knows_language(author)
402
+ author.schema_profile_value("knows_language").to_a.map(&:to_s).compact_blank.uniq
403
+ end
404
+
405
+ def schema_settings_value(key, default = nil)
406
+ schema_settings_object.schema_setting(key, default:)
407
+ end
408
+
409
+ def schema_settings_object
410
+ @schema_settings_object ||= setting_current
411
+ end
412
+
413
+ def current_schema_locale
414
+ locale = if content_for?(:lang)
415
+ content_for(:lang)
416
+ elsif respond_to?(:current_language)
417
+ current_language&.html_lang
418
+ else
419
+ I18n.locale.to_s
420
+ end
421
+ locale.to_s
422
+ end
423
+
424
+ def schema_in_language
425
+ current_schema_locale.presence || DEFAULT_LANGUAGE
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,79 @@
1
+ module Postnhost
2
+ module SettingsHelper
3
+ DEFAULT_SITE_LOGO_PATH = "postnhost/logo.webp".freeze
4
+ DEFAULT_OG_IMAGE_PATH = "postnhost/og-image.webp".freeze
5
+ SITE_COPY_LABELS = {
6
+ "postnhost.public.site.schema_site_name" => "Site Name",
7
+ "postnhost.public.site.blog_tagline" => "Tagline",
8
+ "postnhost.public.site.blog_subtitle" => "Subtitle",
9
+ "postnhost.public.site.blog_meta_title" => "Meta Title",
10
+ "postnhost.public.site.blog_meta_description" => "Meta Description"
11
+ }.freeze
12
+
13
+ def setting_site_logo_src
14
+ setting = setting_current
15
+ setting&.site_logo? ? setting.site_logo.url : DEFAULT_SITE_LOGO_PATH
16
+ end
17
+
18
+ def setting_og_image_src
19
+ setting = setting_current
20
+ setting&.og_image? ? setting.og_image.url : DEFAULT_OG_IMAGE_PATH
21
+ end
22
+
23
+ def setting_site_logo_absolute_url
24
+ setting = setting_current
25
+ setting&.site_logo? ? setting.site_logo.url : asset_url(DEFAULT_SITE_LOGO_PATH)
26
+ end
27
+
28
+ def setting_og_image_absolute_url
29
+ setting = setting_current
30
+ setting&.og_image? ? setting.og_image.url : asset_url(DEFAULT_OG_IMAGE_PATH)
31
+ end
32
+
33
+ def render_site_scripts(placement)
34
+ scripts = site_scripts_by_placement.fetch(placement, [])
35
+ safe_join(scripts.map { |site_script| site_script.script.to_s.html_safe }, "\n")
36
+ end
37
+
38
+ def postnhost_timezone_options
39
+ ActiveSupport::TimeZone.all.map { |timezone| ["(UTC#{timezone.formatted_offset}) #{timezone.name}", timezone.tzinfo.name] }
40
+ end
41
+
42
+ def translation_entries_primary
43
+ translation_entries_site_and_advanced.first
44
+ end
45
+
46
+ def translation_entries_advanced
47
+ translation_entries_site_and_advanced.last
48
+ end
49
+
50
+ def site_copy_label(key, include_key: false)
51
+ label = SITE_COPY_LABELS.fetch(key, key)
52
+ include_key && label != key ? "#{label} (#{key})" : label
53
+ end
54
+
55
+ def cms_template_options
56
+ [
57
+ ["Default", "default"],
58
+ ["Swiss Editorial", "swiss-editorial"],
59
+ ["Workspace Journal", "workspace-journal"]
60
+ ]
61
+ end
62
+
63
+ private
64
+
65
+ def site_scripts_by_placement
66
+ @site_scripts_by_placement ||= setting_current.site_scripts.order(:id).group_by(&:placement)
67
+ end
68
+
69
+ def translation_entries_site_and_advanced
70
+ return [[], []] if @translation_entries.blank?
71
+
72
+ @translation_entries.partition { |key, _value| key.start_with?("postnhost.public.site.") }
73
+ end
74
+
75
+ def setting_current
76
+ @setting_current ||= Postnhost::Setting.current
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ // Entry point for the build script in your package.json
2
+ import "@hotwired/turbo-rails"
3
+ import "./controllers"
@@ -0,0 +1,42 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { setPrimaryButton, setDisabledButton } from "../helpers/style_helpers"
3
+
4
+ const DEFAULT_TEXT = "Select languages to translate"
5
+
6
+ export default class extends Controller {
7
+ static targets = ["checkbox", "submitButton"]
8
+
9
+ toggleCheckbox(event) {
10
+ this.#updateSubmitButton()
11
+ }
12
+
13
+ selectAll() {
14
+ this.checkboxTargets.forEach(checkbox => checkbox.checked = true)
15
+ this.#updateSubmitButton()
16
+ }
17
+
18
+ selectNone() {
19
+ this.checkboxTargets.forEach(checkbox => checkbox.checked = false)
20
+ this.#updateSubmitButton()
21
+ }
22
+
23
+ #updateSubmitButton() {
24
+ const checkedCount = this.#checkedCount
25
+
26
+ if (checkedCount > 0) {
27
+ setPrimaryButton(this.submitButtonTarget)
28
+ this.submitButtonTarget.textContent = `Translate to ${checkedCount} language${checkedCount > 1 ? 's' : ''}`
29
+ } else {
30
+ this.#resetSubmitButton()
31
+ }
32
+ }
33
+
34
+ #resetSubmitButton() {
35
+ setDisabledButton(this.submitButtonTarget)
36
+ this.submitButtonTarget.textContent = DEFAULT_TEXT
37
+ }
38
+
39
+ get #checkedCount() {
40
+ return this.checkboxTargets.filter(cb => cb.checked).length
41
+ }
42
+ }
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }