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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/app/assets/builds/open_blog/blog.css +2 -0
- data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
- data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
- data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
- data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
- data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
- data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
- data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
- data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
- data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
- data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
- data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
- data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
- data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
- data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
- data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
- data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
- data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
- data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
- data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
- data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
- data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
- data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
- data/app/controllers/open_blog/application_controller.rb +5 -0
- data/app/controllers/open_blog/authors_controller.rb +14 -0
- data/app/controllers/open_blog/categories_controller.rb +15 -0
- data/app/controllers/open_blog/errors_controller.rb +7 -0
- data/app/controllers/open_blog/feeds_controller.rb +22 -0
- data/app/controllers/open_blog/mcp_controller.rb +38 -0
- data/app/controllers/open_blog/media_controller.rb +20 -0
- data/app/controllers/open_blog/pages_controller.rb +12 -0
- data/app/controllers/open_blog/posts_controller.rb +57 -0
- data/app/controllers/open_blog/previews_controller.rb +35 -0
- data/app/controllers/open_blog/search_controller.rb +32 -0
- data/app/controllers/open_blog/series_controller.rb +15 -0
- data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
- data/app/controllers/open_blog/tags_controller.rb +15 -0
- data/app/helpers/open_blog/content_helper.rb +79 -0
- data/app/helpers/open_blog/dates_helper.rb +18 -0
- data/app/helpers/open_blog/faq_helper.rb +59 -0
- data/app/helpers/open_blog/head_helper.rb +75 -0
- data/app/helpers/open_blog/icons_helper.rb +10 -0
- data/app/helpers/open_blog/notices_helper.rb +47 -0
- data/app/helpers/open_blog/pagination_helper.rb +18 -0
- data/app/helpers/open_blog/structured_data_helper.rb +63 -0
- data/app/helpers/open_blog/theme_helper.rb +52 -0
- data/app/helpers/open_blog/url_helper.rb +58 -0
- data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
- data/app/models/concerns/open_blog/immutable.rb +13 -0
- data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
- data/app/models/concerns/open_blog/previewable.rb +38 -0
- data/app/models/open_blog/api_token.rb +34 -0
- data/app/models/open_blog/application_record.rb +5 -0
- data/app/models/open_blog/approval.rb +19 -0
- data/app/models/open_blog/author.rb +11 -0
- data/app/models/open_blog/baseline.rb +26 -0
- data/app/models/open_blog/category.rb +10 -0
- data/app/models/open_blog/connection_declaration.rb +16 -0
- data/app/models/open_blog/faq.rb +22 -0
- data/app/models/open_blog/image.rb +45 -0
- data/app/models/open_blog/page.rb +23 -0
- data/app/models/open_blog/page_view.rb +7 -0
- data/app/models/open_blog/post.rb +132 -0
- data/app/models/open_blog/publication.rb +17 -0
- data/app/models/open_blog/redirect.rb +16 -0
- data/app/models/open_blog/revision.rb +12 -0
- data/app/models/open_blog/series.rb +10 -0
- data/app/models/open_blog/tag.rb +11 -0
- data/app/models/open_blog/tagging.rb +7 -0
- data/app/queries/open_blog/api_post_query.rb +54 -0
- data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
- data/app/serializers/open_blog/author_serializer.rb +10 -0
- data/app/serializers/open_blog/category_serializer.rb +7 -0
- data/app/serializers/open_blog/doctor_serializer.rb +7 -0
- data/app/serializers/open_blog/error_serializer.rb +7 -0
- data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
- data/app/serializers/open_blog/findings_serializer.rb +13 -0
- data/app/serializers/open_blog/image_serializer.rb +9 -0
- data/app/serializers/open_blog/page_serializer.rb +9 -0
- data/app/serializers/open_blog/post_serializer.rb +59 -0
- data/app/serializers/open_blog/preview_serializer.rb +11 -0
- data/app/serializers/open_blog/records_serializer.rb +28 -0
- data/app/serializers/open_blog/redirect_serializer.rb +7 -0
- data/app/serializers/open_blog/series_serializer.rb +7 -0
- data/app/serializers/open_blog/tag_serializer.rb +7 -0
- data/app/serializers/open_blog/write_result_serializer.rb +9 -0
- data/app/views/open_blog/attachments/_blob.html.erb +1 -0
- data/app/views/open_blog/feeds/atom.xml.builder +27 -0
- data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
- data/config/locales/open_blog.en.yml +95 -0
- data/config/routes.rb +52 -0
- data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
- data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
- data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
- data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
- data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
- data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
- data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
- data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
- data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
- data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
- data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
- data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
- data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
- data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
- data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
- data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
- data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
- data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
- data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
- data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
- data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
- data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
- data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
- data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
- data/lib/generators/open_blog/generator_support.rb +64 -0
- data/lib/generators/open_blog/install/install_generator.rb +188 -0
- data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
- data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
- data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
- data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
- data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
- data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
- data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
- data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
- data/lib/generators/open_blog/views/views_generator.rb +21 -0
- data/lib/open_blog/actor.rb +7 -0
- data/lib/open_blog/admin_suite.rb +73 -0
- data/lib/open_blog/adopt/contract.rb +152 -0
- data/lib/open_blog/adopt/snapshot.rb +35 -0
- data/lib/open_blog/adopt.rb +159 -0
- data/lib/open_blog/api_fields.rb +40 -0
- data/lib/open_blog/approve.rb +18 -0
- data/lib/open_blog/authentication.rb +18 -0
- data/lib/open_blog/build_css.rb +53 -0
- data/lib/open_blog/configuration.rb +185 -0
- data/lib/open_blog/configuration_error.rb +4 -0
- data/lib/open_blog/content_guard.rb +77 -0
- data/lib/open_blog/doctor/files.rb +61 -0
- data/lib/open_blog/doctor/javascript.rb +57 -0
- data/lib/open_blog/doctor.rb +177 -0
- data/lib/open_blog/engine.rb +45 -0
- data/lib/open_blog/errors.rb +40 -0
- data/lib/open_blog/faq_extraction.rb +184 -0
- data/lib/open_blog/findings/body_checks.rb +58 -0
- data/lib/open_blog/findings/check.rb +19 -0
- data/lib/open_blog/findings/link_check.rb +78 -0
- data/lib/open_blog/findings/record_checks.rb +94 -0
- data/lib/open_blog/findings/registry.rb +18 -0
- data/lib/open_blog/findings.rb +27 -0
- data/lib/open_blog/image_fetch.rb +115 -0
- data/lib/open_blog/image_import.rb +164 -0
- data/lib/open_blog/image_resolution.rb +171 -0
- data/lib/open_blog/instructions.rb +7 -0
- data/lib/open_blog/json_feed.rb +36 -0
- data/lib/open_blog/label_policy.rb +41 -0
- data/lib/open_blog/markdown_view.rb +57 -0
- data/lib/open_blog/mcp/definition.rb +28 -0
- data/lib/open_blog/mcp/dispatcher.rb +33 -0
- data/lib/open_blog/mcp/image_input.rb +30 -0
- data/lib/open_blog/mcp/registry.rb +139 -0
- data/lib/open_blog/mcp/schemas.rb +72 -0
- data/lib/open_blog/mcp.rb +24 -0
- data/lib/open_blog/not_found.rb +4 -0
- data/lib/open_blog/operation.rb +36 -0
- data/lib/open_blog/page_views.rb +72 -0
- data/lib/open_blog/pagination.rb +18 -0
- data/lib/open_blog/plain_text.rb +36 -0
- data/lib/open_blog/policy_url.rb +9 -0
- data/lib/open_blog/post_attributes.rb +172 -0
- data/lib/open_blog/post_identity.rb +28 -0
- data/lib/open_blog/publish.rb +7 -0
- data/lib/open_blog/reader_dates.rb +21 -0
- data/lib/open_blog/reader_page.rb +21 -0
- data/lib/open_blog/reader_queries.rb +90 -0
- data/lib/open_blog/record_release.rb +66 -0
- data/lib/open_blog/redirect_target.rb +58 -0
- data/lib/open_blog/remove.rb +43 -0
- data/lib/open_blog/renderer/highlighter.rb +44 -0
- data/lib/open_blog/renderer/markdown.rb +14 -0
- data/lib/open_blog/renderer/rich_text.rb +19 -0
- data/lib/open_blog/renderer/sanitizer.rb +72 -0
- data/lib/open_blog/renderer/scrubber.rb +35 -0
- data/lib/open_blog/renderer/transform.rb +115 -0
- data/lib/open_blog/renderer.rb +51 -0
- data/lib/open_blog/result.rb +11 -0
- data/lib/open_blog/revision_payload.rb +67 -0
- data/lib/open_blog/rich_text_guard.rb +26 -0
- data/lib/open_blog/sample.rb +56 -0
- data/lib/open_blog/save_draft.rb +7 -0
- data/lib/open_blog/search.rb +21 -0
- data/lib/open_blog/sitemap_entries.rb +66 -0
- data/lib/open_blog/surface_report/expected_content.rb +27 -0
- data/lib/open_blog/surface_report/http.rb +92 -0
- data/lib/open_blog/surface_report/page_checks.rb +303 -0
- data/lib/open_blog/surface_report/reach_checks.rb +141 -0
- data/lib/open_blog/surface_report/record_checks.rb +248 -0
- data/lib/open_blog/surface_report/robots.rb +49 -0
- data/lib/open_blog/surface_report.rb +223 -0
- data/lib/open_blog/syntax_css.rb +55 -0
- data/lib/open_blog/unpublish.rb +13 -0
- data/lib/open_blog/version.rb +3 -0
- data/lib/open_blog/write_post.rb +205 -0
- data/lib/open_blog.rb +91 -0
- data/lib/tasks/open_blog.rake +71 -0
- data/skills/open-blog-adopt/SKILL.md +12 -0
- data/skills/open-blog-install/SKILL.md +10 -0
- data/skills/open-blog-policy-pages/SKILL.md +10 -0
- data/skills/open-blog-publish/SKILL.md +14 -0
- data/skills/open-blog-report/SKILL.md +10 -0
- data/skills/open-blog-update/SKILL.md +16 -0
- metadata +515 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "time"
|
|
3
|
+
require_relative "expected_content"
|
|
4
|
+
|
|
5
|
+
module OpenBlog
|
|
6
|
+
class SurfaceReport
|
|
7
|
+
class RecordChecks
|
|
8
|
+
GENERATED = ".ob-notice, .ob-highlights, .ob-date, .ob-toc, .ob-correction, .ob-disclosure, [data-open-blog-preview]".freeze
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
posts = @context.respond_to?(:scope) && @context.scope == :site ? [] : @context.posts
|
|
16
|
+
posts.each do |post|
|
|
17
|
+
modified_date(post)
|
|
18
|
+
corrections(post)
|
|
19
|
+
approval(post)
|
|
20
|
+
revision(post)
|
|
21
|
+
notice(post)
|
|
22
|
+
connections(post)
|
|
23
|
+
end
|
|
24
|
+
redirects
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def record(predicate, status, reason, post: nil, **options)
|
|
30
|
+
@context.record(predicate, status, reason, post: post, **options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def readable(page)
|
|
34
|
+
page && page.status == 200 && page.body
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def modified_date(post)
|
|
38
|
+
latest = post.publications.where(entry_type: %w[substantive correction]).where("occurred_at <= ?", @context.now).maximum(:occurred_at)
|
|
39
|
+
baseline = post.baseline
|
|
40
|
+
expected = latest || baseline&.last_modified_at || (!baseline && post.publications.find_by(entry_type: "first")&.occurred_at)
|
|
41
|
+
return record("E6", :not_verified, "The release history has no supported modification date.", post: post) unless expected
|
|
42
|
+
page = @context.post_page(post)
|
|
43
|
+
sitemap = @context.page(@context.absolute("#{OpenBlog.mount_path.chomp('/')}/sitemap.xml"))
|
|
44
|
+
return record("E6", :not_verified, "The article or sitemap could not be inspected.", post: post) unless readable(page) && readable(sitemap)
|
|
45
|
+
xml = Nokogiri::XML(sitemap.body) { |options| options.strict.nonet }
|
|
46
|
+
entry = xml.xpath("//*[local-name()='url']").find { |node| node.at_xpath("./*[local-name()='loc']")&.text == @context.absolute(post.path) }
|
|
47
|
+
values = [ page.article&.fetch("dateModified", nil) ]
|
|
48
|
+
sitemap_date = entry&.at_xpath("./*[local-name()='lastmod']")&.text
|
|
49
|
+
values << sitemap_date if sitemap_date.present?
|
|
50
|
+
values.concat(page.document.css(".ob-date--updated time").map { |node| node["datetime"] })
|
|
51
|
+
valid = values.all? { |value| same_time?(value, expected) }
|
|
52
|
+
record("E6", valid ? :pass : :fail, valid ? "Displayed modification dates agree with release history." : "A modification date differs from release history.", post: post)
|
|
53
|
+
rescue Nokogiri::XML::SyntaxError
|
|
54
|
+
record("E6", :fail, "The sitemap date evidence is not valid XML.", post: post)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def corrections(post)
|
|
58
|
+
entries = post.publications.where(entry_type: "correction").where("occurred_at <= ?", @context.now).to_a
|
|
59
|
+
return record("E7", :not_applicable, "No correction release is recorded.", post: post) if entries.empty?
|
|
60
|
+
page = @context.post_page(post)
|
|
61
|
+
return record("E7", :not_verified, "The article could not be inspected.", post: post) unless readable(page)
|
|
62
|
+
blocks = page.document.css(".ob-correction")
|
|
63
|
+
label = I18n.t("open_blog.notices.correction", locale: @context.config.locale)
|
|
64
|
+
valid = entries.all? { |entry| blocks.any? { |block| visible_text(block).include?(label) && block.css("time").any? { |node| same_time?(node["datetime"], entry.occurred_at) } } }
|
|
65
|
+
record("E7", valid ? :pass : :fail, valid ? "Recorded corrections have dated notices." : "A recorded correction lacks a dated notice.", post: post)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def approved(post)
|
|
69
|
+
post.approvals.where(revision_id: post.public_revision_id, facts_checked: true).order(:approved_at, :id).first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def approval(post)
|
|
73
|
+
return record("E18", :not_applicable, "The article is recorded as written by a person.", post: post) if post.provenance_human_written?
|
|
74
|
+
review = approved(post)
|
|
75
|
+
unless review
|
|
76
|
+
return record("E18", post.provenance_unknown? ? :not_verified : :fail, "No fact-checked review covers the public revision.", post: post)
|
|
77
|
+
end
|
|
78
|
+
baseline = post.baseline
|
|
79
|
+
released = if baseline && baseline.adopted_revision_id == post.public_revision_id
|
|
80
|
+
baseline.first_published_at || baseline.declared_first_published_at
|
|
81
|
+
else
|
|
82
|
+
post.publications.where(revision_id: post.public_revision_id).minimum(:occurred_at)
|
|
83
|
+
end
|
|
84
|
+
marks = []
|
|
85
|
+
marks << "publisher declaration" if review.kind == "declared"
|
|
86
|
+
marks << "review after release" if released && review.approved_at > released
|
|
87
|
+
record("E18", :pass, "A fact-checked review covers the public revision.", post: post, marks: marks,
|
|
88
|
+
details: { approved_at: review.approved_at.iso8601, released_at: released&.iso8601 })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def revision(post)
|
|
92
|
+
return record("E19", :not_applicable, "The article is recorded as written by a person.", post: post) if post.provenance_human_written?
|
|
93
|
+
stored = post.public_revision
|
|
94
|
+
page = @context.post_page(post)
|
|
95
|
+
unless stored && readable(page) && (!post.provenance_unknown? || approved(post))
|
|
96
|
+
return record("E19", :not_verified, "The revision, review, or fetched article is unavailable.", post: post)
|
|
97
|
+
end
|
|
98
|
+
payload = JSON.parse(stored.payload)
|
|
99
|
+
expected = ExpectedContent.call(post, payload, base_url: @context.config.public_base_url)
|
|
100
|
+
actual = page.document.at_css("[data-open-blog-content]")
|
|
101
|
+
return record("E19", :not_verified, "The stored revision content could not be rendered.", post: post) unless expected
|
|
102
|
+
return record("E19", :fail, "The fetched page has no declared article content region.", post: post) unless actual
|
|
103
|
+
parts = {}
|
|
104
|
+
linked = post.publications.exists?(revision_id: stored.id) && approved(post)
|
|
105
|
+
parts["A"] = mark(Digest::SHA256.hexdigest(stored.payload) == stored.identifier && linked && RevisionPayload.new(post).identifier == stored.identifier)
|
|
106
|
+
parts["B"] = mark(text(project(expected)) == text(project(actual)))
|
|
107
|
+
parts["C"] = mark(links(project(expected)) == links(project(actual)))
|
|
108
|
+
images = payload.fetch("images").select { |image| %w[cover body].include?(image["role"]) }
|
|
109
|
+
parts["D"] = mark(actual.css("img").map { |node| [ node["src"], node["alt"].to_s ] } == images.map { |image| image.values_at("url", "alt") })
|
|
110
|
+
parts["E"] = image_files(images)
|
|
111
|
+
parts["F"] = head(page, payload)
|
|
112
|
+
faq = actual.css("[data-open-blog-faq-entry]").map do |entry|
|
|
113
|
+
question = entry.at_css("h3")
|
|
114
|
+
[ text(question), entry.css("p").map { |node| text(node) }.join(" ") ]
|
|
115
|
+
end
|
|
116
|
+
expected_faq = payload.fetch("faq").map { |entry| [ normalize(entry["question"]), normalize(entry["answer"]) ] }
|
|
117
|
+
parts["G"] = mark(faq == expected_faq && (expected_faq.any? || actual.css("[data-open-blog-faq]").empty?))
|
|
118
|
+
status = parts.value?("fail") ? :fail : parts.value?("not verified") ? :not_verified : :pass
|
|
119
|
+
record("E19", status, status == :pass ? "Fetched content agrees with the stored public revision." : "Some revision comparisons differ or lack evidence.", post: post, details: parts)
|
|
120
|
+
rescue JSON::ParserError, KeyError, ActionView::Template::Error => error
|
|
121
|
+
record("E19", :not_verified, "Stored content could not be compared (#{error.class.name}).", post: post)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def image_files(images)
|
|
125
|
+
results = images.map do |image|
|
|
126
|
+
next "not verified" if image["sha256"].blank?
|
|
127
|
+
page = image_page(image["url"])
|
|
128
|
+
next "not verified" unless readable(page)
|
|
129
|
+
mark(Digest::SHA256.hexdigest(page.body) == image["sha256"])
|
|
130
|
+
end
|
|
131
|
+
results.include?("fail") ? "fail" : results.include?("not verified") ? "not verified" : "pass"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def image_page(path)
|
|
135
|
+
url = @context.absolute(path)
|
|
136
|
+
seen = []
|
|
137
|
+
6.times do
|
|
138
|
+
return if url.nil? || seen.include?(url)
|
|
139
|
+
seen << url
|
|
140
|
+
page = @context.page(url)
|
|
141
|
+
return page unless page && [ 301, 302, 303, 307, 308 ].include?(page.status)
|
|
142
|
+
location = page.headers["location"] || page.headers["Location"]
|
|
143
|
+
return if location.blank?
|
|
144
|
+
url = URI.join(url, location).to_s
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
rescue URI::InvalidURIError, URI::BadURIError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def head(page, payload)
|
|
152
|
+
document = page.document
|
|
153
|
+
title = payload["search_title"].presence || payload["title"]
|
|
154
|
+
description = payload["search_description"].presence || payload["description"]
|
|
155
|
+
return "fail" unless document.at_css("title")&.text&.start_with?(title) && document.at_css('meta[name="description"]')&.[]("content") == description
|
|
156
|
+
social = payload.fetch("images").select { |image| image["role"] == "social" }
|
|
157
|
+
return "pass" if social.empty?
|
|
158
|
+
url = document.at_css('meta[property="og:image"]')&.[]("content")
|
|
159
|
+
return "fail" unless url && URI.parse(url).path == social.first["url"]
|
|
160
|
+
image_files([ social.first.merge("url" => url) ])
|
|
161
|
+
rescue URI::InvalidURIError
|
|
162
|
+
"fail"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def notice(post)
|
|
166
|
+
dependencies = [ @context.row("E4", post: post), @context.row("E18", post: post), @context.row("E19", post: post) ]
|
|
167
|
+
needed = !post.provenance_human_written? && dependencies.any? { |row| !row || %w[fail].include?(row[:result].to_s) || row[:result].to_s.tr("_", " ") == "not verified" }
|
|
168
|
+
return record("E20", :not_applicable, "The inspected evidence does not require an AI notice.", post: post) unless needed
|
|
169
|
+
page = @context.post_page(post)
|
|
170
|
+
return record("E20", :not_verified, "The article could not be inspected for a notice.", post: post) unless readable(page)
|
|
171
|
+
key = post.provenance_ai_assisted? ? "ai_assisted" : "ai_unknown"
|
|
172
|
+
label = I18n.t("open_blog.notices.#{key}", locale: @context.config.locale)
|
|
173
|
+
valid = page.document.css(".ob-notice--ai").any? { |node| visible_text(node).include?(label) }
|
|
174
|
+
record("E20", valid ? :pass : :fail, valid ? "The fetched article includes the relevant AI notice." : "The fetched article lacks the relevant AI notice.", post: post)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def connections(post)
|
|
178
|
+
declaration = post.connection_declarations.order(:id).last
|
|
179
|
+
page = @context.post_page(post)
|
|
180
|
+
{ "E21" => declaration && declaration.connections.any?, "E22" => declaration&.third_party_paid? }.each do |predicate, needed|
|
|
181
|
+
if !declaration
|
|
182
|
+
record(predicate, :not_verified, "No connection declaration was supplied.", post: post)
|
|
183
|
+
elsif !needed
|
|
184
|
+
record(predicate, :not_applicable, "The publisher declared no matching connection.", post: post)
|
|
185
|
+
elsif !readable(page)
|
|
186
|
+
record(predicate, :not_verified, "The article could not be inspected.", post: post)
|
|
187
|
+
else
|
|
188
|
+
selector = predicate == "E21" ? "[data-open-blog-content] .ob-disclosure" : "[data-open-blog-content] .ob-notice--paid"
|
|
189
|
+
document = page.document
|
|
190
|
+
valid = document.css(selector).any? do |node|
|
|
191
|
+
next false if visible_text(node).blank?
|
|
192
|
+
body = document.at_css("[data-open-blog-body]")
|
|
193
|
+
predicate == "E21" || (body && (node <=> body) == -1)
|
|
194
|
+
end
|
|
195
|
+
record(predicate, valid ? :pass : :fail, valid ? "A disclosure is present in the article markup." : "A declared connection has no disclosure markup.", post: post)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def redirects
|
|
201
|
+
entries = Redirect.all.to_a
|
|
202
|
+
if entries.empty?
|
|
203
|
+
began = Baseline.minimum(:adopted_at)
|
|
204
|
+
reason = "No URL changes are recorded; earlier moves require imported history."
|
|
205
|
+
reason += " Imported coverage begins #{began.iso8601}." if began
|
|
206
|
+
return record("T4", :not_applicable, reason)
|
|
207
|
+
end
|
|
208
|
+
entries.each do |entry|
|
|
209
|
+
page = @context.page(@context.absolute(entry.old_path))
|
|
210
|
+
unless page && page.status.to_i.positive?
|
|
211
|
+
record("T4", :not_verified, "The recorded old URL could not be fetched.", details: { path: entry.old_path })
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
valid = if entry.new_path
|
|
215
|
+
[ 301, 308 ].include?(page.status) && @context.absolute(page.headers["location"] || page.headers["Location"]) == @context.absolute(entry.new_path)
|
|
216
|
+
else
|
|
217
|
+
[ 404, 410 ].include?(page.status)
|
|
218
|
+
end
|
|
219
|
+
record("T4", valid ? :pass : :fail, valid ? "The old URL has the recorded response." : "The old URL differs from its recorded destination or removal.", details: { path: entry.old_path })
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def project(node)
|
|
224
|
+
node = node.dup
|
|
225
|
+
node.css(GENERATED).remove
|
|
226
|
+
node
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def visible_text(node)
|
|
230
|
+
return "" unless node && ![ node, *node.ancestors ].any? { |parent| parent.key?("hidden") || parent["aria-hidden"] == "true" }
|
|
231
|
+
text(node)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def text(node) = normalize(node&.text)
|
|
235
|
+
def normalize(value) = value.to_s.gsub(/\s+/, " ").strip
|
|
236
|
+
def links(node) = node.css("a[href]").map { |link| [ text(link), link["href"] ] }
|
|
237
|
+
def mark(value) = value ? "pass" : "fail"
|
|
238
|
+
|
|
239
|
+
def same_time?(value, expected)
|
|
240
|
+
return false if value.blank?
|
|
241
|
+
value = value.to_s
|
|
242
|
+
value.length == 10 ? Date.iso8601(value) == expected.to_date : Time.iso8601(value).to_i == expected.to_i
|
|
243
|
+
rescue ArgumentError
|
|
244
|
+
false
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class SurfaceReport
|
|
3
|
+
module Robots
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def allowed?(body, path, agent)
|
|
7
|
+
return true if path == "/robots.txt"
|
|
8
|
+
groups = []
|
|
9
|
+
group = nil
|
|
10
|
+
body.to_s.dup.force_encoding("UTF-8").scrub.delete_prefix("\uFEFF").split(/\r\n|\n|\r/).each do |line|
|
|
11
|
+
key, value = line.sub(/#.*/, "").strip.split(":", 2).map(&:strip)
|
|
12
|
+
next unless value
|
|
13
|
+
if key.downcase == "user-agent"
|
|
14
|
+
next unless value.match?(/\A(?:[a-z_-]+|\*)\z/i)
|
|
15
|
+
if !group || group[:started]
|
|
16
|
+
group = { agents: [], rules: [], started: false }
|
|
17
|
+
groups << group
|
|
18
|
+
end
|
|
19
|
+
group[:agents] << value.downcase
|
|
20
|
+
elsif %w[allow disallow].include?(key.downcase) && group
|
|
21
|
+
group[:started] = true
|
|
22
|
+
group[:rules] << [ key.downcase, normalize(value) ] if value.start_with?("/")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
selected = groups.select { |entry| entry[:agents].include?(agent.downcase) }
|
|
26
|
+
selected = groups.select { |entry| entry[:agents].include?("*") } if selected.empty?
|
|
27
|
+
target = normalize(path)
|
|
28
|
+
matches = selected.flat_map { |entry| entry[:rules] }.select do |_kind, pattern|
|
|
29
|
+
ending = pattern.end_with?("$")
|
|
30
|
+
value = ending ? pattern[0...-1] : pattern
|
|
31
|
+
expression = value.split("*", -1).map { |part| Regexp.escape(part) }.join(".*")
|
|
32
|
+
target.match?(Regexp.new("\\A#{expression}#{'\\z' if ending}"))
|
|
33
|
+
end
|
|
34
|
+
winner = matches.max_by { |kind, pattern| [ pattern.delete("*").delete_suffix("$").bytesize, kind == "allow" ? 1 : 0 ] }
|
|
35
|
+
winner.nil? || winner.first == "allow"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize(value)
|
|
39
|
+
escaped = value.to_s.b.bytes.map { |byte| byte > 127 ? "%%%02X" % byte : byte.chr }.join
|
|
40
|
+
escaped.gsub(/%([0-9a-f]{2})/i) do
|
|
41
|
+
byte = Regexp.last_match(1).to_i(16)
|
|
42
|
+
character = byte.chr
|
|
43
|
+
character.match?(/[a-z0-9._~-]/i) ? character : "%%%02X" % byte
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_class_method :normalize
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "json"
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module OpenBlog
|
|
6
|
+
class SurfaceReport
|
|
7
|
+
autoload :Http, "open_blog/surface_report/http"
|
|
8
|
+
autoload :Robots, "open_blog/surface_report/robots"
|
|
9
|
+
autoload :PageChecks, "open_blog/surface_report/page_checks"
|
|
10
|
+
autoload :ExpectedContent, "open_blog/surface_report/expected_content"
|
|
11
|
+
autoload :RecordChecks, "open_blog/surface_report/record_checks"
|
|
12
|
+
autoload :ReachChecks, "open_blog/surface_report/reach_checks"
|
|
13
|
+
|
|
14
|
+
STATUSES = [ "pass", "fail", "not applicable", "not verified" ].freeze
|
|
15
|
+
LIMIT = 50
|
|
16
|
+
NOTICE = "Automated observations do not establish editorial quality or replace manual review, browser testing, and field measurements.".freeze
|
|
17
|
+
NOT_ASSESSED = %w[E2 E9 E10 E11 E12 E13 E14 E15 E23 E24 E25].freeze
|
|
18
|
+
NOT_RUN = [
|
|
19
|
+
{ predicate: "T12_render", reason: "Small-screen layout requires a browser session." },
|
|
20
|
+
{ predicate: "T18", reason: "Hidden content and navigation behavior need manual inspection." },
|
|
21
|
+
{ predicate: "T19", reason: "Accessibility requires browser tooling and human testing." },
|
|
22
|
+
{ predicate: "T20", reason: "Performance assessment needs real-user field measurements." }
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def self.run(scope: :site, post: nil, page: 1, reach: false, http: nil)
|
|
26
|
+
context = Context.new(scope: scope, post: post, page: page, reach: reach, http: http)
|
|
27
|
+
PageChecks.new(context).call
|
|
28
|
+
RecordChecks.new(context).call
|
|
29
|
+
ReachChecks.new(context).call unless context.scope == :site
|
|
30
|
+
Result.new(context)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.declarations
|
|
34
|
+
{
|
|
35
|
+
field_map: { title: "posts.title", description: "posts.description", search_title: "posts.search_title",
|
|
36
|
+
search_description: "posts.search_description", body: "posts.body_markdown or rich_body", author: "posts.author_name",
|
|
37
|
+
images: "cover_image, body image manifest, social_image", faq: "faqs.question and faqs.answer ordered by position" },
|
|
38
|
+
content_selector: "[data-open-blog-content]", faq_selectors: OpenBlog::FAQ_SELECTORS.values,
|
|
39
|
+
primary_list_type: OpenBlog.config.primary_list_type, sign_in_destinations: OpenBlog.config.sign_in_destinations
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Page
|
|
44
|
+
attr_reader :url, :status, :headers, :body, :error
|
|
45
|
+
|
|
46
|
+
def initialize(url:, status: 0, headers: {}, body: "", error: nil)
|
|
47
|
+
@url, @status, @body, @error = url, status.to_i, body, error
|
|
48
|
+
@headers = headers.to_h.transform_keys { |key| key.to_s.downcase }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def document
|
|
52
|
+
@document ||= Nokogiri::HTML5.parse(body.to_s.dup.force_encoding(Encoding::UTF_8).scrub)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def articles
|
|
56
|
+
@articles ||= document.css('script[type="application/ld+json"]').flat_map do |script|
|
|
57
|
+
json_nodes(JSON.parse(script.text))
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
[]
|
|
60
|
+
end.select { |node| (Array(node["@type"]) & %w[Article BlogPosting]).any? }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def article
|
|
64
|
+
articles.first
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def json_nodes(value)
|
|
70
|
+
case value
|
|
71
|
+
when Array then value.flat_map { |item| json_nodes(item) }
|
|
72
|
+
when Hash then [ value, *json_nodes(value["@graph"]) ]
|
|
73
|
+
else []
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Context
|
|
79
|
+
attr_reader :scope, :posts, :config, :now, :reach, :results, :pagination, :limitations
|
|
80
|
+
|
|
81
|
+
def initialize(scope:, post:, page:, reach:, http:)
|
|
82
|
+
@config, @now, @results, @pages = OpenBlog.config, Time.current, [], {}
|
|
83
|
+
@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
|
|
84
|
+
@response_bytes = 0
|
|
85
|
+
@limitations = []
|
|
86
|
+
@scope = scope.to_s.to_sym
|
|
87
|
+
invalid!(:scope) unless %i[site post all].include?(@scope)
|
|
88
|
+
page = page.to_i if page.is_a?(String) && page.match?(/\A[1-9]\d*\z/)
|
|
89
|
+
invalid!(:page) unless page.is_a?(Integer) && page.between?(1, 1_000_000)
|
|
90
|
+
@reach = case reach
|
|
91
|
+
when true, "true" then true
|
|
92
|
+
when false, "false" then false
|
|
93
|
+
else invalid!(:reach)
|
|
94
|
+
end
|
|
95
|
+
invalid!(:post) if @scope == :post ? post.blank? : post.present?
|
|
96
|
+
@http = http || Http.new(trusted_origin: config.public_base_url)
|
|
97
|
+
select_posts(post, page)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record(predicate, status, reason, post: nil, marks: [], details: nil)
|
|
101
|
+
status = status.to_s.tr("_", " ")
|
|
102
|
+
raise ArgumentError, "Unknown report status" unless STATUSES.include?(status)
|
|
103
|
+
value = { predicate: predicate.to_s, result: status, reason: reason.to_s, marks: marks }
|
|
104
|
+
value[:post_id] = post.respond_to?(:id) ? post.id : post if post
|
|
105
|
+
value[:details] = details if details
|
|
106
|
+
results << value
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def row(predicate, post: nil)
|
|
111
|
+
id = post.respond_to?(:id) ? post.id : post
|
|
112
|
+
results.find { |value| value[:predicate] == predicate.to_s && value[:post_id] == id }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def absolute(path)
|
|
116
|
+
return if path.blank? || config.public_base_url.blank?
|
|
117
|
+
URI.join("#{config.public_base_url.chomp('/')}/", path.to_s).to_s
|
|
118
|
+
rescue URI::InvalidURIError, URI::BadURIError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def post_page(post)
|
|
123
|
+
page(absolute(post.path))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def page(url)
|
|
127
|
+
return Page.new(url: nil, error: "Set public_base_url to inspect reader pages.") if url.blank?
|
|
128
|
+
return @pages[url] if @pages.key?(url)
|
|
129
|
+
if @byte_budget_exhausted || @pages.length >= 500 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
|
|
130
|
+
return limited_page(url, "Report request budget exhausted; narrow the scope and retry.")
|
|
131
|
+
end
|
|
132
|
+
@pages[url] ||= begin
|
|
133
|
+
response = @http.get(url)
|
|
134
|
+
@response_bytes += response.body.to_s.bytesize
|
|
135
|
+
if @response_bytes > 32 * 1024 * 1024
|
|
136
|
+
@byte_budget_exhausted = true
|
|
137
|
+
limited_page(url, "Report response-byte budget exhausted; narrow the scope and retry.")
|
|
138
|
+
else
|
|
139
|
+
Page.new(url: url, status: response.status, headers: response.headers, body: response.body)
|
|
140
|
+
end
|
|
141
|
+
rescue Http::FetchError => error
|
|
142
|
+
Page.new(url: url, error: error.message)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def robots_allowed?(url, agent)
|
|
147
|
+
return if url.blank?
|
|
148
|
+
uri = URI.parse(url)
|
|
149
|
+
target = URI.join(url, "/robots.txt").to_s
|
|
150
|
+
6.times do |attempt|
|
|
151
|
+
response = page(target)
|
|
152
|
+
return if response.status.zero? || response.status >= 500
|
|
153
|
+
return true if response.status.between?(400, 499)
|
|
154
|
+
return Robots.allowed?(response.body, uri.request_uri, agent) if response.status == 200
|
|
155
|
+
return unless [ 301, 302, 303, 307, 308 ].include?(response.status)
|
|
156
|
+
return if response.headers["location"].blank? || attempt == 5
|
|
157
|
+
target = URI.join(target, response.headers["location"]).to_s
|
|
158
|
+
end
|
|
159
|
+
rescue URI::InvalidURIError, URI::BadURIError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def limited_page(url, reason)
|
|
166
|
+
@limitations << reason unless @limitations.include?(reason)
|
|
167
|
+
Page.new(url: url, error: reason)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def select_posts(identity, number)
|
|
171
|
+
scope = Post.listed.order(:id)
|
|
172
|
+
if @scope == :post
|
|
173
|
+
identity = identity.id if identity.is_a?(Post)
|
|
174
|
+
selected = scope.find_by(id: identity) if identity.to_s.match?(/\A\d+\z/)
|
|
175
|
+
selected ||= scope.find_by(slug: identity.to_s)
|
|
176
|
+
raise Error::NotFound unless selected
|
|
177
|
+
@posts = [ selected ]
|
|
178
|
+
@pagination = { page: 1, per_page: 1, total: 1, pages: 1 }
|
|
179
|
+
else
|
|
180
|
+
total = scope.count
|
|
181
|
+
@posts = scope.offset((number - 1) * LIMIT).limit(LIMIT).to_a
|
|
182
|
+
@pagination = { page: number, per_page: LIMIT, total: total, pages: (total.to_f / LIMIT).ceil }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def invalid!(field)
|
|
187
|
+
raise Error::ValidationFailed.new(details: [ field.to_s ])
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class Result
|
|
192
|
+
def initialize(context)
|
|
193
|
+
@data = { gem_version: OpenBlog::VERSION, report_version: "1", date: context.now.iso8601,
|
|
194
|
+
scope: context.scope.to_s, post_ids: context.posts.map(&:id), pagination: context.pagination,
|
|
195
|
+
declarations: SurfaceReport.declarations, results: context.results, limitations: context.limitations,
|
|
196
|
+
counts: STATUSES.to_h { |status| [ status, context.results.count { |row| row[:result] == status } ] },
|
|
197
|
+
not_run: NOT_RUN, not_assessed: NOT_ASSESSED, sentence: NOTICE }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def to_h
|
|
201
|
+
@data.deep_dup
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def to_text
|
|
205
|
+
lines = [ "OpenBlog page and record report", "#{@data[:date]} | scope: #{@data[:scope]}",
|
|
206
|
+
"Articles: #{@data[:post_ids].join(', ')} | page #{@data[:pagination][:page]} of #{@data[:pagination][:pages]}" ]
|
|
207
|
+
lines << "Declarations: #{JSON.generate(@data[:declarations])}"
|
|
208
|
+
@data[:results].each do |row|
|
|
209
|
+
identity = row[:post_id] ? " article #{row[:post_id]}" : ""
|
|
210
|
+
lines << "#{row[:predicate]}#{identity}: #{row[:result]} — #{row[:reason]}"
|
|
211
|
+
lines << " #{row[:marks].join('; ')}" if row[:marks].any?
|
|
212
|
+
lines << " Evidence: #{JSON.generate(row[:details])}" if row[:details]
|
|
213
|
+
end
|
|
214
|
+
lines << @data[:counts].map { |status, count| "#{status}: #{count}" }.join(" | ")
|
|
215
|
+
lines << "Not run: #{@data[:not_run].map { |item| item[:predicate] }.join(', ')}"
|
|
216
|
+
lines << "Not assessed: #{@data[:not_assessed].join(', ')}"
|
|
217
|
+
@data[:limitations].each { |reason| lines << "Limit: #{reason}" }
|
|
218
|
+
lines << NOTICE
|
|
219
|
+
lines.join("\n") + "\n"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "rouge"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module OpenBlog
|
|
6
|
+
class SyntaxCss
|
|
7
|
+
LIGHT_SCOPE = ".ob-highlight"
|
|
8
|
+
DARK_SCOPE = '[data-theme="dark"] .ob-highlight'
|
|
9
|
+
SYSTEM_SCOPE = 'html:not([data-theme="light"]) .ob-highlight'
|
|
10
|
+
|
|
11
|
+
def self.render(theme: OpenBlog.config.syntax_theme)
|
|
12
|
+
selected = Rouge::Theme.find(theme)
|
|
13
|
+
unless selected && selected.respond_to?(:mode) && selected.respond_to?(:make_light!) && selected.respond_to?(:make_dark!)
|
|
14
|
+
raise ConfigurationError, "Syntax theme #{theme.inspect} must provide light and dark modes; try github, base16, or gruvbox."
|
|
15
|
+
end
|
|
16
|
+
light = selected.mode(:light).render(scope: LIGHT_SCOPE)
|
|
17
|
+
dark = selected.mode(:dark).render(scope: DARK_SCOPE)
|
|
18
|
+
system = selected.mode(:dark).render(scope: SYSTEM_SCOPE)
|
|
19
|
+
css = "#{light}\n\n#{dark}\n\n@media (prefers-color-scheme: dark) {\n#{system}\n}\n"
|
|
20
|
+
css += github_contrast if selected == Rouge::Themes::Github
|
|
21
|
+
css
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.github_contrast
|
|
25
|
+
comments = %w[c ch cd cm cp cpf c1 cs gl gt].map { |name| "#{LIGHT_SCOPE} .#{name}" }.join(", ")
|
|
26
|
+
<<~CSS
|
|
27
|
+
#{LIGHT_SCOPE} .gr { color: #cf222e; }
|
|
28
|
+
#{comments} { color: #57606a; }
|
|
29
|
+
#{DARK_SCOPE} .gh, #{DARK_SCOPE} .gu { color: #79c0ff; }
|
|
30
|
+
@media (prefers-color-scheme: dark) {
|
|
31
|
+
#{SYSTEM_SCOPE} .gh, #{SYSTEM_SCOPE} .gu { color: #79c0ff; }
|
|
32
|
+
}
|
|
33
|
+
CSS
|
|
34
|
+
end
|
|
35
|
+
private_class_method :github_contrast
|
|
36
|
+
|
|
37
|
+
def self.write(root: Rails.root, path: nil, theme: OpenBlog.config.syntax_theme)
|
|
38
|
+
root = Pathname(root)
|
|
39
|
+
destination = path ? root.join(path) : default_path(root)
|
|
40
|
+
css = render(theme: theme)
|
|
41
|
+
FileUtils.mkdir_p(destination.dirname)
|
|
42
|
+
destination.write(css)
|
|
43
|
+
destination
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.default_path(root)
|
|
47
|
+
tailwind = root.join("app/assets/tailwind/open_blog/syntax.css")
|
|
48
|
+
stylesheets = root.join("app/assets/stylesheets/open_blog/syntax.css")
|
|
49
|
+
return tailwind if tailwind.file?
|
|
50
|
+
return stylesheets if stylesheets.file?
|
|
51
|
+
root.join("app/assets/tailwind/application.css").file? ? tailwind : stylesheets
|
|
52
|
+
end
|
|
53
|
+
private_class_method :default_path
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Unpublish
|
|
3
|
+
def self.call(post, actor:, now: Time.current)
|
|
4
|
+
Operation.run(post: post, actor: actor, now: now) do |current, _created|
|
|
5
|
+
raise Error::NotFound unless current.persisted?
|
|
6
|
+
was_public = current.published?
|
|
7
|
+
current.update!(status: "draft", publish_at: nil)
|
|
8
|
+
Remove.write_redirect(current, target: nil, source: "unpublish", now: now) if was_public
|
|
9
|
+
{ revision: nil, publication: nil, approval: nil }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|