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,35 @@
1
+ module OpenBlog
2
+ class Renderer
3
+ class Scrubber < Rails::HTML::PermitScrubber
4
+ def initialize(metadata)
5
+ super()
6
+ self.tags = Sanitizer::TAGS
7
+ self.attributes = Sanitizer::ATTRIBUTES + %w[type checked disabled] + [ Sanitizer::TOKEN ]
8
+ @metadata = metadata
9
+ end
10
+
11
+ def scrub(node)
12
+ if node.element? && ((node.name == "input" && (node["type"] != "checkbox" || !node.key?("disabled"))) ||
13
+ (node.name == "img" && !Sanitizer.safe_url?(node["src"], image: true)))
14
+ node.remove
15
+ return STOP
16
+ end
17
+ super
18
+ end
19
+
20
+ protected
21
+
22
+ def scrub_attribute(node, attribute)
23
+ name = attribute.name
24
+ invalid_input = %w[type checked disabled].include?(name) && node.name != "input"
25
+ invalid_token = name == Sanitizer::TOKEN && !@metadata.key?(attribute.value)
26
+ invalid_url = %w[href src].include?(name) && !Sanitizer.safe_url?(attribute.value, image: name == "src")
27
+ if invalid_input || invalid_token || invalid_url
28
+ attribute.remove
29
+ else
30
+ super
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,115 @@
1
+ require "set"
2
+
3
+ module OpenBlog
4
+ class Renderer
5
+ class Transform
6
+ def initialize(document, post: nil)
7
+ @fragment = Sanitizer.restore!(document)
8
+ @post = post
9
+ end
10
+
11
+ def call
12
+ headings = @fragment.css("h1,h2,h3,h4,h5,h6")
13
+ shift = headings.any? ? 2 - headings.map { |node| node.name[1].to_i }.min : 0
14
+ used_ids = @fragment.css("[id]").map { |node| node["id"] }.to_set
15
+ @fragment.css("h1,h2,h3,h4,h5,h6,pre,img,table,a").each do |node|
16
+ case node.name
17
+ when /\Ah[1-6]\z/ then heading(node, shift, used_ids)
18
+ when "pre" then Highlighter.call(node)
19
+ when "img" then image(node)
20
+ when "table" then table(node)
21
+ when "a" then link(node)
22
+ end
23
+ end
24
+ @fragment
25
+ end
26
+
27
+ private
28
+
29
+ def heading(node, shift, used_ids)
30
+ node.name = "h#{[ node.name[1].to_i + shift, 6 ].min}"
31
+ base = node.text.parameterize.presence || "section"
32
+ identifier, suffix = base, 0
33
+ while used_ids.include?(identifier)
34
+ suffix += 1
35
+ identifier = "#{base}-#{suffix}"
36
+ end
37
+ used_ids << identifier
38
+ node["id"] = identifier
39
+ anchor = Nokogiri::XML::Node.new("a", node.document)
40
+ anchor["class"] = "ob-heading-anchor"
41
+ anchor["href"] = "##{identifier}"
42
+ anchor["aria-label"] = I18n.t("open_blog.headings.link", heading: node.text, locale: OpenBlog.config.locale)
43
+ anchor.content = "#"
44
+ node.add_child(anchor)
45
+ end
46
+
47
+ def image(node)
48
+ stored = ImageResolution.image_for_url(node["src"], post: @post)
49
+ if stored
50
+ node["src"] = stored.path
51
+ node["width"] = stored.width.to_s if stored.width
52
+ node["height"] = stored.height.to_s if stored.height
53
+ end
54
+ node["alt"] ||= ""
55
+ node["loading"] = "lazy"
56
+ figure = node.ancestors("figure").first
57
+ unless figure
58
+ figure = Nokogiri::XML::Node.new("figure", node.document)
59
+ node.replace(figure)
60
+ figure.add_child(node)
61
+ lift_figure(figure)
62
+ end
63
+ figure["class"] = "ob-figure"
64
+ if node["title"].present? && !figure.at_css("figcaption")
65
+ caption = Nokogiri::XML::Node.new("figcaption", node.document)
66
+ caption.content = node["title"]
67
+ figure.add_child(caption)
68
+ end
69
+ end
70
+
71
+ def lift_figure(figure)
72
+ while figure.parent && %w[p a strong em del span sup sub].include?(figure.parent.name)
73
+ parent = figure.parent
74
+ before, after = copy_wrapper(parent), copy_wrapper(parent)
75
+ figure.xpath("preceding-sibling::node()").each { |sibling| before.add_child(sibling) }
76
+ figure.xpath("following-sibling::node()").each { |sibling| after.add_child(sibling) }
77
+ unless parent.name == "p"
78
+ wrapper = copy_wrapper(parent)
79
+ figure.children.each { |child| wrapper.add_child(child) }
80
+ figure.add_child(wrapper)
81
+ end
82
+ parent.add_previous_sibling(before) if before.children.any?
83
+ parent.add_previous_sibling(figure)
84
+ parent.add_previous_sibling(after) if after.children.any?
85
+ parent.remove
86
+ end
87
+ end
88
+
89
+ def copy_wrapper(node)
90
+ node.dup(0).tap do |copy|
91
+ node.attribute_nodes.each { |attribute| copy[attribute.name] = attribute.value }
92
+ end
93
+ end
94
+
95
+ def table(node)
96
+ wrapper = Nokogiri::XML::Node.new("div", node.document)
97
+ wrapper["class"] = "ob-table"
98
+ wrapper["tabindex"] = "0"
99
+ node.replace(wrapper)
100
+ wrapper.add_child(node)
101
+ end
102
+
103
+ def link(node)
104
+ return if node["href"].blank?
105
+ uri = URI.parse(node["href"])
106
+ return unless uri.host
107
+ base = URI.parse(OpenBlog.config.public_base_url) if OpenBlog.config.public_base_url
108
+ return if base && uri.host.casecmp?(base.host) && uri.port == base.port
109
+ node["rel"] = (node["rel"].to_s.split + [ "noopener" ]).uniq.join(" ")
110
+ rescue URI::InvalidURIError
111
+ nil
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,51 @@
1
+ require "digest"
2
+
3
+ module OpenBlog
4
+ class Renderer
5
+ VERSION = 2
6
+ autoload :Markdown, "open_blog/renderer/markdown"
7
+ autoload :RichText, "open_blog/renderer/rich_text"
8
+ autoload :Sanitizer, "open_blog/renderer/sanitizer"
9
+ autoload :Scrubber, "open_blog/renderer/scrubber"
10
+ autoload :Transform, "open_blog/renderer/transform"
11
+ autoload :Highlighter, "open_blog/renderer/highlighter"
12
+
13
+ class << self
14
+ def render(post, cache: true)
15
+ return render_string(post.body_for_payload, format: post.body_format, post: post) unless post.persisted? && cache
16
+ options = [ OpenBlog.config.markdown_hardbreaks, OpenBlog.config.public_base_url, OpenBlog.config.locale.to_s ]
17
+ configuration = Digest::SHA256.hexdigest(JSON.generate(options))
18
+ Rails.cache.fetch([ "open_blog/body", post.id, post.current_revision_identifier, VERSION, configuration ]) do
19
+ render_string(post.body_for_payload, format: post.body_format, post: post)
20
+ end.html_safe
21
+ end
22
+
23
+ def render_string(text, format:, post: nil)
24
+ parsed = sanitized(text, format: format, post: post)
25
+ Transform.new(parsed, post: post).call.to_html.html_safe
26
+ end
27
+
28
+ def document(post)
29
+ parsed = sanitized(post.body_for_payload, format: post.body_format, post: post)
30
+ Sanitizer.restore!(parsed)
31
+ end
32
+
33
+ def body_images(post)
34
+ ImageResolution.body_images(post)
35
+ end
36
+
37
+ private
38
+
39
+ def sanitized(text, format:, post:)
40
+ case format.to_s
41
+ when "markdown"
42
+ Sanitizer.call(Markdown.render(text), trusted: true)
43
+ when "rich_text"
44
+ Sanitizer.call(RichText.render(text, post: post))
45
+ else
46
+ raise ArgumentError, "Unsupported body format: #{format}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ module OpenBlog
2
+ Result = Data.define(:post, :created, :records, :label, :findings, :error, :dry_run) do
3
+ def initialize(post: nil, created: false, records: { revision: nil, publication: nil, approval: nil }, label: :ai_unknown, findings: [], error: nil, dry_run: false)
4
+ super
5
+ end
6
+
7
+ def success?
8
+ error.nil?
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,67 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module OpenBlog
5
+ class RevisionPayload
6
+ VERSION = "1".freeze
7
+
8
+ def self.from_fields(title:, description:, search_title:, search_description:, body:, author:, images:, faq:)
9
+ new(nil, title: title, description: description, search_title: search_title,
10
+ search_description: search_description, body: body, author: author, images: images, faq: faq)
11
+ end
12
+
13
+ def initialize(post, **fields)
14
+ fields = fields_from_post(post) if post
15
+ @payload = {
16
+ "author" => normalize(fields[:author]),
17
+ "body" => normalize(fields[:body], body: true),
18
+ "description" => normalize(fields[:description]),
19
+ "faq" => fields.fetch(:faq).map { |entry| normalized_entry(entry, %w[answer question]) },
20
+ "images" => fields.fetch(:images).map { |entry| normalized_entry(entry, %w[alt role sha256 url]) },
21
+ "search_description" => normalize(fields[:search_description]),
22
+ "search_title" => normalize(fields[:search_title]),
23
+ "title" => normalize(fields[:title]),
24
+ "version" => VERSION
25
+ }
26
+ end
27
+
28
+ def to_h
29
+ @payload
30
+ end
31
+
32
+ def to_json(*)
33
+ JSON.generate(@payload, ascii_only: false, script_safe: false)
34
+ end
35
+
36
+ def identifier
37
+ Digest::SHA256.hexdigest(to_json)
38
+ end
39
+
40
+ private
41
+
42
+ def fields_from_post(post)
43
+ images = []
44
+ images << image_entry(post.cover_image, "cover", post.cover_alt) if post.cover_image
45
+ images.concat(Renderer.body_images(post))
46
+ images << image_entry(post.social_image, "social", "") if post.social_image
47
+ {
48
+ title: post.title, description: post.description, search_title: post.search_title,
49
+ search_description: post.search_description, body: post.body_for_payload,
50
+ author: post.author_name, images: images, faq: post.faq_list
51
+ }
52
+ end
53
+
54
+ def image_entry(image, role, alt)
55
+ { role: role, url: image.path, sha256: image.sha256, alt: alt }
56
+ end
57
+
58
+ def normalized_entry(entry, keys)
59
+ keys.to_h { |key| [ key, normalize(entry.key?(key) ? entry[key] : entry[key.to_sym]) ] }
60
+ end
61
+
62
+ def normalize(value, body: false)
63
+ text = value.to_s.encode(Encoding::UTF_8).unicode_normalize(:nfc).gsub(/\r\n?/, "\n")
64
+ body ? text.sub(/\n+\z/, "") : text.gsub(/\A[ \t\n]+|[ \t\n]+\z/, "")
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,26 @@
1
+ module OpenBlog
2
+ module RichTextGuard
3
+ extend ActiveSupport::Concern
4
+ include ContentGuard
5
+
6
+ private
7
+
8
+ def content_guard_target?(attributes)
9
+ candidate = attributes.stringify_keys
10
+ candidate.fetch("record_type", record_type) == "OpenBlog::Post" && candidate.fetch("name", name) == "rich_body"
11
+ end
12
+
13
+ def content_guard_applies?
14
+ (record_type == "OpenBlog::Post" && name == "rich_body") ||
15
+ (attribute_in_database("record_type") == "OpenBlog::Post" && attribute_in_database("name") == "rich_body")
16
+ end
17
+
18
+ def content_guard_identity_changed?
19
+ persisted? && %w[record_type record_id name].any? { |attribute| will_save_change_to_attribute?(attribute) }
20
+ end
21
+
22
+ def content_guard_parent
23
+ record
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,56 @@
1
+ require "digest"
2
+ require "stringio"
3
+
4
+ module OpenBlog
5
+ class Sample
6
+ EXTERNAL_ID = "open_blog_sample".freeze
7
+ ASSETS = File.expand_path("../generators/open_blog/install/templates", __dir__).freeze
8
+
9
+ def self.call
10
+ new.call
11
+ end
12
+
13
+ def call
14
+ existing = Post.find_by(external_id: EXTERNAL_ID)
15
+ return existing if existing
16
+
17
+ Post.transaction(requires_new: true) do
18
+ image = cover
19
+ markdown = File.read(File.join(ASSETS, "sample_post.md")).gsub("{{sample_image}}", image.path)
20
+ format = OpenBlog.config.default_body_format
21
+ body = format.to_s == "rich_text" ? rich_text(markdown) : markdown
22
+ result = Publish.call({ external_id: EXTERNAL_ID, title: "A small guide to your new blog",
23
+ slug: "open-blog-sample", description: "Explore headings, images, code and reader questions in this sample article.",
24
+ body: body, body_format: format, category: "General", provenance: "ai_assisted",
25
+ cover_image: image, cover_alt: "Blue and green blocks arranged as a simple landscape",
26
+ faq: [ { question: "Can I edit this sample?", answer: "Yes. Replace it with your own article, or remove it when you are ready." },
27
+ { question: "Will running the sample task create another copy?", answer: "No. Running the task again keeps the existing sample and its content." } ] },
28
+ post: Post.new, actor: "OpenBlog sample")
29
+ raise result.error unless result.success?
30
+ result.post
31
+ end.tap { @completed = true }
32
+ ensure
33
+ @uploaded_blob.service.delete(@uploaded_blob.key) if @uploaded_blob && !@completed
34
+ end
35
+
36
+ private
37
+
38
+ def rich_text(markdown)
39
+ document = Nokogiri::HTML5.fragment(Renderer::Markdown.render(markdown))
40
+ # Rich text removes source IDs. Keep footnote text without broken links.
41
+ document.css("a[data-footnote-ref]").each { |link| link.replace(link.children) }
42
+ document.css("a[data-footnote-backref]").remove
43
+ document.to_html
44
+ end
45
+
46
+ def cover
47
+ bytes = File.binread(File.join(ASSETS, "sample_cover.png"))
48
+ existing = Image.find_by(sha256: Digest::SHA256.hexdigest(bytes))
49
+ return existing if existing
50
+ @uploaded_blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(bytes), filename: "sample-cover.png",
51
+ content_type: "image/png", identify: false,
52
+ service_name: OpenBlog.config.storage_service || Rails.application.config.active_storage.service)
53
+ ImageImport.prepare(signed_id: @uploaded_blob.signed_id).persist(uploaded_by: "OpenBlog sample")
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ class SaveDraft
3
+ def self.call(attributes, post: nil, actor:, now: Time.current, authorize: nil)
4
+ WritePost.call(attributes, post: post, actor: actor, now: now, publish: false, authorize: authorize)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ module OpenBlog
2
+ class Search
3
+ def self.call(query, scope: Post.listed, limit: nil)
4
+ return scope.none unless query.is_a?(String)
5
+ query = query.strip
6
+ return scope.none unless (2..100).cover?(query.length)
7
+
8
+ relation = if Post.connection.adapter_name == "PostgreSQL"
9
+ vector = "to_tsvector('simple', open_blog_posts.search_text)"
10
+ expression = Post.sanitize_sql_array([ "websearch_to_tsquery('simple', ?)", query ])
11
+ scope.where("#{vector} @@ #{expression}").reorder(Arel.sql("ts_rank(#{vector}, #{expression}) DESC"))
12
+ else
13
+ query.split.reduce(scope) do |matches, word|
14
+ matches.where("LOWER(open_blog_posts.search_text) LIKE ? ESCAPE '\\'", "%#{Post.sanitize_sql_like(word.downcase)}%")
15
+ end.reorder(nil)
16
+ end
17
+ relation = relation.order(created_at: :desc, id: :desc)
18
+ limit ? relation.limit(limit) : relation
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,66 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ class SitemapEntries
5
+ def self.call(base_url: OpenBlog.config.public_base_url)
6
+ raise ConfigurationError, "Set public_base_url or pass base_url to sitemap_entries" if base_url.blank?
7
+ new(base_url).call
8
+ end
9
+
10
+ def initialize(base_url)
11
+ unless base_url.is_a?(String)
12
+ raise ConfigurationError, "Sitemap base_url must be an absolute HTTP(S) origin"
13
+ end
14
+ @origin = URI.parse(base_url)
15
+ unless @origin.is_a?(URI::HTTP) && @origin.host.present? && @origin.userinfo.nil? &&
16
+ [ "", "/" ].include?(@origin.path) && @origin.query.nil? && @origin.fragment.nil?
17
+ raise ConfigurationError, "Sitemap base_url must be an absolute HTTP(S) origin"
18
+ end
19
+ @base_url = base_url.chomp("/")
20
+ rescue URI::InvalidURIError
21
+ raise ConfigurationError, "Sitemap base_url must be an absolute HTTP(S) origin"
22
+ end
23
+
24
+ def call
25
+ featured = ReaderQueries.featured
26
+ count = Post.listed.count - (featured ? 1 : 0)
27
+ entries = list_pages(OpenBlog.mount_path, count)
28
+ Post.listed.find_each do |post|
29
+ next if off_site?(post.canonical_url)
30
+ entry = { loc: post.url(base: @base_url) }
31
+ date = ReaderDates.modified_at(post)
32
+ entry[:lastmod] = date if date
33
+ entries << entry
34
+ end
35
+ Page.published.find_each do |page|
36
+ next if OpenBlog.config.policy_urls[page.kind.to_sym].present?
37
+ entries << { loc: page.url(base: @base_url), lastmod: page.updated_at }
38
+ end
39
+ if OpenBlog.config.primary_list_type == :tags
40
+ Tag.find_each do |tag|
41
+ entries.concat(list_pages(ReaderQueries.tag_path(tag), tag.posts.listed.count))
42
+ end
43
+ else
44
+ Category.find_each do |category|
45
+ entries.concat(list_pages(ReaderQueries.category_path(category), category.posts.listed.count))
46
+ end
47
+ end
48
+ entries
49
+ end
50
+
51
+ private
52
+
53
+ def list_pages(path, count)
54
+ total = [ (count.to_f / OpenBlog.config.posts_per_page).ceil, 1 ].max
55
+ (1..total).map { |page| { loc: "#{@base_url}#{path}#{page > 1 ? "?page=#{page}" : ''}" } }
56
+ end
57
+
58
+ def off_site?(url)
59
+ return false if url.blank?
60
+ uri = URI.parse(url)
61
+ uri.host&.downcase != @origin.host.downcase || uri.port != @origin.port
62
+ rescue URI::InvalidURIError
63
+ true
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,27 @@
1
+ module OpenBlog
2
+ class SurfaceReport
3
+ class ExpectedContent
4
+ class RenderingController < OpenBlog::ApplicationController
5
+ helper do
6
+ def open_blog_notices(*) = "".html_safe
7
+ end
8
+ end
9
+
10
+ def self.call(post, payload, base_url:)
11
+ snapshot = post.dup
12
+ %w[title description search_title search_description].each { |field| snapshot.public_send("#{field}=", payload.fetch(field)) }
13
+ snapshot.author = post.author
14
+ snapshot.author_name = payload.fetch("author")
15
+ snapshot.define_singleton_method(:body_for_payload) { payload.fetch("body") }
16
+ snapshot.define_singleton_method(:faq_list) { payload.fetch("faq").map(&:symbolize_keys) }
17
+ cover = payload.fetch("images").find { |entry| entry["role"] == "cover" }
18
+ snapshot.cover_image = cover && Image.find_by(sha256: cover["sha256"])
19
+ snapshot.cover_alt = cover&.fetch("alt")
20
+ page = ReaderPage.new(kind: :preview, record: snapshot, path: post.path, base_url: base_url)
21
+ html = RenderingController.render(template: "open_blog/posts/show", layout: false,
22
+ assigns: { open_blog_page: page, related_posts: [], related_cache_key: [] })
23
+ Nokogiri::HTML5(html).at_css("[data-open-blog-content]")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,92 @@
1
+ require "net/http"
2
+ require "resolv"
3
+ require "ipaddr"
4
+ require "timeout"
5
+ require_relative "../image_fetch"
6
+
7
+ module OpenBlog
8
+ class SurfaceReport
9
+ class Http
10
+ Response = Struct.new(:status, :headers, :body, :url, keyword_init: true)
11
+ class FetchError < StandardError
12
+ def initialize(_message = nil) = super("Request unavailable")
13
+ end
14
+
15
+ def initialize(trusted_origin: nil, max_bytes: 10 * 1024 * 1024, timeout: 10, client: nil)
16
+ raise ArgumentError unless max_bytes.is_a?(Integer) && max_bytes.positive? && timeout.is_a?(Numeric) && timeout.positive?
17
+ @trusted_origin = origin(parse(trusted_origin)) if trusted_origin
18
+ @max_bytes, @timeout, @client = max_bytes, timeout, client
19
+ end
20
+
21
+ def get(url)
22
+ uri = parse(url)
23
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
24
+ Timeout.timeout(@timeout, FetchError) do
25
+ headers = { "User-Agent" => "OpenBlogSurfaceBot", "Accept-Encoding" => "identity", "Accept" => "*/*" }
26
+ if @client
27
+ response = @client.get(uri.to_s, headers: headers)
28
+ body = response.body.to_s.b
29
+ raise FetchError if body.bytesize > @max_bytes
30
+ return Response.new(status: response.status.to_i, headers: normalize_headers(response.headers), body: body, url: uri.to_s)
31
+ end
32
+ addresses = Resolv.getaddresses(uri.hostname)
33
+ raise FetchError if addresses.empty?
34
+ addresses.each { |address| public_address!(address) } unless origin(uri) == @trusted_origin
35
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
36
+ http.ipaddr = addresses.first
37
+ http.use_ssl = uri.scheme == "https"
38
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
39
+ http.verify_hostname = true
40
+ http.max_retries = 0
41
+ http.open_timeout = http.read_timeout = http.write_timeout = remaining(deadline)
42
+ result = nil
43
+ http.start do |connection|
44
+ connection.request(Net::HTTP::Get.new(uri.request_uri, headers)) do |response|
45
+ bytes = +"".b
46
+ response.read_body do |chunk|
47
+ raise FetchError if bytes.bytesize + chunk.bytesize > @max_bytes
48
+ bytes << chunk
49
+ http.read_timeout = remaining(deadline)
50
+ end
51
+ result = Response.new(status: response.code.to_i, headers: normalize_headers(response.to_hash), body: bytes, url: uri.to_s)
52
+ end
53
+ end
54
+ remaining(deadline)
55
+ result
56
+ end
57
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError, Resolv::ResolvError, SocketError, SystemCallError,
58
+ IOError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Timeout::Error, OpenSSL::SSL::SSLError, ArgumentError
59
+ raise FetchError
60
+ end
61
+
62
+ private
63
+
64
+ def parse(value)
65
+ raise FetchError unless value.is_a?(String)
66
+ uri = URI.parse(value)
67
+ raise FetchError unless uri.is_a?(URI::HTTP) && uri.hostname.present? && uri.userinfo.nil? && !uri.hostname.include?("%")
68
+ uri.fragment = nil
69
+ uri
70
+ end
71
+
72
+ def origin(uri) = [ uri.scheme.downcase, uri.hostname.downcase, uri.port ]
73
+
74
+ def public_address!(value)
75
+ ip = IPAddr.new(value).native
76
+ valid = ip.ipv4? ? ImageFetch::NONPUBLIC_V4.none? { |range| range.include?(ip) } :
77
+ ImageFetch::GLOBAL_V6.include?(ip) && ImageFetch::NONPUBLIC_V6.none? { |range| range.include?(ip) }
78
+ raise FetchError unless valid
79
+ end
80
+
81
+ def normalize_headers(headers)
82
+ headers.to_h.to_h { |key, value| [ key.to_s.downcase, Array(value).join(", ") ] }
83
+ end
84
+
85
+ def remaining(deadline)
86
+ seconds = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ raise FetchError unless seconds.positive?
88
+ seconds
89
+ end
90
+ end
91
+ end
92
+ end