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,94 @@
1
+ require "uri"
2
+ require "commonmarker"
3
+
4
+ module OpenBlog
5
+ module Findings
6
+ module RecordChecks
7
+ class << self
8
+ def build
9
+ [
10
+ check(:description_absent, "T8", "Add a description for readers and search results.", "description") { |post, _| description(post).blank? },
11
+ check(:title_duplicate, "T7", "Another published post uses this title.", "title") { |post, _| other_posts(post).exists?(title: post.title) },
12
+ check(:description_duplicate, "T8", "Another published post uses this search description.", "search_description") { |post, _| duplicate_description?(post) },
13
+ check(:category_absent, "T21", "Choose a category for this post.", "category") { |post, _| primary_categories? && post.category.nil? },
14
+ check(:category_description_absent, "T21", "Add an introduction to this category.", "category.description") { |post, _| primary_categories? && post.category && post.category.description.blank? },
15
+ check(:faq_question_duplicate, nil, "An FAQ question appears more than once.", ->(_, _, index) { "faq[#{index}].question" }) { |post, _| duplicate_question(post) },
16
+ check(:faq_markup_in_answer, "E19", "FAQ answers display plain text; remove formatting markup.", ->(_, _, index) { "faq[#{index}].answer" }) { |post, _| markup_answer(post) },
17
+ check(:faq_in_body, nil, "Move this FAQ section into the post's FAQ entries to avoid duplicate content.", "body") { |post, _| post.markdown? && FaqExtraction.contains_faq_heading?(post.body_markdown) },
18
+ check(:author_default_used, "E1", "This post uses the configured default author.", "author") { |_, context| context[:author_default_used] },
19
+ check(:provenance_unknown, "E18", "Specify whether AI contributed to this post.", "provenance") { |post, _| post.provenance_unknown? },
20
+ check(:approval_absent, "E18", "This revision has no approval confirming its facts.", "approval") { |post, _| approval_absent?(post) },
21
+ check(:approval_incomplete, "E18", "The supplied approval says its facts were not checked.", "approval.facts_checked") { |_, context| context[:approval_incomplete] },
22
+ check(:connections_not_declared, "E21, E22", "Record any relevant relationships, including an explicit declaration of none.", "connections") { |post, _| !post.connection_declarations.exists? },
23
+ check(:responsible_party_absent, "E4", "Configure a page naming the person or organization responsible for the blog.") { |_, _| OpenBlog.policy_url(:responsible_party).blank? },
24
+ check(:canonical_off_site, "T5", "This canonical URL points outside the blog's configured origin.", "canonical_url") { |post, _| canonical_off_site?(post) },
25
+ check(:slug_changed, "T4", "The previous public URL now redirects to this post.", "slug") { |_, context| context[:slug_changed] },
26
+ check(:social_image_absent, "T17", "Choose a social image, cover image, or blog default image.", "social_image") { |post, _| post.cover_image_id.nil? && post.social_image_id.nil? && OpenBlog.config.default_social_image_url.blank? }
27
+ ]
28
+ end
29
+
30
+ private
31
+
32
+ def check(code, rule, message, location = nil, &predicate)
33
+ Check.new(code: code, rule: rule, message: message, location: location, &predicate)
34
+ end
35
+
36
+ def other_posts(post)
37
+ Post.listed.where.not(id: post.id)
38
+ end
39
+
40
+ def description(post)
41
+ post.search_description.presence || post.description
42
+ end
43
+
44
+ def duplicate_description?(post)
45
+ value = description(post)
46
+ value.present? && other_posts(post).pluck(:description, :search_description).any? do |editorial, search|
47
+ (search.presence || editorial) == value
48
+ end
49
+ end
50
+
51
+ def primary_categories?
52
+ OpenBlog.config.primary_list_type == :categories
53
+ end
54
+
55
+ def duplicate_question(post)
56
+ seen = []
57
+ post.faq_list.each_with_index do |entry, index|
58
+ question = entry[:question].to_s.unicode_normalize(:nfc).strip.downcase
59
+ return index if seen.include?(question)
60
+ seen << question
61
+ end
62
+ nil
63
+ end
64
+
65
+ def markup_answer(post)
66
+ post.faq_list.index { |entry| markup?(Commonmarker.parse(entry[:answer].to_s), entry[:answer].to_s) }
67
+ end
68
+
69
+ def markup?(node, source)
70
+ if node.type == :link
71
+ return true if source.match?(/[\[<]/)
72
+ elsif !%i[document paragraph text softbreak linebreak].include?(node.type)
73
+ return true
74
+ end
75
+ node.each.any? { |child| markup?(child, source) }
76
+ end
77
+
78
+ def approval_absent?(post)
79
+ return false if post.provenance_human_written? || (!post.published? && !post.scheduled?)
80
+ identifier = post.published? ? post.public_revision&.identifier : post.current_revision_identifier
81
+ !identifier || !post.approvals.joins(:revision).exists?(facts_checked: true, open_blog_revisions: { identifier: identifier })
82
+ end
83
+
84
+ def canonical_off_site?(post)
85
+ return false if post.canonical_url.blank? || OpenBlog.config.public_base_url.blank?
86
+ canonical, base = [ post.canonical_url, OpenBlog.config.public_base_url ].map { |value| URI.parse(value) }
87
+ [ canonical.scheme, canonical.host&.downcase, canonical.port ] != [ base.scheme, base.host&.downcase, base.port ]
88
+ rescue URI::InvalidURIError
89
+ false
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,18 @@
1
+ module OpenBlog
2
+ module Findings
3
+ class Registry
4
+ def initialize
5
+ @checks = {}
6
+ end
7
+
8
+ def register(check)
9
+ @checks[check.code] = check
10
+ self
11
+ end
12
+
13
+ def for(post, context: {})
14
+ @checks.values.flat_map { |check| check.call(post, context: context) }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ require_relative "findings/check"
2
+ require_relative "findings/registry"
3
+ require_relative "findings/record_checks"
4
+ require_relative "findings/body_checks"
5
+ require_relative "findings/link_check"
6
+
7
+ module OpenBlog
8
+ module Findings
9
+ class << self
10
+ def registry
11
+ @registry ||= Registry.new.tap do |registry|
12
+ RecordChecks.build.each { |check| registry.register(check) }
13
+ BodyChecks.build.each { |check| registry.register(check) }
14
+ registry.register(LinkCheck.new)
15
+ end
16
+ end
17
+
18
+ def register(check)
19
+ registry.register(check)
20
+ end
21
+
22
+ def for(post, context: {})
23
+ registry.for(post, context: context.dup)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,115 @@
1
+ require "net/http"
2
+ require "resolv"
3
+ require "ipaddr"
4
+ require "timeout"
5
+ require "stringio"
6
+ require "marcel"
7
+
8
+ module OpenBlog
9
+ class ImageFetch
10
+ TIMEOUT = 10
11
+ REDIRECTS = [ 301, 302, 303, 307, 308 ].freeze
12
+ NONPUBLIC_V4 = %w[0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
13
+ 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16
14
+ 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4].map { |range| IPAddr.new(range) }.freeze
15
+ GLOBAL_V6 = IPAddr.new("2000::/3")
16
+ NONPUBLIC_V6 = %w[2001::/23 2001:db8::/32 2002::/16 3fff::/20].map { |range| IPAddr.new(range) }.freeze
17
+ class DeadlineExceeded < StandardError; end
18
+
19
+ def self.call(url, max_bytes: OpenBlog.config.max_image_bytes, policy: OpenBlog.config.image_fetch_policy)
20
+ new(max_bytes: max_bytes, policy: policy).call(url)
21
+ end
22
+
23
+ def initialize(max_bytes:, policy:)
24
+ raise Error::ImageNotPermitted unless max_bytes.is_a?(Integer) && max_bytes.positive? && %i[public_only open].include?(policy)
25
+ @max_bytes, @policy = max_bytes, policy
26
+ end
27
+
28
+ def call(url)
29
+ @deadline = monotonic + TIMEOUT
30
+ Timeout.timeout(TIMEOUT, DeadlineExceeded) do
31
+ uri = parse_url(url)
32
+ redirects = 0
33
+ loop do
34
+ addresses = Resolv.getaddresses(uri.hostname)
35
+ raise Error::ImageNotPermitted if addresses.empty?
36
+ addresses.each { |address| permitted_address!(address) } unless @policy == :open
37
+ status, location, bytes = download(uri, addresses.first)
38
+ if REDIRECTS.include?(status)
39
+ raise Error::ImageNotPermitted if redirects >= 3 || location.nil?
40
+ uri = parse_url(URI.join(uri.to_s, location).to_s)
41
+ redirects += 1
42
+ else
43
+ raise Error::ImageNotPermitted unless status.between?(200, 299)
44
+ type = Marcel::MimeType.for(StringIO.new(bytes))
45
+ raise Error::ImageNotPermitted if type == "image/svg+xml" || !OpenBlog.config.image_content_types.include?(type)
46
+ filename = File.basename(URI::DEFAULT_PARSER.unescape(uri.path).tr("\\", "/"))
47
+ filename = "image" if filename.blank? || %w[/ . ..].include?(filename)
48
+ return { io: StringIO.new(bytes), content_type: type, filename: filename }
49
+ end
50
+ end
51
+ end
52
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError, Resolv::ResolvError, SocketError,
53
+ SystemCallError, IOError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
54
+ Timeout::Error, DeadlineExceeded, OpenSSL::SSL::SSLError, ArgumentError
55
+ raise Error::ImageNotPermitted
56
+ end
57
+
58
+ private
59
+
60
+ def parse_url(value)
61
+ raise Error::ImageNotPermitted unless value.is_a?(String)
62
+ uri = URI.parse(value)
63
+ unless uri.is_a?(URI::HTTP) && uri.hostname.present? && uri.userinfo.nil? && !uri.hostname.include?("%")
64
+ raise Error::ImageNotPermitted
65
+ end
66
+ uri.fragment = nil
67
+ uri
68
+ end
69
+
70
+ def permitted_address!(address)
71
+ ip = IPAddr.new(address).native
72
+ public = if ip.ipv4?
73
+ NONPUBLIC_V4.none? { |range| range.include?(ip) }
74
+ else
75
+ GLOBAL_V6.include?(ip) && NONPUBLIC_V6.none? { |range| range.include?(ip) }
76
+ end
77
+ raise Error::ImageNotPermitted unless public
78
+ end
79
+
80
+ def download(uri, address)
81
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
82
+ http.ipaddr = address
83
+ http.use_ssl = uri.scheme == "https"
84
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
85
+ http.verify_hostname = true
86
+ http.max_retries = 0
87
+ http.open_timeout = http.read_timeout = http.write_timeout = remaining
88
+ request = Net::HTTP::Get.new(uri.request_uri, "Accept-Encoding" => "identity")
89
+ bytes = +"".b
90
+ result = nil
91
+ http.start do |connection|
92
+ connection.request(request) do |response|
93
+ response.read_body do |chunk|
94
+ raise Error::ImageNotPermitted if bytes.bytesize + chunk.bytesize > @max_bytes
95
+ bytes << chunk
96
+ http.read_timeout = remaining
97
+ end
98
+ result = [ response.code.to_i, response["location"], bytes ]
99
+ end
100
+ end
101
+ remaining
102
+ result
103
+ end
104
+
105
+ def remaining
106
+ seconds = @deadline - monotonic
107
+ raise DeadlineExceeded unless seconds.positive?
108
+ seconds
109
+ end
110
+
111
+ def monotonic
112
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,164 @@
1
+ require "delegate"
2
+ require "digest"
3
+ require "stringio"
4
+ require "tempfile"
5
+ require "marcel"
6
+
7
+ module OpenBlog
8
+ class ImageImport
9
+ def self.prepare(input)
10
+ return prepare_url(input[:url] || input["url"]) if input.is_a?(Hash) && input.keys.map(&:to_s) == [ "url" ]
11
+ unless input.is_a?(Hash) && input.keys.map(&:to_s) == [ "signed_id" ]
12
+ raise Error::ImageNotPermitted
13
+ end
14
+ signed_id = input[:signed_id] || input["signed_id"]
15
+ raise Error::ImageNotPermitted unless signed_id.is_a?(String) && signed_id.present?
16
+ blob = ActiveStorage::Blob.find_signed!(signed_id)
17
+ new(blob).prepare
18
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound, ActiveStorage::FileNotFoundError
19
+ raise Error::ImageNotPermitted
20
+ end
21
+
22
+ def self.prepare_url(url)
23
+ fetched = ImageFetch.call(url, max_bytes: OpenBlog.config.max_image_bytes, policy: OpenBlog.config.image_fetch_policy)
24
+ prepared = prepare_upload(fetched.fetch(:io), filename: fetched.fetch(:filename), content_type: fetched.fetch(:content_type))
25
+ Prepared.new(nil, prepared.attributes.merge(source_url: url), bytes: prepared.bytes)
26
+ ensure
27
+ fetched&.fetch(:io)&.close
28
+ end
29
+
30
+ def self.prepare_upload(io, filename:, content_type:)
31
+ unless io.respond_to?(:read) && filename.is_a?(String) && filename.present? && !filename.include?("\0")
32
+ raise Error::ImageNotPermitted
33
+ end
34
+ filename = ActiveStorage::Filename.new(filename.tr("\\", "/").split("/").last.to_s).sanitized
35
+ raise Error::ImageNotPermitted if filename.blank?
36
+ bytes = +"".b
37
+ io.rewind if io.respond_to?(:rewind)
38
+ limit = OpenBlog.config.max_image_bytes
39
+ loop do
40
+ chunk = io.read([ 64 * 1024, limit - bytes.bytesize + 1 ].min)
41
+ break if chunk.nil? || chunk.empty?
42
+ bytes << chunk
43
+ raise Error::ImageNotPermitted if bytes.bytesize > limit
44
+ end
45
+ source = ActiveStorage::Blob.new(filename: filename, content_type: content_type, byte_size: bytes.bytesize)
46
+ new(source).prepare_bytes(bytes.freeze, uploaded: true)
47
+ rescue IOError, SystemCallError
48
+ raise Error::ImageNotPermitted
49
+ end
50
+
51
+ def initialize(blob)
52
+ @blob = blob
53
+ end
54
+
55
+ def prepare
56
+ permitted!(@blob.content_type)
57
+ limit = OpenBlog.config.max_image_bytes
58
+ raise Error::ImageNotPermitted if @blob.byte_size > limit
59
+ bytes = +"".b
60
+ @blob.download do |chunk|
61
+ raise Error::ImageNotPermitted if bytes.bytesize + chunk.bytesize > limit
62
+ bytes << chunk
63
+ end
64
+ unless bytes.bytesize == @blob.byte_size && Digest::MD5.base64digest(bytes) == @blob.checksum
65
+ raise Error::ImageNotPermitted
66
+ end
67
+ prepare_bytes(bytes)
68
+ end
69
+
70
+ def prepare_bytes(bytes, uploaded: false)
71
+ permitted!(@blob.content_type)
72
+ content_type = Marcel::MimeType.for(StringIO.new(bytes))
73
+ permitted!(content_type)
74
+ raise Error::ImageNotPermitted unless content_type == @blob.content_type
75
+ source = ReadOnlySource.new(@blob, bytes)
76
+ analyzer = ActiveStorage.analyzers.find { |candidate| candidate.accept?(source) }
77
+ raise Error::ImageNotPermitted unless analyzer
78
+ metadata = analyzer.new(source).metadata
79
+ dimensions = metadata.symbolize_keys.slice(:width, :height)
80
+ unless dimensions.size == 2 && dimensions.values.all? { |value| value.is_a?(Integer) && value.positive? }
81
+ raise Error::ImageNotPermitted
82
+ end
83
+ Prepared.new(uploaded ? nil : @blob, { sha256: Digest::SHA256.hexdigest(bytes), filename: @blob.filename.to_s,
84
+ byte_size: bytes.bytesize, content_type: content_type, **dimensions }, bytes: uploaded ? bytes : nil)
85
+ end
86
+
87
+ private
88
+
89
+ def permitted!(content_type)
90
+ unless content_type != "image/svg+xml" && OpenBlog.config.image_content_types.include?(content_type)
91
+ raise Error::ImageNotPermitted
92
+ end
93
+ end
94
+
95
+ class ReadOnlySource < SimpleDelegator
96
+ def initialize(blob, bytes)
97
+ super(blob)
98
+ @bytes = bytes
99
+ end
100
+
101
+ def open(tmpdir: nil, **)
102
+ Tempfile.create([ "open-blog-image", File.extname(filename.to_s) ], tmpdir, binmode: true) do |file|
103
+ file.write(@bytes)
104
+ file.rewind
105
+ yield file
106
+ end
107
+ end
108
+ end
109
+
110
+ class Prepared
111
+ attr_reader :attributes, :bytes
112
+
113
+ def initialize(blob, attributes, bytes: nil)
114
+ @blob, @attributes, @bytes = blob, attributes.freeze, bytes
115
+ end
116
+
117
+ def image
118
+ @image ||= Image.find_by(sha256: attributes[:sha256]) || Image.new(attributes)
119
+ end
120
+
121
+ def persist(uploaded_by: nil)
122
+ Image.transaction(requires_new: true) do
123
+ existing = Image.find_by(sha256: attributes[:sha256])
124
+ next existing if existing
125
+ next persist_upload(uploaded_by) unless @blob
126
+ blob = ActiveStorage::Blob.lock.find(@blob.id)
127
+ existing = Image.find_by(sha256: attributes[:sha256])
128
+ next existing if existing
129
+ fields = %w[key service_name checksum byte_size content_type]
130
+ unless blob.attributes.slice(*fields) == @blob.attributes.slice(*fields)
131
+ raise Error::ImageNotPermitted
132
+ end
133
+ blob.update!(metadata: blob.metadata.merge("identified" => true, "analyzed" => true,
134
+ "width" => attributes[:width], "height" => attributes[:height]))
135
+ record = Image.new(attributes.merge(uploaded_by: uploaded_by))
136
+ record.file = blob
137
+ record.save!
138
+ record
139
+ end
140
+ rescue ActiveRecord::RecordNotUnique
141
+ Image.find_by!(sha256: attributes[:sha256])
142
+ rescue ActiveRecord::RecordInvalid => error
143
+ errors = error.record.errors.details
144
+ if error.record.is_a?(Image) && errors.keys == [ :sha256 ] && errors[:sha256].all? { |detail| detail[:error] == :taken }
145
+ existing = Image.find_by(sha256: attributes[:sha256])
146
+ return existing if existing
147
+ end
148
+ raise Error::ImageNotPermitted
149
+ rescue ActiveRecord::RecordNotFound
150
+ raise Error::ImageNotPermitted
151
+ end
152
+ private
153
+
154
+ def persist_upload(uploaded_by)
155
+ record = Image.new(attributes.merge(uploaded_by: uploaded_by))
156
+ record.file = { io: StringIO.new(bytes), filename: attributes[:filename], content_type: attributes[:content_type],
157
+ identify: false, metadata: { "identified" => true, "analyzed" => true,
158
+ "width" => attributes[:width], "height" => attributes[:height] } }
159
+ record.save!
160
+ record
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,171 @@
1
+ require "digest"
2
+ require "uri"
3
+
4
+ module OpenBlog
5
+ class ImageResolution
6
+ PAYLOAD_FIELDS = %w[url sha256 alt role].freeze
7
+
8
+ def self.prepare(attributes:, post: nil)
9
+ attributes = attributes.symbolize_keys
10
+ format = attributes.fetch(:body_format, post&.body_format || OpenBlog.config.default_body_format).to_s
11
+ unless OpenBlog.config.body_formats.map(&:to_s).include?(format)
12
+ raise Error::BodyFormatNotPermitted
13
+ end
14
+ body = attributes.key?(:body) ? attributes[:body] : (format == "rich_text" ? post&.rich_body&.body : post&.body_markdown)
15
+ raise Error::ValidationFailed.new(details: [ "body" ]) unless body.nil? || body.is_a?(String) || body.is_a?(ActionText::Content)
16
+ text = format == "rich_text" ? ActionText::Content.new(body.to_s).to_html : body.to_s
17
+ digest = source_digest(text, format)
18
+ if post && !attributes.key?(:body) && !attributes.key?(:body_format) && post.body_image_source_digest == digest
19
+ return Prepared.new(digest, post.body_image_manifest.deep_dup, {}, sources(text, format), reuse_current: true)
20
+ end
21
+ build(text, format, external: true)
22
+ end
23
+
24
+ def self.prepare_inputs(attributes)
25
+ %i[cover_image social_image].each_with_object({}) do |field, prepared|
26
+ value = attributes[field] || attributes[field.to_s]
27
+ if value.is_a?(Hash) && (value.keys.map(&:to_s) & %w[signed_id url]).any?
28
+ prepared[field] = ImageImport.prepare(value)
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.source_digest(text, format)
34
+ Digest::SHA256.hexdigest("#{format}\0#{text}")
35
+ end
36
+
37
+ def self.fingerprint(post)
38
+ source_digest(post.body_for_payload, post.body_format)
39
+ end
40
+
41
+ def self.fetch_external(url)
42
+ fetched = ImageFetch.call(url)
43
+ Digest::SHA256.hexdigest(fetched.fetch(:io).read)
44
+ rescue Error::ImageNotPermitted
45
+ nil
46
+ ensure
47
+ fetched&.fetch(:io)&.close
48
+ end
49
+
50
+ def self.native!(post)
51
+ return if post.body_image_source_digest == fingerprint(post)
52
+ build(post.body_for_payload, post.body_format, external: false).materialize(post)
53
+ end
54
+
55
+ def self.body_images(post)
56
+ entries = if post.body_image_source_digest == fingerprint(post)
57
+ post.body_image_manifest
58
+ else
59
+ build(post.body_for_payload, post.body_format, external: false, import: false).entries
60
+ end
61
+ entries.map { |entry| entry.slice(*PAYLOAD_FIELDS) }
62
+ end
63
+
64
+ def self.image_for_attachment(blob, post: nil)
65
+ return unless blob.is_a?(ActiveStorage::Blob)
66
+ image = Image.joins(:file_attachment).find_by(active_storage_attachments: { blob_id: blob.id })
67
+ return image if image
68
+ entry = post&.body_image_manifest&.find { |candidate| candidate["blob_id"] == blob.id }
69
+ Image.new(entry.fetch("image_attributes")) if entry && entry["image_attributes"]
70
+ end
71
+
72
+ def self.image_for_url(url, post: nil)
73
+ prefix = "#{OpenBlog.mount_path.chomp('/')}/media/"
74
+ match = url.to_s.match(/\A#{Regexp.escape(prefix)}([0-9a-f]{64})\//)
75
+ return unless match
76
+ Image.find_by(sha256: match[1]) || begin
77
+ entry = post&.body_image_manifest&.find { |candidate| candidate["sha256"] == match[1] }
78
+ Image.new(entry.fetch("image_attributes")) if entry && entry["image_attributes"]
79
+ end
80
+ end
81
+
82
+ def self.build(text, format, external:, import: true)
83
+ imports = {}
84
+ source_entries = sources(text, format)
85
+ entries = source_entries.filter_map do |source|
86
+ blob = source[:blob]
87
+ image = blob ? image_for_attachment(blob) : image_for_url(source[:url])
88
+ if blob && !image && import
89
+ prepared = imports[blob.id] ||= ImageImport.prepare(signed_id: blob.signed_id)
90
+ image = prepared.image
91
+ end
92
+ next if blob && !image
93
+ url = image ? image.path : source[:url]
94
+ sha = image&.sha256 || (external && URI.parse(url).host ? fetch_external(url) : nil) || ""
95
+ entry = { "url" => url, "sha256" => sha, "alt" => source[:alt].to_s, "role" => "body" }
96
+ if blob
97
+ entry["blob_id"] = blob.id
98
+ entry["image_attributes"] = image.attributes.slice("sha256", "filename", "content_type", "byte_size", "width", "height")
99
+ end
100
+ entry
101
+ end
102
+ Prepared.new(source_digest(text, format), entries, imports, source_entries)
103
+ end
104
+ private_class_method :build
105
+
106
+ def self.sources(text, format)
107
+ html = format == "markdown" ? Renderer::Markdown.render(text) : text
108
+ fragment = Nokogiri::HTML5.fragment(html)
109
+ attachments = {}
110
+ prefix = "/open-blog-prepared-attachment/"
111
+ urls = fragment.css("img[src]").map { |node| node["src"] }
112
+ prefix += "x/" while urls.any? { |url| url.start_with?(prefix) }
113
+ fragment.css("action-text-attachment").each_with_index do |node, index|
114
+ attachable = ActionText::Attachable.from_node(node)
115
+ next node.remove unless attachable.is_a?(ActiveStorage::Blob) && attachable.content_type.start_with?("image/")
116
+ image = Nokogiri::XML::Node.new("img", fragment.document)
117
+ image["src"] = "#{prefix}#{index}"
118
+ image["alt"] = node["alt"].presence || PlainText.from_html(node["caption"].to_s)
119
+ attachments[image["src"]] = attachable
120
+ node.replace(image)
121
+ end
122
+ document = Renderer::Sanitizer.call(fragment.to_html)
123
+ Renderer::Sanitizer.restore!(document).css("img[src]").map do |node|
124
+ blob = attachments[node["src"]]
125
+ { url: blob ? nil : node["src"], alt: node["alt"], blob: blob }
126
+ end
127
+ end
128
+ private_class_method :sources
129
+
130
+ class Prepared
131
+ attr_reader :entries
132
+
133
+ def initialize(digest, entries, imports, sources, reuse_current: false)
134
+ @digest, @entries, @imports, @sources = digest, entries, imports, sources
135
+ @reuse_current = reuse_current
136
+ end
137
+
138
+ def verify!(post)
139
+ current = ImageResolution.fingerprint(post)
140
+ return if current == @digest
141
+ sources = ImageResolution.send(:sources, post.body_for_payload, post.body_format)
142
+ unless sources == @sources
143
+ raise Error::ValidationFailed.new(details: [ "body" ])
144
+ end
145
+ @digest = current
146
+ end
147
+
148
+ def preview(post)
149
+ verify!(post)
150
+ post.body_image_source_digest = @digest
151
+ post.body_image_manifest = entries.deep_dup
152
+ end
153
+
154
+ def materialize(post, actor: nil)
155
+ if @reuse_current && post.body_image_source_digest == ImageResolution.fingerprint(post)
156
+ @digest = post.body_image_source_digest
157
+ @entries = post.body_image_manifest.deep_dup
158
+ @sources = ImageResolution.send(:sources, post.body_for_payload, post.body_format)
159
+ end
160
+ verify!(post)
161
+ images = @imports.transform_values { |prepared| prepared.persist(uploaded_by: actor) }
162
+ @entries = entries.map do |entry|
163
+ image = images[entry["blob_id"]]
164
+ image ? entry.merge("url" => image.path, "sha256" => image.sha256,
165
+ "image_attributes" => image.attributes.slice("sha256", "filename", "content_type", "byte_size", "width", "height")) : entry
166
+ end
167
+ preview(post)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ APPROVAL_INSTRUCTION = "Show the final text to the user before publication and ask once whether they approve that exact version and have checked its facts. Include the preview link for unpublished content. Record approval only from an actual answer: send the user's name, facts_checked with their stated true or false value, and the reviewed draft's revision_identifier. For an approved public edit without a saved draft identifier, send the complete reviewed content and inline approval with the user's name and facts_checked answer, omitting approval.revision_identifier so the operation binds it to the new revision. Never attach the old public identifier to changed text. Compare the returned content with the reviewed version and report any unexpected difference. If no answer was given, omit approval. Never invent a review or a fact-check. When text changes after approval, show the new version and ask again.".freeze
3
+ PROVENANCE_INSTRUCTION = "Set provenance to ai_assisted if AI authored or rewrote any content, or human_written only when a person wrote all of it.".freeze
4
+ ADOPTION_INSTRUCTION = "Ask whether the original system has an approval record for the imported article. Use imported_approval only with its reviewer, original time, evidence, and the person confirming that it covers this content. Otherwise, record a declaration only when the user supplies the reviewer, approval time, facts_checked answer, declaration date, and their own name. Ask for a declared first publication date when historical evidence is missing, and place that answer in declaration.declared_first_published_at. Do not send approval in an adoption request or invent historical evidence or declarations.".freeze
5
+ APPROVAL_POINTER = "This operation does not release a post revision and does not require editorial approval. Before blog_publish_post, blog_update_post, or blog_correct_post, show the final version and ask the user to approve it and confirm whether its facts were checked.".freeze
6
+ DRAFT_INSTRUCTION = "Saving a draft requires no approval. Share its preview link when requesting approval to publish.".freeze
7
+ end
@@ -0,0 +1,36 @@
1
+ module OpenBlog
2
+ class JsonFeed
3
+ def initialize(posts:, base_url:, feed_path:, title:, category: nil)
4
+ @posts, @base_url, @feed_path, @title, @category = posts, base_url.chomp("/"), feed_path, title, category
5
+ end
6
+
7
+ def to_h
8
+ home_path = @category ? ReaderQueries.category_path(@category) : OpenBlog.mount_path
9
+ { version: "https://jsonfeed.org/version/1.1", title: @title,
10
+ home_page_url: "#{@base_url}#{home_path}", feed_url: "#{@base_url}#{@feed_path}",
11
+ authors: [ { name: OpenBlog.config.publisher&.fetch(:name, nil) || OpenBlog.config.site_name } ],
12
+ items: @posts.map { |post| item(post) } }
13
+ end
14
+
15
+ def to_json(*arguments)
16
+ to_h.to_json(*arguments)
17
+ end
18
+
19
+ private
20
+
21
+ def item(post)
22
+ url = post.url(base: @base_url)
23
+ entry = { id: url, url: url, title: post.title, summary: post.description,
24
+ authors: [ { name: post.author_name, url: "#{@base_url}#{ReaderQueries.author_path(post.author)}" } ],
25
+ date_modified: ReaderDates.feed_updated_at(post).iso8601 }
26
+ published = ReaderDates.published_at(post)
27
+ entry[:date_published] = published.iso8601 if published
28
+ if OpenBlog.config.feed_content == :full
29
+ entry[:content_html] = Renderer.render(post).to_s
30
+ else
31
+ entry[:content_text] = post.description
32
+ end
33
+ entry
34
+ end
35
+ end
36
+ end