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,248 @@
1
+ require "digest"
2
+ require "time"
3
+ require_relative "expected_content"
4
+
5
+ module OpenBlog
6
+ class SurfaceReport
7
+ class RecordChecks
8
+ GENERATED = ".ob-notice, .ob-highlights, .ob-date, .ob-toc, .ob-correction, .ob-disclosure, [data-open-blog-preview]".freeze
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ def call
15
+ posts = @context.respond_to?(:scope) && @context.scope == :site ? [] : @context.posts
16
+ posts.each do |post|
17
+ modified_date(post)
18
+ corrections(post)
19
+ approval(post)
20
+ revision(post)
21
+ notice(post)
22
+ connections(post)
23
+ end
24
+ redirects
25
+ end
26
+
27
+ private
28
+
29
+ def record(predicate, status, reason, post: nil, **options)
30
+ @context.record(predicate, status, reason, post: post, **options)
31
+ end
32
+
33
+ def readable(page)
34
+ page && page.status == 200 && page.body
35
+ end
36
+
37
+ def modified_date(post)
38
+ latest = post.publications.where(entry_type: %w[substantive correction]).where("occurred_at <= ?", @context.now).maximum(:occurred_at)
39
+ baseline = post.baseline
40
+ expected = latest || baseline&.last_modified_at || (!baseline && post.publications.find_by(entry_type: "first")&.occurred_at)
41
+ return record("E6", :not_verified, "The release history has no supported modification date.", post: post) unless expected
42
+ page = @context.post_page(post)
43
+ sitemap = @context.page(@context.absolute("#{OpenBlog.mount_path.chomp('/')}/sitemap.xml"))
44
+ return record("E6", :not_verified, "The article or sitemap could not be inspected.", post: post) unless readable(page) && readable(sitemap)
45
+ xml = Nokogiri::XML(sitemap.body) { |options| options.strict.nonet }
46
+ entry = xml.xpath("//*[local-name()='url']").find { |node| node.at_xpath("./*[local-name()='loc']")&.text == @context.absolute(post.path) }
47
+ values = [ page.article&.fetch("dateModified", nil) ]
48
+ sitemap_date = entry&.at_xpath("./*[local-name()='lastmod']")&.text
49
+ values << sitemap_date if sitemap_date.present?
50
+ values.concat(page.document.css(".ob-date--updated time").map { |node| node["datetime"] })
51
+ valid = values.all? { |value| same_time?(value, expected) }
52
+ record("E6", valid ? :pass : :fail, valid ? "Displayed modification dates agree with release history." : "A modification date differs from release history.", post: post)
53
+ rescue Nokogiri::XML::SyntaxError
54
+ record("E6", :fail, "The sitemap date evidence is not valid XML.", post: post)
55
+ end
56
+
57
+ def corrections(post)
58
+ entries = post.publications.where(entry_type: "correction").where("occurred_at <= ?", @context.now).to_a
59
+ return record("E7", :not_applicable, "No correction release is recorded.", post: post) if entries.empty?
60
+ page = @context.post_page(post)
61
+ return record("E7", :not_verified, "The article could not be inspected.", post: post) unless readable(page)
62
+ blocks = page.document.css(".ob-correction")
63
+ label = I18n.t("open_blog.notices.correction", locale: @context.config.locale)
64
+ valid = entries.all? { |entry| blocks.any? { |block| visible_text(block).include?(label) && block.css("time").any? { |node| same_time?(node["datetime"], entry.occurred_at) } } }
65
+ record("E7", valid ? :pass : :fail, valid ? "Recorded corrections have dated notices." : "A recorded correction lacks a dated notice.", post: post)
66
+ end
67
+
68
+ def approved(post)
69
+ post.approvals.where(revision_id: post.public_revision_id, facts_checked: true).order(:approved_at, :id).first
70
+ end
71
+
72
+ def approval(post)
73
+ return record("E18", :not_applicable, "The article is recorded as written by a person.", post: post) if post.provenance_human_written?
74
+ review = approved(post)
75
+ unless review
76
+ return record("E18", post.provenance_unknown? ? :not_verified : :fail, "No fact-checked review covers the public revision.", post: post)
77
+ end
78
+ baseline = post.baseline
79
+ released = if baseline && baseline.adopted_revision_id == post.public_revision_id
80
+ baseline.first_published_at || baseline.declared_first_published_at
81
+ else
82
+ post.publications.where(revision_id: post.public_revision_id).minimum(:occurred_at)
83
+ end
84
+ marks = []
85
+ marks << "publisher declaration" if review.kind == "declared"
86
+ marks << "review after release" if released && review.approved_at > released
87
+ record("E18", :pass, "A fact-checked review covers the public revision.", post: post, marks: marks,
88
+ details: { approved_at: review.approved_at.iso8601, released_at: released&.iso8601 })
89
+ end
90
+
91
+ def revision(post)
92
+ return record("E19", :not_applicable, "The article is recorded as written by a person.", post: post) if post.provenance_human_written?
93
+ stored = post.public_revision
94
+ page = @context.post_page(post)
95
+ unless stored && readable(page) && (!post.provenance_unknown? || approved(post))
96
+ return record("E19", :not_verified, "The revision, review, or fetched article is unavailable.", post: post)
97
+ end
98
+ payload = JSON.parse(stored.payload)
99
+ expected = ExpectedContent.call(post, payload, base_url: @context.config.public_base_url)
100
+ actual = page.document.at_css("[data-open-blog-content]")
101
+ return record("E19", :not_verified, "The stored revision content could not be rendered.", post: post) unless expected
102
+ return record("E19", :fail, "The fetched page has no declared article content region.", post: post) unless actual
103
+ parts = {}
104
+ linked = post.publications.exists?(revision_id: stored.id) && approved(post)
105
+ parts["A"] = mark(Digest::SHA256.hexdigest(stored.payload) == stored.identifier && linked && RevisionPayload.new(post).identifier == stored.identifier)
106
+ parts["B"] = mark(text(project(expected)) == text(project(actual)))
107
+ parts["C"] = mark(links(project(expected)) == links(project(actual)))
108
+ images = payload.fetch("images").select { |image| %w[cover body].include?(image["role"]) }
109
+ parts["D"] = mark(actual.css("img").map { |node| [ node["src"], node["alt"].to_s ] } == images.map { |image| image.values_at("url", "alt") })
110
+ parts["E"] = image_files(images)
111
+ parts["F"] = head(page, payload)
112
+ faq = actual.css("[data-open-blog-faq-entry]").map do |entry|
113
+ question = entry.at_css("h3")
114
+ [ text(question), entry.css("p").map { |node| text(node) }.join(" ") ]
115
+ end
116
+ expected_faq = payload.fetch("faq").map { |entry| [ normalize(entry["question"]), normalize(entry["answer"]) ] }
117
+ parts["G"] = mark(faq == expected_faq && (expected_faq.any? || actual.css("[data-open-blog-faq]").empty?))
118
+ status = parts.value?("fail") ? :fail : parts.value?("not verified") ? :not_verified : :pass
119
+ record("E19", status, status == :pass ? "Fetched content agrees with the stored public revision." : "Some revision comparisons differ or lack evidence.", post: post, details: parts)
120
+ rescue JSON::ParserError, KeyError, ActionView::Template::Error => error
121
+ record("E19", :not_verified, "Stored content could not be compared (#{error.class.name}).", post: post)
122
+ end
123
+
124
+ def image_files(images)
125
+ results = images.map do |image|
126
+ next "not verified" if image["sha256"].blank?
127
+ page = image_page(image["url"])
128
+ next "not verified" unless readable(page)
129
+ mark(Digest::SHA256.hexdigest(page.body) == image["sha256"])
130
+ end
131
+ results.include?("fail") ? "fail" : results.include?("not verified") ? "not verified" : "pass"
132
+ end
133
+
134
+ def image_page(path)
135
+ url = @context.absolute(path)
136
+ seen = []
137
+ 6.times do
138
+ return if url.nil? || seen.include?(url)
139
+ seen << url
140
+ page = @context.page(url)
141
+ return page unless page && [ 301, 302, 303, 307, 308 ].include?(page.status)
142
+ location = page.headers["location"] || page.headers["Location"]
143
+ return if location.blank?
144
+ url = URI.join(url, location).to_s
145
+ end
146
+ nil
147
+ rescue URI::InvalidURIError, URI::BadURIError
148
+ nil
149
+ end
150
+
151
+ def head(page, payload)
152
+ document = page.document
153
+ title = payload["search_title"].presence || payload["title"]
154
+ description = payload["search_description"].presence || payload["description"]
155
+ return "fail" unless document.at_css("title")&.text&.start_with?(title) && document.at_css('meta[name="description"]')&.[]("content") == description
156
+ social = payload.fetch("images").select { |image| image["role"] == "social" }
157
+ return "pass" if social.empty?
158
+ url = document.at_css('meta[property="og:image"]')&.[]("content")
159
+ return "fail" unless url && URI.parse(url).path == social.first["url"]
160
+ image_files([ social.first.merge("url" => url) ])
161
+ rescue URI::InvalidURIError
162
+ "fail"
163
+ end
164
+
165
+ def notice(post)
166
+ dependencies = [ @context.row("E4", post: post), @context.row("E18", post: post), @context.row("E19", post: post) ]
167
+ needed = !post.provenance_human_written? && dependencies.any? { |row| !row || %w[fail].include?(row[:result].to_s) || row[:result].to_s.tr("_", " ") == "not verified" }
168
+ return record("E20", :not_applicable, "The inspected evidence does not require an AI notice.", post: post) unless needed
169
+ page = @context.post_page(post)
170
+ return record("E20", :not_verified, "The article could not be inspected for a notice.", post: post) unless readable(page)
171
+ key = post.provenance_ai_assisted? ? "ai_assisted" : "ai_unknown"
172
+ label = I18n.t("open_blog.notices.#{key}", locale: @context.config.locale)
173
+ valid = page.document.css(".ob-notice--ai").any? { |node| visible_text(node).include?(label) }
174
+ record("E20", valid ? :pass : :fail, valid ? "The fetched article includes the relevant AI notice." : "The fetched article lacks the relevant AI notice.", post: post)
175
+ end
176
+
177
+ def connections(post)
178
+ declaration = post.connection_declarations.order(:id).last
179
+ page = @context.post_page(post)
180
+ { "E21" => declaration && declaration.connections.any?, "E22" => declaration&.third_party_paid? }.each do |predicate, needed|
181
+ if !declaration
182
+ record(predicate, :not_verified, "No connection declaration was supplied.", post: post)
183
+ elsif !needed
184
+ record(predicate, :not_applicable, "The publisher declared no matching connection.", post: post)
185
+ elsif !readable(page)
186
+ record(predicate, :not_verified, "The article could not be inspected.", post: post)
187
+ else
188
+ selector = predicate == "E21" ? "[data-open-blog-content] .ob-disclosure" : "[data-open-blog-content] .ob-notice--paid"
189
+ document = page.document
190
+ valid = document.css(selector).any? do |node|
191
+ next false if visible_text(node).blank?
192
+ body = document.at_css("[data-open-blog-body]")
193
+ predicate == "E21" || (body && (node <=> body) == -1)
194
+ end
195
+ record(predicate, valid ? :pass : :fail, valid ? "A disclosure is present in the article markup." : "A declared connection has no disclosure markup.", post: post)
196
+ end
197
+ end
198
+ end
199
+
200
+ def redirects
201
+ entries = Redirect.all.to_a
202
+ if entries.empty?
203
+ began = Baseline.minimum(:adopted_at)
204
+ reason = "No URL changes are recorded; earlier moves require imported history."
205
+ reason += " Imported coverage begins #{began.iso8601}." if began
206
+ return record("T4", :not_applicable, reason)
207
+ end
208
+ entries.each do |entry|
209
+ page = @context.page(@context.absolute(entry.old_path))
210
+ unless page && page.status.to_i.positive?
211
+ record("T4", :not_verified, "The recorded old URL could not be fetched.", details: { path: entry.old_path })
212
+ next
213
+ end
214
+ valid = if entry.new_path
215
+ [ 301, 308 ].include?(page.status) && @context.absolute(page.headers["location"] || page.headers["Location"]) == @context.absolute(entry.new_path)
216
+ else
217
+ [ 404, 410 ].include?(page.status)
218
+ end
219
+ record("T4", valid ? :pass : :fail, valid ? "The old URL has the recorded response." : "The old URL differs from its recorded destination or removal.", details: { path: entry.old_path })
220
+ end
221
+ end
222
+
223
+ def project(node)
224
+ node = node.dup
225
+ node.css(GENERATED).remove
226
+ node
227
+ end
228
+
229
+ def visible_text(node)
230
+ return "" unless node && ![ node, *node.ancestors ].any? { |parent| parent.key?("hidden") || parent["aria-hidden"] == "true" }
231
+ text(node)
232
+ end
233
+
234
+ def text(node) = normalize(node&.text)
235
+ def normalize(value) = value.to_s.gsub(/\s+/, " ").strip
236
+ def links(node) = node.css("a[href]").map { |link| [ text(link), link["href"] ] }
237
+ def mark(value) = value ? "pass" : "fail"
238
+
239
+ def same_time?(value, expected)
240
+ return false if value.blank?
241
+ value = value.to_s
242
+ value.length == 10 ? Date.iso8601(value) == expected.to_date : Time.iso8601(value).to_i == expected.to_i
243
+ rescue ArgumentError
244
+ false
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,49 @@
1
+ module OpenBlog
2
+ class SurfaceReport
3
+ module Robots
4
+ module_function
5
+
6
+ def allowed?(body, path, agent)
7
+ return true if path == "/robots.txt"
8
+ groups = []
9
+ group = nil
10
+ body.to_s.dup.force_encoding("UTF-8").scrub.delete_prefix("\uFEFF").split(/\r\n|\n|\r/).each do |line|
11
+ key, value = line.sub(/#.*/, "").strip.split(":", 2).map(&:strip)
12
+ next unless value
13
+ if key.downcase == "user-agent"
14
+ next unless value.match?(/\A(?:[a-z_-]+|\*)\z/i)
15
+ if !group || group[:started]
16
+ group = { agents: [], rules: [], started: false }
17
+ groups << group
18
+ end
19
+ group[:agents] << value.downcase
20
+ elsif %w[allow disallow].include?(key.downcase) && group
21
+ group[:started] = true
22
+ group[:rules] << [ key.downcase, normalize(value) ] if value.start_with?("/")
23
+ end
24
+ end
25
+ selected = groups.select { |entry| entry[:agents].include?(agent.downcase) }
26
+ selected = groups.select { |entry| entry[:agents].include?("*") } if selected.empty?
27
+ target = normalize(path)
28
+ matches = selected.flat_map { |entry| entry[:rules] }.select do |_kind, pattern|
29
+ ending = pattern.end_with?("$")
30
+ value = ending ? pattern[0...-1] : pattern
31
+ expression = value.split("*", -1).map { |part| Regexp.escape(part) }.join(".*")
32
+ target.match?(Regexp.new("\\A#{expression}#{'\\z' if ending}"))
33
+ end
34
+ winner = matches.max_by { |kind, pattern| [ pattern.delete("*").delete_suffix("$").bytesize, kind == "allow" ? 1 : 0 ] }
35
+ winner.nil? || winner.first == "allow"
36
+ end
37
+
38
+ def normalize(value)
39
+ escaped = value.to_s.b.bytes.map { |byte| byte > 127 ? "%%%02X" % byte : byte.chr }.join
40
+ escaped.gsub(/%([0-9a-f]{2})/i) do
41
+ byte = Regexp.last_match(1).to_i(16)
42
+ character = byte.chr
43
+ character.match?(/[a-z0-9._~-]/i) ? character : "%%%02X" % byte
44
+ end
45
+ end
46
+ private_class_method :normalize
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,223 @@
1
+ require "uri"
2
+ require "json"
3
+ require "nokogiri"
4
+
5
+ module OpenBlog
6
+ class SurfaceReport
7
+ autoload :Http, "open_blog/surface_report/http"
8
+ autoload :Robots, "open_blog/surface_report/robots"
9
+ autoload :PageChecks, "open_blog/surface_report/page_checks"
10
+ autoload :ExpectedContent, "open_blog/surface_report/expected_content"
11
+ autoload :RecordChecks, "open_blog/surface_report/record_checks"
12
+ autoload :ReachChecks, "open_blog/surface_report/reach_checks"
13
+
14
+ STATUSES = [ "pass", "fail", "not applicable", "not verified" ].freeze
15
+ LIMIT = 50
16
+ NOTICE = "Automated observations do not establish editorial quality or replace manual review, browser testing, and field measurements.".freeze
17
+ NOT_ASSESSED = %w[E2 E9 E10 E11 E12 E13 E14 E15 E23 E24 E25].freeze
18
+ NOT_RUN = [
19
+ { predicate: "T12_render", reason: "Small-screen layout requires a browser session." },
20
+ { predicate: "T18", reason: "Hidden content and navigation behavior need manual inspection." },
21
+ { predicate: "T19", reason: "Accessibility requires browser tooling and human testing." },
22
+ { predicate: "T20", reason: "Performance assessment needs real-user field measurements." }
23
+ ].freeze
24
+
25
+ def self.run(scope: :site, post: nil, page: 1, reach: false, http: nil)
26
+ context = Context.new(scope: scope, post: post, page: page, reach: reach, http: http)
27
+ PageChecks.new(context).call
28
+ RecordChecks.new(context).call
29
+ ReachChecks.new(context).call unless context.scope == :site
30
+ Result.new(context)
31
+ end
32
+
33
+ def self.declarations
34
+ {
35
+ field_map: { title: "posts.title", description: "posts.description", search_title: "posts.search_title",
36
+ search_description: "posts.search_description", body: "posts.body_markdown or rich_body", author: "posts.author_name",
37
+ images: "cover_image, body image manifest, social_image", faq: "faqs.question and faqs.answer ordered by position" },
38
+ content_selector: "[data-open-blog-content]", faq_selectors: OpenBlog::FAQ_SELECTORS.values,
39
+ primary_list_type: OpenBlog.config.primary_list_type, sign_in_destinations: OpenBlog.config.sign_in_destinations
40
+ }
41
+ end
42
+
43
+ class Page
44
+ attr_reader :url, :status, :headers, :body, :error
45
+
46
+ def initialize(url:, status: 0, headers: {}, body: "", error: nil)
47
+ @url, @status, @body, @error = url, status.to_i, body, error
48
+ @headers = headers.to_h.transform_keys { |key| key.to_s.downcase }
49
+ end
50
+
51
+ def document
52
+ @document ||= Nokogiri::HTML5.parse(body.to_s.dup.force_encoding(Encoding::UTF_8).scrub)
53
+ end
54
+
55
+ def articles
56
+ @articles ||= document.css('script[type="application/ld+json"]').flat_map do |script|
57
+ json_nodes(JSON.parse(script.text))
58
+ rescue JSON::ParserError
59
+ []
60
+ end.select { |node| (Array(node["@type"]) & %w[Article BlogPosting]).any? }
61
+ end
62
+
63
+ def article
64
+ articles.first
65
+ end
66
+
67
+ private
68
+
69
+ def json_nodes(value)
70
+ case value
71
+ when Array then value.flat_map { |item| json_nodes(item) }
72
+ when Hash then [ value, *json_nodes(value["@graph"]) ]
73
+ else []
74
+ end
75
+ end
76
+ end
77
+
78
+ class Context
79
+ attr_reader :scope, :posts, :config, :now, :reach, :results, :pagination, :limitations
80
+
81
+ def initialize(scope:, post:, page:, reach:, http:)
82
+ @config, @now, @results, @pages = OpenBlog.config, Time.current, [], {}
83
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
84
+ @response_bytes = 0
85
+ @limitations = []
86
+ @scope = scope.to_s.to_sym
87
+ invalid!(:scope) unless %i[site post all].include?(@scope)
88
+ page = page.to_i if page.is_a?(String) && page.match?(/\A[1-9]\d*\z/)
89
+ invalid!(:page) unless page.is_a?(Integer) && page.between?(1, 1_000_000)
90
+ @reach = case reach
91
+ when true, "true" then true
92
+ when false, "false" then false
93
+ else invalid!(:reach)
94
+ end
95
+ invalid!(:post) if @scope == :post ? post.blank? : post.present?
96
+ @http = http || Http.new(trusted_origin: config.public_base_url)
97
+ select_posts(post, page)
98
+ end
99
+
100
+ def record(predicate, status, reason, post: nil, marks: [], details: nil)
101
+ status = status.to_s.tr("_", " ")
102
+ raise ArgumentError, "Unknown report status" unless STATUSES.include?(status)
103
+ value = { predicate: predicate.to_s, result: status, reason: reason.to_s, marks: marks }
104
+ value[:post_id] = post.respond_to?(:id) ? post.id : post if post
105
+ value[:details] = details if details
106
+ results << value
107
+ value
108
+ end
109
+
110
+ def row(predicate, post: nil)
111
+ id = post.respond_to?(:id) ? post.id : post
112
+ results.find { |value| value[:predicate] == predicate.to_s && value[:post_id] == id }
113
+ end
114
+
115
+ def absolute(path)
116
+ return if path.blank? || config.public_base_url.blank?
117
+ URI.join("#{config.public_base_url.chomp('/')}/", path.to_s).to_s
118
+ rescue URI::InvalidURIError, URI::BadURIError
119
+ nil
120
+ end
121
+
122
+ def post_page(post)
123
+ page(absolute(post.path))
124
+ end
125
+
126
+ def page(url)
127
+ return Page.new(url: nil, error: "Set public_base_url to inspect reader pages.") if url.blank?
128
+ return @pages[url] if @pages.key?(url)
129
+ if @byte_budget_exhausted || @pages.length >= 500 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
130
+ return limited_page(url, "Report request budget exhausted; narrow the scope and retry.")
131
+ end
132
+ @pages[url] ||= begin
133
+ response = @http.get(url)
134
+ @response_bytes += response.body.to_s.bytesize
135
+ if @response_bytes > 32 * 1024 * 1024
136
+ @byte_budget_exhausted = true
137
+ limited_page(url, "Report response-byte budget exhausted; narrow the scope and retry.")
138
+ else
139
+ Page.new(url: url, status: response.status, headers: response.headers, body: response.body)
140
+ end
141
+ rescue Http::FetchError => error
142
+ Page.new(url: url, error: error.message)
143
+ end
144
+ end
145
+
146
+ def robots_allowed?(url, agent)
147
+ return if url.blank?
148
+ uri = URI.parse(url)
149
+ target = URI.join(url, "/robots.txt").to_s
150
+ 6.times do |attempt|
151
+ response = page(target)
152
+ return if response.status.zero? || response.status >= 500
153
+ return true if response.status.between?(400, 499)
154
+ return Robots.allowed?(response.body, uri.request_uri, agent) if response.status == 200
155
+ return unless [ 301, 302, 303, 307, 308 ].include?(response.status)
156
+ return if response.headers["location"].blank? || attempt == 5
157
+ target = URI.join(target, response.headers["location"]).to_s
158
+ end
159
+ rescue URI::InvalidURIError, URI::BadURIError
160
+ nil
161
+ end
162
+
163
+ private
164
+
165
+ def limited_page(url, reason)
166
+ @limitations << reason unless @limitations.include?(reason)
167
+ Page.new(url: url, error: reason)
168
+ end
169
+
170
+ def select_posts(identity, number)
171
+ scope = Post.listed.order(:id)
172
+ if @scope == :post
173
+ identity = identity.id if identity.is_a?(Post)
174
+ selected = scope.find_by(id: identity) if identity.to_s.match?(/\A\d+\z/)
175
+ selected ||= scope.find_by(slug: identity.to_s)
176
+ raise Error::NotFound unless selected
177
+ @posts = [ selected ]
178
+ @pagination = { page: 1, per_page: 1, total: 1, pages: 1 }
179
+ else
180
+ total = scope.count
181
+ @posts = scope.offset((number - 1) * LIMIT).limit(LIMIT).to_a
182
+ @pagination = { page: number, per_page: LIMIT, total: total, pages: (total.to_f / LIMIT).ceil }
183
+ end
184
+ end
185
+
186
+ def invalid!(field)
187
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
188
+ end
189
+ end
190
+
191
+ class Result
192
+ def initialize(context)
193
+ @data = { gem_version: OpenBlog::VERSION, report_version: "1", date: context.now.iso8601,
194
+ scope: context.scope.to_s, post_ids: context.posts.map(&:id), pagination: context.pagination,
195
+ declarations: SurfaceReport.declarations, results: context.results, limitations: context.limitations,
196
+ counts: STATUSES.to_h { |status| [ status, context.results.count { |row| row[:result] == status } ] },
197
+ not_run: NOT_RUN, not_assessed: NOT_ASSESSED, sentence: NOTICE }
198
+ end
199
+
200
+ def to_h
201
+ @data.deep_dup
202
+ end
203
+
204
+ def to_text
205
+ lines = [ "OpenBlog page and record report", "#{@data[:date]} | scope: #{@data[:scope]}",
206
+ "Articles: #{@data[:post_ids].join(', ')} | page #{@data[:pagination][:page]} of #{@data[:pagination][:pages]}" ]
207
+ lines << "Declarations: #{JSON.generate(@data[:declarations])}"
208
+ @data[:results].each do |row|
209
+ identity = row[:post_id] ? " article #{row[:post_id]}" : ""
210
+ lines << "#{row[:predicate]}#{identity}: #{row[:result]} — #{row[:reason]}"
211
+ lines << " #{row[:marks].join('; ')}" if row[:marks].any?
212
+ lines << " Evidence: #{JSON.generate(row[:details])}" if row[:details]
213
+ end
214
+ lines << @data[:counts].map { |status, count| "#{status}: #{count}" }.join(" | ")
215
+ lines << "Not run: #{@data[:not_run].map { |item| item[:predicate] }.join(', ')}"
216
+ lines << "Not assessed: #{@data[:not_assessed].join(', ')}"
217
+ @data[:limitations].each { |reason| lines << "Limit: #{reason}" }
218
+ lines << NOTICE
219
+ lines.join("\n") + "\n"
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,55 @@
1
+ require "rouge"
2
+ require "fileutils"
3
+ require "pathname"
4
+
5
+ module OpenBlog
6
+ class SyntaxCss
7
+ LIGHT_SCOPE = ".ob-highlight"
8
+ DARK_SCOPE = '[data-theme="dark"] .ob-highlight'
9
+ SYSTEM_SCOPE = 'html:not([data-theme="light"]) .ob-highlight'
10
+
11
+ def self.render(theme: OpenBlog.config.syntax_theme)
12
+ selected = Rouge::Theme.find(theme)
13
+ unless selected && selected.respond_to?(:mode) && selected.respond_to?(:make_light!) && selected.respond_to?(:make_dark!)
14
+ raise ConfigurationError, "Syntax theme #{theme.inspect} must provide light and dark modes; try github, base16, or gruvbox."
15
+ end
16
+ light = selected.mode(:light).render(scope: LIGHT_SCOPE)
17
+ dark = selected.mode(:dark).render(scope: DARK_SCOPE)
18
+ system = selected.mode(:dark).render(scope: SYSTEM_SCOPE)
19
+ css = "#{light}\n\n#{dark}\n\n@media (prefers-color-scheme: dark) {\n#{system}\n}\n"
20
+ css += github_contrast if selected == Rouge::Themes::Github
21
+ css
22
+ end
23
+
24
+ def self.github_contrast
25
+ comments = %w[c ch cd cm cp cpf c1 cs gl gt].map { |name| "#{LIGHT_SCOPE} .#{name}" }.join(", ")
26
+ <<~CSS
27
+ #{LIGHT_SCOPE} .gr { color: #cf222e; }
28
+ #{comments} { color: #57606a; }
29
+ #{DARK_SCOPE} .gh, #{DARK_SCOPE} .gu { color: #79c0ff; }
30
+ @media (prefers-color-scheme: dark) {
31
+ #{SYSTEM_SCOPE} .gh, #{SYSTEM_SCOPE} .gu { color: #79c0ff; }
32
+ }
33
+ CSS
34
+ end
35
+ private_class_method :github_contrast
36
+
37
+ def self.write(root: Rails.root, path: nil, theme: OpenBlog.config.syntax_theme)
38
+ root = Pathname(root)
39
+ destination = path ? root.join(path) : default_path(root)
40
+ css = render(theme: theme)
41
+ FileUtils.mkdir_p(destination.dirname)
42
+ destination.write(css)
43
+ destination
44
+ end
45
+
46
+ def self.default_path(root)
47
+ tailwind = root.join("app/assets/tailwind/open_blog/syntax.css")
48
+ stylesheets = root.join("app/assets/stylesheets/open_blog/syntax.css")
49
+ return tailwind if tailwind.file?
50
+ return stylesheets if stylesheets.file?
51
+ root.join("app/assets/tailwind/application.css").file? ? tailwind : stylesheets
52
+ end
53
+ private_class_method :default_path
54
+ end
55
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ class Unpublish
3
+ def self.call(post, actor:, now: Time.current)
4
+ Operation.run(post: post, actor: actor, now: now) do |current, _created|
5
+ raise Error::NotFound unless current.persisted?
6
+ was_public = current.published?
7
+ current.update!(status: "draft", publish_at: nil)
8
+ Remove.write_redirect(current, target: nil, source: "unpublish", now: now) if was_public
9
+ { revision: nil, publication: nil, approval: nil }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module OpenBlog
2
+ VERSION = "0.1.0"
3
+ end