open_blog 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 (257) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +101 -0
  5. data/app/assets/builds/open_blog/blog.css +2 -0
  6. data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
  7. data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
  8. data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
  9. data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
  10. data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
  11. data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
  12. data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
  13. data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
  14. data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
  15. data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
  16. data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
  17. data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
  18. data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
  19. data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
  20. data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
  21. data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
  22. data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
  23. data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
  24. data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
  25. data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
  26. data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
  27. data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
  28. data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
  29. data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
  30. data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
  31. data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
  32. data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
  33. data/app/controllers/open_blog/application_controller.rb +5 -0
  34. data/app/controllers/open_blog/authors_controller.rb +14 -0
  35. data/app/controllers/open_blog/categories_controller.rb +15 -0
  36. data/app/controllers/open_blog/errors_controller.rb +7 -0
  37. data/app/controllers/open_blog/feeds_controller.rb +22 -0
  38. data/app/controllers/open_blog/mcp_controller.rb +38 -0
  39. data/app/controllers/open_blog/media_controller.rb +20 -0
  40. data/app/controllers/open_blog/pages_controller.rb +12 -0
  41. data/app/controllers/open_blog/posts_controller.rb +57 -0
  42. data/app/controllers/open_blog/previews_controller.rb +35 -0
  43. data/app/controllers/open_blog/search_controller.rb +32 -0
  44. data/app/controllers/open_blog/series_controller.rb +15 -0
  45. data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
  46. data/app/controllers/open_blog/tags_controller.rb +15 -0
  47. data/app/helpers/open_blog/content_helper.rb +79 -0
  48. data/app/helpers/open_blog/dates_helper.rb +18 -0
  49. data/app/helpers/open_blog/faq_helper.rb +59 -0
  50. data/app/helpers/open_blog/head_helper.rb +75 -0
  51. data/app/helpers/open_blog/icons_helper.rb +10 -0
  52. data/app/helpers/open_blog/notices_helper.rb +47 -0
  53. data/app/helpers/open_blog/pagination_helper.rb +18 -0
  54. data/app/helpers/open_blog/structured_data_helper.rb +63 -0
  55. data/app/helpers/open_blog/theme_helper.rb +52 -0
  56. data/app/helpers/open_blog/url_helper.rb +58 -0
  57. data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
  58. data/app/models/concerns/open_blog/immutable.rb +13 -0
  59. data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
  60. data/app/models/concerns/open_blog/previewable.rb +38 -0
  61. data/app/models/open_blog/api_token.rb +34 -0
  62. data/app/models/open_blog/application_record.rb +5 -0
  63. data/app/models/open_blog/approval.rb +19 -0
  64. data/app/models/open_blog/author.rb +11 -0
  65. data/app/models/open_blog/baseline.rb +26 -0
  66. data/app/models/open_blog/category.rb +10 -0
  67. data/app/models/open_blog/connection_declaration.rb +16 -0
  68. data/app/models/open_blog/faq.rb +22 -0
  69. data/app/models/open_blog/image.rb +45 -0
  70. data/app/models/open_blog/page.rb +23 -0
  71. data/app/models/open_blog/page_view.rb +7 -0
  72. data/app/models/open_blog/post.rb +132 -0
  73. data/app/models/open_blog/publication.rb +17 -0
  74. data/app/models/open_blog/redirect.rb +16 -0
  75. data/app/models/open_blog/revision.rb +12 -0
  76. data/app/models/open_blog/series.rb +10 -0
  77. data/app/models/open_blog/tag.rb +11 -0
  78. data/app/models/open_blog/tagging.rb +7 -0
  79. data/app/queries/open_blog/api_post_query.rb +54 -0
  80. data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
  81. data/app/serializers/open_blog/author_serializer.rb +10 -0
  82. data/app/serializers/open_blog/category_serializer.rb +7 -0
  83. data/app/serializers/open_blog/doctor_serializer.rb +7 -0
  84. data/app/serializers/open_blog/error_serializer.rb +7 -0
  85. data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
  86. data/app/serializers/open_blog/findings_serializer.rb +13 -0
  87. data/app/serializers/open_blog/image_serializer.rb +9 -0
  88. data/app/serializers/open_blog/page_serializer.rb +9 -0
  89. data/app/serializers/open_blog/post_serializer.rb +59 -0
  90. data/app/serializers/open_blog/preview_serializer.rb +11 -0
  91. data/app/serializers/open_blog/records_serializer.rb +28 -0
  92. data/app/serializers/open_blog/redirect_serializer.rb +7 -0
  93. data/app/serializers/open_blog/series_serializer.rb +7 -0
  94. data/app/serializers/open_blog/tag_serializer.rb +7 -0
  95. data/app/serializers/open_blog/write_result_serializer.rb +9 -0
  96. data/app/views/open_blog/attachments/_blob.html.erb +1 -0
  97. data/app/views/open_blog/feeds/atom.xml.builder +27 -0
  98. data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
  99. data/config/locales/open_blog.en.yml +95 -0
  100. data/config/routes.rb +52 -0
  101. data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
  102. data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
  103. data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
  104. data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
  105. data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
  106. data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
  107. data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
  108. data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
  109. data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
  110. data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
  111. data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
  112. data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
  113. data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
  114. data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
  115. data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
  116. data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
  117. data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
  118. data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
  119. data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
  120. data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
  121. data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
  122. data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
  123. data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
  124. data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
  125. data/lib/generators/open_blog/generator_support.rb +64 -0
  126. data/lib/generators/open_blog/install/install_generator.rb +188 -0
  127. data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
  128. data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
  129. data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
  130. data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
  131. data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
  132. data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
  133. data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
  134. data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
  135. data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
  136. data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
  137. data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
  138. data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
  139. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
  140. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
  141. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
  142. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
  143. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
  144. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
  145. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
  146. data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
  147. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
  148. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
  149. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
  150. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
  151. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
  152. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
  153. data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
  154. data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
  155. data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
  156. data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
  157. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
  158. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
  159. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
  160. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
  161. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
  162. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
  163. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
  164. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
  165. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
  166. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
  167. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
  168. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
  169. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
  170. data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
  171. data/lib/generators/open_blog/views/views_generator.rb +21 -0
  172. data/lib/open_blog/actor.rb +7 -0
  173. data/lib/open_blog/admin_suite.rb +73 -0
  174. data/lib/open_blog/adopt/contract.rb +152 -0
  175. data/lib/open_blog/adopt/snapshot.rb +35 -0
  176. data/lib/open_blog/adopt.rb +159 -0
  177. data/lib/open_blog/api_fields.rb +40 -0
  178. data/lib/open_blog/approve.rb +18 -0
  179. data/lib/open_blog/authentication.rb +18 -0
  180. data/lib/open_blog/build_css.rb +53 -0
  181. data/lib/open_blog/configuration.rb +185 -0
  182. data/lib/open_blog/configuration_error.rb +4 -0
  183. data/lib/open_blog/content_guard.rb +77 -0
  184. data/lib/open_blog/doctor/files.rb +61 -0
  185. data/lib/open_blog/doctor/javascript.rb +57 -0
  186. data/lib/open_blog/doctor.rb +177 -0
  187. data/lib/open_blog/engine.rb +45 -0
  188. data/lib/open_blog/errors.rb +40 -0
  189. data/lib/open_blog/faq_extraction.rb +184 -0
  190. data/lib/open_blog/findings/body_checks.rb +58 -0
  191. data/lib/open_blog/findings/check.rb +19 -0
  192. data/lib/open_blog/findings/link_check.rb +78 -0
  193. data/lib/open_blog/findings/record_checks.rb +94 -0
  194. data/lib/open_blog/findings/registry.rb +18 -0
  195. data/lib/open_blog/findings.rb +27 -0
  196. data/lib/open_blog/image_fetch.rb +115 -0
  197. data/lib/open_blog/image_import.rb +164 -0
  198. data/lib/open_blog/image_resolution.rb +171 -0
  199. data/lib/open_blog/instructions.rb +7 -0
  200. data/lib/open_blog/json_feed.rb +36 -0
  201. data/lib/open_blog/label_policy.rb +41 -0
  202. data/lib/open_blog/markdown_view.rb +57 -0
  203. data/lib/open_blog/mcp/definition.rb +28 -0
  204. data/lib/open_blog/mcp/dispatcher.rb +33 -0
  205. data/lib/open_blog/mcp/image_input.rb +30 -0
  206. data/lib/open_blog/mcp/registry.rb +139 -0
  207. data/lib/open_blog/mcp/schemas.rb +72 -0
  208. data/lib/open_blog/mcp.rb +24 -0
  209. data/lib/open_blog/not_found.rb +4 -0
  210. data/lib/open_blog/operation.rb +36 -0
  211. data/lib/open_blog/page_views.rb +72 -0
  212. data/lib/open_blog/pagination.rb +18 -0
  213. data/lib/open_blog/plain_text.rb +36 -0
  214. data/lib/open_blog/policy_url.rb +9 -0
  215. data/lib/open_blog/post_attributes.rb +172 -0
  216. data/lib/open_blog/post_identity.rb +28 -0
  217. data/lib/open_blog/publish.rb +7 -0
  218. data/lib/open_blog/reader_dates.rb +21 -0
  219. data/lib/open_blog/reader_page.rb +21 -0
  220. data/lib/open_blog/reader_queries.rb +90 -0
  221. data/lib/open_blog/record_release.rb +66 -0
  222. data/lib/open_blog/redirect_target.rb +58 -0
  223. data/lib/open_blog/remove.rb +43 -0
  224. data/lib/open_blog/renderer/highlighter.rb +44 -0
  225. data/lib/open_blog/renderer/markdown.rb +14 -0
  226. data/lib/open_blog/renderer/rich_text.rb +19 -0
  227. data/lib/open_blog/renderer/sanitizer.rb +72 -0
  228. data/lib/open_blog/renderer/scrubber.rb +35 -0
  229. data/lib/open_blog/renderer/transform.rb +115 -0
  230. data/lib/open_blog/renderer.rb +51 -0
  231. data/lib/open_blog/result.rb +11 -0
  232. data/lib/open_blog/revision_payload.rb +67 -0
  233. data/lib/open_blog/rich_text_guard.rb +26 -0
  234. data/lib/open_blog/sample.rb +56 -0
  235. data/lib/open_blog/save_draft.rb +7 -0
  236. data/lib/open_blog/search.rb +21 -0
  237. data/lib/open_blog/sitemap_entries.rb +66 -0
  238. data/lib/open_blog/surface_report/expected_content.rb +27 -0
  239. data/lib/open_blog/surface_report/http.rb +92 -0
  240. data/lib/open_blog/surface_report/page_checks.rb +303 -0
  241. data/lib/open_blog/surface_report/reach_checks.rb +141 -0
  242. data/lib/open_blog/surface_report/record_checks.rb +248 -0
  243. data/lib/open_blog/surface_report/robots.rb +49 -0
  244. data/lib/open_blog/surface_report.rb +223 -0
  245. data/lib/open_blog/syntax_css.rb +55 -0
  246. data/lib/open_blog/unpublish.rb +13 -0
  247. data/lib/open_blog/version.rb +3 -0
  248. data/lib/open_blog/write_post.rb +205 -0
  249. data/lib/open_blog.rb +91 -0
  250. data/lib/tasks/open_blog.rake +71 -0
  251. data/skills/open-blog-adopt/SKILL.md +12 -0
  252. data/skills/open-blog-install/SKILL.md +10 -0
  253. data/skills/open-blog-policy-pages/SKILL.md +10 -0
  254. data/skills/open-blog-publish/SKILL.md +14 -0
  255. data/skills/open-blog-report/SKILL.md +10 -0
  256. data/skills/open-blog-update/SKILL.md +16 -0
  257. metadata +515 -0
