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,41 @@
1
+ module OpenBlog
2
+ class LabelPolicy
3
+ def self.for(post)
4
+ new(post).label
5
+ end
6
+
7
+ def initialize(post)
8
+ @post = post
9
+ end
10
+
11
+ def label
12
+ return :none if @post.provenance_human_written?
13
+ notice = @post.provenance_ai_assisted? ? :ai_assisted : :ai_unknown
14
+ return notice if @post.provenance_ai_assisted? && OpenBlog.config.ai_label == :always
15
+ return notice unless OpenBlog.policy_url(:responsible_party).present?
16
+ return notice unless revision && @post.current_revision_identifier == revision.identifier
17
+ return notice unless image_digests_present?
18
+ return notice unless @post.approvals.where(revision_id: revision.id, facts_checked: true).exists?
19
+ :none
20
+ end
21
+
22
+ private
23
+
24
+ def revision
25
+ return @revision if defined?(@revision)
26
+ @revision = if @post.published?
27
+ @post.public_revision
28
+ else
29
+ @post.revisions.find_by(identifier: @post.current_revision_identifier)
30
+ end
31
+ end
32
+
33
+ def image_digests_present?
34
+ payload = JSON.parse(revision.payload)
35
+ images = payload["images"] if payload.is_a?(Hash)
36
+ images.is_a?(Array) && images.all? { |entry| entry.is_a?(Hash) && entry["sha256"].present? }
37
+ rescue JSON::ParserError
38
+ false
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,57 @@
1
+ module OpenBlog
2
+ class MarkdownView
3
+ def self.render(post, base_url: OpenBlog.config.public_base_url, now: Time.current)
4
+ new(post, base_url: base_url, now: now).render
5
+ end
6
+
7
+ def initialize(post, base_url:, now:)
8
+ @post, @base_url, @now = post, base_url, now
9
+ end
10
+
11
+ def render
12
+ parts = [ "# #{plain(@post.title)}", plain(@post.description).presence, byline, *notices, body ]
13
+ entries = @post.faq_list
14
+ if entries.any?
15
+ parts << "## #{plain(translate('faq.heading'))}"
16
+ entries.each { |entry| parts.concat([ "### #{plain(entry[:question])}", plain(entry[:answer]) ]) }
17
+ end
18
+ parts << (@post.canonical_url.presence || @post.url(base: @base_url) || @post.path)
19
+ "#{parts.compact.join("\n\n")}\n"
20
+ end
21
+
22
+ private
23
+
24
+ def body
25
+ @post.markdown? ? @post.body_markdown.to_s : plain(@post.rich_body.to_plain_text)
26
+ end
27
+
28
+ def byline
29
+ published = ReaderDates.published_at(@post, now: @now)
30
+ modified = ReaderDates.modified_at(@post, now: @now)
31
+ parts = [ @post.author_name ]
32
+ parts << date(published, :published) if published
33
+ parts << date(modified, :updated) if modified && (!published || modified > published)
34
+ plain(parts.join(" · "))
35
+ end
36
+
37
+ def date(time, label)
38
+ "#{translate("dates.#{label}")} #{I18n.l(time.to_date, format: :long, locale: OpenBlog.config.locale)}"
39
+ end
40
+
41
+ def notices
42
+ label = LabelPolicy.for(@post)
43
+ lines = label == :none ? [] : [ translate("notices.#{label}") ]
44
+ lines << translate("notices.paid") if @post.connection_declarations.order(id: :desc).first&.third_party_paid?
45
+ lines.map { |line| plain(line) }
46
+ end
47
+
48
+ def translate(key)
49
+ I18n.t("open_blog.#{key}", locale: OpenBlog.config.locale)
50
+ end
51
+
52
+ def plain(text)
53
+ text.to_s.gsub(/[\\`*_{}\[\]<>&!#|~+\-]/) { |character| "\\#{character}" }
54
+ .gsub(/^(\s*\d+)([.)])(?=\s)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,28 @@
1
+ module OpenBlog
2
+ module Mcp
3
+ class Definition
4
+ attr_reader :name, :title, :description, :input_schema, :annotations, :scope
5
+
6
+ def initialize(name:, title:, description:, input_schema:, annotations:, scope:, handler:)
7
+ @name, @title, @description = name, title, description
8
+ @input_schema, @annotations, @scope, @handler = input_schema, annotations, scope, handler
9
+ @validator = ::MCP::Tool::InputSchema.new(input_schema)
10
+ end
11
+
12
+ def call(arguments, actor:, base_url: nil)
13
+ raise Error::Unauthenticated unless actor
14
+ unless actor.respond_to?(:scopes) && actor.scopes.map(&:to_s).include?(scope.to_s)
15
+ raise Error::ScopeRequired.new(details: [ scope.to_s ])
16
+ end
17
+ raise Error::ValidationFailed.new(details: [ "arguments" ]) unless arguments.is_a?(Hash)
18
+ @validator.validate_arguments(arguments)
19
+ result = @handler.call(arguments.deep_symbolize_keys, actor: actor, base_url: base_url)
20
+ JSON.parse(JSON.generate(result))
21
+ rescue ::MCP::Tool::InputSchema::ValidationError
22
+ JSON.parse(JSON.generate(ErrorSerializer.call(Error::ValidationFailed.new(details: [ "arguments" ]))))
23
+ rescue Error => error
24
+ JSON.parse(JSON.generate(ErrorSerializer.call(error)))
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ require "stringio"
2
+ require "uri"
3
+
4
+ module OpenBlog
5
+ module Mcp
6
+ class Dispatcher
7
+ CONTROLLERS = %w[posts approvals connections records findings doctor images categories tags authors series redirects adoptions faq_extractions previews pages views report].freeze
8
+ METHODS = %w[GET POST PUT PATCH DELETE].freeze
9
+
10
+ def self.call(controller:, action:, method:, arguments:, actor:, route_params: {}, base_url: nil)
11
+ raise ArgumentError, "Unknown publishing controller" unless CONTROLLERS.include?(controller.to_s)
12
+ verb = method.to_s.upcase
13
+ raise ArgumentError, "Unsupported request method" unless METHODS.include?(verb)
14
+ klass = "OpenBlog::Api::V1::#{controller.to_s.camelize}Controller".constantize
15
+ raise ArgumentError, "Unknown publishing action" unless klass.action_methods.include?(action.to_s)
16
+ origin = URI.parse(OpenBlog.config.public_base_url || base_url || "http://localhost")
17
+ env = { "REQUEST_METHOD" => verb, "SCRIPT_NAME" => "", "PATH_INFO" => "#{OpenBlog.mount_path}/api/v1/#{controller}",
18
+ "QUERY_STRING" => "", "SERVER_NAME" => origin.host, "SERVER_PORT" => origin.port.to_s,
19
+ "rack.url_scheme" => origin.scheme, "rack.input" => StringIO.new(""), "CONTENT_TYPE" => "application/json",
20
+ "action_dispatch.request.request_parameters" => arguments.deep_stringify_keys,
21
+ "action_dispatch.request.path_parameters" => route_params.symbolize_keys.merge(controller: klass.controller_path, action: action.to_s) }
22
+ request = ActionDispatch::Request.new(env)
23
+ response = ActionDispatch::Response.new
24
+ _status, _headers, body = klass.new.send(:dispatch_internal, action.to_s, request, response, actor: actor)
25
+ text = +""
26
+ body.each { |chunk| text << chunk }
27
+ text.empty? ? {} : JSON.parse(text)
28
+ ensure
29
+ body&.close if body.respond_to?(:close)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ require "base64"
2
+ require "tempfile"
3
+
4
+ module OpenBlog
5
+ module Mcp
6
+ module ImageInput
7
+ def self.with_upload(arguments)
8
+ if arguments.key?(:url)
9
+ raise Error::ImageNotPermitted unless arguments.keys == [ :url ]
10
+ return yield(arguments)
11
+ end
12
+ encoded = arguments[:base64]
13
+ maximum = OpenBlog.config.max_image_bytes
14
+ raise Error::ImageNotPermitted unless encoded.is_a?(String) && encoded.bytesize <= ((maximum + 2) / 3) * 4
15
+ bytes = Base64.strict_decode64(encoded)
16
+ raise Error::ImageNotPermitted if bytes.bytesize > maximum
17
+ filename, content_type = arguments.values_at(:filename, :content_type)
18
+ raise Error::ImageNotPermitted unless filename.is_a?(String) && filename.present? && content_type.is_a?(String) && content_type.present?
19
+ Tempfile.create([ "open-blog-upload", ".bin" ], binmode: true) do |file|
20
+ file.write(bytes)
21
+ file.rewind
22
+ upload = ActionDispatch::Http::UploadedFile.new(tempfile: file, filename: filename, type: content_type)
23
+ yield(file: upload)
24
+ end
25
+ rescue ArgumentError
26
+ raise Error::ImageNotPermitted
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,139 @@
1
+ module OpenBlog
2
+ module Mcp
3
+ module Registry
4
+ module_function
5
+
6
+ def definitions
7
+ [
8
+ endpoint("list_posts", :read, "posts", :index, fields: ApiFields::POST_LIST),
9
+ endpoint("search_posts", :read, "posts", :index, fields: ApiFields::POST_LIST, required: [ :q ]),
10
+ endpoint("get_post", :read, "posts", :show, id: :required),
11
+ endpoint("get_post_records", :read, "records", :show, id: :required),
12
+ endpoint("check_post", :read, "findings", :show, id: :required),
13
+ endpoint("save_draft", :write, "posts", :create, fields: ApiFields::POST_WRITE, force: { publish: false }),
14
+ endpoint("get_preview_link", :read, "previews", :show, id: :required),
15
+ endpoint("publish_post", :publish, "posts", :publish, fields: ApiFields::POST_WRITE, id: :optional, create_publish: true),
16
+ endpoint("update_post", :publish, "posts", :update, fields: ApiFields::POST_WRITE, id: :required),
17
+ endpoint("correct_post", :publish, "posts", :update, fields: ApiFields::POST_WRITE, id: :required, required: [ :note ], force: { change: "correction" }),
18
+ endpoint("approve_revision", :publish, "approvals", :create, fields: ApiFields::APPROVAL, id: :required, required: ApiFields::APPROVAL),
19
+ endpoint("declare_connections", :publish, "connections", :create, fields: ApiFields::CONNECTION, id: :required,
20
+ properties: Schemas.connection_fields, required: %i[connections third_party_paid declared_by]),
21
+ endpoint("unpublish_post", :publish, "posts", :unpublish, id: :required),
22
+ endpoint("remove_post", :publish, "posts", :destroy, fields: ApiFields::REMOVE, id: :required),
23
+ upload_definition,
24
+ endpoint("list_categories", :read, "categories", :index, fields: ApiFields::PAGE),
25
+ endpoint("save_category", :write, "categories", :create, fields: ApiFields::CATEGORY, id: :optional, upsert: true),
26
+ endpoint("list_tags", :read, "tags", :index, fields: ApiFields::PAGE),
27
+ endpoint("list_authors", :read, "authors", :index, fields: ApiFields::PAGE),
28
+ endpoint("save_author", :write, "authors", :create, fields: ApiFields::AUTHOR, id: :optional, upsert: true),
29
+ endpoint("list_series", :read, "series", :index, fields: ApiFields::PAGE),
30
+ endpoint("save_series", :write, "series", :create, fields: ApiFields::SERIES, id: :optional, upsert: true),
31
+ endpoint("list_redirects", :read, "redirects", :index, fields: ApiFields::PAGE),
32
+ endpoint("save_redirect", :publish, "redirects", :create, fields: ApiFields::REDIRECT, required: [ :old_path ]),
33
+ site_page_definition("get_site_page", :read),
34
+ site_page_definition("save_site_page", :write),
35
+ page_views_definition,
36
+ report_definition,
37
+ endpoint("doctor", :read, "doctor", :show),
38
+ endpoint("extract_faq", :read, "faq_extractions", :create, fields: ApiFields::EXTRACTION, required: [ :body ]),
39
+ endpoint("adopt_post", :publish, "adoptions", :create, fields: ApiFields::ADOPTION,
40
+ required: %i[source_system source_id slug title body_format body])
41
+ ]
42
+ end
43
+
44
+ def endpoint(name, scope, controller, action, fields: [], id: nil, required: [], force: {}, upsert: false, create_publish: false, properties: nil)
45
+ properties ||= Schemas.fields(fields)
46
+ properties = properties.merge("id" => upsert ? { type: "integer", minimum: 1 } : Schemas.identifier) if id
47
+ required = required + (id == :required ? [ :id ] : [])
48
+ force.each { |key, value| properties[key.to_s] = properties.fetch(key.to_s).merge(const: value) if properties.key?(key.to_s) }
49
+ definition(name, scope, Schemas.object(properties, required: required)) do |arguments, actor:, base_url:|
50
+ arguments = arguments.dup
51
+ identity = arguments.delete(:id) if id
52
+ selected = upsert && identity ? :update : action
53
+ if create_publish && !identity
54
+ selected = :create
55
+ arguments[:publish] = true
56
+ end
57
+ arguments.merge!(force)
58
+ if selected == :index
59
+ maximum = [ OpenBlog.config.mcp.max_page_size, 100 ].min
60
+ arguments[:per_page] = [ arguments.fetch(:per_page, 25).to_i, maximum ].min
61
+ end
62
+ method = { index: :get, show: :get, update: :patch, destroy: :delete }.fetch(selected, :post)
63
+ Dispatcher.call(controller: controller, action: selected, method: method, arguments: arguments,
64
+ actor: actor, route_params: identity ? { id: identity } : {}, base_url: base_url)
65
+ end
66
+ end
67
+
68
+ def site_page_definition(name, scope)
69
+ properties = name == "save_site_page" ? Schemas.fields(ApiFields::SITE_PAGE) : {}
70
+ properties["kind"] = { type: "string", enum: %w[responsible_party corrections editorial ai_use] }
71
+ if name == "save_site_page"
72
+ properties["status"] = { type: "string", enum: %w[draft published] }
73
+ %w[title body slug].each { |field| properties[field] = Schemas.text }
74
+ %w[approved_by approved_on].each { |field| properties[field] = Schemas.text(nullable: true) }
75
+ end
76
+ definition(name, scope, Schemas.object(properties, required: [ :kind ])) do |arguments, actor:, base_url:|
77
+ input = arguments.dup
78
+ kind = input.delete(:kind)
79
+ writing = name == "save_site_page"
80
+ Dispatcher.call(controller: "pages", action: writing ? :update : :show, method: writing ? :put : :get,
81
+ arguments: input, actor: actor, route_params: { kind: kind }, base_url: base_url)
82
+ end
83
+ end
84
+
85
+ def report_definition
86
+ properties = { "scope" => { type: "string", enum: %w[site post all] }, "post" => Schemas.identifier,
87
+ "page" => { anyOf: [ { type: "integer", minimum: 1, maximum: 1_000_000 }, { type: "string", pattern: "^[1-9][0-9]*$" } ] },
88
+ "reach" => { anyOf: [ { type: "boolean" }, { type: "string", enum: %w[true false] } ] } }
89
+ definition("get_surface_report", :read, Schemas.object(properties)) do |arguments, actor:, base_url:|
90
+ Dispatcher.call(controller: "report", action: :show, method: :get, arguments: arguments, actor: actor, base_url: base_url)
91
+ end
92
+ end
93
+
94
+ def page_views_definition
95
+ properties = { "id" => Schemas.identifier }
96
+ ApiFields::VIEWS.each { |field| properties[field.to_s] = Schemas.text(nullable: true) }
97
+ ApiFields::TOP_VIEWS.each do |field|
98
+ maximum = field == :days ? 36500 : 100
99
+ properties[field.to_s] = { anyOf: [ { type: "integer", minimum: 1, maximum: maximum }, { type: "string", pattern: "^[1-9][0-9]*$" } ] }
100
+ end
101
+ schema = Schemas.object(properties).merge(oneOf: [
102
+ { required: [ "id" ], not: { anyOf: [ { required: [ "days" ] }, { required: [ "limit" ] } ] } },
103
+ { not: { anyOf: [ { required: [ "id" ] }, { required: [ "from" ] }, { required: [ "to" ] } ] } }
104
+ ])
105
+ definition("get_page_views", :read, schema) do |arguments, actor:, base_url:|
106
+ input = arguments.dup
107
+ id = input.delete(:id)
108
+ Dispatcher.call(controller: "views", action: id ? :show : :top, method: :get, arguments: input,
109
+ actor: actor, route_params: id ? { post_id: id } : {}, base_url: base_url)
110
+ end
111
+ end
112
+
113
+ def upload_definition
114
+ properties = Schemas.fields(%i[url filename content_type]).merge("base64" => Schemas.text)
115
+ schema = Schemas.object(properties).merge(oneOf: [ { required: [ "url" ], maxProperties: 1 }, { required: %w[base64 filename content_type], maxProperties: 3 } ])
116
+ definition("upload_image", :write, schema) do |arguments, actor:, base_url:|
117
+ ImageInput.with_upload(arguments) do |input|
118
+ Dispatcher.call(controller: "images", action: :create, method: :post, arguments: input, actor: actor, route_params: {}, base_url: base_url)
119
+ end
120
+ end
121
+ end
122
+
123
+ def definition(name, scope, schema, &handler)
124
+ instruction = if name == "adopt_post"
125
+ OpenBlog::ADOPTION_INSTRUCTION
126
+ elsif %w[publish_post update_post correct_post approve_revision].include?(name)
127
+ "#{OpenBlog::APPROVAL_INSTRUCTION} #{OpenBlog::PROVENANCE_INSTRUCTION}"
128
+ else
129
+ OpenBlog::APPROVAL_POINTER
130
+ end
131
+ instruction += " #{OpenBlog::DRAFT_INSTRUCTION}" if name == "save_draft"
132
+ description = "#{name.tr('_', ' ').capitalize}. #{instruction}"
133
+ Definition.new(name: "blog_#{name}", title: name.tr("_", " ").capitalize, description: description,
134
+ input_schema: schema, annotations: { read_only_hint: scope == :read, destructive_hint: name == "remove_post" },
135
+ scope: scope, handler: handler)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,72 @@
1
+ module OpenBlog
2
+ module Mcp
3
+ module Schemas
4
+ module_function
5
+
6
+ def object(properties, required: [])
7
+ { type: "object", properties: properties, required: required.map(&:to_s), additionalProperties: false }
8
+ end
9
+
10
+ def text(nullable: false)
11
+ { type: nullable ? [ "string", "null" ] : "string" }
12
+ end
13
+
14
+ def identifier
15
+ { anyOf: [ { type: "integer", minimum: 1 }, { type: "string", minLength: 1 } ] }
16
+ end
17
+
18
+ def fields(names)
19
+ names.to_h { |name| [ name.to_s, field(name) ] }
20
+ end
21
+
22
+ def field(name)
23
+ case name.to_sym
24
+ when :id then identifier
25
+ when :featured, :facts_checked, :third_party_paid, :dry_run then { type: "boolean" }
26
+ when :made_by_ai then { type: [ "boolean", "null" ] }
27
+ when :position then { type: "integer", minimum: -2_147_483_648, maximum: 2_147_483_647 }
28
+ when :series_position, :post_id then { type: [ "integer", "null" ], minimum: 1 }
29
+ when :page, :per_page
30
+ { anyOf: [ { type: "integer", minimum: 1, maximum: 999_999_999 }, { type: "string", pattern: "^[1-9][0-9]{0,8}$" } ] }
31
+ when :tags, :profile_urls, :standalone_questions then { type: "array", items: text }
32
+ when :faq then { type: "array", items: object(fields(ApiFields::POST_NESTED.fetch(:faq)), required: %w[question answer]) }
33
+ when :author
34
+ { anyOf: [ text, object(fields(ApiFields::POST_NESTED.fetch(:author)), required: [ "name" ]) ] }
35
+ when :cover_image, :social_image, :avatar
36
+ { anyOf: [ { type: "null" }, image ] }
37
+ when :approval
38
+ object(fields(ApiFields::POST_NESTED.fetch(:approval)), required: %w[name facts_checked])
39
+ when :connections
40
+ object(connection_fields, required: %w[connections third_party_paid declared_by])
41
+ when :declaration
42
+ object(fields(%i[reviewer_name approved_at facts_checked declared_on declared_by declared_first_published_at]), required: %w[reviewer_name approved_at facts_checked declared_on declared_by])
43
+ when :imported_approval
44
+ object(fields(%i[reviewer_name approved_at facts_checked evidence confirmed_by]), required: %w[reviewer_name approved_at facts_checked evidence confirmed_by])
45
+ when :old_slugs
46
+ { type: "array", items: { anyOf: [ text, object(fields(%i[slug moved_on]), required: [ "slug" ]) ] } }
47
+ when :image_id then { type: "integer", minimum: 1 }
48
+ when :type then { type: "string", enum: %w[person organization] }
49
+ when :body_format then { type: "string", enum: %w[markdown rich_text] }
50
+ when :provenance then { type: "string", enum: %w[ai_assisted human_written unknown] }
51
+ when :change then { type: "string", enum: %w[substantive correction maintenance] }
52
+ when :status then { type: "string", enum: %w[draft scheduled published archived] }
53
+ when :body, :description, :search_title, :search_description, :canonical_url, :cover_alt, :external_id,
54
+ :category, :series, :provenance_evidence, :publish_at, :first_published_at, :last_modified_at,
55
+ :first_published_evidence, :last_modified_evidence, :declared_first_published_at, :source_body_sha256,
56
+ :bio, :url, :host_reference, :new_path, :redirect_to, :moved_on
57
+ text(nullable: true)
58
+ else text
59
+ end
60
+ end
61
+
62
+ def connection_fields
63
+ fields(ApiFields::CONNECTION - [ :connections ]).merge("connections" => { type: "array",
64
+ items: object(fields(%i[party relation]), required: %w[party relation]) })
65
+ end
66
+
67
+ def image
68
+ object(fields(%i[image_id signed_id url])).merge(minProperties: 1, maxProperties: 1)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,24 @@
1
+ module OpenBlog
2
+ module Mcp
3
+ autoload :Definition, "open_blog/mcp/definition"
4
+ autoload :Dispatcher, "open_blog/mcp/dispatcher"
5
+ autoload :Registry, "open_blog/mcp/registry"
6
+ autoload :Schemas, "open_blog/mcp/schemas"
7
+ autoload :ImageInput, "open_blog/mcp/image_input"
8
+
9
+ def self.definitions
10
+ Registry.definitions
11
+ end
12
+
13
+ def self.tools
14
+ definitions.map do |definition|
15
+ ::MCP::Tool.define(name: definition.name, title: definition.title, description: definition.description,
16
+ input_schema: definition.input_schema, annotations: definition.annotations) do |server_context: nil, **arguments|
17
+ result = definition.call(arguments, actor: server_context&.[](:actor), base_url: server_context&.[](:base_url))
18
+ ::MCP::Tool::Response.new([ { type: "text", text: JSON.generate(result) } ],
19
+ structured_content: result, error: result.key?("error"))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,4 @@
1
+ module OpenBlog
2
+ class NotFound < StandardError
3
+ end
4
+ end
@@ -0,0 +1,36 @@
1
+ module OpenBlog
2
+ class Operation
3
+ def self.run(post: nil, attributes: {}, actor:, now: Time.current, context: {}, authorize: nil)
4
+ current = nil
5
+ result = nil
6
+ Post.transaction(requires_new: true) do
7
+ resolved = PostIdentity.resolve(attributes, post: post)
8
+ current = resolved.persisted? ? Post.lock.find(resolved.id) : resolved
9
+ authorize&.call(current)
10
+ created = current.new_record?
11
+ records = yield(current, created)
12
+ label = current.destroyed? ? :none : LabelPolicy.for(current)
13
+ findings = current.destroyed? ? [] : Findings.for(current, context: context)
14
+ result = Result.new(post: current, created: created, records: records, label: label, findings: findings)
15
+ end
16
+ result
17
+ rescue Error => error
18
+ failure(current, error)
19
+ rescue ActiveRecord::RecordInvalid => error
20
+ collision = error.record.is_a?(Post) && %i[slug external_id].any? do |field|
21
+ error.record.errors.details[field].any? { |detail| detail[:error] == :taken }
22
+ end
23
+ refusal = collision ? Error::IdentityConflict.new : Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s))
24
+ failure(current, refusal)
25
+ rescue ActiveRecord::RecordNotFound
26
+ failure(current, Error::NotFound.new)
27
+ rescue ActiveRecord::RecordNotUnique
28
+ failure(current, Error::IdentityConflict.new)
29
+ end
30
+
31
+ def self.failure(post, error)
32
+ post = post.class.find_by(id: post.id) if post&.persisted?
33
+ Result.new(post: post&.persisted? ? post : nil, error: error)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,72 @@
1
+ module OpenBlog
2
+ module PageViews
3
+ BOT_PATTERN = /bot|crawler|spider|slurp|preview|monitor|curl|wget|headless|facebookexternalhit|WhatsApp|ChatGPT-User|Claude-User|Perplexity-User|anthropic-ai|cohere-ai|Google-InspectionTool/i
4
+
5
+ module_function
6
+
7
+ def count!(post, request, now: Time.current)
8
+ return unless OpenBlog.config.page_views && post.published? && request.get? && request.format.html?
9
+ return if request.user_agent.blank? || OpenBlog.config.page_view_bot_pattern.match?(request.user_agent)
10
+ return if request.headers["Sec-Purpose"].to_s.match?(/prefetch/i)
11
+
12
+ connection = PageView.connection
13
+ table = connection.quote_table_name(PageView.table_name)
14
+ connection.execute("INSERT INTO #{table} (post_id, day, views) VALUES (#{connection.quote(post.id)}, #{connection.quote(now.utc.to_date)}, 1) ON CONFLICT (post_id, day) DO UPDATE SET views = #{table}.views + 1")
15
+ nil
16
+ rescue StandardError => error
17
+ Rails.logger.warn("OpenBlog page view counter failed: #{error.class.name}")
18
+ nil
19
+ end
20
+
21
+ def for(post, from: nil, to: nil, now: Time.current)
22
+ to = date(to, :to) || now.utc.to_date
23
+ from = date(from, :from) || to - 29
24
+ invalid!(:from) if from > to
25
+ days = PageView.where(post_id: post.id, day: from..to).order(:day).pluck(:day, :views).map { |day, views| { day: day, views: views } }
26
+ { post_id: post.id, from: from, to: to, total: days.sum { |row| row[:views] }, days: days }
27
+ end
28
+
29
+ def top(days: 30, limit: 5, base_url: nil, scope: Post.all, now: Time.current)
30
+ days = positive(days, :days, 36500)
31
+ limit = positive(limit, :limit, 100)
32
+ today = now.utc.to_date
33
+ totals = PageView.where(day: (today - days + 1)..today, post_id: scope.select(:id))
34
+ .group(:post_id).order(Arel.sql("SUM(views) DESC"), :post_id).limit(limit).pluck(:post_id, Arel.sql("SUM(views)"))
35
+ posts = Post.where(id: totals.map(&:first)).index_by(&:id)
36
+ { days: days, posts: totals.filter_map do |id, views|
37
+ post = posts[id]
38
+ next unless post
39
+ { id: id, slug: post.slug, title: post.title, url: post.url(base: OpenBlog.config.public_base_url || base_url), views: views }
40
+ end }
41
+ end
42
+
43
+ def prune!(now: Time.current)
44
+ retention = OpenBlog.config.page_view_retention_days
45
+ return 0 if retention.nil?
46
+ invalid!(:page_view_retention_days) unless retention.is_a?(Integer) && retention.positive?
47
+ PageView.where("day < ?", now.utc.to_date - retention).delete_all
48
+ end
49
+
50
+ def date(value, field)
51
+ return value if value.is_a?(Date)
52
+ return nil if value.nil?
53
+ invalid!(field) unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
54
+ Date.iso8601(value)
55
+ rescue Date::Error
56
+ invalid!(field)
57
+ end
58
+ private_class_method :date
59
+
60
+ def positive(value, field, maximum)
61
+ value = value.to_i if value.is_a?(String) && value.match?(/\A[1-9]\d*\z/)
62
+ invalid!(field) unless value.is_a?(Integer) && value.between?(1, maximum)
63
+ value
64
+ end
65
+ private_class_method :positive
66
+
67
+ def invalid!(field)
68
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
69
+ end
70
+ private_class_method :invalid!
71
+ end
72
+ end
@@ -0,0 +1,18 @@
1
+ module OpenBlog
2
+ class Pagination
3
+ Page = Data.define(:records, :page, :total_pages)
4
+
5
+ def self.page(scope, page:, per_page:)
6
+ value = page.nil? ? 1 : page
7
+ unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[1-9]\d*\z/))
8
+ raise NotFound
9
+ end
10
+ number = value.to_i
11
+ raise NotFound unless number.positive? && per_page.is_a?(Integer) && per_page.positive?
12
+ count = scope.reorder(nil).count
13
+ total = [ (count.to_f / per_page).ceil, 1 ].max
14
+ raise NotFound if number > total
15
+ Page.new(records: scope.offset((number - 1) * per_page).limit(per_page).to_a, page: number, total_pages: total)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,36 @@
1
+ require "commonmarker"
2
+ require "nokogiri"
3
+
4
+ module OpenBlog
5
+ module PlainText
6
+ class << self
7
+ def from_markdown(text)
8
+ text_from_node(Commonmarker.parse(text.to_s.encode(Encoding::UTF_8))).strip
9
+ end
10
+
11
+ def from_html(html)
12
+ Nokogiri::HTML.fragment(html.to_s).text
13
+ end
14
+
15
+ private
16
+
17
+ def text_from_node(node)
18
+ case node.type
19
+ when :text, :code
20
+ node.literal
21
+ when :code_block
22
+ "#{node.literal.sub(/\n+\z/, "")}\n"
23
+ when :html_inline
24
+ from_html(node.literal)
25
+ when :html_block
26
+ "#{from_html(node.literal)}\n"
27
+ when :softbreak, :linebreak, :thematic_break
28
+ "\n"
29
+ else
30
+ text = node.each.map { |child| text_from_node(child) }.join
31
+ %i[paragraph heading table_row].include?(node.type) ? "#{text}\n" : text
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ module OpenBlog
2
+ class PolicyUrl
3
+ def self.call(kind, config: OpenBlog.config)
4
+ kind = kind.to_s
5
+ return unless Page::KINDS.include?(kind)
6
+ config.policy_urls[kind.to_sym].presence || Page.published.find_by(kind: kind)&.path
7
+ end
8
+ end
9
+ end