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,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
|