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,205 @@
1
+ module OpenBlog
2
+ class WritePost
3
+ def self.call(attributes, post:, actor:, now:, publish:, authorize: nil)
4
+ attributes = PostAttributes.normalize(attributes)
5
+ attributes = attributes.except(:external_id) if post&.persisted?
6
+ source = post&.persisted? ? Post.find(post.id) : PostIdentity.resolve(attributes, post: post)
7
+ authorize&.call(source)
8
+ image_inputs = ImageResolution.prepare_inputs(attributes)
9
+ images = ImageResolution.prepare(attributes: attributes, post: source)
10
+ context = {}
11
+ Operation.run(post: post, attributes: attributes, actor: actor, now: now, context: context, authorize: authorize) do |current, created|
12
+ old_slug, was_public = current.slug, current.published?
13
+ context[:author_default_used] = created && !attributes.key?(:author)
14
+ approval = attributes[:approval]
15
+ context[:approval_incomplete] = approval.is_a?(Hash) && approval.with_indifferent_access[:facts_checked] == false
16
+ release = publish == :preserve ? current.published? : publish
17
+ records = RedirectTarget.synchronize do
18
+ new(current, attributes, actor: actor, now: now, publish: release, images: images, image_inputs: image_inputs).call
19
+ end
20
+ context[:slug_changed] = was_public && old_slug != current.slug
21
+ records
22
+ end
23
+ rescue Error => error
24
+ Operation.failure(post, error)
25
+ rescue ActiveRecord::RecordNotFound
26
+ Operation.failure(post, Error::NotFound.new)
27
+ end
28
+
29
+ def initialize(post, attributes, actor:, now:, publish:, images:, image_inputs: {})
30
+ @post, @attributes, @actor, @now, @publish = post, attributes, actor, now, publish
31
+ @scheduled_release = publish == :scheduled
32
+ @images, @image_inputs = images, image_inputs
33
+ end
34
+
35
+ def call
36
+ raise Error::PostIsPublic if !@publish && @post.published?
37
+ old_path = @post.path if @post.persisted?
38
+ was_public = @post.published?
39
+ previous_schedule = @post.publish_at
40
+ PostAttributes.assign(@post, @attributes.except(*@image_inputs.keys), actor: @actor, now: @now)
41
+ @image_inputs.each { |field, prepared| @post.public_send("#{field}=", prepared.image) }
42
+ PostIdentity.resolve({ slug: @post.slug }, post: @post)
43
+ apply_status
44
+ validate_records
45
+ @image_inputs.each { |field, prepared| @post.public_send("#{field}=", prepared.persist(uploaded_by: @actor)) }
46
+ @images.materialize(@post, actor: @actor)
47
+ @post.send(:compute_derived)
48
+ validate_approval
49
+ validate_change
50
+ enforce_gates if @publish && !cancel_schedule?
51
+ @images.preview(@post)
52
+ @post.send(:compute_derived)
53
+ validate_approval
54
+ validate_change
55
+ enforce_approval_gate if @publish && !cancel_schedule?
56
+ @post.release_context = { actor: @actor, now: @now, change: @attributes[:change],
57
+ note: @attributes[:note], description: @attributes[:change_description], made_by_ai: @attributes[:made_by_ai] }
58
+ @post.save!
59
+ records = @post.release_records || { revision: nil, publication: nil, approval: nil }
60
+ store_approval(records) if @attributes.key?(:approval)
61
+ store_connections if @attributes.key?(:connections)
62
+ update_redirects(old_path, was_public)
63
+ enqueue if @post.scheduled? && previous_schedule != @post.publish_at
64
+ records
65
+ ensure
66
+ @post.release_context = nil
67
+ end
68
+
69
+ private
70
+
71
+ def cancel_schedule?
72
+ @cancel_schedule
73
+ end
74
+
75
+ def apply_status
76
+ @cancel_schedule = @post.scheduled? && @attributes.key?(:publish_at) && @attributes[:publish_at].nil?
77
+ if @attributes.key?(:publish_at)
78
+ value = @attributes[:publish_at]
79
+ time = value.is_a?(String) ? Time.zone.parse(value) : value
80
+ unless value.nil? || time.respond_to?(:to_time)
81
+ raise Error::ValidationFailed.new(details: [ "publish_at" ])
82
+ end
83
+ @post.publish_at = time
84
+ end
85
+ if @cancel_schedule
86
+ @post.status = "draft"
87
+ elsif @post.publish_at && @attributes.key?(:publish_at) && @post.publish_at > @now
88
+ raise Error::ValidationFailed.new(details: [ "publish_at" ]) unless @publish || @post.scheduled?
89
+ @post.status = "scheduled"
90
+ elsif @publish
91
+ @post.status = "published"
92
+ @post.publish_at = nil
93
+ end
94
+ rescue ArgumentError, TypeError
95
+ raise Error::ValidationFailed.new(details: [ "publish_at" ])
96
+ end
97
+
98
+ def validate_records
99
+ if @attributes.key?(:change) && !%w[substantive correction maintenance].include?(@attributes[:change].to_s)
100
+ raise Error::ValidationFailed.new(details: [ "change" ])
101
+ end
102
+ if @attributes[:change].to_s == "correction" && @attributes[:note].blank?
103
+ raise Error::CorrectionNoteRequired
104
+ end
105
+ if @attributes.key?(:made_by_ai) && ![ true, false, nil ].include?(@attributes[:made_by_ai])
106
+ raise Error::ValidationFailed.new(details: [ "made_by_ai" ])
107
+ end
108
+ end
109
+
110
+ def validate_approval
111
+ return unless @attributes.key?(:approval)
112
+ value = @attributes[:approval]
113
+ unless value.is_a?(Hash)
114
+ raise Error::ApprovalIncomplete
115
+ end
116
+ @approval = value.symbolize_keys
117
+ unless @approval[:name].is_a?(String) && @approval[:name].present? && [ true, false ].include?(@approval[:facts_checked])
118
+ raise Error::ApprovalIncomplete
119
+ end
120
+ if @approval.key?(:revision_identifier) && @approval[:revision_identifier] != @post.current_revision_identifier
121
+ raise Error::RevisionMismatch
122
+ end
123
+ end
124
+
125
+ def validate_change
126
+ return if @scheduled_release
127
+ return unless @publish && @post.published? && @post.public_revision
128
+ if @post.public_revision.identifier != @post.current_revision_identifier && @attributes[:change].blank?
129
+ raise Error::ChangeTypeRequired
130
+ end
131
+ end
132
+
133
+ def enforce_gates
134
+ enforce_approval_gate
135
+ messages = OpenBlog.config.before_publish&.call(@post, { actor: @actor, now: @now, attributes: @attributes })
136
+ raise Error::RefusedByHost.new(details: Array(messages)) if messages.present?
137
+ end
138
+
139
+ def enforce_approval_gate
140
+ return if @scheduled_release
141
+ if OpenBlog.config.require_approval && !@post.provenance_human_written?
142
+ approved = @approval && @approval[:facts_checked] == true
143
+ approved ||= @post.approvals.joins(:revision).exists?(facts_checked: true, open_blog_revisions: { identifier: @post.current_revision_identifier }) if @post.persisted?
144
+ raise Error::ApprovalRequired unless approved
145
+ end
146
+ end
147
+
148
+ def store_approval(records)
149
+ revision = @post.revisions.find_by(identifier: @post.current_revision_identifier)
150
+ if revision
151
+ records[:revision] ||= "same"
152
+ else
153
+ revision = @post.revisions.create!(identifier: @post.current_revision_identifier,
154
+ payload: RevisionPayload.new(@post).to_json, actor: @actor, made_by_ai: @attributes[:made_by_ai], created_at: @now)
155
+ records[:revision] = "new"
156
+ end
157
+ @post.approvals.create!(revision: revision, kind: "sent", reviewer_name: @approval[:name],
158
+ facts_checked: @approval[:facts_checked], approved_at: @now, recorded_by: @actor)
159
+ records[:approval] = "sent"
160
+ end
161
+
162
+ def store_connections
163
+ input = @attributes[:connections]
164
+ unless input.is_a?(Hash)
165
+ raise Error::ValidationFailed.new(details: [ "connections" ])
166
+ end
167
+ input = input.symbolize_keys
168
+ if (input.keys - %i[connections third_party_paid declared_by declared_on]).any? || ![ true, false ].include?(input[:third_party_paid])
169
+ raise Error::ValidationFailed.new(details: [ "connections" ])
170
+ end
171
+ if input[:connections].is_a?(Array)
172
+ input[:connections].each do |entry|
173
+ next unless entry.is_a?(Hash)
174
+ unknown = entry.keys.map(&:to_s) - %w[party relation]
175
+ raise Error::UnknownField.new(details: unknown.map { |key| "connections.connections.#{key}" }) if unknown.any?
176
+ end
177
+ end
178
+ input[:declared_on] = @now.to_date unless input.key?(:declared_on)
179
+ @post.connection_declarations.create!(**input, recorded_by: @actor, created_at: @now)
180
+ end
181
+
182
+ def update_redirects(old_path, was_public)
183
+ if old_path && old_path != @post.path && was_public
184
+ redirect = Redirect.find_or_initialize_by(old_path: old_path)
185
+ raise Error::SlugReserved if redirect.persisted? && redirect.post_id != @post.id
186
+ if redirect.new_record?
187
+ redirect.assign_attributes(post: @post, source: "slug_change", occurred_on: @now.to_date)
188
+ end
189
+ redirect.update!(new_path: @post.path)
190
+ Redirect.where(new_path: old_path).update_all(new_path: @post.path, updated_at: @now)
191
+ end
192
+ if @post.published?
193
+ Redirect.where(old_path: @post.path, post: @post).destroy_all
194
+ Redirect.where(post: @post, source: %w[slug_change adoption unpublish removal]).update_all(new_path: @post.path, updated_at: @now)
195
+ end
196
+ end
197
+
198
+ def enqueue
199
+ id, due = @post.id, @post.publish_at
200
+ ActiveRecord.after_all_transactions_commit do
201
+ PublishScheduledPostJob.set(wait_until: due).perform_later(id)
202
+ end
203
+ end
204
+ end
205
+ end
data/lib/open_blog.rb ADDED
@@ -0,0 +1,91 @@
1
+ require "json"
2
+ require "rails"
3
+ require "commonmarker"
4
+ require "rouge"
5
+ require "mcp"
6
+
7
+ require_relative "open_blog/version"
8
+ require_relative "open_blog/instructions"
9
+
10
+ module OpenBlog
11
+ FAQ_SELECTORS = { section: "[data-open-blog-faq]", entry: "[data-open-blog-faq-entry]" }.freeze
12
+
13
+ autoload :Configuration, "open_blog/configuration"
14
+ autoload :ConfigurationError, "open_blog/configuration_error"
15
+ autoload :RevisionPayload, "open_blog/revision_payload"
16
+ autoload :PlainText, "open_blog/plain_text"
17
+ autoload :Result, "open_blog/result"
18
+ autoload :Error, "open_blog/errors"
19
+ autoload :PostIdentity, "open_blog/post_identity"
20
+ autoload :PostAttributes, "open_blog/post_attributes"
21
+ autoload :Operation, "open_blog/operation"
22
+ autoload :WritePost, "open_blog/write_post"
23
+ autoload :SaveDraft, "open_blog/save_draft"
24
+ autoload :Publish, "open_blog/publish"
25
+ autoload :Approve, "open_blog/approve"
26
+ autoload :RedirectTarget, "open_blog/redirect_target"
27
+ autoload :Remove, "open_blog/remove"
28
+ autoload :Unpublish, "open_blog/unpublish"
29
+ autoload :RecordRelease, "open_blog/record_release"
30
+ autoload :ContentGuard, "open_blog/content_guard"
31
+ autoload :RichTextGuard, "open_blog/rich_text_guard"
32
+ autoload :PolicyUrl, "open_blog/policy_url"
33
+ autoload :LabelPolicy, "open_blog/label_policy"
34
+ autoload :Findings, "open_blog/findings"
35
+ autoload :Adopt, "open_blog/adopt"
36
+ autoload :FaqExtraction, "open_blog/faq_extraction"
37
+ autoload :ImageFetch, "open_blog/image_fetch"
38
+ autoload :ImageImport, "open_blog/image_import"
39
+ autoload :ImageResolution, "open_blog/image_resolution"
40
+ autoload :Renderer, "open_blog/renderer"
41
+ autoload :SyntaxCss, "open_blog/syntax_css"
42
+ autoload :BuildCss, "open_blog/build_css"
43
+ autoload :ReaderPage, "open_blog/reader_page"
44
+ autoload :ReaderDates, "open_blog/reader_dates"
45
+ autoload :JsonFeed, "open_blog/json_feed"
46
+ autoload :MarkdownView, "open_blog/markdown_view"
47
+ autoload :ReaderQueries, "open_blog/reader_queries"
48
+ autoload :Search, "open_blog/search"
49
+ autoload :PageViews, "open_blog/page_views"
50
+ autoload :Pagination, "open_blog/pagination"
51
+ autoload :NotFound, "open_blog/not_found"
52
+ autoload :SitemapEntries, "open_blog/sitemap_entries"
53
+ autoload :SurfaceReport, "open_blog/surface_report"
54
+ autoload :Doctor, "open_blog/doctor"
55
+ autoload :Sample, "open_blog/sample"
56
+ autoload :Actor, "open_blog/actor"
57
+ autoload :Mcp, "open_blog/mcp"
58
+ autoload :ApiFields, "open_blog/api_fields"
59
+ autoload :Authentication, "open_blog/authentication"
60
+
61
+ class << self
62
+ def config
63
+ @config ||= Configuration.new
64
+ end
65
+
66
+ def configure
67
+ yield config
68
+ config
69
+ end
70
+
71
+ def policy_url(kind, config: self.config)
72
+ PolicyUrl.call(kind, config: config)
73
+ end
74
+
75
+ def sitemap_entries(base_url: config.public_base_url)
76
+ SitemapEntries.call(base_url: base_url)
77
+ end
78
+
79
+ def mount_path
80
+ application = Rails.application
81
+ return config.mount_path unless application&.initialized?
82
+
83
+ route = application.routes.routes.find do |candidate|
84
+ candidate.app.respond_to?(:app) && candidate.app.app == Engine
85
+ end
86
+ route ? route.path.spec.to_s : config.mount_path
87
+ end
88
+ end
89
+ end
90
+
91
+ require_relative "open_blog/engine"
@@ -0,0 +1,71 @@
1
+ namespace :open_blog do
2
+ desc "Set up authentication during installation without replacing existing tokens"
3
+ task install_token: :environment do
4
+ if OpenBlog.config.authenticate
5
+ puts "API token: using host authentication."
6
+ elsif OpenBlog::ApiToken.exists?
7
+ puts "API token: existing token kept; use open_blog:token to issue another if needed."
8
+ else
9
+ _record, secret = OpenBlog::ApiToken.generate(name: "Blog publisher")
10
+ puts "API token: #{secret}"
11
+ puts "Save this secret now; it will not be shown again."
12
+ end
13
+ end
14
+
15
+ desc "Issue an API token and print its secret once"
16
+ task token: :environment do
17
+ name = ENV.fetch("NAME") { abort "Set NAME to identify this token's actor." }
18
+ scopes = ENV.key?("SCOPES") ? ENV.fetch("SCOPES").split(",").map(&:strip) : OpenBlog::ApiToken::SCOPES
19
+ expires_at = Time.iso8601(ENV.fetch("EXPIRES_AT")) if ENV["EXPIRES_AT"].present?
20
+ _record, secret = OpenBlog::ApiToken.generate(name: name, scopes: scopes, expires_at: expires_at)
21
+ puts secret
22
+ end
23
+
24
+ desc "Check the blog installation and report remaining setup"
25
+ task doctor: :environment do
26
+ checks = OpenBlog::Doctor.run
27
+ checks.each { |check| puts "#{check[:status]}: #{check[:name]} — #{check[:message]}" }
28
+ exit 1 if checks.any? { |check| check[:status].to_s == "error" }
29
+ end
30
+
31
+ desc "Create the sample article if it does not already exist"
32
+ task sample: :environment do
33
+ puts "Sample article: #{OpenBlog::Sample.call.path}"
34
+ end
35
+
36
+ desc "Generate scoped syntax colors for the configured light and dark theme"
37
+ task :syntax_css, [ :path ] => :environment do |_task, arguments|
38
+ puts "Wrote #{OpenBlog::SyntaxCss.write(path: arguments[:path])}"
39
+ end
40
+ desc "Build the packaged stylesheet and token-only theme override"
41
+ task build_css: :environment do
42
+ options = ENV["OUT"].present? ? { path: ENV["OUT"] } : {}
43
+ puts "Wrote #{OpenBlog::BuildCss.write(**options)}"
44
+ end
45
+ end
46
+
47
+ namespace :open_blog do
48
+ desc "Publish scheduled posts that are due"
49
+ task publish_due: :environment do
50
+ OpenBlog::Post.where(status: "scheduled").where("publish_at <= ?", Time.current).find_each do |post|
51
+ OpenBlog::PublishScheduledPostJob.perform_now(post.id)
52
+ end
53
+ end
54
+ end
55
+
56
+ namespace :open_blog do
57
+ desc "Delete daily view totals older than the configured retention period"
58
+ task prune_page_views: :environment do
59
+ puts "Pruned #{OpenBlog::PageViews.prune!} daily view totals"
60
+ end
61
+ end
62
+
63
+ namespace :open_blog do
64
+ desc "Inspect public pages and publishing records"
65
+ task report: :environment do
66
+ puts OpenBlog::SurfaceReport.run(scope: ENV.fetch("SCOPE", "site"), post: ENV["POST"],
67
+ page: ENV.fetch("PAGE", "1"), reach: ENV.fetch("REACH", "false")).to_text
68
+ rescue OpenBlog::Error => error
69
+ abort "#{error.code}: #{error.details.join(', ')}"
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: open-blog-adopt
3
+ description: Import existing articles into Open Blog with historical evidence, reviewed FAQ extraction, and dry runs.
4
+ ---
5
+
6
+ Read the source articles without changing the source system. Preserve each exact slug, source identity, original body, and available historical records. Use `blog_extract_faq` to propose a separate FAQ list. Before applying a proposal, check that each answer is complete, the suggested cuts remove only FAQ content, the remaining body reads correctly, internal links still work, and the body does not duplicate the stored FAQ. Review ambiguous classifications with the user. Store source_body_sha256 when the submitted body differs from the original.
7
+
8
+ 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.
9
+
10
+ Use `blog_adopt_post` with dry_run true first. Inspect the proposed full article, notices, records, and redirects. A complete snapshot replaces omitted FAQ and tag lists with empty lists; send every field that must survive. Missing evidence leaves historical dates unknown. Do not assign today's date as an invented publication date.
11
+
12
+ After reviewing the dry-run result, import within the user's authorized migration scope. Check article totals, slugs, redirects, images, FAQ records, and notices. Reusing a source pair updates that import only until later publishing activity makes replacement unsafe; investigate an already_changed_in_gem refusal instead of forcing it.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: open-blog-install
3
+ description: Install and configure Open Blog in a Rails host, customize its reader theme, and check setup.
4
+ ---
5
+
6
+ Install the gem in the requested Rails application, then run `bin/rails generate open_blog:install`. Inspect the host's JavaScript, Tailwind, image processor, mount path, and authentication setup before selecting options. Use `--skip-tailwind` when the host should keep its CSS configuration; use the generated theme override for branding.
7
+
8
+ Run `bin/rails open_blog:doctor` or `blog_doctor` and resolve relevant errors. Configure the actual publisher, author, public base URL, and policy links. Replace placeholders with user-supplied identities. Keep the initial API token secret; installation prints it once and repeat runs do not recover it. A host authentication hook takes precedence over tokens.
9
+
10
+ Check `tools/list` before using MCP tools. Finish with an actual reader request and authenticated `blog_list_posts` call. Do not publish unrelated content, change production routing, or deploy merely because installation was requested.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: open-blog-policy-pages
3
+ description: Prepare accurate editorial policy pages for an Open Blog publisher from the publisher’s own answers.
4
+ ---
5
+
6
+ Use `blog_doctor` to inspect policy links. Read existing pages with `blog_get_site_page`, passing `kind`: `responsible_party`, `corrections`, `editorial`, or `ai_use`. A missing page returns `not_found`; start with the publisher’s answers below.
7
+
8
+ Ask for the accountable publisher and contact route, correction handling and reader reports, editorial selection and review practices, and how AI is used. Write only practices the user confirms. Reuse an existing host policy URL when that is the user's chosen source.
9
+
10
+ Show the final pages for approval before publishing. Use `blog_save_site_page` with `kind`, `title`, Markdown `body`, and `status: "draft"` while preparing text. After approval, save `status: "published"` with the actual `approved_by` name and ISO date `approved_on`; this requires both write and publish scopes. Do not fill approval fields from assumptions. Verify the published links and rendered text.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: open-blog-publish
3
+ description: Draft, review, publish, or schedule an Open Blog article using revision-bound approval.
4
+ ---
5
+
6
+ Use `blog_search_posts` to check for existing coverage. Reuse or upload images with `blog_upload_image`; put their returned URLs in the body. Supply alternative text and keep FAQ answers as plain text.
7
+
8
+ Save with `blog_save_draft`, inspect the returned findings, and correct the draft before asking for approval. Use the returned preview URL or `blog_get_preview_link` so the user sees the version being reviewed. Keep its revision identifier with the answer.
9
+
10
+ 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.
11
+
12
+ Set provenance to ai_assisted if AI authored or rewrote any content, or human_written only when a person wrote all of it.
13
+
14
+ Call `blog_publish_post` only within the user's publication request. Include the recorded approval when available; do not turn an unanswered fact-check question into true. A future `publish_at` schedules the post. Check the response state, notices, and findings; a schedule is not confirmation that the article is already public.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: open-blog-report
3
+ description: Inspect Open Blog diagnostics and available automated checks, then explain concrete findings and limits.
4
+ ---
5
+
6
+ Start with `blog_doctor` for installation checks and `blog_check_post` for a particular article. Check `tools/list` before requesting broader reports. If `blog_get_surface_report` is available, request the relevant scope; opt into reach checks only when network checks fit the task.
7
+
8
+ Separate observed failures, warnings, skipped checks, and matters that require a person to assess. Automated checks describe observed behavior; they cannot certify editorial practices. Explain each actionable finding using the affected page or field and propose a proportionate correction.
9
+
10
+ Report only what the returned evidence supports. Do not fabricate results for unavailable tools, treat a successful HTTP response as proof of review, or silently rewrite and publish content while inspecting it.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: open-blog-update
3
+ description: Revise or correct a public Open Blog article while preserving its publishing history.
4
+ ---
5
+
6
+ Read the current post and history with `blog_get_post` and `blog_get_post_records`. Choose substantive for a meaningful content revision, correction for an error that needs a reader-visible note, or maintenance for changes that should not advance the modification date.
7
+
8
+ A public article has one content state: an update is immediately public. Present the proposed final text before calling a write tool; there is no separate preview of edits to an already-public post. Fetch the current content again if another editor may have changed it.
9
+
10
+ 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.
11
+
12
+ Set provenance to ai_assisted if AI authored or rewrote any content, or human_written only when a person wrote all of it.
13
+
14
+ Use `blog_update_post` with the selected change type, or `blog_correct_post` with a concrete correction note. Omitted fields remain unchanged; supplied FAQ and tag lists replace their lists. Read the returned findings and revision identifier. `blog_approve_revision` records a later review only for the current public identifier; never reuse an approval for text the reviewer did not see.
15
+
16
+ Include the complete reviewed title, body, FAQ list, and image fields in the update. An inline approval without an identifier binds to the resulting revision and works when the host requires approval. Compare the returned content with the reviewed version. A separate later approval is appropriate only when publication without approval is allowed and the user has reviewed that resulting revision.