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,303 @@
1
+ require "uri"
2
+ require "json"
3
+ require "time"
4
+
5
+ module OpenBlog
6
+ class SurfaceReport
7
+ class PageChecks
8
+ PAGE_IDS = %w[E1 E3 E5 T3 T7 T8 T9 T10 T11 T12 T13 T15 T16 T17].freeze
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ def call
15
+ @context.posts.each { |post| check_post(post) } unless site_scope?
16
+ check_policies
17
+ check_sitemap
18
+ check_feeds
19
+ check_lists
20
+ end
21
+
22
+ private
23
+
24
+ def site_scope?
25
+ @context.respond_to?(:scope) && @context.scope == :site
26
+ end
27
+
28
+ def record(id, status, reason, **options)
29
+ @context.record(id, status, reason, **options)
30
+ end
31
+
32
+ def outcome(id, valid, reason, **options)
33
+ record(id, valid ? :pass : :fail, reason, **options)
34
+ end
35
+
36
+ def unavailable(page)
37
+ page.status.to_i == 0 ? :not_verified : :fail
38
+ end
39
+
40
+ def check_post(post)
41
+ page = @context.post_page(post)
42
+ unless page.status == 200
43
+ PAGE_IDS.each { |id| record(id, unavailable(page), "The post page could not be read successfully.", post: post) }
44
+ return
45
+ end
46
+ doc, article = page.document, page.article || {}
47
+ visible_author = doc.at_css(".ob-byline-author")
48
+ author = article["author"]
49
+ author = author.first if author.is_a?(Array)
50
+ author = {} unless author.is_a?(Hash)
51
+ outcome("E1", text(visible_author).present? && author["name"].present?, "Visible and structured author names are required.", post: post)
52
+ author_url = resolve(page.url, visible_author&.[]("href")) if text(visible_author).present?
53
+ author_page = @context.page(author_url)
54
+ record("E3", author_url ? (author_page.status == 200 ? :pass : unavailable(author_page)) : :fail,
55
+ "The visible author link must open successfully.", post: post)
56
+ check_publication_date(post, doc)
57
+ canonicals = doc.css("head link[rel~='canonical']")
58
+ canonical = canonicals.first&.[]("href")
59
+ expected = post.canonical_url.presence || @context.absolute(post.path)
60
+ if canonicals.length != 1 || !absolute_http?(canonical) || canonical != expected
61
+ record("T3", :fail, "One absolute canonical link must identify this post or its original.", post: post)
62
+ else
63
+ target = @context.page(canonical)
64
+ record("T3", target.status == 200 ? :pass : unavailable(target), "The canonical destination must open successfully.", post: post)
65
+ end
66
+ { "T7" => "head title", "T8" => "head meta[name='description']" }.each do |id, selector|
67
+ value = metadata(doc, selector)
68
+ duplicates = @context.posts.count { |other| metadata(@context.post_page(other).document, selector) == value }
69
+ outcome(id, value.present? && duplicates == 1, "The page needs a nonempty, distinct #{id == 'T7' ? 'title' : 'description'}.", post: post)
70
+ end
71
+ levels = doc.css("h1,h2,h3,h4,h5,h6").map { |node| node.name[1].to_i }
72
+ outcome("T9", levels.each_cons(2).none? { |a, b| b > a + 1 }, "Heading levels must progress without skipping a level.", post: post)
73
+ h1 = doc.css("h1")
74
+ outcome("T10", h1.length == 1 && text(h1.first) == post.title.squish, "One main heading must contain the post title.", post: post)
75
+ lang = doc.at_css("html")&.[]("lang").to_s
76
+ outcome("T11", language_tag?(lang), "The document needs a language tag.", post: post)
77
+ viewport = doc.at_css("head meta[name='viewport']")&.[]("content").to_s
78
+ outcome("T12", viewport.match?(/(?:\A|[,;\s])width\s*=\s*device-width(?:\z|[,;\s])/i), "The viewport declaration must use device width.", post: post)
79
+ outcome("T13", doc.css("img").all? { |image| image.key?("alt") }, "Every image needs an alt attribute, including decorative images.", post: post)
80
+ structured = page.articles.length == 1 && article["headline"].present? && author["name"].present? && author["@type"].present? &&
81
+ timestamp(article["datePublished"]) && timestamp(article["dateModified"]) && (!(post.cover_image || post.social_image) || article["image"].present?)
82
+ outcome("T15", structured, post.published_at ? "Article data needs its title, author and publication dates, plus an image when used." : "The publication date is unknown in the imported history.", post: post)
83
+ published, modified = timestamp(article["datePublished"]), timestamp(article["dateModified"])
84
+ visible_published, visible_modified = visible_date(doc, :published), visible_date(doc, :updated)
85
+ agrees = author["name"].to_s.squish == text(visible_author) && text(visible_author).present? && article["headline"].to_s.squish == post.title.squish &&
86
+ published && modified && published <= @context.now && modified <= @context.now && visible_published == published.to_date &&
87
+ (!doc.at_css(".ob-date--updated time") || visible_modified == modified.to_date)
88
+ outcome("T16", agrees, "Structured author and dates must agree with visible content and cannot be in the future.", post: post)
89
+ outcome("T17", %w[title type image url].all? { |field| doc.at_css("head meta[property='og:#{field}']")&.[]("content").present? },
90
+ "Sharing metadata needs a title, type, image and URL.", post: post)
91
+ end
92
+
93
+ def check_publication_date(post, doc)
94
+ first = post.publications.find_by(entry_type: "first")&.occurred_at
95
+ baseline = post.baseline
96
+ date = first || baseline&.first_published_at || baseline&.declared_first_published_at
97
+ unless date
98
+ return record("E5", :not_verified, "A reliable first publication date is unavailable.", post: post)
99
+ end
100
+ marks = !first && baseline&.first_published_at.nil? && baseline&.declared_first_published_at ? [ "publisher declaration" ] : []
101
+ outcome("E5", visible_date(doc, :published) == date.to_date, "The labeled publication date must match the recorded first publication.", post: post, marks: marks)
102
+ end
103
+
104
+ def check_policies
105
+ { "E4" => :responsible_party, "E8" => :corrections, "E16" => :editorial, "E17" => :ai_use }.each do |id, kind|
106
+ url = @context.absolute(OpenBlog.policy_url(kind, config: @context.config))
107
+ page = @context.page(url)
108
+ status = url ? (page.status == 200 ? :pass : unavailable(page)) : (@context.config.public_base_url.present? ? :fail : :not_verified)
109
+ if id == "E4" && @context.posts.any? && !site_scope?
110
+ @context.posts.each do |post|
111
+ post_page = @context.post_page(post)
112
+ result = post_page.status == 200 ? status : unavailable(post_page)
113
+ if result == :pass
114
+ linked = post_page.document.css("a[href]").any? { |node| resolve(post_page.url, node["href"]) == url }
115
+ result = :fail unless linked
116
+ end
117
+ record(id, result, "The responsible-party page must open and be linked from the post.", post: post)
118
+ end
119
+ else
120
+ if id == "E4" && status == :pass
121
+ statuses = @context.posts.map do |post|
122
+ source = @context.post_page(post)
123
+ next unavailable(source) unless source.status == 200
124
+ source.document.css("a[href]").any? { |node| resolve(source.url, node["href"]) == url } ? :pass : :fail
125
+ end
126
+ status = combine(statuses)
127
+ end
128
+ record(id, status, "The configured policy page must open successfully.")
129
+ end
130
+ end
131
+ end
132
+
133
+ def check_sitemap
134
+ page = @context.page(@context.absolute("#{OpenBlog.mount_path.chomp('/')}/sitemap.xml"))
135
+ return record("T5", unavailable(page), "The sitemap could not be read successfully.") unless page.status == 200
136
+ xml = Nokogiri::XML(page.body) { |options| options.strict.nonet }
137
+ entries = xml.xpath("//*[local-name()='url']").to_h do |entry|
138
+ [ entry.at_xpath("./*[local-name()='loc']")&.text, entry.at_xpath("./*[local-name()='lastmod']")&.text ]
139
+ end
140
+ valid = xml.root&.name == "urlset" && @context.posts.all? do |post|
141
+ url = @context.absolute(post.path)
142
+ canonical = post.canonical_url.presence || url
143
+ if origin(canonical) != origin(url)
144
+ !entries.key?(url)
145
+ else
146
+ value = entries[url]
147
+ modified = modified_at(post)
148
+ entries.key?(url) && (value.blank? || (calendar_date(value) == modified&.to_date && modified.present?))
149
+ end
150
+ end
151
+ outcome("T5", valid, "Sitemap membership and modification dates must match public posts.")
152
+ rescue Nokogiri::XML::SyntaxError
153
+ record("T5", :fail, "The sitemap is not valid XML.")
154
+ end
155
+
156
+ def check_feeds
157
+ pages = [ @context.page(@context.absolute(OpenBlog.mount_path)) ] + @context.posts.map { |post| @context.post_page(post) }
158
+ statuses = pages.map do |page|
159
+ next unavailable(page) unless page.status == 200
160
+ links = page.document.css("head link[rel~='alternate']").select { |link| %w[application/atom+xml application/feed+json].include?(link["type"]) }
161
+ next :fail if links.empty?
162
+ checks = links.map do |link|
163
+ feed = @context.page(resolve(page.url, link["href"]))
164
+ next unavailable(feed) unless feed.status == 200
165
+ valid_feed?(feed, link["type"]) ? :pass : :fail
166
+ end
167
+ combine(checks)
168
+ end
169
+ record("T6", combine(statuses), "Feed discovery, required fields and the newest post must be available.")
170
+ end
171
+
172
+ def valid_feed?(page, type)
173
+ newest = ReaderQueries.posts.first
174
+ url = newest && @context.absolute(newest.path)
175
+ if type == "application/feed+json"
176
+ feed = JSON.parse(page.body)
177
+ return false unless feed.is_a?(Hash)
178
+ items = feed["items"]
179
+ feed["version"] == "https://jsonfeed.org/version/1.1" && feed["title"].is_a?(String) && items.is_a?(Array) &&
180
+ items.all? { |item| item.is_a?(Hash) && item["id"].is_a?(String) && (item["content_html"].is_a?(String) || item["content_text"].is_a?(String)) } &&
181
+ (!url || items.any? { |item| item["id"] == url })
182
+ else
183
+ xml = Nokogiri::XML(page.body) { |options| options.strict.nonet }
184
+ ns = { "a" => "http://www.w3.org/2005/Atom" }
185
+ xml.root&.name == "feed" && xml.root&.namespace&.href == ns["a"] && %w[id title updated].all? { |field| xml.at_xpath("/a:feed/a:#{field}", ns)&.text.present? } &&
186
+ xml.xpath("/a:feed/a:entry", ns).all? { |entry| %w[id title updated].all? { |field| entry.at_xpath("a:#{field}", ns)&.text.present? } &&
187
+ (entry.at_xpath("a:author/a:name", ns)&.text.present? || xml.at_xpath("/a:feed/a:author/a:name", ns)&.text.present?) } &&
188
+ (!url || xml.xpath("/a:feed/a:entry/a:id", ns).any? { |entry| entry.text == url })
189
+ end
190
+ rescue JSON::ParserError, Nokogiri::XML::SyntaxError
191
+ false
192
+ end
193
+
194
+ def check_lists
195
+ return record("T21", :not_verified, "A primary list type is not configured.") unless @context.config.primary_list_type
196
+ pages = list_urls.map { |url, primary| [ @context.page(url), primary ] }
197
+ policies = OpenBlog::Page.published.reject { |page| @context.config.policy_urls[page.kind.to_sym].present? }
198
+ visible = @context.posts.map { |post| @context.post_page(post) } + pages.map(&:first) +
199
+ policies.map { |page| @context.page(@context.absolute(page.path)) }
200
+ indexed = visible.select { |page| page.status == 200 && !robots(page).include?("noindex") }
201
+ statuses = pages.map do |page, primary|
202
+ next unavailable(page) unless page.status == 200
203
+ directives = robots(page)
204
+ next :fail if primary && directives.include?("noindex")
205
+ next(directives.include?("nofollow") ? :fail : :pass) if directives.include?("noindex")
206
+ unique = [ "head title", "head meta[name='description']" ].all? do |selector|
207
+ value = metadata(page.document, selector)
208
+ value.present? && indexed.count { |other| metadata(other.document, selector) == value } == 1
209
+ end
210
+ next :fail unless unique
211
+ allowed = %w[Googlebot bingbot].map { |agent| @context.robots_allowed?(page.url, agent) }
212
+ allowed.include?(false) ? :fail : (allowed.include?(nil) ? :not_verified : :pass)
213
+ end
214
+ record("T21", combine(statuses), "List visibility, crawler access and distinct metadata must agree with the configured list type.")
215
+ end
216
+
217
+ def list_urls
218
+ mount = OpenBlog.mount_path.chomp("/")
219
+ featured = ReaderQueries.featured
220
+ paths = pagination_urls(mount.presence || "/", Post.listed.count - (featured ? 1 : 0), true)
221
+ { category: Category, tag: Tag, author: Author, series: Series }.each do |kind, model|
222
+ model.find_each do |record|
223
+ path = "#{mount}/#{@context.config.route_segments.fetch(kind)}/#{record.slug}"
224
+ paths.concat(pagination_urls(path, record.posts.listed.count, @context.config.primary_list_type.to_s.singularize == kind.to_s))
225
+ end
226
+ end
227
+ paths
228
+ end
229
+
230
+ def pagination_urls(path, count, primary)
231
+ total = [ (count.to_f / @context.config.posts_per_page).ceil, 1 ].max
232
+ (1..total).map { |number| [ @context.absolute(number == 1 ? path : "#{path}?page=#{number}"), primary ] }
233
+ end
234
+
235
+ def robots(page)
236
+ (page.document.css("meta[name='robots']").map { |node| node["content"] }.join(",") + "," + page.headers.to_h.find { |key, _| key.to_s.downcase == "x-robots-tag" }&.last.to_s).downcase.split(/[\s,]+/)
237
+ end
238
+
239
+ def modified_at(post)
240
+ post.publications.where(entry_type: %w[substantive correction]).maximum(:occurred_at) || post.baseline&.last_modified_at ||
241
+ post.publications.find_by(entry_type: "first")&.occurred_at || post.baseline&.first_published_at || post.baseline&.declared_first_published_at
242
+ end
243
+
244
+ def combine(statuses)
245
+ statuses.include?(:fail) ? :fail : (statuses.include?(:not_verified) ? :not_verified : :pass)
246
+ end
247
+
248
+ def metadata(doc, selector)
249
+ node = doc.at_css(selector)
250
+ node&.name == "meta" ? node["content"].to_s.squish : text(node)
251
+ end
252
+
253
+ def text(node)
254
+ return "" unless node && ![ node, *node.ancestors ].any? { |parent| parent.key?("hidden") || parent["aria-hidden"] == "true" }
255
+ node.text.squish
256
+ end
257
+
258
+ def visible_date(doc, kind)
259
+ wrapper = doc.at_css(".ob-date--#{kind}")
260
+ return unless wrapper && text(wrapper.at_css("span")).present? && text(wrapper.at_css("time")).present?
261
+ timestamp(wrapper.at_css("time")&.[]("datetime"))&.to_date
262
+ end
263
+
264
+ def language_tag?(value)
265
+ value.match?(/\A(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)\z/i)
266
+ end
267
+
268
+ def calendar_date(value)
269
+ timestamp(value)&.to_date || Date.iso8601(value.to_s)
270
+ rescue ArgumentError
271
+ nil
272
+ end
273
+
274
+ def timestamp(value)
275
+ Time.iso8601(value.to_s)
276
+ rescue ArgumentError
277
+ nil
278
+ end
279
+
280
+ def absolute_http?(value)
281
+ uri = URI.parse(value.to_s)
282
+ uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
283
+ rescue URI::InvalidURIError
284
+ false
285
+ end
286
+
287
+ def resolve(base, path)
288
+ return unless base.present? && path.present?
289
+ url = URI.join(base, path).to_s
290
+ url if absolute_http?(url)
291
+ rescue URI::InvalidURIError
292
+ nil
293
+ end
294
+
295
+ def origin(url)
296
+ uri = URI.parse(url.to_s)
297
+ [ uri.scheme, uri.host&.downcase, uri.port ]
298
+ rescue URI::InvalidURIError
299
+ nil
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,141 @@
1
+ require "uri"
2
+
3
+ module OpenBlog
4
+ class SurfaceReport
5
+ class ReachChecks
6
+ REDIRECTS = [ 301, 302, 303, 307, 308 ].freeze
7
+ SIGN_IN_PATH = %r{(?:\A|/)(?:login|log-in|signin|sign-in|sign_in|sign-on)(?:/|\z)}i
8
+
9
+ def initialize(context)
10
+ @context = context
11
+ end
12
+
13
+ def call
14
+ unless @context.reach
15
+ %w[T1 T2 T14].each { |key| @context.record(key, "not_verified", "Network checks were not requested.") }
16
+ return
17
+ end
18
+ @context.posts.each do |post|
19
+ page = @context.post_page(post)
20
+ if page.status == 0
21
+ %w[T1 T2 T14].each { |key| record(key, "not_verified", "The public page could not be fetched.", post) }
22
+ next
23
+ end
24
+ crawler_check(post, page)
25
+ transport_check(post, page)
26
+ link_check(post, page)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def crawler_check(post, page)
33
+ if page.status != 200 || page.document.at_css("[data-open-blog-content]").nil? || page.document.at_css("[data-open-blog-content]").text.strip.empty? || noindex?(page)
34
+ return record("T1", "fail", "The page is unavailable, empty, or excludes search indexing.", post)
35
+ end
36
+ allowed = %w[Googlebot bingbot].map { |agent| @context.robots_allowed?(page.url, agent) }
37
+ if allowed.include?(false)
38
+ record("T1", "fail", "A search crawler is blocked by robots rules.", post)
39
+ elsif allowed.include?(nil)
40
+ record("T1", "not_verified", "Crawler access rules could not be checked.", post)
41
+ else
42
+ record("T1", "pass", "The page permits both checked search crawlers.", post)
43
+ end
44
+ end
45
+
46
+ def noindex?(page)
47
+ values = page.document.css("meta[name]").filter_map do |node|
48
+ node["content"] if %w[robots googlebot bingbot].include?(node["name"].to_s.downcase)
49
+ end
50
+ header = page.headers["x-robots-tag"].to_s
51
+ agent = nil
52
+ header.split(",").each do |part|
53
+ if part.include?(":")
54
+ prefix, directive = part.split(":", 2)
55
+ if prefix.strip.match?(/\A[a-z_-]+\z/i)
56
+ agent = prefix.strip.downcase
57
+ part = directive
58
+ end
59
+ end
60
+ values << part if agent.nil? || %w[googlebot bingbot].include?(agent)
61
+ end
62
+ values.any? { |value| value.to_s.match?(/\b(?:noindex|none)\b/i) }
63
+ end
64
+
65
+ def transport_check(post, page)
66
+ secure = URI(page.url)
67
+ secure.scheme = "https"
68
+ secure.port = nil if secure.port == 80
69
+ secured = secure.to_s == page.url ? page : @context.page(secure.to_s)
70
+ plain = secure.dup
71
+ plain.scheme = "http"
72
+ plain.port = nil if plain.port == 443
73
+ response = @context.page(plain.to_s)
74
+ if secured.status == 0 || response.status == 0
75
+ return record("T2", "not_verified", "Both transport variants could not be fetched.", post)
76
+ end
77
+ destination = resolve(response.url, response.headers["location"])
78
+ passed = secured.status == 200 && REDIRECTS.include?(response.status) && destination && URI(destination).scheme == "https"
79
+ record("T2", passed ? "pass" : "fail", passed ? "HTTPS is available and HTTP redirects to HTTPS." : "HTTPS or the HTTP upgrade is missing.", post)
80
+ rescue URI::InvalidURIError
81
+ record("T2", "fail", "The transport redirect has an invalid destination.", post)
82
+ end
83
+
84
+ def link_check(post, page)
85
+ unless page.status == 200
86
+ return record("T14", "not_verified", "Links require an available page.", post)
87
+ end
88
+ links = page.document.css("a[href]").filter_map { |node| resolve(page.url, node["href"]) }.uniq
89
+ links.select! { |url| origin(url) == origin(page.url) }
90
+ statuses = links.map { |url| check_link(url) }
91
+ failed = statuses.select { |entry| entry[:result] == "fail" }
92
+ unknown = statuses.select { |entry| entry[:result] == "not_verified" }
93
+ status = failed.any? ? "fail" : unknown.any? ? "not_verified" : "pass"
94
+ reasons = { "pass" => "Same-site links meet the response checks.", "fail" => "Some same-site links failed the response checks.", "not_verified" => "Some link destinations could not be classified or fetched." }
95
+ record("T14", status, reasons.fetch(status), post, details: statuses.reject { |entry| entry[:result] == "pass" })
96
+ end
97
+
98
+ def check_link(url)
99
+ response = @context.page(url)
100
+ return { url: url, result: "not_verified" } if response.status == 0
101
+ declaration = @context.config.sign_in_destinations
102
+ declared = Array(declaration).any? { |prefix| URI(url).path.start_with?(prefix) }
103
+ redirected = REDIRECTS.include?(response.status)
104
+ if redirected
105
+ destination = resolve(response.url, response.headers["location"])
106
+ return { url: url, result: "fail" } unless destination
107
+ response = @context.page(destination)
108
+ end
109
+ return { url: url, result: "not_verified" } if response.status == 0
110
+ sign_in = URI(response.url).path.match?(SIGN_IN_PATH) || response.document.at_css('input[type="password"]') || [ 401, 403 ].include?(response.status)
111
+ result = if !declared && sign_in && declaration.nil?
112
+ "not_verified"
113
+ elsif declared
114
+ ([ 200, 401, 403 ].include?(response.status) || (redirected && sign_in)) && !REDIRECTS.include?(response.status) ? "pass" : "fail"
115
+ else
116
+ response.status == 200 && !sign_in ? "pass" : "fail"
117
+ end
118
+ { url: url, result: result }
119
+ end
120
+
121
+ def resolve(base, reference)
122
+ return unless reference.is_a?(String) && reference.present?
123
+ uri = URI.join(base, reference)
124
+ return unless uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
125
+ uri.fragment = nil
126
+ uri.to_s
127
+ rescue URI::InvalidURIError, URI::BadURIError
128
+ nil
129
+ end
130
+
131
+ def origin(url)
132
+ uri = URI(url)
133
+ [ uri.scheme, uri.host&.downcase, uri.port ]
134
+ end
135
+
136
+ def record(key, status, reason, post, details: nil)
137
+ @context.record(key, status, reason, post: post, details: details)
138
+ end
139
+ end
140
+ end
141
+ end