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,172 @@
1
+ require "digest"
2
+
3
+ module OpenBlog
4
+ class PostAttributes
5
+ CONTENT_FIELDS = %i[title description search_title search_description body body_format author cover_image cover_alt social_image faq category tags series series_position featured canonical_url slug external_id].freeze
6
+ RECORD_FIELDS = %i[provenance provenance_evidence made_by_ai approval change change_description note connections publish_at].freeze
7
+ TEXT_FIELDS = %i[title description search_title search_description body cover_alt canonical_url slug external_id provenance_evidence].freeze
8
+
9
+ def self.normalize(attributes)
10
+ unless attributes.is_a?(Hash) && attributes.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
11
+ raise Error::ValidationFailed.new(details: [ "attributes" ])
12
+ end
13
+ normalized = attributes.symbolize_keys
14
+ unknown = normalized.keys - CONTENT_FIELDS - RECORD_FIELDS
15
+ raise Error::UnknownField.new(details: unknown.map(&:to_s)) if unknown.any?
16
+ normalized
17
+ end
18
+
19
+ def self.assign(post, attributes, actor:, now:)
20
+ new(post, normalize(attributes), actor: actor, now: now).assign
21
+ rescue ActiveRecord::RecordNotFound
22
+ raise Error::NotFound
23
+ rescue ActiveRecord::RecordInvalid => error
24
+ raise Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s))
25
+ end
26
+
27
+ def initialize(post, attributes, actor:, now:)
28
+ @post, @attributes, @actor, @now = post, attributes, actor, now
29
+ end
30
+
31
+ def assign
32
+ validate_types
33
+ assign_scalars
34
+ assign_author if @attributes.key?(:author) || @post.author.nil?
35
+ %i[category series].each do |field|
36
+ @post.public_send("#{field}=", taxonomy(OpenBlog.const_get(field.to_s.classify), @attributes[field])) if @attributes.key?(field)
37
+ end
38
+ assign_faq if @attributes.key?(:faq)
39
+ assign_tags if @attributes.key?(:tags)
40
+ %i[cover_image social_image].each do |field|
41
+ @post.public_send("#{field}=", image(@attributes[field])) if @attributes.key?(field)
42
+ end
43
+ assign_provenance
44
+ PostIdentity.resolve({ slug: @post.slug }, post: @post)
45
+ @post
46
+ end
47
+
48
+ private
49
+
50
+ def invalid(field)
51
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
52
+ end
53
+
54
+ def validate_types
55
+ TEXT_FIELDS.each { |field| invalid(field) if @attributes.key?(field) && !@attributes[field].nil? && !@attributes[field].is_a?(String) }
56
+ invalid(:featured) if @attributes.key?(:featured) && ![ true, false ].include?(@attributes[:featured])
57
+ if @attributes.key?(:series_position) && !@attributes[:series_position].nil?
58
+ invalid(:series_position) unless @attributes[:series_position].is_a?(Integer) && @attributes[:series_position].positive?
59
+ end
60
+ %i[category series].each do |field|
61
+ invalid(field) if @attributes.key?(field) && !@attributes[field].nil? && !@attributes[field].is_a?(String)
62
+ end
63
+ if @attributes.key?(:body_format)
64
+ format = @attributes[:body_format]
65
+ unless (format.is_a?(String) || format.is_a?(Symbol)) && OpenBlog.config.body_formats.map(&:to_s).include?(format.to_s)
66
+ raise Error::BodyFormatNotPermitted
67
+ end
68
+ end
69
+ if @attributes.key?(:provenance) && !%w[ai_assisted human_written unknown].include?(@attributes[:provenance].to_s)
70
+ invalid(:provenance)
71
+ end
72
+ if @attributes.key?(:tags)
73
+ invalid(:tags) unless @attributes[:tags].is_a?(Array) && @attributes[:tags].all? { |tag| tag.is_a?(String) && tag.present? }
74
+ end
75
+ if @attributes.key?(:faq)
76
+ invalid(:faq) unless @attributes[:faq].is_a?(Array)
77
+ @attributes[:faq].each do |entry|
78
+ invalid(:faq) unless entry.is_a?(Hash)
79
+ values = entry.stringify_keys
80
+ invalid(:faq) unless values.keys.sort == %w[answer question] && values.values.all? { |value| value.is_a?(String) && value.present? }
81
+ end
82
+ end
83
+ end
84
+
85
+ def assign_scalars
86
+ fields = TEXT_FIELDS - %i[body provenance_evidence]
87
+ (fields + %i[featured series_position body_format]).each do |field|
88
+ @post.public_send("#{field}=", @attributes[field]) if @attributes.key?(field)
89
+ end
90
+ %i[description search_title search_description cover_alt].each do |field|
91
+ @post.public_send("#{field}=", "") if @post.public_send(field).nil?
92
+ end
93
+ @post.canonical_url = nil if @post.canonical_url.blank?
94
+ if @post.slug.blank?
95
+ @post.slug = @post.title.to_s.parameterize.presence || "post-#{Digest::SHA256.hexdigest(@post.title.to_s)[0, 12]}"
96
+ end
97
+ if @attributes.key?(:body)
98
+ @post.public_send(@post.markdown? ? "body_markdown=" : "rich_body=", @attributes[:body])
99
+ end
100
+ end
101
+
102
+ def assign_author
103
+ input = @attributes.key?(:author) ? @attributes[:author] : OpenBlog.config.default_author
104
+ author = case input
105
+ when Author then input
106
+ when Integer then Author.find(input)
107
+ when String
108
+ invalid(:author) if input.blank?
109
+ if input.match?(/\A[1-9]\d*\z/)
110
+ Author.find(input)
111
+ else
112
+ Author.find_by(slug: input) || Author.find_by(name: input) || Author.create!(name: input)
113
+ end
114
+ when Hash
115
+ fields = input.symbolize_keys
116
+ invalid(:author) unless (fields.keys - %i[name type url]).empty? && fields[:name].is_a?(String) && fields[:name].present?
117
+ invalid(:author) if fields.key?(:type) && !%w[person organization].include?(fields[:type].to_s)
118
+ invalid(:author) if fields.key?(:url) && !fields[:url].nil? && !fields[:url].is_a?(String)
119
+ Author.find_by(name: fields[:name]) || Author.create!(name: fields[:name], author_type: fields.fetch(:type, :person).to_s, url: fields[:url])
120
+ else invalid(:author)
121
+ end
122
+ @post.author = author
123
+ @post.author_name = author.name
124
+ end
125
+
126
+ def taxonomy(model, value)
127
+ return if value.blank?
128
+ model.find_by(slug: value) || model.find_by(name: value) || model.create!(name: value)
129
+ end
130
+
131
+ def assign_faq
132
+ existing = @post.faqs.to_a.sort_by(&:position)
133
+ @attributes[:faq].each_with_index do |entry, index|
134
+ record = existing[index] || @post.faqs.build(position: (existing.last&.position || 0) + index + 1)
135
+ record.assign_attributes(entry.symbolize_keys)
136
+ end
137
+ existing.drop(@attributes[:faq].length).each(&:mark_for_destruction)
138
+ end
139
+
140
+ def assign_tags
141
+ tags = @attributes[:tags].uniq(&:downcase).map do |name|
142
+ Tag.where("LOWER(name) = ?", name.downcase).first || Tag.create!(name: name)
143
+ end
144
+ @post.taggings.each { |tagging| tagging.mark_for_destruction unless tags.any? { |tag| tag.id == tagging.tag_id } }
145
+ existing = @post.taggings.reject(&:marked_for_destruction?).map(&:tag_id)
146
+ tags.each { |tag| @post.taggings.build(tag: tag) unless existing.include?(tag.id) }
147
+ @post.association(:tags).target = tags
148
+ @post.association(:tags).loaded!
149
+ end
150
+
151
+ def image(input)
152
+ return if input.nil?
153
+ return input if input.is_a?(Image) && input.persisted?
154
+ return Image.find(input) if input.is_a?(Integer)
155
+ if input.is_a?(Hash) && input.keys.map(&:to_s) == [ "image_id" ]
156
+ id = input[:image_id] || input["image_id"]
157
+ return Image.find(id) if id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[1-9]\d*\z/))
158
+ end
159
+ raise Error::ImageNotPermitted
160
+ end
161
+
162
+ def assign_provenance
163
+ changed = @attributes.key?(:provenance) && @post.provenance != @attributes[:provenance].to_s
164
+ @post.provenance = @attributes[:provenance] if @attributes.key?(:provenance)
165
+ if @attributes.key?(:provenance_evidence)
166
+ @post.provenance_evidence = @attributes[:provenance_evidence]
167
+ elsif changed
168
+ @post.provenance_evidence = "Stated by #{@actor} in the call of #{@now.iso8601}"
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,28 @@
1
+ module OpenBlog
2
+ class PostIdentity
3
+ def self.resolve(attributes, post: nil)
4
+ raise Error::ValidationFailed.new(details: [ "attributes" ]) unless attributes.is_a?(Hash)
5
+ attributes = attributes.symbolize_keys
6
+ %i[external_id slug].each do |field|
7
+ if attributes.key?(field) && !attributes[field].nil? && !attributes[field].is_a?(String)
8
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
9
+ end
10
+ end
11
+ unless post
12
+ external = Post.find_by(external_id: attributes[:external_id]) if attributes[:external_id].present?
13
+ slug = Post.find_by(slug: attributes[:slug]) if attributes[:slug].present?
14
+ raise Error::IdentityConflict if external && slug && external != slug
15
+ post = external || slug || Post.new
16
+ end
17
+ candidate = attributes[:slug].presence || post.slug
18
+ if candidate.present?
19
+ reserved = Post::RESERVED_SLUGS + OpenBlog.config.route_segments.values
20
+ redirect = Redirect.find_by(old_path: "#{OpenBlog.mount_path.chomp('/')}/#{candidate}")
21
+ if reserved.include?(candidate) || (redirect && (post.new_record? || redirect.post_id != post.id))
22
+ raise Error::SlugReserved
23
+ end
24
+ end
25
+ post
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class Publish
3
+ def self.call(attributes, post: nil, actor:, now: Time.current, authorize: nil)
4
+ WritePost.call(attributes, post: post, actor: actor, now: now, publish: true, authorize: authorize)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ module OpenBlog
2
+ module ReaderDates
3
+ def self.visible(time, now: Time.current)
4
+ time if time && time <= now
5
+ end
6
+
7
+ def self.published_at(post, now: Time.current)
8
+ visible(post.published_at, now: now)
9
+ end
10
+
11
+ def self.modified_at(post, now: Time.current)
12
+ visible(post.modified_at, now: now)
13
+ end
14
+
15
+ def self.feed_updated_at(post, now: Time.current)
16
+ modified_at(post, now: now) ||
17
+ post.publications.where(entry_type: %w[first substantive correction adopted]).where("occurred_at <= ?", now).maximum(:occurred_at) ||
18
+ visible(post.created_at, now: now) || now
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module OpenBlog
2
+ class ReaderPage
3
+ attr_reader :kind, :record, :pagination, :path, :breadcrumbs, :base_url
4
+
5
+ def initialize(kind:, path:, base_url:, record: nil, pagination: nil, breadcrumbs: [])
6
+ @kind, @record, @pagination, @path, @breadcrumbs, @base_url = kind, record, pagination, path, breadcrumbs, base_url.chomp("/")
7
+ end
8
+
9
+ def page_number
10
+ pagination&.page || 1
11
+ end
12
+
13
+ def path_for(number)
14
+ number > 1 ? "#{path}#{path.include?("?") ? "&" : "?"}page=#{number}" : path
15
+ end
16
+
17
+ def canonical_path
18
+ path_for(page_number)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,90 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module OpenBlog
5
+ module ReaderQueries
6
+ def self.posts
7
+ Post.listed.includes(:author, :category, :tags, cover_image: { file_attachment: :blob }).order(Arel.sql("published_at IS NULL ASC"), published_at: :desc, id: :desc)
8
+ end
9
+
10
+ def self.featured
11
+ posts.where(featured: true).first
12
+ end
13
+
14
+ def self.category_path(category)
15
+ "#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:category)}/#{category.slug}"
16
+ end
17
+
18
+ def self.tag_path(tag)
19
+ "#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:tag)}/#{tag.slug}"
20
+ end
21
+
22
+ def self.author_path(author)
23
+ "#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:author)}/#{author.slug}"
24
+ end
25
+
26
+ def self.series_path(series)
27
+ "#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:series)}/#{series.slug}"
28
+ end
29
+
30
+ def self.series_posts(series)
31
+ posts.where(series_id: series.id).reorder(Arel.sql("series_position IS NULL ASC"), series_position: :asc, id: :asc)
32
+ end
33
+
34
+ def self.series_neighbors(post)
35
+ return {} unless post.series_id
36
+ scope = series_posts(post.series).where.not(id: post.id)
37
+ if post.series_position
38
+ previous = scope.where("series_position < ?", post.series_position).reverse_order.first
39
+ following = scope.where("series_position > ? OR series_position IS NULL", post.series_position).first
40
+ else
41
+ previous = scope.where("series_position IS NOT NULL OR id < ?", post.id).reverse_order.first
42
+ following = scope.where(series_position: nil).where("id > ?", post.id).first
43
+ end
44
+ { previous: previous, next: following }
45
+ end
46
+
47
+ def self.sidebar
48
+ category_counts = Post.listed.where.not(category_id: nil).group(:category_id).count
49
+ tag_counts = Tagging.joins(:post).merge(Post.listed).group(:tag_id).count
50
+ categories = Category.where(id: category_counts.keys).order(:position, :name).map { |category| { category: category, count: category_counts.fetch(category.id) } }
51
+ tags = Tag.where(id: tag_counts.keys).to_a.sort_by { |tag| [ -tag_counts.fetch(tag.id), tag.name, tag.id ] }.first(20).map { |tag| { tag: tag, count: tag_counts.fetch(tag.id) } }
52
+ { categories: categories, tags: tags }
53
+ end
54
+
55
+ def self.sidebar_cache_key(sidebar)
56
+ fields = sidebar[:categories].map { |entry| [ entry[:category].attributes.slice("id", "name", "slug", "position"), entry[:count] ] } +
57
+ sidebar[:tags].map { |entry| [ entry[:tag].attributes.slice("id", "name", "slug"), entry[:count] ] }
58
+ [ "open_blog/sidebar", Post.listed.maximum(:modified_at), Post.listed.count,
59
+ OpenBlog.config.locale, OpenBlog.mount_path, Digest::SHA256.hexdigest(JSON.generate([ fields, OpenBlog.config.route_segments ])) ]
60
+ end
61
+
62
+ def self.related_cache_key(post, related)
63
+ fields = related.map do |entry|
64
+ [ entry.id, entry.current_revision_identifier, entry.slug, entry.author.slug, entry.author.avatar.blob&.key,
65
+ entry.category&.attributes&.slice("name", "slug"), entry.cover_image&.path,
66
+ ReaderDates.published_at(entry), ReaderDates.modified_at(entry), entry.reading_time_minutes ]
67
+ end
68
+ [ "open_blog/related", post.id, Post.listed.maximum(:modified_at), Post.listed.count,
69
+ OpenBlog.config.locale, OpenBlog.mount_path, Digest::SHA256.hexdigest(JSON.generate([ fields, OpenBlog.config.route_segments ])) ]
70
+ end
71
+
72
+ def self.popular
73
+ settings = OpenBlog.config.popular_posts
74
+ return [] unless settings[:enabled]
75
+ days, limit = settings.fetch(:days, 30), settings.fetch(:limit, 5)
76
+ key = [ "open_blog/popular", days, limit, OpenBlog.config.locale, OpenBlog.mount_path, OpenBlog.config.route_segments ]
77
+ ids = Rails.cache.fetch(key, expires_in: 1.hour) do
78
+ PageViews.top(days: days, limit: limit, scope: Post.listed).fetch(:posts).map { |post| post.fetch(:id) }
79
+ end
80
+ current = Post.listed.where(id: ids).index_by(&:id)
81
+ ids.filter_map { |id| current[id] }
82
+ end
83
+
84
+ def self.related(post)
85
+ scope = posts.where.not(id: post.id)
86
+ scope = scope.where(category_id: post.category_id) if post.category_id
87
+ scope.limit(3).to_a
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,66 @@
1
+ module OpenBlog
2
+ class RecordRelease
3
+ def self.call(post, change: nil, note: nil, description: nil, actor: nil, now: Time.current, made_by_ai: nil, republishing: false)
4
+ post.class.transaction do
5
+ stored = post.class.lock.find(post.id)
6
+ ImageResolution.native!(stored)
7
+ unless stored.published?
8
+ stored.send(:compute_derived)
9
+ post.update_columns(stored.attributes.slice("current_revision_identifier", "word_count", "reading_time_minutes", "search_text", "body_image_manifest", "body_image_source_digest"))
10
+ next { revision: nil, publication: nil, approval: nil }
11
+ end
12
+
13
+ new(stored, post, change: change, note: note, description: description, actor: actor,
14
+ now: now, made_by_ai: made_by_ai, republishing: republishing).call
15
+ end
16
+ end
17
+
18
+ def initialize(stored, original, **context)
19
+ @post, @original, @context = stored, original, context
20
+ end
21
+
22
+ def call
23
+ @post.send(:compute_derived)
24
+ payload = RevisionPayload.new(@post)
25
+ changed = @post.public_revision&.identifier != payload.identifier
26
+ type = entry_type(changed)
27
+ revision = @post.revisions.find_by(identifier: payload.identifier)
28
+ revision_kind = revision ? "same" : "new"
29
+ revision ||= Revision.create!(post: @post, identifier: payload.identifier, payload: payload.to_json,
30
+ actor: @context[:actor], made_by_ai: @context[:made_by_ai], created_at: @context[:now])
31
+ if type
32
+ Publication.create!(post: @post, revision: revision, entry_type: type, occurred_at: @context[:now],
33
+ released_by: @context[:actor], note: @context[:note], description: description(changed))
34
+ end
35
+ attributes = @post.attributes.slice("current_revision_identifier", "word_count", "reading_time_minutes", "search_text", "body_image_manifest", "body_image_source_digest")
36
+ attributes.merge!(public_revision_id: revision.id, published_at: published_at, modified_at: modified_at)
37
+ @original.update_columns(attributes)
38
+ @original.association(:public_revision).reset
39
+ { revision: revision_kind, publication: type, approval: nil }
40
+ end
41
+
42
+ private
43
+
44
+ def entry_type(changed)
45
+ return "first" unless @post.public_revision_id || @post.publications.exists?
46
+ return @context[:change].to_s if @context[:change].present?
47
+ return "substantive" if changed
48
+ "maintenance" if @context[:republishing]
49
+ end
50
+
51
+ def description(changed)
52
+ return @context[:description] if @context[:description].present?
53
+ "republished" if @context[:republishing] && !changed
54
+ end
55
+
56
+ def published_at
57
+ @post.publications.find_by(entry_type: "first")&.occurred_at ||
58
+ @post.baseline&.first_published_at || @post.baseline&.declared_first_published_at
59
+ end
60
+
61
+ def modified_at
62
+ @post.publications.where(entry_type: %w[substantive correction]).maximum(:occurred_at) ||
63
+ @post.baseline&.last_modified_at || published_at
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,58 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ class RedirectTarget
5
+ LOCK_KEY = 1_867_541_239
6
+
7
+ def self.synchronize
8
+ Redirect.transaction do
9
+ connection = Redirect.connection
10
+ if connection.adapter_name == "PostgreSQL"
11
+ connection.execute("SELECT pg_advisory_xact_lock(#{LOCK_KEY})")
12
+ elsif connection.adapter_name == "SQLite"
13
+ connection.execute("UPDATE open_blog_redirects SET id = id WHERE 1 = 0")
14
+ end
15
+ yield
16
+ end
17
+ rescue ActiveRecord::StatementInvalid => error
18
+ raise unless error.cause.class.name == "SQLite3::BusyException"
19
+ raise Error::ValidationFailed.new(details: [ "redirects" ])
20
+ end
21
+
22
+ def self.call(target, from:)
23
+ visited = [ from ]
24
+ while target
25
+ target, path = normalize_target(target)
26
+ raise Error::ValidationFailed.new(details: [ "redirect_to" ]) if visited.include?(path)
27
+ visited << path
28
+ redirect = Redirect.find_by(old_path: path)
29
+ break unless redirect
30
+ target = redirect.new_path
31
+ end
32
+ target
33
+ end
34
+ def self.normalize_target(target)
35
+ unless target.is_a?(String) && target.present? && !target.match?(/\s/)
36
+ raise Error::ValidationFailed.new(details: [ "redirect_to" ])
37
+ end
38
+ uri = URI.parse(target)
39
+ if target.start_with?("/") && !target.start_with?("//") && !uri.host && !uri.scheme
40
+ return [ target, uri.path ]
41
+ end
42
+ unless uri.is_a?(URI::HTTP) && uri.host.present? && !uri.userinfo
43
+ raise Error::ValidationFailed.new(details: [ "redirect_to" ])
44
+ end
45
+ origin = URI.parse(OpenBlog.config.public_base_url) if OpenBlog.config.public_base_url
46
+ if origin && [ origin.scheme, origin.host, origin.port ] == [ uri.scheme, uri.host, uri.port ]
47
+ target = uri.request_uri
48
+ target += "##{uri.fragment}" if uri.fragment
49
+ [ target, uri.path ]
50
+ else
51
+ [ target, target ]
52
+ end
53
+ rescue URI::InvalidURIError
54
+ raise Error::ValidationFailed.new(details: [ "redirect_to" ])
55
+ end
56
+ private_class_method :normalize_target
57
+ end
58
+ end
@@ -0,0 +1,43 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ class Remove
5
+ def self.call(post, redirect_to: nil, actor:, now: Time.current)
6
+ Operation.run(post: post, actor: actor, now: now) do |current, _created|
7
+ raise Error::NotFound unless current.persisted?
8
+ if (current.draft? || current.scheduled?) && !retained_records?(current)
9
+ current.destroy!
10
+ else
11
+ current.update!(status: "archived", publish_at: nil)
12
+ write_redirect(current, target: redirect_to, source: "removal", now: now)
13
+ end
14
+ { revision: nil, publication: nil, approval: nil }
15
+ end
16
+ end
17
+
18
+ def self.write_redirect(post, target:, source:, now:)
19
+ RedirectTarget.synchronize do
20
+ target = RedirectTarget.call(target, from: post.path)
21
+ redirect = Redirect.find_by(old_path: post.path)
22
+ raise Error::SlugReserved if redirect && redirect.post_id != post.id
23
+
24
+ if redirect
25
+ redirect.update!(new_path: target)
26
+ else
27
+ Redirect.create!(old_path: post.path, new_path: target, source: source,
28
+ post: post, occurred_on: now.to_date)
29
+ end
30
+ Redirect.where(new_path: post.path).where.not(old_path: post.path).find_each do |incoming|
31
+ incoming.update!(new_path: target)
32
+ end
33
+ end
34
+ end
35
+
36
+ def self.retained_records?(post)
37
+ post.public_revision_id.present? || post.revisions.exists? || post.publications.exists? ||
38
+ post.approvals.exists? || post.connection_declarations.exists? || post.baseline.present? ||
39
+ Redirect.where(post_id: post.id).exists?
40
+ end
41
+ private_class_method :retained_records?
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ require "rouge"
2
+
3
+ module OpenBlog
4
+ class Renderer
5
+ module Highlighter
6
+ def self.call(pre)
7
+ code = pre.at_css("code")
8
+ return unless code
9
+ language = pre["lang"].presence || code["lang"].presence
10
+ pre.remove_attribute("lang")
11
+ lexer = Rouge::Lexer.find(language) if language
12
+ code.inner_html = Rouge::Formatters::HTML.new.format(lexer.new.lex(code.text)) if lexer
13
+ pre["class"] = "ob-highlight"
14
+ pre["tabindex"] = "0"
15
+ if language
16
+ pre["data-lang"] = language
17
+ label = Nokogiri::XML::Node.new("span", pre.document)
18
+ label["class"] = "ob-code-language"
19
+ label["aria-hidden"] = "true"
20
+ label.content = language
21
+ pre.prepend_child(label)
22
+ end
23
+ pre["data-controller"] = "open-blog--code-copy"
24
+ code["data-open-blog--code-copy-target"] = "code"
25
+ pre["data-open-blog--code-copy-copied-label-value"] = translate(:copied)
26
+ pre["data-open-blog--code-copy-error-label-value"] = translate(:copy_failed)
27
+ button = Nokogiri::XML::Node.new("button", pre.document)
28
+ button["type"] = "button"
29
+ button["class"] = "ob-code-copy"
30
+ button["data-action"] = "open-blog--code-copy#copy"
31
+ button["data-open-blog--code-copy-target"] = "button"
32
+ button["hidden"] = "hidden"
33
+ button["aria-live"] = "polite"
34
+ button["aria-label"] = translate(:copy_label)
35
+ button.content = translate(:copy)
36
+ pre.add_child(button)
37
+ end
38
+
39
+ def self.translate(key)
40
+ I18n.t("open_blog.code.#{key}", locale: OpenBlog.config.locale)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ require "commonmarker"
2
+
3
+ module OpenBlog
4
+ class Renderer
5
+ module Markdown
6
+ def self.render(text)
7
+ Commonmarker.to_html(text.to_s.encode(Encoding::UTF_8), options: {
8
+ extension: { header_ids: nil, footnotes: true, alerts: true, table: true, tasklist: true, strikethrough: true, autolink: true },
9
+ render: { unsafe: false, hardbreaks: OpenBlog.config.markdown_hardbreaks }
10
+ }, plugins: { syntax_highlighter: nil })
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ module OpenBlog
2
+ class Renderer
3
+ module RichText
4
+ def self.render(text, post: nil)
5
+ ActionText::Content.new(text.to_s).render_attachments(with_full_attributes: false) do |attachment|
6
+ blob = attachment.attachable
7
+ image = ImageResolution.image_for_attachment(blob, post: post) if blob.is_a?(ActiveStorage::Blob)
8
+ if image
9
+ caption = PlainText.from_html(attachment.caption.to_s)
10
+ alt = attachment.node["alt"].presence || caption
11
+ ActionText::Content.render(partial: "open_blog/attachments/blob", locals: { image: image, alt: alt, caption: caption })
12
+ else
13
+ ERB::Util.html_escape(PlainText.from_html(attachment.caption.to_s))
14
+ end
15
+ end.to_html
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,72 @@
1
+ require "uri"
2
+ require "rails/html/sanitizer"
3
+ require "nokogiri"
4
+
5
+ module OpenBlog
6
+ class Renderer
7
+ module Sanitizer
8
+ TAGS = %w[p h1 h2 h3 h4 h5 h6 ul ol li a strong em del code pre blockquote br hr img figure figcaption table thead tbody tr th td sup sub section div span input].freeze
9
+ ATTRIBUTES = %w[href rel src alt title width height lang align].freeze
10
+ TOKEN = "data-ob-render-token".freeze
11
+ Document = Struct.new(:fragment, :metadata, keyword_init: true)
12
+
13
+ def self.call(html, trusted: false)
14
+ fragment = Nokogiri::HTML5.fragment(html)
15
+ fragment.css("pre").each do |node|
16
+ code = Nokogiri::XML::Node.new("code", node.document)
17
+ code["lang"] = node.at_css("code")["lang"] if node.at_css("code")&.key?("lang")
18
+ code.content = node.text
19
+ node.children.remove
20
+ node.add_child(code)
21
+ end
22
+ fragment.css("code").each { |node| node.content = node.text }
23
+ metadata = trusted ? capture(fragment) : {}
24
+ output = Rails::HTML5::SafeListSanitizer.new.sanitize(fragment.to_html, scrubber: Scrubber.new(metadata))
25
+ Document.new(fragment: Nokogiri::HTML5.fragment(output), metadata: metadata)
26
+ end
27
+
28
+ def self.safe_url?(value, image: false)
29
+ return false unless value.is_a?(String) && value.present?
30
+ uri = URI.parse(value)
31
+ uri.scheme.nil? || (image ? %w[http https] : %w[http https mailto]).include?(uri.scheme.downcase)
32
+ rescue URI::InvalidURIError
33
+ false
34
+ end
35
+
36
+ def self.restore!(document)
37
+ document.fragment.css("[#{TOKEN}]").each do |node|
38
+ document.metadata.fetch(node[TOKEN], {}).each { |name, value| node[name] = value }
39
+ node.remove_attribute(TOKEN)
40
+ end
41
+ document.fragment
42
+ end
43
+
44
+ def self.capture(fragment)
45
+ metadata = {}
46
+ fragment.traverse do |node|
47
+ next unless node.element?
48
+ attributes = {}
49
+ classes = node["class"].to_s.split.select do |name|
50
+ name.match?(/\A(?:markdown-alert(?:-(?:note|tip|important|warning|caution|title))?|footnotes?|footnote-(?:ref|backref)|task-list(?:-item)?)\z/)
51
+ end
52
+ attributes["class"] = classes.join(" ") if classes.any?
53
+ if node["id"].to_s.match?(/\Afn(?:ref)?-/)
54
+ attributes["id"] = "ob-footnote-#{node['id']}"
55
+ end
56
+ if node["href"].to_s.match?(/\A#fn(?:ref)?-/)
57
+ attributes["href"] = "#ob-footnote-#{node['href'].delete_prefix('#')}"
58
+ end
59
+ %w[data-footnote-ref data-footnotes data-footnote-backref data-footnote-backref-idx aria-label].each do |name|
60
+ attributes[name] = node[name] if node.key?(name)
61
+ end
62
+ next if attributes.empty?
63
+ token = metadata.length.to_s
64
+ metadata[token] = attributes
65
+ node[TOKEN] = token
66
+ end
67
+ metadata
68
+ end
69
+ private_class_method :capture
70
+ end
71
+ end
72
+ end