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,57 @@
1
+ module OpenBlog
2
+ class Doctor
3
+ module Javascript
4
+ private
5
+
6
+ def javascript
7
+ layout = layout_source
8
+ tag = layout.match(/javascript_(importmap_tags|include_tag)\s*(?:\(?\s*)(?:["']([^"']+)["'])?/)
9
+ return [ "error", "The blog layout has no recognizable JavaScript entry point." ] unless tag
10
+ if tag[2].nil? && layout[tag.end(0)..].match?(/\A[^%\s)]/)
11
+ return [ "warning", "Dynamic JavaScript entry point cannot be verified statically." ]
12
+ end
13
+ entry = tag[2] || "application"
14
+ maps = javascript_map_source(layout)
15
+ pins = maps.scan(/\bpin\s+["']([^"']+)["']\s*,\s*to:\s*["']([^"']+)["']/).to_h
16
+ source = javascript_tree(entry, pins: pins)
17
+ return [ "error", "JavaScript entry point #{entry} was not found." ] if source.empty?
18
+ importmap = tag[1] == "importmap_tags"
19
+ loaders = source.scan(/(?:eager|lazy)LoadControllersFrom\s*\(\s*["']([^"']+)["']/).flatten
20
+ missing = Dir[Engine.root.join("app/assets/javascripts/open_blog/controllers/*_controller.js")].filter_map do |path|
21
+ file = File.basename(path)
22
+ name = "open-blog--#{file.delete_suffix('_controller.js').tr('_', '-')}"
23
+ local = @root.join("app/javascript/controllers/open_blog/#{file}")
24
+ next name unless local.file?
25
+ explicit = source.match?(/\.register\s*\(\s*["']#{Regexp.escape(name)}["']/) && source.include?("open_blog/#{file.delete_suffix('.js')}")
26
+ automatic = importmap && loaders.any? do |prefix|
27
+ prefix == "controllers" && maps.match?(/pin_all_from[^\n]*controllers[^\n]*under:\s*["']controllers["']/)
28
+ end
29
+ name unless explicit || automatic
30
+ end
31
+ missing.any? ? [ "error", "Register controllers reachable from #{entry}: #{missing.join(', ')}." ] : [ "ok", "All blog controllers are reachable from #{entry}." ]
32
+ end
33
+
34
+ def javascript_map_source(layout)
35
+ custom = layout[/importmap:\s*([\w.]+)/, 1]
36
+ return read("config/importmap.rb") if custom.nil? || custom == "Rails.application.importmap"
37
+ name = custom.split(".").last.sub(/\Aimportmap_/, "")
38
+ read("config/importmap_#{name}.rb")
39
+ end
40
+
41
+ def javascript_tree(specifier, from: nil, seen: [], pins: {})
42
+ specifier = pins.fetch(specifier, specifier)
43
+ base = if specifier.start_with?(".") && from
44
+ from.dirname.join(specifier)
45
+ else
46
+ @root.join("app/javascript", specifier)
47
+ end
48
+ file = [ base, Pathname("#{base}.js"), base.join("index.js") ].find(&:file?)
49
+ return "" unless file && !seen.include?(file.cleanpath.to_s)
50
+ seen << file.cleanpath.to_s
51
+ source = file.read
52
+ imports = source.scan(/\bimport\s+(?:[^;\n]*?\s+from\s+)?["']([^"']+)["']/).flatten
53
+ source + imports.map { |name| javascript_tree(name, from: file, seen: seen, pins: pins) }.join("\n")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,177 @@
1
+ require "net/http"
2
+ require "securerandom"
3
+ require "timeout"
4
+ require_relative "doctor/files"
5
+ require_relative "doctor/javascript"
6
+
7
+ module OpenBlog
8
+ class Doctor
9
+ include Files
10
+ include Javascript
11
+
12
+ CHECKS = { "Configuration" => :configuration, "Migrations" => :migrations, "Route" => :route,
13
+ "Views" => :views, "Theme" => :theme, "JavaScript" => :javascript, "Authentication" => :authentication,
14
+ "Rate limit" => :rate_limit, "Jobs" => :jobs, "Storage" => :storage, "Policy pages" => :policy_pages,
15
+ "Records" => :records, "Declarations" => :declarations }.freeze
16
+ TABLES = %w[open_blog_authors open_blog_categories open_blog_series open_blog_images open_blog_posts
17
+ open_blog_api_tokens open_blog_pages open_blog_page_views open_blog_faqs open_blog_tags open_blog_taggings open_blog_redirects open_blog_revisions
18
+ open_blog_approvals open_blog_publications open_blog_baselines open_blog_connection_declarations
19
+ active_storage_blobs active_storage_attachments active_storage_variant_records action_text_rich_texts].freeze
20
+
21
+ def self.run(application: Rails.application, config: OpenBlog.config, http_get: nil)
22
+ new(application: application, config: config, http_get: http_get).run
23
+ end
24
+
25
+ def initialize(application:, config:, http_get:)
26
+ @application, @config, @http_get = application, config, http_get
27
+ @root = application.root
28
+ end
29
+
30
+ def run
31
+ CHECKS.map do |name, method|
32
+ status, message = send(method)
33
+ { name: name, status: status, message: message }
34
+ rescue StandardError => error
35
+ { name: name, status: "error", message: "Check unavailable: #{error.class}: #{error.message}" }
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def configuration
42
+ @config.validate!
43
+ values = [ @config.site_name, @config.default_author&.dig(:name), @config.publisher&.dig(:name) ]
44
+ return [ "warning", "Replace installer placeholder names." ] if (values & [ "Example", "Ada Example" ]).any?
45
+ [ "ok", "Required configuration is present." ]
46
+ rescue ConfigurationError => error
47
+ [ "error", error.message ]
48
+ end
49
+
50
+ def missing_tables
51
+ @missing_tables ||= TABLES.reject { |name| ActiveRecord::Base.connection.data_source_exists?(name) }
52
+ end
53
+
54
+ def migrations
55
+ return [ "error", "Missing tables: #{missing_tables.join(', ')}. Run the installed migrations." ] if missing_tables.any?
56
+ pool = ActiveRecord::Base.connection_pool
57
+ paths = @application.paths["db/migrate"].existent
58
+ context = ActiveRecord::MigrationContext.new(paths, pool.schema_migration, pool.internal_metadata)
59
+ expected = Dir[Engine.root.join("db/migrate/*.rb")].map { |path| File.basename(path).sub(/\A\d+_/, "").delete_suffix(".rb").camelize }
60
+ installed = context.migrations.select { |migration| expected.include?(migration.name) }
61
+ absent = expected - installed.map(&:name)
62
+ pending = installed.reject { |migration| context.get_all_versions.include?(migration.version) }.map(&:name)
63
+ return [ "error", "Install and run migrations: #{(absent + pending).join(', ')}." ] if absent.any? || pending.any?
64
+ [ "ok", "Content, Active Storage and Action Text migrations are installed." ]
65
+ end
66
+
67
+ def route
68
+ routes = @application.routes.routes.to_a
69
+ index = routes.index { |candidate| candidate.app.respond_to?(:app) && candidate.app.app == Engine }
70
+ return [ "error", "Mount OpenBlog::Engine after host routes under its path." ] unless index
71
+ prefix = routes[index].path.spec.to_s.sub(/\(.*\z/, "").chomp("/")
72
+ shadowed = routes.drop(index + 1).filter_map do |candidate|
73
+ path = candidate.path.spec.to_s.sub(/\(.*\z/, "")
74
+ path if path == prefix || path.start_with?("#{prefix}/")
75
+ end
76
+ return [ "error", "Move the engine mount after these host routes: #{shadowed.join(', ')}." ] if shadowed.any?
77
+ [ "ok", "Engine mounted at #{prefix.presence || '/'} after matching host routes." ]
78
+ end
79
+
80
+ def authentication
81
+ return [ "ok", "Host authentication hook is configured." ] if @config.authenticate
82
+ if ActiveRecord::Base.connection.data_source_exists?("open_blog_api_tokens") && ApiToken.active.exists?
83
+ [ "ok", "An active API token is configured." ]
84
+ else
85
+ [ "warning", "No host authentication hook or active API token is configured." ]
86
+ end
87
+ end
88
+
89
+ def rate_limit
90
+ key = "open-blog-doctor-#{SecureRandom.hex(12)}"
91
+ store = @config.rate_limit_store
92
+ store.write(key, 0, raw: true, expires_in: 30.seconds)
93
+ value = store.increment(key, 1)
94
+ value == 1 ? [ "ok", "Rate limit store supports increment." ] : [ "warning", "Rate limit store cannot count; configure a store with atomic increment." ]
95
+ rescue NotImplementedError, StandardError
96
+ [ "warning", "Rate limit store cannot count; configure a store with atomic increment." ]
97
+ ensure
98
+ store&.delete(key)
99
+ end
100
+
101
+ def jobs
102
+ adapter = @application.config.active_job.queue_adapter.to_s
103
+ if %w[async inline test].include?(adapter)
104
+ [ "warning", "#{adapter} jobs do not survive restart; use a persistent adapter and arrange open_blog:publish_due for scheduled posts." ]
105
+ else
106
+ [ "ok", "Job adapter is #{adapter}." ]
107
+ end
108
+ end
109
+
110
+ def storage
111
+ service = @config.storage_service || @application.config.active_storage.service
112
+ return [ "error", "Configure an Active Storage service." ] unless service
113
+ ActiveStorage::Blob.services.fetch(service)
114
+ return [ "error", "Install image_processing for reader image variants." ] unless Gem.loaded_specs.key?("image_processing")
115
+ processor = @application.config.active_storage.variant_processor || :vips
116
+ if processor.to_sym == :vips
117
+ begin
118
+ require "vips"
119
+ rescue LoadError
120
+ return [ "error", "Install ruby-vips and libvips for the configured image processor." ]
121
+ end
122
+ elsif !ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |directory| %w[magick identify].any? { |name| File.executable?(File.join(directory, name)) } }
123
+ return [ "error", "Install ImageMagick for the configured image processor." ]
124
+ end
125
+ return [ "warning", "Image URL address protection is disabled (image_fetch_policy = :open)." ] if @config.image_fetch_policy == :open
126
+ [ "ok", "Active Storage and the configured image processor are available." ]
127
+ end
128
+
129
+ def policy_pages
130
+ problems = %i[responsible_party corrections editorial ai_use].filter_map do |key|
131
+ url = @config.policy_urls[key]
132
+ if url.blank?
133
+ next "#{key}: install the open_blog_pages table" unless ActiveRecord::Base.connection.data_source_exists?("open_blog_pages")
134
+ next OpenBlog.policy_url(key, config: @config).present? ? nil : "#{key}: no published page or URL configured"
135
+ end
136
+ begin
137
+ code = @http_get ? @http_get.call(url).to_i : policy_response(url)
138
+ "#{key}: HTTP #{code}" unless code == 200
139
+ rescue StandardError => error
140
+ "#{key}: unavailable (#{error.class})"
141
+ end
142
+ end
143
+ problems.any? ? [ "warning", problems.join("; ") ] : [ "ok", "All policies have a published page or a URL responding with HTTP 200." ]
144
+ end
145
+
146
+ def policy_response(url)
147
+ uri = URI.parse(url)
148
+ raise ArgumentError unless uri.is_a?(URI::HTTP) && uri.host && uri.userinfo.nil?
149
+ Timeout.timeout(3) do
150
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 2, read_timeout: 2) do |http|
151
+ http.request(Net::HTTP::Get.new(uri)) { |response| return response.code.to_i }
152
+ end
153
+ end
154
+ end
155
+
156
+ def records
157
+ return [ "warning", "Records cannot be checked until all content tables are installed." ] if missing_tables.any?
158
+ problems = []
159
+ Post.where(status: "published").find_each do |post|
160
+ revision = post.public_revision
161
+ problems << "#{post.id}: content differs from public revision" unless revision && RevisionPayload.new(post).identifier == revision.identifier
162
+ published = post.publications.find_by(entry_type: "first")&.occurred_at || post.baseline&.first_published_at || post.baseline&.declared_first_published_at
163
+ modified = post.publications.where(entry_type: %w[substantive correction]).maximum(:occurred_at) || post.baseline&.last_modified_at || published
164
+ problems << "#{post.id}: modified_at differs from publication records" unless post.modified_at == modified
165
+ end
166
+ problems.any? ? [ "error", problems.join("; ") ] : [ "ok", "Public content and modification dates match their records." ]
167
+ end
168
+
169
+ def declarations
170
+ source = read("config/initializers/open_blog.rb")
171
+ absent = %i[primary_list_type sign_in_destinations].reject do |name|
172
+ !@config.public_send(name).nil? && source.match?(/\.#{name}\s*=/)
173
+ end
174
+ absent.any? ? [ "warning", "Declare #{absent.join(' and ')} in the blog initializer." ] : [ "ok", "List type and sign-in destinations are declared." ]
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,45 @@
1
+ require "rails/engine"
2
+ require "action_dispatch"
3
+
4
+ module OpenBlog
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace OpenBlog
7
+ engine_name "open_blog"
8
+
9
+ initializer "open_blog.parameter_filters" do |application|
10
+ application.config.filter_parameters |= [ :authorization, :token, :token_string ]
11
+ end
12
+
13
+ initializer "open_blog.assets", before: "propshaft.assets_middleware" do |application|
14
+ if application.config.respond_to?(:assets)
15
+ application.config.assets.paths << root.join("app/assets/builds")
16
+ application.config.assets.paths << root.join("app/assets/javascripts")
17
+ end
18
+ end
19
+
20
+ initializer "open_blog.markdown_mime" do
21
+ Mime::Type.register "text/markdown", :md unless Mime[:md]
22
+ end
23
+
24
+ config.to_prepare do
25
+ ActionText::RichText.include(OpenBlog::RichTextGuard) if defined?(ActionText::RichText)
26
+ if defined?(ActiveStorage::Attachment)
27
+ ActiveStorage::Attachment.include(OpenBlog::ImmutableImageAttachment)
28
+ end
29
+ end
30
+
31
+ config.after_initialize do |application|
32
+ if Engine.first_install?(application)
33
+ OpenBlog.config.validate_structure!
34
+ else
35
+ OpenBlog.config.validate!
36
+ end
37
+ end
38
+
39
+ def self.first_install?(application)
40
+ defined?(Rails::Command::GenerateCommand) &&
41
+ ARGV.first == "open_blog:install" &&
42
+ !application.root.join("config/initializers/open_blog.rb").exist?
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,40 @@
1
+ module OpenBlog
2
+ class Error < StandardError
3
+ attr_reader :code, :status, :details
4
+
5
+ def initialize(message = nil, details: [])
6
+ @code = self.class::CODE
7
+ @status = self.class::STATUS
8
+ @details = Array(details)
9
+ super(message || self.class::MESSAGE)
10
+ end
11
+
12
+ {
13
+ unauthenticated: [ 401, "An authenticated actor is required." ],
14
+ scope_required: [ 403, "The actor lacks the required scope." ],
15
+ not_found: [ 404, "The requested record was not found." ],
16
+ identity_conflict: [ 409, "The external ID and slug identify different posts." ],
17
+ slug_reserved: [ 409, "This slug belongs to a route or another post's redirect." ],
18
+ post_is_public: [ 409, "This post is public. Update it through Publish." ],
19
+ revision_mismatch: [ 409, "The revision identifier does not match the content being approved." ],
20
+ already_changed_in_gem: [ 409, "This post has changed since its adoption." ],
21
+ validation_failed: [ 422, "One or more fields are invalid." ],
22
+ unknown_field: [ 422, "The request contains unsupported fields." ],
23
+ change_type_required: [ 422, "This update changes the public revision. Send change: substantive, correction or maintenance." ],
24
+ correction_note_required: [ 422, "A correction needs a note describing what changed." ],
25
+ approval_required: [ 422, "This publication requires approval with facts checked." ],
26
+ approval_incomplete: [ 422, "Approval requires a reviewer name and a boolean facts_checked value." ],
27
+ refused_by_host: [ 422, "The host declined this publication." ],
28
+ body_format_not_permitted: [ 422, "This body format is not enabled." ],
29
+ slug_not_supported: [ 422, "The slug contains unsupported characters." ],
30
+ image_not_permitted: [ 422, "The image input is not supported." ],
31
+ rate_limited: [ 429, "The request limit has been reached. Try again later." ]
32
+ }.each do |code, (status, message)|
33
+ subclass = Class.new(self)
34
+ subclass.const_set(:CODE, code)
35
+ subclass.const_set(:STATUS, status)
36
+ subclass.const_set(:MESSAGE, message.freeze)
37
+ const_set(code.to_s.split("_").map(&:capitalize).join, subclass)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,184 @@
1
+ require "digest"
2
+ require "commonmarker"
3
+
4
+ module OpenBlog
5
+ class FaqExtraction
6
+ HEADING = /\A[ \t]*(\#{2,6})[ \t]+(.+?)(?:[ \t]+\#+)?[ \t]*\z/
7
+ FAQ_TITLE = /\bFAQ\b|\A(?:Frequently asked questions|Common questions|Q&A)/i
8
+ PLAIN_NODES = %i[document paragraph text softbreak].freeze
9
+
10
+ def self.call(body:, standalone_questions: [])
11
+ new(body, standalone_questions).call
12
+ end
13
+
14
+ def self.faq_heading?(text)
15
+ text.is_a?(String) && text.match?(FAQ_TITLE)
16
+ end
17
+
18
+ def self.contains_faq_heading?(body)
19
+ body.to_s.each_line.any? do |line|
20
+ match = HEADING.match(line.chomp)
21
+ match && [ 2, 3 ].include?(match[1].length) && faq_heading?(match[2])
22
+ end
23
+ end
24
+
25
+ def initialize(body, standalone_questions)
26
+ unless body.is_a?(String) && body.valid_encoding? && standalone_questions.is_a?(Array) && standalone_questions.all? { |question| question.is_a?(String) }
27
+ raise Error::ValidationFailed.new(details: [ "body", "standalone_questions" ])
28
+ end
29
+ @body, @standalone_questions = body, standalone_questions
30
+ @pairs, @leftover, @reasons, @cut = [], [], [], []
31
+ @lines = scan_lines
32
+ end
33
+
34
+ def call
35
+ headings = @lines.select { |line| [ 2, 3 ].include?(line[:level]) && self.class.faq_heading?(line[:heading]) }
36
+ @reasons << "faq_heading_in_code" if headings.any? { |line| line[:code] }
37
+ headings = headings.reject { |line| line[:code] }
38
+ @reasons << "multiple_faq_headings" if headings.length > 1
39
+ sections = []
40
+ sections << section(headings.first, standalone: false) if headings.first
41
+ @lines.each do |line|
42
+ next unless !line[:code] && line[:level] == 2 && @standalone_questions.include?(line[:heading])
43
+ next if sections.any? { |existing| line[:start] >= existing[:start] && line[:start] < existing[:end] }
44
+ sections << section(line, standalone: true)
45
+ end
46
+ sections.sort_by { |entry| entry[:start] }.each { |entry| extract_section(entry) }
47
+ questions = @pairs.map { |pair| pair[:question] }
48
+ @reasons << "duplicate_question" if questions.uniq.length != questions.length
49
+ @reasons << "leftover_text" if @leftover.any?
50
+ @reasons.uniq!
51
+ @cut = @cut.each_with_object([]) do |range, merged|
52
+ if merged.last && range[:start] <= merged.last[:end]
53
+ merged.last[:end] = [ merged.last[:end], range[:end] ].max
54
+ else
55
+ merged << range.dup
56
+ end
57
+ end
58
+ { pairs: @pairs, cut: @cut, body_after: cut_body, leftover: @leftover,
59
+ class: @reasons.any? ? "review" : (@cut.any? ? "clean" : "none"),
60
+ reasons: @reasons, source_body_sha256: Digest::SHA256.hexdigest(@body) }
61
+ end
62
+
63
+ private
64
+
65
+ def scan_lines
66
+ offset = 0
67
+ fence = nil
68
+ @body.each_line.map do |raw|
69
+ text = raw.chomp
70
+ was_fenced = !fence.nil?
71
+ if fence
72
+ fence = nil if text.match?(/\A {0,3}#{Regexp.escape(fence[0])}{#{fence.length},}[ \t]*\z/)
73
+ elsif (opening = text.match(/\A {0,3}(`{3,}|~{3,})/))
74
+ fence = opening[1]
75
+ end
76
+ match = HEADING.match(text)
77
+ line = { text: text, start: offset, end: offset + raw.bytesize,
78
+ level: match && match[1].length, heading: match && match[2],
79
+ code: was_fenced || !fence.nil? || text.match?(/\A(?: {4}|\t)/) }
80
+ offset += raw.bytesize
81
+ line
82
+ end
83
+ end
84
+
85
+ def section(heading, standalone:)
86
+ following = @lines.find { |line| line[:start] > heading[:start] && line[:level] == 2 && !line[:code] }
87
+ { start: heading[:start], end: following ? following[:start] : @body.bytesize,
88
+ heading: heading, standalone: standalone }
89
+ end
90
+
91
+ def extract_section(section)
92
+ @cut << section.slice(:start, :end)
93
+ heading = section[:heading]
94
+ if section[:standalone]
95
+ @reasons << "standalone_question"
96
+ add_pair(heading[:heading], bytes(heading[:end], section[:end]), bold: false)
97
+ return
98
+ end
99
+ lines = @lines.select { |line| line[:start] >= heading[:end] && line[:start] < section[:end] }
100
+ candidates = lines.reject { |line| line[:code] }
101
+ level = candidates.any? { |line| line[:level] == 4 } ? 4 : 3
102
+ questions = candidates.select { |line| line[:level] == level }
103
+ bold = questions.empty?
104
+ questions = candidates.select { |line| bold_question(line[:text]) } if bold
105
+ if questions.empty?
106
+ add_leftover(bytes(heading[:end], section[:end]))
107
+ @reasons << "questions_absent" if @leftover.empty?
108
+ return
109
+ end
110
+ add_leftover(bytes(heading[:end], questions.first[:start]))
111
+ questions.each_with_index do |line, index|
112
+ finish = questions[index + 1]&.fetch(:start) || section[:end]
113
+ answer = bytes(line[:end], finish)
114
+ if bold
115
+ match = bold_question(line[:text])
116
+ question = match[1]
117
+ answer = "#{match[2]}\n#{answer}" if match[2].present?
118
+ else
119
+ question = line[:heading]
120
+ end
121
+ add_pair(question, answer, bold: bold)
122
+ end
123
+ end
124
+
125
+ def bold_question(text)
126
+ text.match(/\A {0,3}\*\*(.+?)\*\*[ \t]*(.*)\z/)
127
+ end
128
+
129
+ def add_pair(question, answer, bold:)
130
+ question = question.strip
131
+ plain_question = PlainText.from_markdown(question)
132
+ if plain_question != question
133
+ @reasons << "markdown_in_question"
134
+ question = plain_question
135
+ end
136
+ answer = answer.gsub(/\r\n?/, "\n").strip
137
+ @reasons << "question_without_question_mark" unless question.end_with?("?")
138
+ @reasons << "answer_absent" if answer.empty?
139
+ @reasons << "multiline_bold_answer" if bold && answer.lines.length > 1
140
+ nodes = Commonmarker.parse(answer, options: { extension: { autolink: false } })
141
+ types = node_types(nodes)
142
+ @reasons << "heading_in_answer" if types.include?(:heading)
143
+ if (types - PLAIN_NODES).any? || plain_text(nodes).strip != answer
144
+ @reasons << "markdown_in_answer"
145
+ answer = PlainText.from_markdown(answer)
146
+ end
147
+ @pairs << { question: question, answer: answer }
148
+ end
149
+
150
+ def plain_text(node)
151
+ return node.literal if node.type == :text
152
+ return "\n" if node.type == :softbreak
153
+ node.each.map { |child| plain_text(child) }.join(node.type == :document ? "\n\n" : "")
154
+ end
155
+
156
+ def node_types(node)
157
+ [ node.type ] + node.each.flat_map { |child| node_types(child) }
158
+ end
159
+
160
+ def add_leftover(text)
161
+ @leftover << text.strip if text.present?
162
+ end
163
+
164
+ def bytes(start, finish)
165
+ @body.byteslice(start...finish)
166
+ end
167
+
168
+ def cut_body
169
+ result = @body.dup
170
+ @cut.reverse_each do |range|
171
+ before = result.byteslice(0...range[:start]).rstrip
172
+ after = result.byteslice(range[:end]..-1).to_s
173
+ result = if before.empty?
174
+ after
175
+ elsif after.empty?
176
+ "#{before}\n"
177
+ else
178
+ "#{before}\n\n#{after}"
179
+ end
180
+ end
181
+ result
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,58 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ module Findings
5
+ module BodyChecks
6
+ class << self
7
+ def build
8
+ [
9
+ Check.new(code: :heading_level_skipped, rule: "T9", message: "Use consecutive heading levels within the body.",
10
+ location: ->(_, _, index) { "body.headings[#{index}]" }) { |post, context| skipped_heading(post, context) },
11
+ Check.new(code: :heading_h1_in_body, rule: "T10", message: "Use a level-two heading or lower in the body; the title supplies level one.",
12
+ location: ->(_, _, index) { "body.headings[#{index}]" }) { |post, context| headings(post, context).index { |node| node.name == "h1" } },
13
+ Check.new(code: :image_alt_absent, rule: "T13", message: "Describe this image in alternative text.",
14
+ location: ->(_, _, location) { location }) { |post, context| absent_alt(post, context) },
15
+ Check.new(code: :image_external, rule: "E19", message: "Store this image in the blog to keep a stable copy of its bytes.",
16
+ location: ->(_, _, index) { "body.images[#{index}]" }) { |post, context| images(post, context).index { |node| external?(node["src"]) } }
17
+ ]
18
+ end
19
+
20
+ private
21
+
22
+ def document(post, context)
23
+ context[:body_document] ||= Renderer.document(post)
24
+ end
25
+
26
+ def headings(post, context)
27
+ document(post, context).css("h1, h2, h3, h4, h5, h6")
28
+ end
29
+
30
+ def images(post, context)
31
+ document(post, context).css("img")
32
+ end
33
+
34
+ def skipped_heading(post, context)
35
+ levels = headings(post, context).map { |node| node.name.delete_prefix("h").to_i }
36
+ pair = levels.each_cons(2).find_index { |previous, current| current > previous + 1 }
37
+ pair + 1 if pair
38
+ end
39
+
40
+ def absent_alt(post, context)
41
+ return "cover_alt" if post.cover_image && post.cover_alt.blank?
42
+ index = images(post, context).index { |node| node["alt"].blank? }
43
+ "body.images[#{index}].alt" if index
44
+ end
45
+
46
+ def external?(source)
47
+ return false if source.blank?
48
+ uri = URI.parse(source)
49
+ return false unless uri.host
50
+ base = URI.parse(OpenBlog.config.public_base_url.to_s)
51
+ uri.host.downcase != base.host&.downcase
52
+ rescue URI::InvalidURIError
53
+ false
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ module OpenBlog
2
+ module Findings
3
+ class Check
4
+ attr_reader :code
5
+
6
+ def initialize(code:, rule:, message:, location: nil, &predicate)
7
+ @code, @rule, @message, @location, @predicate = code, rule, message, location, predicate
8
+ end
9
+
10
+ def call(post, context: {})
11
+ matched = @predicate.call(post, context)
12
+ return [] unless matched
13
+
14
+ location = @location.respond_to?(:call) ? @location.call(post, context, matched) : @location
15
+ [ { code: code, rule: @rule, message: @message, location: location } ]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,78 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ module Findings
5
+ class LinkCheck
6
+ def code
7
+ :link_same_site_broken
8
+ end
9
+
10
+ def call(post, context: {})
11
+ document = context[:body_document] ||= Renderer.document(post)
12
+ document.css("a[href]").map { |node| node["href"] }.uniq.filter_map do |href|
13
+ path = local_path(href, post)
14
+ next unless path && !resolves?(path)
15
+ { code: code, rule: "T14", message: "Point this link to an available page.", location: href }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def local_path(href, post)
22
+ return if href.blank? || href.start_with?("#", "?")
23
+ reference = URI.parse(href)
24
+ return if reference.scheme && !%w[http https].include?(reference.scheme.downcase)
25
+ configured = OpenBlog.config.public_base_url
26
+ return if (reference.host || reference.scheme) && configured.blank?
27
+ origin = URI.parse(configured.presence || "https://open-blog.invalid")
28
+ target = URI.join("#{origin.to_s.chomp('/')}#{post.path}", href)
29
+ return unless [ target.scheme, target.host&.downcase, target.port ] == [ origin.scheme, origin.host&.downcase, origin.port ]
30
+ mount = OpenBlog.mount_path.chomp("/")
31
+ path = target.path
32
+ return unless path == mount || path.start_with?("#{mount}/")
33
+ relative = path.delete_prefix(mount)
34
+ return if relative.match?(%r{\A/(?:api|mcp)(?:/|\z)})
35
+ path
36
+ rescue URI::InvalidURIError, URI::BadURIError
37
+ nil
38
+ end
39
+
40
+ def resolves?(path)
41
+ mount = OpenBlog.mount_path.chomp("/")
42
+ route = Engine.routes.recognize_path(path.delete_prefix(mount).presence || "/", method: :get)
43
+ format = route[:format].to_s
44
+ controller = route[:controller].delete_prefix("open_blog/")
45
+ case controller
46
+ when "posts"
47
+ return format.blank? || format == "html" if route[:action] == "index"
48
+ return false unless format.blank? || %w[html md].include?(format)
49
+ Post.listed.exists?(slug: route[:slug]) || Redirect.exists?(old_path: "#{mount}/#{route[:slug]}")
50
+ when "categories", "tags", "authors", "series"
51
+ return false unless format.blank? || format == "html"
52
+ model = { "categories" => Category, "tags" => Tag, "authors" => Author, "series" => Series }.fetch(controller)
53
+ model.exists?(slug: route[:slug])
54
+ when "feeds"
55
+ route[:slug].nil? || Category.exists?(slug: route[:slug])
56
+ when "search"
57
+ format.blank? || %w[html json].include?(format)
58
+ when "sitemaps"
59
+ OpenBlog.config.serve_sitemap
60
+ when "pages"
61
+ page = Page.published.find_by(slug: route[:slug])
62
+ page && OpenBlog.config.policy_urls[page.kind.to_sym].blank?
63
+ when "previews"
64
+ return false unless format.blank? || format == "html"
65
+ post = Post.find_by_preview_token(route[:token])
66
+ post && (post.previewable? || post.published?)
67
+ when "media"
68
+ image = Image.find_by(sha256: route[:sha256])
69
+ image && image.file.attached?
70
+ else
71
+ false
72
+ end
73
+ rescue ActionController::RoutingError
74
+ false
75
+ end
76
+ end
77
+ end
78
+ end