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,16 @@
1
+ module OpenBlog
2
+ class Redirect < ApplicationRecord
3
+ belongs_to :post, optional: true
4
+ validates :old_path, :occurred_on, presence: true
5
+ validates :old_path, uniqueness: true, format: { with: /\A\/(?!\/)/ }
6
+ validates :source, inclusion: { in: %w[slug_change removal unpublish adoption manual] }
7
+ validate :preserve_history, on: :update
8
+
9
+ private
10
+ def preserve_history
11
+ if (changed - %w[new_path updated_at]).any?
12
+ errors.add(:base, "Only the redirect target can change")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ module OpenBlog
2
+ class Revision < ApplicationRecord
3
+ include Immutable
4
+ belongs_to :post
5
+ has_many :approvals, dependent: :restrict_with_exception
6
+ has_many :publications, dependent: :restrict_with_exception
7
+
8
+ validates :identifier, presence: true, uniqueness: { scope: :post_id }, format: { with: /\A[0-9a-f]{64}\z/ }
9
+ validates :payload, presence: true
10
+ validates :made_by_ai, inclusion: { in: [ true, false ] }, allow_nil: true
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ module OpenBlog
2
+ class Series < ApplicationRecord
3
+ self.table_name = "open_blog_series"
4
+ has_many :posts, dependent: :nullify
5
+
6
+ before_validation { self.slug = name.to_s.parameterize if slug.blank? }
7
+ validates :name, :slug, presence: true
8
+ validates :slug, uniqueness: true, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ module OpenBlog
2
+ class Tag < ApplicationRecord
3
+ has_many :taggings, dependent: :destroy
4
+ has_many :posts, through: :taggings
5
+
6
+ before_validation { self.slug = name.to_s.parameterize if slug.blank? }
7
+ validates :name, :slug, presence: true
8
+ validates :name, uniqueness: { case_sensitive: false }
9
+ validates :slug, uniqueness: true, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class Tagging < ApplicationRecord
3
+ belongs_to :post
4
+ belongs_to :tag
5
+ validates :tag_id, uniqueness: { scope: :post_id }
6
+ end
7
+ end
@@ -0,0 +1,54 @@
1
+ module OpenBlog
2
+ class ApiPostQuery
3
+ FILTERS = ApiFields::POST_LIST
4
+
5
+ def self.call(filters, base_url: nil)
6
+ new(filters, base_url: base_url).call
7
+ end
8
+
9
+ def initialize(filters, base_url:)
10
+ @filters, @base_url = filters, base_url
11
+ end
12
+
13
+ def call
14
+ page = positive_integer(:page, 1)
15
+ per_page = [ positive_integer(:per_page, 25), 100 ].min
16
+ relation = Post.all
17
+ if @filters.key?(:status)
18
+ invalid(:status) unless Post.statuses.key?(@filters[:status])
19
+ relation = relation.where(status: @filters[:status])
20
+ end
21
+ { category: :category, tag: :tags, author: :author, series: :series }.each do |field, association|
22
+ next unless @filters.key?(field)
23
+ value = @filters[field]
24
+ invalid(field) unless value.is_a?(String) && value.present?
25
+ model = Post.reflect_on_association(association).klass
26
+ matches = model.where(slug: value).or(model.where(name: value))
27
+ matches = matches.or(model.where(id: value.to_i)) if value.match?(/\A[1-9]\d*\z/)
28
+ relation = relation.where(id: Post.joins(association).merge(matches).select(:id))
29
+ end
30
+ if @filters.key?(:q)
31
+ query = @filters[:q]
32
+ invalid(:q) unless query.is_a?(String)
33
+ relation = Search.call(query, scope: relation)
34
+ end
35
+ total = relation.count
36
+ posts = relation.order(created_at: :desc, id: :desc).offset((page - 1) * per_page).limit(per_page)
37
+ .includes(:author, :category, :tags, :public_revision)
38
+ { posts: posts.map { |post| PostSerializer.call(post, card: true, base_url: @base_url) }, page: page, per_page: per_page, total: total }
39
+ end
40
+
41
+ private
42
+
43
+ def positive_integer(field, default)
44
+ return default unless @filters.key?(field)
45
+ value = @filters[field]
46
+ invalid(field) unless (value.is_a?(Integer) || value.is_a?(String)) && value.to_s.match?(/\A[1-9]\d*\z/) && value.to_s.length <= 9
47
+ value.to_i
48
+ end
49
+
50
+ def invalid(field)
51
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,9 @@
1
+ module OpenBlog
2
+ class AdoptionResultSerializer
3
+ def self.call(result, base_url: nil)
4
+ { post: PostSerializer.call(result.post, base_url: base_url, label: result.label), created: result.created,
5
+ records: result.records.slice(:revision, :publication, :approval, :baseline, :redirects),
6
+ findings: FindingsSerializer.entries(result.findings), dry_run: result.dry_run }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module OpenBlog
2
+ class AuthorSerializer
3
+ def self.call(record)
4
+ image = ImageResolution.image_for_attachment(record.avatar.blob) if record.avatar.attached?
5
+ { id: record.id, name: record.name, slug: record.slug, type: record.author_type, bio: record.bio,
6
+ url: record.url, profile_urls: record.profile_urls, avatar: ImageSerializer.call(image),
7
+ host_reference: record.host_reference, posts_count: record.posts.count }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class CategorySerializer
3
+ def self.call(record)
4
+ { id: record.id, name: record.name, slug: record.slug, description: record.description, position: record.position, posts_count: record.posts.count }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class DoctorSerializer
3
+ def self.call(checks)
4
+ { checks: checks.map { |check| { name: check[:name], status: check[:status], message: check[:message] } } }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class ErrorSerializer
3
+ def self.call(error)
4
+ { error: { code: error.code, message: error.message, details: error.details } }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class ExtractionResultSerializer
3
+ def self.call(result)
4
+ result.slice(:pairs, :cut, :body_after, :leftover, :class, :reasons, :source_body_sha256)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ class FindingsSerializer
3
+ def self.call(findings)
4
+ { findings: entries(findings) }
5
+ end
6
+
7
+ def self.entries(findings)
8
+ findings.map do |finding|
9
+ { code: finding[:code], rule: finding[:rule], message: finding[:message], location: finding[:location] }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module OpenBlog
2
+ class ImageSerializer
3
+ def self.call(image)
4
+ return unless image
5
+ { image_id: image.id, url: image.path, sha256: image.sha256, filename: image.filename,
6
+ content_type: image.content_type, byte_size: image.byte_size, width: image.width, height: image.height }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module OpenBlog
2
+ class PageSerializer
3
+ def self.call(page, base_url: nil)
4
+ { kind: page.kind, slug: page.slug, url: page.url(base: OpenBlog.config.public_base_url || base_url),
5
+ title: page.title, body: page.body_markdown, status: page.status,
6
+ approved_by: page.approved_by, approved_on: page.approved_on, updated_at: page.updated_at&.iso8601 }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,59 @@
1
+ module OpenBlog
2
+ class PostSerializer
3
+ def self.call(post, base_url: nil, card: false, label: nil)
4
+ new(post, base_url: base_url, label: label).call(card: card)
5
+ end
6
+
7
+ def initialize(post, base_url:, label: nil)
8
+ @post, @base_url, @label = post, OpenBlog.config.public_base_url || base_url, label
9
+ end
10
+
11
+ def call(card: false)
12
+ result = {
13
+ id: @post.id, slug: @post.slug, url: @post.url(base: @base_url), status: @post.status,
14
+ title: @post.title, description: @post.description, author: author,
15
+ category: taxonomy(@post.category), tags: @post.tags.map(&:name),
16
+ published_at: timestamp(@post.published_at), modified_at: timestamp(@post.modified_at),
17
+ revision_identifier: @post.current_revision_identifier, label: (@label || LabelPolicy.for(@post)).to_s
18
+ }
19
+ return result if card
20
+
21
+ result.merge(search_title: @post.search_title, search_description: @post.search_description,
22
+ body_format: @post.body_format, body: @post.body_for_payload,
23
+ series: @post.series && taxonomy(@post.series).merge(position: @post.series_position),
24
+ featured: @post.featured, canonical_url: @post.canonical_url,
25
+ cover_image: ImageSerializer.call(@post.cover_image), cover_alt: @post.cover_alt,
26
+ social_image: ImageSerializer.call(@post.social_image), faq: @post.faq_list,
27
+ provenance: @post.provenance, provenance_evidence: @post.provenance_evidence,
28
+ external_id: @post.external_id, publish_at: timestamp(@post.publish_at),
29
+ public_revision_identifier: @post.public_revision&.identifier,
30
+ approved: approved?,
31
+ preview_url: @post.previewable? ? PreviewSerializer.call(@post, base_url: @base_url)[:preview_url] : nil, reading_time_minutes: @post.reading_time_minutes, word_count: @post.word_count,
32
+ created_at: timestamp(@post.created_at), updated_at: timestamp(@post.updated_at))
33
+ end
34
+
35
+ private
36
+
37
+ def approved?
38
+ return false unless @post.public_revision_id
39
+ if @post.association(:approvals).loaded?
40
+ @post.approvals.any? { |approval| approval.revision_id == @post.public_revision_id && approval.facts_checked }
41
+ else
42
+ @post.approvals.exists?(revision_id: @post.public_revision_id, facts_checked: true)
43
+ end
44
+ end
45
+
46
+ def author
47
+ record = @post.author
48
+ { id: record.id, name: record.name, slug: record.slug, type: record.author_type, url: record.url }
49
+ end
50
+
51
+ def taxonomy(record)
52
+ { id: record.id, name: record.name, slug: record.slug } if record
53
+ end
54
+
55
+ def timestamp(value)
56
+ value&.iso8601
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ module OpenBlog
2
+ class PreviewSerializer
3
+ def self.call(post, base_url: nil)
4
+ credentials = post.preview_credentials
5
+ origin = OpenBlog.config.public_base_url || base_url
6
+ path = "#{OpenBlog.mount_path.chomp('/')}/preview/#{ERB::Util.url_encode(credentials.fetch(:token))}"
7
+ { preview_url: "#{origin.to_s.chomp('/')}#{path}", revision_identifier: post.current_revision_identifier,
8
+ expires_at: credentials.fetch(:expires_at).iso8601 }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,28 @@
1
+ module OpenBlog
2
+ class RecordsSerializer
3
+ BASELINE_FIELDS = %i[post_id adopted_at adopted_revision_id provenance provenance_evidence first_published_at
4
+ first_published_evidence declared_first_published_at last_modified_at last_modified_evidence
5
+ source_system source_id source_body_sha256 adopted_by].freeze
6
+ CONNECTION_FIELDS = %i[post_id connections third_party_paid declared_by declared_on recorded_by].freeze
7
+
8
+ def self.call(post)
9
+ {
10
+ revisions: post.revisions.order(:id).map { |row| fields(row, %i[identifier actor made_by_ai created_at]) },
11
+ approvals: post.approvals.includes(:revision).order(:id).map do |row|
12
+ fields(row, %i[kind reviewer_name facts_checked approved_at declared_on declared_by confirmed_by evidence recorded_by])
13
+ .merge(revision_identifier: row.revision.identifier)
14
+ end,
15
+ publications: post.publications.includes(:revision).order(:id).map do |row|
16
+ fields(row, %i[entry_type occurred_at released_by description note]).merge(revision_identifier: row.revision&.identifier)
17
+ end,
18
+ baseline: post.baseline && fields(post.baseline, BASELINE_FIELDS),
19
+ connections: post.connection_declarations.order(:id).map { |row| fields(row, CONNECTION_FIELDS) }
20
+ }
21
+ end
22
+
23
+ def self.fields(record, names)
24
+ names.to_h { |name| [ name, record.public_send(name) ] }
25
+ end
26
+ private_class_method :fields
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class RedirectSerializer
3
+ def self.call(record)
4
+ { id: record.id, old_path: record.old_path, new_path: record.new_path, source: record.source, occurred_on: record.occurred_on, post_id: record.post_id }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class SeriesSerializer
3
+ def self.call(record)
4
+ { id: record.id, name: record.name, slug: record.slug, description: record.description, posts: record.posts.order(:series_position, :id).map { |post| { id: post.id, slug: post.slug, title: post.title, position: post.series_position } } }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class TagSerializer
3
+ def self.call(record)
4
+ { id: record.id, name: record.name, slug: record.slug, posts_count: record.posts.count }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module OpenBlog
2
+ class WriteResultSerializer
3
+ def self.call(result, base_url: nil)
4
+ { post: PostSerializer.call(result.post, base_url: base_url), created: result.created,
5
+ records: result.records.slice(:revision, :publication, :approval), label: result.label.to_s,
6
+ findings: FindingsSerializer.entries(result.findings) }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1 @@
1
+ <img src="<%= image.path %>" alt="<%= alt %>" title="<%= caption %>"<% if image.width %> width="<%= image.width %>"<% end %><% if image.height %> height="<%= image.height %>"<% end %>>
@@ -0,0 +1,27 @@
1
+ xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
2
+ xml.feed xmlns: "http://www.w3.org/2005/Atom" do
3
+ xml.id "#{@base_url}#{OpenBlog.mount_path}"
4
+ xml.title @feed_title
5
+ xml.updated @feed_updated_at.iso8601
6
+ xml.author { xml.name(OpenBlog.config.publisher&.fetch(:name, nil) || OpenBlog.config.site_name) }
7
+ xml.link href: "#{@base_url}#{request.path}", rel: "self", type: "application/atom+xml"
8
+ xml.link href: "#{@base_url}#{OpenBlog.mount_path}", rel: "alternate", type: "text/html"
9
+ @posts.each do |post|
10
+ xml.entry do
11
+ xml.id post.url(base: @base_url)
12
+ xml.title post.title
13
+ xml.link href: post.url(base: @base_url), rel: "alternate", type: "text/html"
14
+ xml.published OpenBlog::ReaderDates.published_at(post).iso8601 if OpenBlog::ReaderDates.published_at(post)
15
+ xml.updated OpenBlog::ReaderDates.feed_updated_at(post).iso8601
16
+ xml.author do
17
+ xml.name post.author_name
18
+ xml.uri "#{@base_url}#{OpenBlog::ReaderQueries.author_path(post.author)}"
19
+ end
20
+ if OpenBlog.config.feed_content == :full
21
+ xml.content OpenBlog::Renderer.render(post), type: "html"
22
+ else
23
+ xml.summary post.description, type: "text"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
2
+ xml.urlset xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9" do
3
+ @entries.each do |entry|
4
+ xml.url do
5
+ xml.loc entry.fetch(:loc)
6
+ xml.lastmod entry[:lastmod].iso8601 if entry[:lastmod]
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,95 @@
1
+ en:
2
+ open_blog:
3
+ search:
4
+ heading: "Search articles"
5
+ label: "Search the blog"
6
+ submit: "Search"
7
+ suggestions: "Suggested articles"
8
+ description: "Find articles by their title, text, or frequently asked questions."
9
+ empty: "No matching articles. Try another word or phrase."
10
+ notices:
11
+ ai_assisted: "AI helped to make this post."
12
+ ai_unknown: "The use of AI for this post is not known."
13
+ paid: "A third party paid for this post."
14
+ disclosure: "Disclosure"
15
+ connections_none: "The author declared no relevant connections."
16
+ correction: "Correction"
17
+ dates:
18
+ published: "Published"
19
+ updated: "Updated"
20
+ reading_time:
21
+ one: "%{count} minute read"
22
+ other: "%{count} minute read"
23
+ navigation:
24
+ blog: "Blog navigation"
25
+ footer: "Blog resources"
26
+ breadcrumbs: "Breadcrumbs"
27
+ categories: "Categories"
28
+ all_posts: "All posts"
29
+ popular: "Popular articles"
30
+ tags: "Tags"
31
+ feed: "Atom feed"
32
+ responsible_party: "Responsible party"
33
+ toc: "On this page"
34
+ pagination: "Pagination"
35
+ series: "In this series"
36
+ previous_post: "Previous post"
37
+ next_post: "Next post"
38
+ page: "Page %{page}"
39
+ titles:
40
+ preview_expired: "This preview link has stopped"
41
+ page: "%{title} — Page %{page}"
42
+ tag: "Tagged: %{name}"
43
+ not_found: "Page not found"
44
+ gone: "This page has been removed"
45
+ descriptions:
46
+ index: "The blog of %{site_name}."
47
+ index_page: "Page %{page} of the blog of %{site_name}."
48
+ category: "Posts about %{name} — %{site_name}."
49
+ category_page: "Page %{page} of the posts about %{name} — %{site_name}."
50
+ tag: "Posts tagged %{name} — %{site_name}."
51
+ tag_page: "Page %{page} of the posts tagged %{name} — %{site_name}."
52
+ author: "Posts by %{name} — %{site_name}."
53
+ author_page: "Page %{page} of the posts by %{name} — %{site_name}."
54
+ series: "Posts in %{name} — %{site_name}."
55
+ series_page: "Page %{page} of the posts in %{name} — %{site_name}."
56
+ headings:
57
+ link: "Link to %{heading}"
58
+ highlights: "Article highlights"
59
+ about_author: "About the author"
60
+ related: "Related posts"
61
+ recent: "Recent posts"
62
+ cards:
63
+ featured: "Featured"
64
+ faq:
65
+ heading: "Frequently asked questions"
66
+ lists:
67
+ empty: "There are no posts here yet."
68
+ share:
69
+ heading: "Share this post"
70
+ x: "Share on X"
71
+ linkedin: "Share on LinkedIn"
72
+ device: "Share"
73
+ copy: "Copy link"
74
+ copied: "Copied!"
75
+ failed: "Sharing failed. Please try again."
76
+ theme:
77
+ system: "Theme: System"
78
+ light: "Theme: Light"
79
+ dark: "Theme: Dark"
80
+ code:
81
+ copy: "Copy"
82
+ copy_label: "Copy code"
83
+ copied: "Copied"
84
+ copy_failed: "Copy failed"
85
+ policies:
86
+ responsible_party: "Responsible party"
87
+ corrections: "Corrections policy"
88
+ editorial: "Editorial policy"
89
+ ai_use: "AI use policy"
90
+ errors:
91
+ not_found: "We could not find this page. Browse the latest posts below."
92
+ gone: "This page is no longer available. Browse the latest posts below."
93
+ preview:
94
+ heading: "Draft preview"
95
+ stopped: "Ask the author for a new preview link."
data/config/routes.rb ADDED
@@ -0,0 +1,52 @@
1
+ OpenBlog::Engine.routes.draw do
2
+ match "/mcp", to: "mcp#create", via: [ :post, :get, :delete ]
3
+ get "/preview/:token", to: "previews#show", as: :preview
4
+ get "/policies/:slug", to: "pages#show", as: :policy_page, format: false
5
+ namespace :api do
6
+ namespace :v1 do
7
+ patch "posts/:id", to: "posts#update"
8
+ resources :posts, only: [ :index, :show, :create, :destroy ] do
9
+ post :publish, on: :member
10
+ post :unpublish, on: :member
11
+ resources :approvals, only: :create
12
+ resources :connections, only: :create
13
+ resource :views, only: :show, controller: "views"
14
+ resource :preview, only: :show, controller: "previews"
15
+ resource :records, only: :show, controller: "records"
16
+ resource :findings, only: :show, controller: "findings"
17
+ end
18
+ resources :images, only: :create
19
+ %i[categories authors series].each do |resource|
20
+ resources resource, only: [ :index, :create ]
21
+ patch "#{resource}/:id", to: "#{resource}#update"
22
+ end
23
+ resources :tags, only: :index
24
+ resources :redirects, only: [ :index, :create, :destroy ]
25
+ resources :adoptions, only: :create
26
+ resources :faq_extractions, only: :create
27
+ get "pages", to: "pages#index"
28
+ get "pages/:kind", to: "pages#show"
29
+ put "pages/:kind", to: "pages#update"
30
+ get "views/top", to: "views#top"
31
+ get "report", to: "report#show"
32
+ get "doctor", to: "doctor#show"
33
+ end
34
+ end
35
+ root "posts#index"
36
+ get "/search", to: "search#show", as: :search
37
+ get "/#{OpenBlog.config.route_segments.fetch(:series)}/:slug", to: "series#show", as: :series
38
+ get "/feed.json", to: "feeds#show", as: :feed_json, format: false, defaults: { format: :json }
39
+ get "/feed.xml", to: "feeds#show", as: :feed, format: false, defaults: { format: :xml }
40
+ get "/sitemap.xml", to: "sitemaps#show", as: :sitemap, format: false, defaults: { format: :xml }
41
+ get "/media/:sha256/:filename", to: "media#show", as: :media, format: false, constraints: { sha256: /[0-9a-f]{64}/, filename: /[^\/]+/ }
42
+ get "/#{OpenBlog.config.route_segments.fetch(:category)}/:slug/feed.json", to: "feeds#show", as: :category_feed_json, format: false, defaults: { format: :json }
43
+ get "/#{OpenBlog.config.route_segments.fetch(:category)}/:slug/feed.xml", to: "feeds#show", as: :category_feed, format: false, defaults: { format: :xml }
44
+ get "/#{OpenBlog.config.route_segments.fetch(:category)}/:slug", to: "categories#show", as: :category
45
+ get "/#{OpenBlog.config.route_segments.fetch(:tag)}/:slug", to: "tags#show", as: :tag
46
+ get "/#{OpenBlog.config.route_segments.fetch(:author)}/:slug", to: "authors#show", as: :author
47
+ get "/:slug", to: "posts#show", as: :post, constraints: ->(request) {
48
+ slug = request.path_parameters[:slug]
49
+ slug.match?(/\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/) && !(OpenBlog::Post::RESERVED_SLUGS + OpenBlog.config.route_segments.values).include?(slug)
50
+ }
51
+ get "/*path", to: "errors#not_found"
52
+ end
@@ -0,0 +1,16 @@
1
+ class CreateOpenBlogAuthors < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :open_blog_authors do |t|
4
+ t.string :name, null: false
5
+ t.string :slug, null: false
6
+ t.string :author_type, null: false, default: "person"
7
+ t.text :bio
8
+ t.string :url
9
+ t.public_send(connection.adapter_name == "PostgreSQL" ? :jsonb : :json, :profile_urls, null: false, default: [])
10
+ t.string :host_reference
11
+ t.timestamps
12
+ end
13
+ add_index :open_blog_authors, :slug, unique: true
14
+ add_check_constraint :open_blog_authors, "author_type IN ('person', 'organization')", name: "open_blog_authors_type"
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ class CreateOpenBlogCategories < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :open_blog_categories do |t|
4
+ t.string :name, null: false
5
+ t.string :slug, null: false
6
+ t.text :description
7
+ t.integer :position, null: false, default: 0
8
+ t.timestamps
9
+ end
10
+ add_index :open_blog_categories, :name, unique: true
11
+ add_index :open_blog_categories, :slug, unique: true
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ class CreateOpenBlogSeries < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :open_blog_series do |t|
4
+ t.string :name, null: false
5
+ t.string :slug, null: false
6
+ t.text :description
7
+ t.timestamps
8
+ end
9
+ add_index :open_blog_series, :slug, unique: true
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ class CreateOpenBlogImages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :open_blog_images do |t|
4
+ t.string :sha256, limit: 64, null: false
5
+ t.string :filename, null: false
6
+ t.string :content_type, null: false
7
+ t.bigint :byte_size, null: false
8
+ t.integer :width
9
+ t.integer :height
10
+ t.string :source_url
11
+ t.string :uploaded_by
12
+ t.datetime :created_at, null: false
13
+ end
14
+ add_index :open_blog_images, :sha256, unique: true
15
+ add_check_constraint :open_blog_images, "byte_size >= 0", name: "open_blog_images_size"
16
+ end
17
+ end
@@ -0,0 +1,47 @@
1
+ class CreateOpenBlogPosts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :open_blog_posts do |t|
4
+ t.string :slug, null: false
5
+ t.string :title, null: false
6
+ t.text :description, null: false, default: ""
7
+ t.string :search_title, null: false, default: ""
8
+ t.text :search_description, null: false, default: ""
9
+ t.string :body_format, null: false
10
+ t.text :body_markdown
11
+ t.references :author, null: false, foreign_key: { to_table: :open_blog_authors }
12
+ t.string :author_name, null: false
13
+ t.references :category, foreign_key: { to_table: :open_blog_categories }
14
+ t.references :series, foreign_key: { to_table: :open_blog_series }
15
+ t.integer :series_position
16
+ t.string :status, null: false, default: "draft"
17
+ t.datetime :published_at
18
+ t.datetime :modified_at
19
+ t.datetime :publish_at
20
+ t.string :provenance, null: false, default: "unknown"
21
+ t.text :provenance_evidence
22
+ t.boolean :featured, null: false, default: false
23
+ t.string :canonical_url, limit: 2048
24
+ t.string :external_id
25
+ t.references :cover_image, foreign_key: { to_table: :open_blog_images }
26
+ t.string :cover_alt, null: false, default: ""
27
+ t.references :social_image, foreign_key: { to_table: :open_blog_images }
28
+ t.string :current_revision_identifier, limit: 64
29
+ t.references :public_revision
30
+ t.integer :word_count, null: false, default: 0
31
+ t.integer :reading_time_minutes, null: false, default: 1
32
+ t.text :search_text
33
+ t.timestamps
34
+ end
35
+ add_index :open_blog_posts, :slug, unique: true
36
+ add_index :open_blog_posts, :external_id, unique: true
37
+ add_index :open_blog_posts, [ :status, :published_at ]
38
+ add_index :open_blog_posts, [ :status, :publish_at ]
39
+ add_index :open_blog_posts, [ :series_id, :series_position ], unique: true
40
+ if connection.adapter_name == "PostgreSQL"
41
+ add_index :open_blog_posts, "to_tsvector('simple', search_text)", using: :gin, name: "index_open_blog_posts_search"
42
+ end
43
+ add_check_constraint :open_blog_posts, "body_format IN ('markdown', 'rich_text')", name: "open_blog_posts_body_format"
44
+ add_check_constraint :open_blog_posts, "status IN ('draft', 'scheduled', 'published', 'archived')", name: "open_blog_posts_status"
45
+ add_check_constraint :open_blog_posts, "provenance IN ('ai_assisted', 'human_written', 'unknown')", name: "open_blog_posts_provenance"
46
+ end
47
+ end