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,159 @@
1
+ module OpenBlog
2
+ class Adopt
3
+ autoload :Contract, "open_blog/adopt/contract"
4
+ autoload :Snapshot, "open_blog/adopt/snapshot"
5
+
6
+ def self.call(attributes, actor:, now: Time.current)
7
+ contract = Contract.new(attributes, now: now)
8
+ prepared = ImageResolution.prepare_inputs(contract.attributes)
9
+ body_images = ImageResolution.prepare(attributes: contract.content)
10
+ current = nil
11
+ result = nil
12
+ Post.transaction(requires_new: true) do
13
+ baseline = Baseline.find_by(contract.attributes.slice(:source_system, :source_id))
14
+ resolved = baseline ? baseline.post : PostIdentity.resolve(contract.attributes.slice(:external_id, :slug))
15
+ current = resolved.persisted? ? Post.lock.find(resolved.id) : resolved
16
+ result = RedirectTarget.synchronize do
17
+ new(current, contract, prepared, actor: actor, now: now, body_images: body_images).call
18
+ end
19
+ raise ActiveRecord::Rollback if contract.dry_run?
20
+ end
21
+ result
22
+ rescue Error => error
23
+ failure(current, error, contract)
24
+ rescue ActiveRecord::RecordInvalid => error
25
+ collision = error.record.is_a?(Baseline) && %i[source_id post_id].any? do |field|
26
+ error.record.errors.details[field].any? { |detail| detail[:error] == :taken }
27
+ end
28
+ refusal = collision ? Error::IdentityConflict.new : Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s))
29
+ failure(current, refusal, contract)
30
+ rescue ActiveRecord::RecordNotFound
31
+ failure(current, Error::NotFound.new, contract)
32
+ rescue ActiveRecord::RecordNotUnique
33
+ failure(current, Error::IdentityConflict.new, contract)
34
+ end
35
+
36
+ def self.failure(post, error, contract)
37
+ result = Operation.failure(post, error)
38
+ Result.new(**result.to_h.merge(dry_run: contract&.dry_run? || false))
39
+ end
40
+ private_class_method :failure
41
+
42
+ def initialize(post, contract, prepared, actor:, now:, body_images:)
43
+ @post, @contract, @prepared, @actor, @now = post, contract, prepared, actor, now
44
+ @body_images = body_images
45
+ @created = post.new_record?
46
+ end
47
+
48
+ def call
49
+ baseline = @post.baseline if @post.persisted?
50
+ verify_identity(baseline)
51
+ previous = Snapshot.stored(@post, baseline, include_connections: @contract.connections.present?)
52
+ assign_candidate
53
+ redirects = @contract.redirects(default_date: baseline&.adopted_at&.to_date || @now.to_date, current_slug: @post.slug)
54
+ validate_redirects(redirects)
55
+ candidate = Snapshot.candidate(@post, @contract, redirects)
56
+ if previous == candidate
57
+ @post.reload
58
+ return result({ revision: "same", publication: nil, approval: nil, baseline: nil, redirects: 0 })
59
+ end
60
+ replace_records(baseline) if baseline
61
+ @prepared.each { |field, value| @post.public_send("#{field}=", value.persist(uploaded_by: @actor)) }
62
+ @body_images.materialize(@post, actor: @actor)
63
+ @post.status = "draft"
64
+ @post.publish_at = nil
65
+ @post.save!
66
+ fill_category_description
67
+ revision = @post.revisions.create!(identifier: @post.current_revision_identifier,
68
+ payload: RevisionPayload.new(@post).to_json, actor: @actor, created_at: @now)
69
+ @post.create_baseline!(**@contract.baseline, adopted_revision: revision, adopted_at: @now, adopted_by: @actor)
70
+ @post.publications.create!(revision: revision, entry_type: "adopted", occurred_at: @now, released_by: @actor)
71
+ if @contract.approval
72
+ @post.approvals.create!(**@contract.approval, revision: revision, recorded_by: @actor)
73
+ end
74
+ if @contract.connections && Snapshot.latest_connections(@post) != @contract.connections
75
+ @post.connection_declarations.create!(**@contract.connections, recorded_by: @actor, created_at: @now)
76
+ end
77
+ write_redirects(redirects)
78
+ published_at = @contract.baseline[:first_published_at] || @contract.baseline[:declared_first_published_at]
79
+ @post.update_columns(status: "published", public_revision_id: revision.id, published_at: published_at,
80
+ modified_at: @contract.baseline[:last_modified_at] || published_at)
81
+ @post.association(:public_revision).reset
82
+ result({ revision: "new", publication: "adopted", approval: @contract.approval&.fetch(:kind),
83
+ baseline: baseline ? "replaced" : "new", redirects: redirects.length })
84
+ end
85
+
86
+ private
87
+
88
+ def verify_identity(baseline)
89
+ if baseline
90
+ if !@post.published? || @post.publications.where.not(entry_type: "adopted").exists? ||
91
+ Redirect.where(post: @post).where.not(source: "adoption").exists?
92
+ raise Error::AlreadyChangedInGem
93
+ end
94
+ unless baseline.source_system == @contract.attributes[:source_system] && baseline.source_id == @contract.attributes[:source_id]
95
+ raise Error::IdentityConflict
96
+ end
97
+ elsif @post.persisted? && (@post.public_revision_id || @post.revisions.exists? || @post.publications.exists? || @post.approvals.exists? || @post.connection_declarations.exists?)
98
+ raise Error::IdentityConflict
99
+ end
100
+ end
101
+
102
+ def assign_candidate
103
+ attributes = @contract.content.except(*@prepared.keys)
104
+ PostAttributes.assign(@post, attributes, actor: @actor, now: @now)
105
+ @prepared.each { |field, value| @post.public_send("#{field}=", value.image) }
106
+ @post.status = "published"
107
+ description = @contract.attributes[:category_description]
108
+ @post.category.description = description if @post.category && @post.category.description.blank? && description.present?
109
+ @body_images.preview(@post)
110
+ @post.send(:compute_derived)
111
+ @post.valid?
112
+ raise ActiveRecord::RecordInvalid, @post if @post.errors.any?
113
+ end
114
+
115
+ def fill_category_description
116
+ @post.category.save! if @post.category&.changed?
117
+ end
118
+
119
+ def replace_records(baseline)
120
+ revision_id = baseline.adopted_revision_id
121
+ @post.update_columns(public_revision_id: nil)
122
+ @post.association(:public_revision).reset
123
+ @post.approvals.where(revision_id: revision_id).delete_all
124
+ @post.publications.where(entry_type: "adopted").delete_all
125
+ Baseline.where(id: baseline.id).delete_all
126
+ @post.association(:baseline).reset
127
+ @post.revisions.where(id: revision_id).delete_all
128
+ end
129
+
130
+ def validate_redirects(redirects)
131
+ redirects.each do |entry|
132
+ slug = entry[:old_path].delete_prefix("#{OpenBlog.mount_path.chomp('/')}/")
133
+ owner = Redirect.find_by(old_path: entry[:old_path])
134
+ occupied = Post.where(slug: slug)
135
+ occupied = occupied.where.not(id: @post.id) if @post.persisted?
136
+ if (Post::RESERVED_SLUGS + OpenBlog.config.route_segments.values).include?(slug) || occupied.exists? ||
137
+ (owner && (owner.post_id != @post.id || owner.source != "adoption"))
138
+ raise Error::SlugReserved
139
+ end
140
+ end
141
+ end
142
+
143
+ def write_redirects(redirects)
144
+ Redirect.where(post: @post, source: "adoption").delete_all
145
+ redirects.each { |attributes| Redirect.create!(**attributes, post: @post, source: "adoption") }
146
+ end
147
+
148
+ def result(records)
149
+ if @contract.dry_run?
150
+ %i[author category series cover_image social_image public_revision baseline faqs tags revisions approvals publications connection_declarations rich_text_rich_body].each do |name|
151
+ @post.association(name).load_target
152
+ end
153
+ end
154
+ Result.new(post: @post, created: @created, records: records, label: LabelPolicy.for(@post),
155
+ findings: Findings.for(@post, context: { author_default_used: !@contract.attributes.key?(:author),
156
+ approval_incomplete: @contract.approval&.fetch(:facts_checked) == false }), dry_run: @contract.dry_run?)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,40 @@
1
+ module OpenBlog
2
+ module ApiFields
3
+ REPORT = %i[scope post page reach].freeze
4
+ POST_WRITE = (PostAttributes::CONTENT_FIELDS + PostAttributes::RECORD_FIELDS).freeze
5
+ POST_LIST = %i[status category tag author series q page per_page].freeze
6
+ POST_NESTED = {
7
+ author: %i[name type url], approval: %i[name facts_checked revision_identifier],
8
+ cover_image: %i[image_id signed_id url], social_image: %i[image_id signed_id url],
9
+ connections: %i[connections third_party_paid declared_by declared_on], faq: %i[question answer]
10
+ }.freeze
11
+ APPROVAL = %i[revision_identifier name facts_checked].freeze
12
+ CONNECTION = %i[connections third_party_paid declared_by declared_on].freeze
13
+ CATEGORY = %i[name slug description position].freeze
14
+ AUTHOR = %i[name slug type bio url profile_urls host_reference avatar].freeze
15
+ SERIES = %i[name slug description].freeze
16
+ REDIRECT = %i[old_path new_path occurred_on post_id].freeze
17
+ ADOPTION = (Adopt::Contract::CONTENT_FIELDS + Adopt::Contract::EXTRA_FIELDS).freeze
18
+ EXTRACTION = %i[body standalone_questions].freeze
19
+ PAGE = %i[page per_page].freeze
20
+ VIEWS = %i[from to].freeze
21
+ TOP_VIEWS = %i[days limit].freeze
22
+ SITE_PAGE = %i[title body status slug approved_by approved_on].freeze
23
+ IMAGE = %i[file url].freeze
24
+ REMOVE = %i[redirect_to].freeze
25
+
26
+ def self.validate_post_images!(attributes)
27
+ %i[cover_image social_image].each do |field|
28
+ value = attributes[field]
29
+ next if value.nil?
30
+ raise Error::ImageNotPermitted unless value.is_a?(Hash) && value.size == 1
31
+ if value.key?(:image_id)
32
+ raise Error::ImageNotPermitted unless value[:image_id].is_a?(Integer) && value[:image_id].positive?
33
+ else
34
+ key = value.keys.first
35
+ raise Error::ImageNotPermitted unless %i[signed_id url].include?(key) && value[key].is_a?(String)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,18 @@
1
+ module OpenBlog
2
+ class Approve
3
+ def self.call(post, revision_identifier:, name:, facts_checked:, actor:, now: Time.current)
4
+ Operation.run(post: post, actor: actor, now: now, context: { approval_incomplete: facts_checked == false }) do |current, _created|
5
+ raise Error::NotFound unless current.persisted?
6
+ unless name.is_a?(String) && name.present? && [ true, false ].include?(facts_checked)
7
+ raise Error::ApprovalIncomplete
8
+ end
9
+ revision = current.public_revision
10
+ raise Error::RevisionMismatch unless revision && revision.identifier == revision_identifier
11
+
12
+ current.approvals.create!(revision: revision, kind: "sent", reviewer_name: name,
13
+ facts_checked: facts_checked, approved_at: now, recorded_by: actor)
14
+ { revision: "same", publication: nil, approval: "sent" }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ module OpenBlog
2
+ module Authentication
3
+ def self.actor_for(request)
4
+ if (hook = OpenBlog.config.authenticate)
5
+ source = hook.call(request)
6
+ return unless source
7
+ scopes = source.respond_to?(:scopes) ? source.scopes : ApiToken::SCOPES
8
+ identity = source.respond_to?(:id) && source.id.present? ? source.id : source.name
9
+ return Actor.new(name: source.name, scopes: scopes, id: "hook:#{identity}")
10
+ end
11
+
12
+ match = /\ABearer (ob_[1-9A-HJ-NP-Za-km-z]{40})\z/.match(request.authorization.to_s)
13
+ return unless match
14
+ token = ApiToken.authenticate(match[1])
15
+ Actor.new(name: token.name, scopes: token.scopes, id: "token:#{token.id}") if token
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,53 @@
1
+ require "fileutils"
2
+ require "open3"
3
+ require "pathname"
4
+ require "tmpdir"
5
+ require "tailwindcss/ruby"
6
+
7
+ module OpenBlog
8
+ class BuildCss
9
+ COMPILER_VERSION = "4.3.3"
10
+
11
+ def self.render
12
+ unless Gem.loaded_specs.fetch("tailwindcss-ruby").version.to_s == COMPILER_VERSION
13
+ raise ConfigurationError, "Build stylesheets with tailwindcss-ruby #{COMPILER_VERSION}"
14
+ end
15
+ Dir.mktmpdir("open-blog-css") do |directory|
16
+ input = Pathname(directory).join("input.css")
17
+ output = Pathname(directory).join("output.css")
18
+ input.write(<<~CSS)
19
+ @import "tailwindcss" source(none);
20
+ @import #{theme_root.join("theme.css").to_s.to_json};
21
+ @source #{Engine.root.join("lib/generators/open_blog/install/templates/views").to_s.to_json};
22
+ CSS
23
+ _stdout, stderr, status = Open3.capture3(Tailwindcss::Ruby.executable.to_s, "--input", input.to_s,
24
+ "--output", output.to_s, "--minify", chdir: Engine.root.to_s)
25
+ raise ConfigurationError, "Stylesheet build failed: #{stderr.strip}" unless status.success?
26
+ output.binread
27
+ end
28
+ end
29
+
30
+ def self.tokens
31
+ source = theme_root.join("theme.css").read
32
+ block = source.match(%r{/\* open-blog:tokens:start \*/(.*?)/\* open-blog:tokens:end \*/}m)
33
+ raise ConfigurationError, "The theme must contain its marked token block" unless block
34
+ "#{block[1].strip}\n"
35
+ end
36
+
37
+ def self.write(path: Engine.root.join("app/assets/builds/open_blog/blog.css"), tokens_path: theme_root.join("open_blog_theme.css"))
38
+ css = render
39
+ values = tokens
40
+ [ [ path, css ], [ tokens_path, values ] ].each do |destination, bytes|
41
+ destination = Pathname(destination)
42
+ FileUtils.mkdir_p(destination.dirname)
43
+ destination.binwrite(bytes)
44
+ end
45
+ Pathname(path)
46
+ end
47
+
48
+ def self.theme_root
49
+ Engine.root.join("lib/generators/open_blog/install/templates/theme")
50
+ end
51
+ private_class_method :theme_root
52
+ end
53
+ end
@@ -0,0 +1,185 @@
1
+ require "rails"
2
+ require "i18n"
3
+ require "uri"
4
+ require "active_support/core_ext/numeric/time"
5
+ require "active_support/core_ext/numeric/bytes"
6
+ require "open_blog/configuration_error"
7
+ require "open_blog/page_views"
8
+
9
+ module OpenBlog
10
+ class Configuration
11
+ class Mcp
12
+ attr_accessor :enabled, :max_page_size
13
+
14
+ def initialize
15
+ @enabled = true
16
+ @max_page_size = 50
17
+ end
18
+ end
19
+
20
+ attr_accessor :site_name, :public_base_url, :default_author, :publisher, :locale,
21
+ :blog_title, :blog_tagline, :layout, :parent_controller, :mount_path,
22
+ :route_segments, :posts_per_page, :primary_list_type, :call_to_action,
23
+ :default_social_image_url, :policy_urls, :sign_in_destinations, :body_formats,
24
+ :default_body_format, :markdown_hardbreaks, :ai_label, :require_approval,
25
+ :color_scheme, :syntax_theme, :feed_content, :feed_size, :serve_sitemap,
26
+ :page_views, :page_view_bot_pattern, :page_view_retention_days, :popular_posts,
27
+ :preview_expires_in, :storage_service, :image_delivery, :max_image_bytes,
28
+ :image_content_types, :image_fetch_policy, :api_rate_limit, :search_rate_limit
29
+ attr_reader :mcp, :authenticate, :before_publish
30
+ attr_writer :title_suffix, :rate_limit_store
31
+
32
+ def initialize
33
+ @locale = I18n.default_locale
34
+ @blog_title = "Blog".dup
35
+ @layout = "open_blog".dup
36
+ @parent_controller = "ActionController::Base".dup
37
+ @mount_path = "/blog".dup
38
+ @route_segments = { category: "category", tag: "tag", author: "author", series: "series" }.transform_values(&:dup)
39
+ @posts_per_page = 12
40
+ @primary_list_type = :categories
41
+ @policy_urls = { responsible_party: nil, corrections: nil, editorial: nil, ai_use: nil }
42
+ @body_formats = [ :markdown ]
43
+ @default_body_format = :markdown
44
+ @markdown_hardbreaks = true
45
+ @ai_label = :when_required
46
+ @require_approval = false
47
+ @color_scheme = :system
48
+ @syntax_theme = "github".dup
49
+ @feed_content = :summary
50
+ @feed_size = 20
51
+ @serve_sitemap = true
52
+ @page_views = true
53
+ @page_view_bot_pattern = PageViews::BOT_PATTERN
54
+ @popular_posts = { enabled: false, days: 30, limit: 5 }
55
+ @preview_expires_in = 7.days
56
+ @image_delivery = :redirect
57
+ @max_image_bytes = 10.megabytes
58
+ @image_content_types = %w[image/png image/jpeg image/webp image/gif image/avif].map(&:dup)
59
+ @image_fetch_policy = :public_only
60
+ @api_rate_limit = { to: 120, within: 1.minute }
61
+ @search_rate_limit = { to: 30, within: 1.minute }
62
+ @mcp = Mcp.new
63
+ end
64
+
65
+ def title_suffix
66
+ defined?(@title_suffix) ? @title_suffix : " — #{site_name}"
67
+ end
68
+
69
+ def rate_limit_store
70
+ defined?(@rate_limit_store) ? @rate_limit_store : Rails.cache
71
+ end
72
+
73
+ def authenticate=(hook)
74
+ validate_hook!(:authenticate, hook, 1)
75
+ @authenticate = hook
76
+ end
77
+
78
+ def before_publish=(hook)
79
+ validate_hook!(:before_publish, hook, 2)
80
+ @before_publish = hook
81
+ end
82
+
83
+ def validate!
84
+ %i[site_name default_author publisher].each do |key|
85
+ invalid!(key, "is required") if public_send(key).nil?
86
+ end
87
+ invalid!(:public_base_url, "is required in production") if Rails.env.production? && public_base_url.nil?
88
+ validate_structure!
89
+ end
90
+
91
+ def validate_structure!
92
+ invalid!(:site_name, "must be a nonblank string") unless site_name.nil? || nonblank_string?(site_name)
93
+ validate_identity!(:default_author, default_author)
94
+ validate_identity!(:publisher, publisher)
95
+ if default_author && !%i[person organization].include?(default_author[:type])
96
+ invalid!(:default_author, "type must be :person or :organization")
97
+ end
98
+ validate_base_url! unless public_base_url.nil?
99
+ unless body_formats.is_a?(Array) && body_formats.any? && (body_formats - %i[markdown rich_text]).empty?
100
+ invalid!(:body_formats, "must be a nonempty array containing :markdown or :rich_text")
101
+ end
102
+ invalid!(:default_body_format, "must be enabled in body_formats") unless body_formats.include?(default_body_format)
103
+ preview_duration
104
+ validate_view_settings!
105
+ invalid!(:mcp, "enabled must be true or false") unless [ true, false ].include?(mcp.enabled)
106
+ invalid!(:mcp, "max_page_size must be a positive integer") unless mcp.max_page_size.is_a?(Integer) && mcp.max_page_size.positive?
107
+ self
108
+ end
109
+
110
+ def preview_duration
111
+ value = preview_expires_in
112
+ unless (value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)) && value.to_f.finite? && value.to_f.positive?
113
+ invalid!(:preview_expires_in, "must be a positive finite duration")
114
+ end
115
+ value.to_f
116
+ end
117
+
118
+ private
119
+
120
+ def validate_view_settings!
121
+ invalid!(:page_views, "must be true or false") unless [ true, false ].include?(page_views)
122
+ invalid!(:page_view_bot_pattern, "must be a regular expression") unless page_view_bot_pattern.is_a?(Regexp)
123
+ unless page_view_retention_days.nil? || (page_view_retention_days.is_a?(Integer) && page_view_retention_days.positive?)
124
+ invalid!(:page_view_retention_days, "must be nil or a positive integer")
125
+ end
126
+ unless popular_posts.is_a?(Hash) && [ true, false ].include?(popular_posts[:enabled])
127
+ invalid!(:popular_posts, "must include an enabled boolean")
128
+ end
129
+ { days: 36500, limit: 100 }.each do |key, maximum|
130
+ value = popular_posts.fetch(key, key == :days ? 30 : 5)
131
+ invalid!(:popular_posts, "#{key} must be an integer from 1 to #{maximum}") unless value.is_a?(Integer) && value.between?(1, maximum)
132
+ end
133
+ end
134
+
135
+ def validate_identity!(key, identity)
136
+ return if identity.nil?
137
+ unless identity.is_a?(Hash) && nonblank_string?(identity[:name])
138
+ invalid!(key, "must be a hash with a nonblank name")
139
+ end
140
+ %i[url logo_url].each do |field|
141
+ next if identity[field].nil?
142
+ invalid!(key, "#{field} must be an absolute HTTP(S) URL") unless http_url?(identity[field])
143
+ end
144
+ end
145
+
146
+ def validate_base_url!
147
+ unless http_url?(public_base_url)
148
+ invalid!(:public_base_url, "must be an absolute HTTP(S) origin")
149
+ end
150
+ uri = URI.parse(public_base_url)
151
+ unless [ "", "/" ].include?(uri.path) && uri.query.nil? && uri.fragment.nil?
152
+ invalid!(:public_base_url, "must contain only the scheme, host and optional port")
153
+ end
154
+ end
155
+
156
+ def http_url?(value)
157
+ return false unless value.is_a?(String)
158
+ uri = URI.parse(value)
159
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
160
+ rescue URI::InvalidURIError
161
+ false
162
+ end
163
+
164
+ def nonblank_string?(value)
165
+ value.is_a?(String) && !value.strip.empty?
166
+ end
167
+
168
+ def validate_hook!(name, hook, argument_count)
169
+ return if hook.nil?
170
+ invalid!(name, "must be callable") unless hook.respond_to?(:call)
171
+ parameters = hook.is_a?(Proc) || hook.is_a?(Method) ? hook.parameters : hook.method(:call).parameters
172
+ required = parameters.count { |kind, _| kind == :req }
173
+ capacity = parameters.count { |kind, _| %i[req opt].include?(kind) }
174
+ rest = parameters.any? { |kind, _| kind == :rest }
175
+ keywords = parameters.any? { |kind, _| kind == :keyreq }
176
+ unless required <= argument_count && (rest || capacity >= argument_count) && !keywords
177
+ invalid!(name, "must accept #{argument_count} positional arguments without required keywords")
178
+ end
179
+ end
180
+
181
+ def invalid!(name, message)
182
+ raise ConfigurationError, "OpenBlog #{name} #{message}"
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,4 @@
1
+ module OpenBlog
2
+ class ConfigurationError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,77 @@
1
+ module OpenBlog
2
+ module ContentGuard
3
+ extend ActiveSupport::Concern
4
+
5
+ STATE_KEY = :open_blog_content_parents
6
+
7
+ def self.with_parent(post)
8
+ previous = ActiveSupport::IsolatedExecutionState[STATE_KEY]
9
+ Post.lock.find(post.id) if post.persisted?
10
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = Array(previous) + [ post ]
11
+ yield
12
+ ensure
13
+ if previous
14
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = previous
15
+ else
16
+ ActiveSupport::IsolatedExecutionState.delete(STATE_KEY)
17
+ end
18
+ end
19
+
20
+ def self.active?(post)
21
+ return false unless post
22
+ Array(ActiveSupport::IsolatedExecutionState[STATE_KEY]).any? do |parent|
23
+ parent.equal?(post) || (parent.id && parent.id == post.id)
24
+ end
25
+ end
26
+
27
+ included do
28
+ around_save :guard_content_write, prepend: true
29
+ around_destroy :guard_content_write, prepend: true
30
+ end
31
+
32
+ def update_columns(attributes)
33
+ reject_content_bypass if content_guard_applies? || content_guard_target?(attributes)
34
+ super
35
+ end
36
+
37
+ def delete
38
+ reject_content_bypass if content_guard_applies?
39
+ super
40
+ end
41
+
42
+ def increment!(attribute, by = 1, touch: nil)
43
+ reject_content_bypass if content_guard_applies?
44
+ super
45
+ end
46
+
47
+ def touch(*names, time: nil)
48
+ if content_guard_applies? && (names.map(&:to_s) - %w[created_at updated_at]).any?
49
+ reject_content_bypass
50
+ end
51
+ super
52
+ end
53
+
54
+ private
55
+
56
+ def content_guard_target?(_attributes)
57
+ false
58
+ end
59
+
60
+ def guard_content_write
61
+ return yield unless content_guard_applies?
62
+ reject_content_bypass if content_guard_identity_changed?
63
+ parent = content_guard_parent
64
+ return yield if ContentGuard.active?(parent)
65
+ reject_content_bypass unless parent&.persisted?
66
+
67
+ fresh = Post.lock.find(parent.id)
68
+ result = yield
69
+ RecordRelease.call(fresh) if result
70
+ result
71
+ end
72
+
73
+ def reject_content_bypass
74
+ raise ActiveRecord::ReadOnlyRecord, "Save or destroy content through its existing post association"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,61 @@
1
+ module OpenBlog
2
+ class Doctor
3
+ module Files
4
+ private
5
+
6
+ def read(path)
7
+ file = @root.join(path)
8
+ file.file? ? file.read : ""
9
+ end
10
+
11
+ def layout_source
12
+ read("app/views/layouts/#{@config.layout}.html.erb")
13
+ end
14
+
15
+ def views
16
+ layout = layout_source
17
+ content = view_tree("app/views/open_blog/posts/show.html.erb")
18
+ required = %w[open_blog_post_content open_blog_notices open_blog_byline open_blog_faq open_blog_responsible_party_link open_blog_cover]
19
+ absent = required.reject { |helper| content.match?(/\b#{helper}\b/) }
20
+ absent += %w[open_blog_head open_blog_structured_data].reject { |helper| layout.match?(/\b#{helper}\b/) }
21
+ absent << "original content cover (variant found)" if content.match?(/cover_image[^\n]*\.variant\b/)
22
+ absent.any? ? [ "error", "Copied views are missing required helpers: #{absent.join(', ')}." ] : [ "ok", "Copied layout and post partials call the reader helpers." ]
23
+ end
24
+
25
+ def view_tree(path, seen = [])
26
+ return "" if seen.include?(path)
27
+ seen << path
28
+ source = read(path)
29
+ partials = source.scan(/\brender\s*(?:\(?\s*)(?:partial:\s*)?["']([^"']+)["']/).flatten
30
+ source + partials.map do |partial|
31
+ directory, name = File.dirname(partial), File.basename(partial)
32
+ view_tree("app/views/#{directory}/_#{name}.html.erb", seen)
33
+ end.join("\n")
34
+ end
35
+
36
+ def theme
37
+ layout = layout_source
38
+ tailwind = @root.join("app/assets/tailwind/application.css")
39
+ bundled = @root.join("app/assets/stylesheets/application.tailwind.css")
40
+ if !layout.include?("open_blog_stylesheets")
41
+ asset = layout[/stylesheet_link_tag\s*\(?\s*["']([^"']+)["']/, 1]
42
+ return [ "error", "The blog layout must load the installed stylesheet." ] unless %w[tailwind application].include?(asset)
43
+ entry = asset == "tailwind" ? tailwind : bundled
44
+ return [ "error", "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." ] unless entry.file?
45
+ source = entry.read
46
+ return [ "error", "Import open_blog/theme.css in #{entry.relative_path_from(@root)}." ] unless source.match?(/@import\s+["'][^"']*open_blog\/theme(?:\.css)?["']/)
47
+ absent = %w[theme blog syntax].reject { |name| @root.join("app/assets/tailwind/open_blog/#{name}.css").file? }
48
+ return [ "error", "Missing theme sources: #{absent.join(', ')}." ] if absent.any?
49
+ asset = "#{asset}.css"
50
+ else
51
+ return [ "error", "Install app/assets/stylesheets/open_blog_theme.css." ] unless @root.join("app/assets/stylesheets/open_blog_theme.css").file?
52
+ asset = "open_blog/blog.css"
53
+ return [ "error", "The blog layout must load open_blog_stylesheets." ] unless layout.include?("open_blog_stylesheets")
54
+ end
55
+ return [ "error", "Build the missing #{asset} stylesheet." ] unless @application.assets&.resolver&.resolve(asset)
56
+ all = Dir[@root.join("app/views/layouts/**/*")].select { |path| File.file?(path) }.any? { |path| File.read(path).match?(/stylesheet_link_tag\s*(?:\(?\s*):all\b/) }
57
+ all ? [ "warning", "Replace stylesheet_link_tag :all to avoid loading the blog reset on other pages." ] : [ "ok", "Theme sources and compiled stylesheet are available." ]
58
+ end
59
+ end
60
+ end
61
+ end