@@ -0,0 +1,19 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class ViewsController < BaseController
5
+ def show
6
+ require_scope!(:read)
7
+ input = input_fields!(*ApiFields::VIEWS)
8
+ render json: PageViews.for(find_post!, **input)
9
+ end
10
+
11
+ def top
12
+ require_scope!(:read)
13
+ input = input_fields!(*ApiFields::TOP_VIEWS)
14
+ render json: PageViews.top(**input, base_url: request.base_url)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ module OpenBlog
2
+ class ApplicationController < OpenBlog.config.parent_controller.constantize
3
+ include ReaderController
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ module OpenBlog
2
+ class AuthorsController < ApplicationController
3
+ before_action :require_html
4
+
5
+ def show
6
+ @author = Author.find_by!(slug: params[:slug])
7
+ pagination = paginate(ReaderQueries.posts.where(author_id: @author.id))
8
+ @posts = pagination.records
9
+ path = ReaderQueries.author_path(@author)
10
+ page_context(:author, record: @author, path: path, pagination: pagination,
11
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: @author.name, path: path } ])
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ module OpenBlog
2
+ class CategoriesController < ApplicationController
3
+ before_action :require_html
4
+
5
+ def show
6
+ @category = Category.find_by!(slug: params[:slug])
7
+ pagination = paginate(ReaderQueries.posts.where(category_id: @category.id))
8
+ @posts = pagination.records
9
+ load_sidebar
10
+ path = ReaderQueries.category_path(@category)
11
+ page_context(:category, record: @category, path: path, pagination: pagination,
12
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: @category.name, path: path } ])
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class ErrorsController < ApplicationController
3
+ def not_found
4
+ render_not_found
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ module OpenBlog
2
+ class FeedsController < ApplicationController
3
+ def show
4
+ scope = ReaderQueries.posts
5
+ if params[:slug]
6
+ @category = Category.find_by!(slug: params[:slug])
7
+ scope = scope.where(category_id: @category.id)
8
+ end
9
+ @posts = scope.limit(OpenBlog.config.feed_size).to_a
10
+ @base_url = origin.chomp("/")
11
+ @feed_title = @category&.name || OpenBlog.config.blog_title
12
+ @feed_updated_at = @posts.map { |post| ReaderDates.feed_updated_at(post) }.max || Time.current
13
+ expires_in 1.hour, public: true
14
+ if request.format.json?
15
+ feed = JsonFeed.new(posts: @posts, base_url: @base_url, feed_path: request.path, title: @feed_title, category: @category)
16
+ render body: feed.to_json, content_type: "application/feed+json"
17
+ else
18
+ render "open_blog/feeds/atom", formats: [ :xml ], content_type: "application/atom+xml", layout: false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,38 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ class McpController < Api::V1::BaseController
5
+ prepend_before_action :check_transport!
6
+
7
+ def create
8
+ server = ::MCP::Server.new(name: "open_blog", tools: Mcp.tools,
9
+ server_context: { actor: actor, base_url: request.base_url })
10
+ result = server.handle_json(request.raw_post)
11
+ result.nil? ? head(:accepted) : render(body: result, content_type: "application/json")
12
+ end
13
+
14
+ private
15
+
16
+ def check_transport!
17
+ prevent_caching!
18
+ raise Error::NotFound unless OpenBlog.config.mcp.enabled
19
+ unless request.post?
20
+ response.headers["Allow"] = "POST"
21
+ return head :method_not_allowed
22
+ end
23
+ return unless request.headers.key?("Origin")
24
+ origin = normalized_origin(request.headers["Origin"])
25
+ allowed = [ request.base_url, OpenBlog.config.public_base_url ].compact.map { |value| normalized_origin(value) }
26
+ raise Error::ScopeRequired.new(details: [ "origin" ]) unless origin && allowed.include?(origin)
27
+ end
28
+
29
+ def normalized_origin(value)
30
+ uri = URI.parse(value.to_s)
31
+ return unless uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil? &&
32
+ uri.query.nil? && uri.fragment.nil? && [ "", "/" ].include?(uri.path)
33
+ [ uri.scheme.downcase, uri.host.downcase, uri.port ]
34
+ rescue URI::InvalidURIError
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ module OpenBlog
2
+ class MediaController < ApplicationController
3
+ include ActiveStorage::SetCurrent
4
+ rescue_from ActiveStorage::FileNotFoundError, with: :render_not_found
5
+
6
+ def show
7
+ image = Image.find_by!(sha256: params[:sha256])
8
+ raise NotFound unless image.file.attached?
9
+ if OpenBlog.config.image_delivery == :proxy
10
+ bytes = image.file.download
11
+ expires_in 365.days, public: true, immutable: true
12
+ send_data bytes, type: image.content_type, filename: image.filename, disposition: "inline"
13
+ else
14
+ lifetime = [ ActiveStorage.service_urls_expire_in.to_i, 365.days.to_i ].min
15
+ expires_in lifetime, public: true
16
+ redirect_to image.file.blob.url(expires_in: lifetime, disposition: "inline"), allow_other_host: true
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ module OpenBlog
2
+ class PagesController < ApplicationController
3
+ before_action :require_html
4
+
5
+ def show
6
+ @page = Page.published.find_by!(slug: params[:slug])
7
+ raise NotFound if OpenBlog.config.policy_urls[@page.kind.to_sym].present?
8
+ page_context(:page, record: @page, path: @page.path,
9
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: @page.title, path: @page.path } ])
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,57 @@
1
+ module OpenBlog
2
+ class PostsController < ApplicationController
3
+ before_action :require_html, only: :index
4
+ before_action :require_post_format, only: :show
5
+ after_action :count_page_view, only: :show
6
+
7
+ def index
8
+ @featured = ReaderQueries.featured
9
+ scope = ReaderQueries.posts
10
+ scope = scope.where.not(id: @featured.id) if @featured
11
+ pagination = paginate(scope)
12
+ @posts = pagination.records
13
+ load_sidebar
14
+ page_context(:index, path: OpenBlog.mount_path, pagination: pagination,
15
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path } ])
16
+ end
17
+
18
+ def show
19
+ @post = ReaderQueries.posts.find_by(slug: params[:slug])
20
+ unless @post
21
+ redirect = Redirect.find_by(old_path: "#{OpenBlog.mount_path.chomp('/')}/#{params[:slug]}")
22
+ raise NotFound unless redirect
23
+ return redirect_to(redirect.new_path, status: :moved_permanently, allow_other_host: true) if redirect.new_path
24
+ request.format = :html
25
+ @posts = ReaderQueries.posts.limit(6).to_a
26
+ page_context(:gone, path: request.path)
27
+ return render "open_blog/errors/not_found", status: :gone, formats: [ :html ], content_type: "text/html"
28
+ end
29
+ if request.format == Mime[:md]
30
+ canonical = @post.canonical_url.presence || @post.url(base: origin)
31
+ response.headers["X-Robots-Tag"] = "noindex"
32
+ response.headers["Link"] = "<#{canonical}>; rel=\"canonical\""
33
+ return render plain: MarkdownView.render(@post, base_url: origin), content_type: "text/markdown"
34
+ end
35
+ @related_posts = ReaderQueries.related(@post)
36
+ @related_cache_key = ReaderQueries.related_cache_key(@post, @related_posts)
37
+ crumbs = [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path } ]
38
+ crumbs << { name: @post.category.name, path: ReaderQueries.category_path(@post.category) } if @post.category
39
+ crumbs << { name: @post.title, path: @post.path }
40
+ page_context(:post, record: @post, path: @post.path, breadcrumbs: crumbs)
41
+ end
42
+
43
+ private
44
+
45
+ def count_page_view
46
+ return unless @post && request.format.html? && [ 200, 304 ].include?(response.status)
47
+ PageViews.count!(@post, request)
48
+ rescue StandardError => error
49
+ Rails.logger.warn("OpenBlog page view count failed (#{error.class})")
50
+ end
51
+
52
+ def require_post_format
53
+ raise NotFound unless params[:format].blank? || %w[html md].include?(params[:format])
54
+ request.format = params[:format] == "md" ? :md : :html
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,35 @@
1
+ module OpenBlog
2
+ class PreviewsController < ActionController::Base
3
+ include ReaderController
4
+ before_action :preview_headers
5
+ before_action :require_html
6
+
7
+ def show
8
+ @post = Post.find_by_preview_token(params[:token])
9
+ raise NotFound unless @post && (@post.previewable? || @post.published?)
10
+ return redirect_to(@post.path, status: :found) if @post.published?
11
+ @related_posts = ReaderQueries.related(@post)
12
+ @related_cache_key = ReaderQueries.related_cache_key(@post, @related_posts)
13
+ page_context(:preview, record: @post, path: @post.path,
14
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: @post.title, path: @post.path } ])
15
+ render "open_blog/posts/show"
16
+ end
17
+
18
+ private
19
+
20
+ def preview_headers
21
+ request.delete_header("HTTP_IF_NONE_MATCH")
22
+ request.delete_header("HTTP_IF_MODIFIED_SINCE")
23
+ response.headers["Cache-Control"] = "no-store"
24
+ response.headers["X-Robots-Tag"] = "noindex, nofollow"
25
+ response.headers["Referrer-Policy"] = "no-referrer"
26
+ end
27
+
28
+ def render_not_found
29
+ preview_headers
30
+ request.format = :html
31
+ page_context(:preview_expired, path: "#{OpenBlog.mount_path.chomp('/')}/preview")
32
+ render "open_blog/errors/preview_expired", status: :not_found, formats: [ :html ], content_type: "text/html"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ module OpenBlog
2
+ class SearchController < ApplicationController
3
+ before_action :limit_search_requests
4
+
5
+ def show
6
+ raise NotFound if params[:format].present? && !%w[html json].include?(params[:format])
7
+ response.headers["X-Robots-Tag"] = "noindex"
8
+ @query = params[:q].is_a?(String) ? params[:q].strip : ""
9
+ scope = Search.call(@query, scope: ReaderQueries.posts)
10
+ if params[:format] == "json"
11
+ render json: scope.limit(8).map { |post| { title: post.title, url: post.url(base: origin), description: post.description } }
12
+ else
13
+ request.format = :html
14
+ pagination = paginate(scope)
15
+ @posts = pagination.records
16
+ load_sidebar
17
+ path = "#{OpenBlog.mount_path.chomp('/')}/search?#{ { q: @query }.to_query }"
18
+ page_context(:search, path: path, pagination: pagination,
19
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: I18n.t("open_blog.search.heading"), path: path } ])
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def limit_search_requests
26
+ options = { to: OpenBlog.config.search_rate_limit.fetch(:to), within: OpenBlog.config.search_rate_limit.fetch(:within),
27
+ by: -> { request.remote_ip }, with: -> { head :too_many_requests }, store: OpenBlog.config.rate_limit_store, name: nil }
28
+ options[:scope] = "open_blog/search" if method(:rate_limiting).parameters.any? { |kind, name| kind == :keyreq && name == :scope }
29
+ rate_limiting(**options)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ module OpenBlog
2
+ class SeriesController < ApplicationController
3
+ before_action :require_html
4
+
5
+ def show
6
+ @series = Series.find_by!(slug: params[:slug])
7
+ pagination = paginate(ReaderQueries.series_posts(@series))
8
+ @posts = pagination.records
9
+ load_sidebar
10
+ path = ReaderQueries.series_path(@series)
11
+ page_context(:series, record: @series, path: path, pagination: pagination,
12
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: @series.name, path: path } ])
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module OpenBlog
2
+ class SitemapsController < ApplicationController
3
+ def show
4
+ raise NotFound unless OpenBlog.config.serve_sitemap
5
+ @entries = OpenBlog.sitemap_entries(base_url: origin)
6
+ expires_in 1.hour, public: true
7
+ render "open_blog/sitemaps/show", formats: [ :xml ], content_type: "application/xml", layout: false
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ module OpenBlog
2
+ class TagsController < ApplicationController
3
+ before_action :require_html
4
+
5
+ def show
6
+ @tag = Tag.find_by!(slug: params[:slug])
7
+ pagination = paginate(ReaderQueries.posts.where(id: Tagging.where(tag_id: @tag.id).select(:post_id)))
8
+ @posts = pagination.records
9
+ load_sidebar
10
+ path = ReaderQueries.tag_path(@tag)
11
+ page_context(:tag, record: @tag, path: path, pagination: pagination,
12
+ breadcrumbs: [ { name: OpenBlog.config.blog_title, path: OpenBlog.mount_path }, { name: @tag.name, path: path } ])
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ module OpenBlog
2
+ module ContentHelper
3
+ include FaqHelper
4
+
5
+ def open_blog_post_content(_post, &block)
6
+ tag.article(class: "ob-post-content", data: { open_blog_content: true }, &block)
7
+ end
8
+
9
+ def open_blog_title(post)
10
+ tag.h1(post.title, class: "ob-post-title")
11
+ end
12
+
13
+ def open_blog_byline(post)
14
+ tag.div(class: "ob-byline") do
15
+ safe_join([ link_to(post.author_name, open_blog_list_path(:author, post.author), class: "ob-byline-author"), open_blog_dates(post) ], " ")
16
+ end
17
+ end
18
+
19
+ def open_blog_cover(post)
20
+ return "".html_safe unless post.cover_image
21
+ image = post.cover_image
22
+ tag.figure(class: "ob-cover") do
23
+ tag.img(src: image.path, alt: post.cover_alt.to_s, width: image.width, height: image.height)
24
+ end
25
+ end
26
+
27
+ def open_blog_card_image(post)
28
+ image = post.cover_image
29
+ return tag.div(class: "ob-card-placeholder", aria: { hidden: true }) unless image
30
+ source = image.file.attached? ? main_app.url_for(image.file.variant(resize_to_fill: [ 640, 360 ])) : image.path
31
+ tag.img(src: source, alt: post.cover_alt.to_s, width: 640, height: 360, loading: "lazy", class: "ob-card-image")
32
+ end
33
+
34
+ def open_blog_avatar(author, name: nil)
35
+ name ||= author.name
36
+ if author.avatar.attached?
37
+ tag.img(src: main_app.url_for(author.avatar.variant(resize_to_fill: [ 80, 80 ])), alt: name, width: 40, height: 40, class: "ob-avatar", loading: "lazy")
38
+ else
39
+ tag.span(name.to_s.chars.first(2).join.upcase, class: "ob-avatar ob-avatar--initials", aria: { hidden: true })
40
+ end
41
+ end
42
+
43
+ def open_blog_body(post)
44
+ tag.div(Renderer.render(post, cache: @open_blog_page&.kind != :preview), class: "ob-prose", data: { open_blog_body: true })
45
+ end
46
+
47
+ def open_blog_reading_time(post)
48
+ tag.span(open_blog_translate("reading_time", count: post.reading_time_minutes || 1), class: "ob-reading-time")
49
+ end
50
+
51
+ def open_blog_toc(post)
52
+ fragment = Nokogiri::HTML5.fragment(Renderer.render(post, cache: @open_blog_page&.kind != :preview))
53
+ headings = fragment.css("h2, h3")
54
+ has_faq = post.faq_list.any?
55
+ return "".html_safe if headings.length + (has_faq ? 1 : 0) < 3
56
+ items = headings.map do |heading|
57
+ text = heading.dup
58
+ text.css(".ob-heading-anchor").remove
59
+ tag.li(link_to(text.text, "##{heading['id']}"), class: "ob-toc-item ob-toc-item--#{heading.name}")
60
+ end
61
+ if has_faq
62
+ items << tag.li(link_to(open_blog_translate("faq.heading"), "##{open_blog_faq_section_id(post, body: fragment)}"), class: "ob-toc-item ob-toc-item--h2")
63
+ end
64
+ tag.nav(class: "ob-toc", aria: { label: open_blog_translate("navigation.toc") }, data: { controller: "open-blog--toc" }) do
65
+ safe_join([ tag.h2(open_blog_translate("navigation.toc")), tag.ol(safe_join(items)) ])
66
+ end
67
+ end
68
+
69
+ def open_blog_breadcrumbs(page = nil)
70
+ page = open_blog_page(page)
71
+ tag.nav(class: "ob-breadcrumbs", aria: { label: open_blog_translate("navigation.breadcrumbs") }) do
72
+ tag.ol(safe_join(page.breadcrumbs.each_with_index.map do |crumb, index|
73
+ last = index == page.breadcrumbs.length - 1
74
+ tag.li(last ? tag.span(crumb[:name], aria: { current: "page" }) : link_to(crumb[:name], crumb[:path]))
75
+ end))
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,18 @@
1
+ module OpenBlog
2
+ module DatesHelper
3
+ def open_blog_dates(post)
4
+ published = ReaderDates.published_at(post)
5
+ modified = ReaderDates.modified_at(post)
6
+ dates = []
7
+ dates << open_blog_date(published, :published) if published
8
+ dates << open_blog_date(modified, :updated) if modified && (!published || modified > published)
9
+ safe_join(dates, " ")
10
+ end
11
+
12
+ def open_blog_date(time, label)
13
+ tag.span(class: "ob-date ob-date--#{label}") do
14
+ safe_join([ tag.span(open_blog_translate("dates.#{label}")), tag.time(I18n.l(time.to_date, format: :long, locale: OpenBlog.config.locale), datetime: time.iso8601) ], " ")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,59 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ module FaqHelper
5
+ def open_blog_faq(post)
6
+ entries = post.faq_list
7
+ return "".html_safe if entries.empty?
8
+ heading = tag.h2(open_blog_translate("faq.heading"), id: open_blog_faq_section_id(post))
9
+ items = entries.map do |entry|
10
+ question = tag.h3(ERB::Util.html_escape(String.new(entry[:question].to_s)))
11
+ paragraphs = entry[:answer].to_s.gsub(/\r\n?/, "\n").split(/\n[\t ]*\n+/).map do |paragraph|
12
+ tag.p(safe_join(paragraph.split("\n", -1).map { |line| open_blog_faq_links(line) }, safe_join([ tag.br, "\n" ])))
13
+ end
14
+ tag.div(safe_join([ question, *paragraphs ], "\n"), class: "ob-faq-entry", data: { open_blog_faq_entry: true })
15
+ end
16
+ tag.section(safe_join([ heading, *items ], "\n"), class: "ob-faq", data: { open_blog_faq: true })
17
+ end
18
+
19
+ def open_blog_faq_section_id(post, body: nil)
20
+ body ||= Nokogiri::HTML5.fragment(Renderer.render(post, cache: @open_blog_page&.kind != :preview))
21
+ used = body.css("[id]").map { |node| node["id"] }
22
+ identifier, suffix = "open-blog-faq", 0
23
+ while used.include?(identifier)
24
+ suffix += 1
25
+ identifier = "open-blog-faq-#{suffix}"
26
+ end
27
+ identifier
28
+ end
29
+
30
+ private
31
+
32
+ def open_blog_faq_links(line)
33
+ line = String.new(line)
34
+ parts, offset = [], 0
35
+ line.to_enum(:scan, %r{https?://[^\s<>"']+}i).each do
36
+ match = Regexp.last_match
37
+ parts << line[offset...match.begin(0)]
38
+ candidate = match[0]
39
+ url = candidate.sub(/[.,;:!?]+\z/, "")
40
+ { ")" => "(", "]" => "[", "}" => "{" }.each do |closing, opening|
41
+ url = url.chop while url.end_with?(closing) && url.count(closing) > url.count(opening)
42
+ end
43
+ url = url.sub(/[.,;:!?]+\z/, "")
44
+ parts << (open_blog_faq_http_url?(url) ? link_to(url, url, rel: "noopener") : url)
45
+ parts << candidate[url.length..]
46
+ offset = match.end(0)
47
+ end
48
+ parts << line[offset..]
49
+ safe_join(parts)
50
+ end
51
+
52
+ def open_blog_faq_http_url?(url)
53
+ parsed = URI.parse(url)
54
+ %w[http https].include?(parsed.scheme&.downcase) && parsed.host.present?
55
+ rescue URI::InvalidURIError
56
+ false
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,75 @@
1
+ module OpenBlog
2
+ module HeadHelper
3
+ include ThemeHelper
4
+
5
+ def open_blog_lang
6
+ OpenBlog.config.locale.to_s.tr("_", "-")
7
+ end
8
+
9
+ def open_blog_head(resource = nil)
10
+ page = open_blog_page(resource)
11
+ title = open_blog_page_title(page)
12
+ description = open_blog_page_description(page)
13
+ canonical = open_blog_canonical_url(page)
14
+ tags = [ tag.title(title), tag.meta(name: "description", content: description), tag.link(rel: "canonical", href: canonical),
15
+ tag.meta(name: "robots", content: open_blog_robots(page)), tag.meta(name: "viewport", content: "width=device-width, initial-scale=1"),
16
+ tag.meta(property: "og:title", content: title), tag.meta(property: "og:description", content: description),
17
+ tag.meta(property: "og:type", content: %i[post preview].include?(page.kind) ? "article" : "website"), tag.meta(property: "og:url", content: canonical),
18
+ tag.meta(property: "og:site_name", content: OpenBlog.config.site_name), tag.meta(name: "twitter:card", content: "summary_large_image") ]
19
+ image = open_blog_social_image_url(page)
20
+ tags << tag.meta(property: "og:image", content: image) if image
21
+ if %i[post preview].include?(page.kind)
22
+ { published_time: ReaderDates.published_at(page.record), modified_time: ReaderDates.modified_at(page.record) }.each do |name, time|
23
+ tags << tag.meta(property: "article:#{name}", content: time.iso8601) if time
24
+ end
25
+ end
26
+ feed_path = page.kind == :category ? "#{page.path}/feed.xml" : "#{open_blog_index_path.chomp('/')}/feed.xml"
27
+ tags << tag.link(rel: "alternate", type: "application/atom+xml", title: OpenBlog.config.blog_title, href: open_blog_absolute_url(feed_path, base: page.base_url))
28
+ tags << tag.link(rel: "alternate", type: "application/feed+json", title: OpenBlog.config.blog_title,
29
+ href: open_blog_absolute_url(feed_path.sub(/\.xml\z/, ".json"), base: page.base_url))
30
+ tags << open_blog_theme_colors
31
+ safe_join(tags, "\n")
32
+ end
33
+
34
+ def open_blog_page_title(page = open_blog_page)
35
+ title = case page.kind
36
+ when :post, :preview then page.record.search_title.presence || page.record.title
37
+ when :page then page.record.title
38
+ when :search then open_blog_translate("search.heading")
39
+ when :category, :author, :series then page.record.name
40
+ when :tag then open_blog_translate("titles.tag", name: page.record.name)
41
+ when :not_found, :gone, :preview_expired then open_blog_translate("titles.#{page.kind}")
42
+ else OpenBlog.config.blog_title
43
+ end
44
+ title = open_blog_translate("titles.page", title: title, page: page.page_number) if page.page_number > 1
45
+ "#{title}#{OpenBlog.config.title_suffix}"
46
+ end
47
+
48
+ def open_blog_page_description(page = open_blog_page)
49
+ record = page.record
50
+ return record.search_description.presence || record.description.to_s if %i[post preview].include?(page.kind)
51
+ return record.title if page.kind == :page
52
+ return open_blog_translate("search.description") if page.kind == :search
53
+ paginated = page.page_number > 1
54
+ if !paginated
55
+ return OpenBlog.config.blog_tagline if page.kind == :index && OpenBlog.config.blog_tagline.present?
56
+ return record.description if %i[category tag series].include?(page.kind) && record.respond_to?(:description) && record.description.present?
57
+ return record.bio if page.kind == :author && record.bio.present?
58
+ end
59
+ kind = %i[index category tag author series].include?(page.kind) ? page.kind : :index
60
+ open_blog_translate("descriptions.#{kind}#{'_page' if paginated}", site_name: OpenBlog.config.site_name, name: record&.try(:name), page: page.page_number)
61
+ end
62
+
63
+ def open_blog_robots(page = open_blog_page)
64
+ return "noindex, nofollow" if %i[not_found gone preview preview_expired].include?(page.kind)
65
+ return "index, follow" if %i[index post page].include?(page.kind)
66
+ primary = OpenBlog.config.primary_list_type.to_s.singularize.to_sym
67
+ page.kind == primary ? "index, follow" : "noindex, follow"
68
+ end
69
+
70
+ def open_blog_social_image_url(page = open_blog_page)
71
+ image = %i[post preview].include?(page.kind) && (page.record.social_image || page.record.cover_image)
72
+ open_blog_absolute_url(image ? image.path : OpenBlog.config.default_social_image_url, base: page.base_url)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,10 @@
1
+ module OpenBlog
2
+ module IconsHelper
3
+ ICONS = %w[sun moon monitor share copy check link].freeze
4
+
5
+ def open_blog_icon(name)
6
+ raise ArgumentError, "Unknown icon: #{name}" unless ICONS.include?(name.to_s)
7
+ render partial: "open_blog/icons/#{name}", formats: [ :svg ]
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,47 @@
1
+ module OpenBlog
2
+ module NoticesHelper
3
+ def open_blog_notices(post)
4
+ notices = []
5
+ label = LabelPolicy.for(post)
6
+ notices << tag.p(open_blog_translate("notices.#{label}"), class: "ob-notice ob-notice--ai") unless label == :none
7
+ if open_blog_connection_declaration(post)&.third_party_paid?
8
+ notices << tag.p(open_blog_translate("notices.paid"), class: "ob-notice ob-notice--paid")
9
+ end
10
+ safe_join(notices)
11
+ end
12
+
13
+ def open_blog_disclosure(post)
14
+ declaration = open_blog_connection_declaration(post)
15
+ return "".html_safe unless declaration
16
+ content = if declaration.connections.empty?
17
+ tag.p(open_blog_translate("notices.connections_none"))
18
+ else
19
+ tag.ul(safe_join(declaration.connections.map { |entry| tag.li("#{entry['party']}: #{entry['relation']}") }))
20
+ end
21
+ tag.aside(class: "ob-disclosure ob-notice", aria: { label: open_blog_translate("notices.disclosure") }) do
22
+ safe_join([ tag.h2(open_blog_translate("notices.disclosure")), content ])
23
+ end
24
+ end
25
+
26
+ def open_blog_corrections(post)
27
+ rows = post.publications.where(entry_type: "correction").where("occurred_at <= ?", Time.current).order(:occurred_at, :id)
28
+ safe_join(rows.map do |entry|
29
+ tag.aside(class: "ob-correction ob-notice") do
30
+ safe_join([ tag.strong(open_blog_translate("notices.correction")),
31
+ tag.time(I18n.l(entry.occurred_at.to_date, format: :long, locale: OpenBlog.config.locale), datetime: entry.occurred_at.iso8601),
32
+ tag.p(entry.note) ], " ")
33
+ end
34
+ end)
35
+ end
36
+
37
+ def open_blog_responsible_party_link
38
+ url = OpenBlog.policy_url(:responsible_party)
39
+ return "".html_safe unless url.present? && Renderer::Sanitizer.safe_url?(url)
40
+ link_to(open_blog_translate("navigation.responsible_party"), url, class: "ob-responsible-party")
41
+ end
42
+
43
+ def open_blog_connection_declaration(post)
44
+ post.connection_declarations.order(id: :desc).first
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ module OpenBlog
2
+ module PaginationHelper
3
+ def open_blog_paginate(pagination = nil)
4
+ page = open_blog_page
5
+ pagination ||= page.pagination
6
+ return "".html_safe unless pagination && pagination.total_pages > 1
7
+ links = (1..pagination.total_pages).map do |number|
8
+ if number == pagination.page
9
+ tag.span(number, class: "ob-pagination-current", aria: { current: "page" })
10
+ else
11
+ path = page.path_for(number)
12
+ link_to(number, path, class: "ob-pagination-link", aria: { label: open_blog_translate("navigation.page", page: number) })
13
+ end
14
+ end
15
+ tag.nav(safe_join(links, " "), class: "ob-pagination", aria: { label: open_blog_translate("navigation.pagination") })
16
+ end
17
+ end
18
+ end