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,159 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Adopt
|
|
3
|
+
autoload :Contract, "open_blog/adopt/contract"
|
|
4
|
+
autoload :Snapshot, "open_blog/adopt/snapshot"
|
|
5
|
+
|
|
6
|
+
def self.call(attributes, actor:, now: Time.current)
|
|
7
|
+
contract = Contract.new(attributes, now: now)
|
|
8
|
+
prepared = ImageResolution.prepare_inputs(contract.attributes)
|
|
9
|
+
body_images = ImageResolution.prepare(attributes: contract.content)
|
|
10
|
+
current = nil
|
|
11
|
+
result = nil
|
|
12
|
+
Post.transaction(requires_new: true) do
|
|
13
|
+
baseline = Baseline.find_by(contract.attributes.slice(:source_system, :source_id))
|
|
14
|
+
resolved = baseline ? baseline.post : PostIdentity.resolve(contract.attributes.slice(:external_id, :slug))
|
|
15
|
+
current = resolved.persisted? ? Post.lock.find(resolved.id) : resolved
|
|
16
|
+
result = RedirectTarget.synchronize do
|
|
17
|
+
new(current, contract, prepared, actor: actor, now: now, body_images: body_images).call
|
|
18
|
+
end
|
|
19
|
+
raise ActiveRecord::Rollback if contract.dry_run?
|
|
20
|
+
end
|
|
21
|
+
result
|
|
22
|
+
rescue Error => error
|
|
23
|
+
failure(current, error, contract)
|
|
24
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
25
|
+
collision = error.record.is_a?(Baseline) && %i[source_id post_id].any? do |field|
|
|
26
|
+
error.record.errors.details[field].any? { |detail| detail[:error] == :taken }
|
|
27
|
+
end
|
|
28
|
+
refusal = collision ? Error::IdentityConflict.new : Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s))
|
|
29
|
+
failure(current, refusal, contract)
|
|
30
|
+
rescue ActiveRecord::RecordNotFound
|
|
31
|
+
failure(current, Error::NotFound.new, contract)
|
|
32
|
+
rescue ActiveRecord::RecordNotUnique
|
|
33
|
+
failure(current, Error::IdentityConflict.new, contract)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.failure(post, error, contract)
|
|
37
|
+
result = Operation.failure(post, error)
|
|
38
|
+
Result.new(**result.to_h.merge(dry_run: contract&.dry_run? || false))
|
|
39
|
+
end
|
|
40
|
+
private_class_method :failure
|
|
41
|
+
|
|
42
|
+
def initialize(post, contract, prepared, actor:, now:, body_images:)
|
|
43
|
+
@post, @contract, @prepared, @actor, @now = post, contract, prepared, actor, now
|
|
44
|
+
@body_images = body_images
|
|
45
|
+
@created = post.new_record?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call
|
|
49
|
+
baseline = @post.baseline if @post.persisted?
|
|
50
|
+
verify_identity(baseline)
|
|
51
|
+
previous = Snapshot.stored(@post, baseline, include_connections: @contract.connections.present?)
|
|
52
|
+
assign_candidate
|
|
53
|
+
redirects = @contract.redirects(default_date: baseline&.adopted_at&.to_date || @now.to_date, current_slug: @post.slug)
|
|
54
|
+
validate_redirects(redirects)
|
|
55
|
+
candidate = Snapshot.candidate(@post, @contract, redirects)
|
|
56
|
+
if previous == candidate
|
|
57
|
+
@post.reload
|
|
58
|
+
return result({ revision: "same", publication: nil, approval: nil, baseline: nil, redirects: 0 })
|
|
59
|
+
end
|
|
60
|
+
replace_records(baseline) if baseline
|
|
61
|
+
@prepared.each { |field, value| @post.public_send("#{field}=", value.persist(uploaded_by: @actor)) }
|
|
62
|
+
@body_images.materialize(@post, actor: @actor)
|
|
63
|
+
@post.status = "draft"
|
|
64
|
+
@post.publish_at = nil
|
|
65
|
+
@post.save!
|
|
66
|
+
fill_category_description
|
|
67
|
+
revision = @post.revisions.create!(identifier: @post.current_revision_identifier,
|
|
68
|
+
payload: RevisionPayload.new(@post).to_json, actor: @actor, created_at: @now)
|
|
69
|
+
@post.create_baseline!(**@contract.baseline, adopted_revision: revision, adopted_at: @now, adopted_by: @actor)
|
|
70
|
+
@post.publications.create!(revision: revision, entry_type: "adopted", occurred_at: @now, released_by: @actor)
|
|
71
|
+
if @contract.approval
|
|
72
|
+
@post.approvals.create!(**@contract.approval, revision: revision, recorded_by: @actor)
|
|
73
|
+
end
|
|
74
|
+
if @contract.connections && Snapshot.latest_connections(@post) != @contract.connections
|
|
75
|
+
@post.connection_declarations.create!(**@contract.connections, recorded_by: @actor, created_at: @now)
|
|
76
|
+
end
|
|
77
|
+
write_redirects(redirects)
|
|
78
|
+
published_at = @contract.baseline[:first_published_at] || @contract.baseline[:declared_first_published_at]
|
|
79
|
+
@post.update_columns(status: "published", public_revision_id: revision.id, published_at: published_at,
|
|
80
|
+
modified_at: @contract.baseline[:last_modified_at] || published_at)
|
|
81
|
+
@post.association(:public_revision).reset
|
|
82
|
+
result({ revision: "new", publication: "adopted", approval: @contract.approval&.fetch(:kind),
|
|
83
|
+
baseline: baseline ? "replaced" : "new", redirects: redirects.length })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def verify_identity(baseline)
|
|
89
|
+
if baseline
|
|
90
|
+
if !@post.published? || @post.publications.where.not(entry_type: "adopted").exists? ||
|
|
91
|
+
Redirect.where(post: @post).where.not(source: "adoption").exists?
|
|
92
|
+
raise Error::AlreadyChangedInGem
|
|
93
|
+
end
|
|
94
|
+
unless baseline.source_system == @contract.attributes[:source_system] && baseline.source_id == @contract.attributes[:source_id]
|
|
95
|
+
raise Error::IdentityConflict
|
|
96
|
+
end
|
|
97
|
+
elsif @post.persisted? && (@post.public_revision_id || @post.revisions.exists? || @post.publications.exists? || @post.approvals.exists? || @post.connection_declarations.exists?)
|
|
98
|
+
raise Error::IdentityConflict
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assign_candidate
|
|
103
|
+
attributes = @contract.content.except(*@prepared.keys)
|
|
104
|
+
PostAttributes.assign(@post, attributes, actor: @actor, now: @now)
|
|
105
|
+
@prepared.each { |field, value| @post.public_send("#{field}=", value.image) }
|
|
106
|
+
@post.status = "published"
|
|
107
|
+
description = @contract.attributes[:category_description]
|
|
108
|
+
@post.category.description = description if @post.category && @post.category.description.blank? && description.present?
|
|
109
|
+
@body_images.preview(@post)
|
|
110
|
+
@post.send(:compute_derived)
|
|
111
|
+
@post.valid?
|
|
112
|
+
raise ActiveRecord::RecordInvalid, @post if @post.errors.any?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def fill_category_description
|
|
116
|
+
@post.category.save! if @post.category&.changed?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def replace_records(baseline)
|
|
120
|
+
revision_id = baseline.adopted_revision_id
|
|
121
|
+
@post.update_columns(public_revision_id: nil)
|
|
122
|
+
@post.association(:public_revision).reset
|
|
123
|
+
@post.approvals.where(revision_id: revision_id).delete_all
|
|
124
|
+
@post.publications.where(entry_type: "adopted").delete_all
|
|
125
|
+
Baseline.where(id: baseline.id).delete_all
|
|
126
|
+
@post.association(:baseline).reset
|
|
127
|
+
@post.revisions.where(id: revision_id).delete_all
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_redirects(redirects)
|
|
131
|
+
redirects.each do |entry|
|
|
132
|
+
slug = entry[:old_path].delete_prefix("#{OpenBlog.mount_path.chomp('/')}/")
|
|
133
|
+
owner = Redirect.find_by(old_path: entry[:old_path])
|
|
134
|
+
occupied = Post.where(slug: slug)
|
|
135
|
+
occupied = occupied.where.not(id: @post.id) if @post.persisted?
|
|
136
|
+
if (Post::RESERVED_SLUGS + OpenBlog.config.route_segments.values).include?(slug) || occupied.exists? ||
|
|
137
|
+
(owner && (owner.post_id != @post.id || owner.source != "adoption"))
|
|
138
|
+
raise Error::SlugReserved
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def write_redirects(redirects)
|
|
144
|
+
Redirect.where(post: @post, source: "adoption").delete_all
|
|
145
|
+
redirects.each { |attributes| Redirect.create!(**attributes, post: @post, source: "adoption") }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def result(records)
|
|
149
|
+
if @contract.dry_run?
|
|
150
|
+
%i[author category series cover_image social_image public_revision baseline faqs tags revisions approvals publications connection_declarations rich_text_rich_body].each do |name|
|
|
151
|
+
@post.association(name).load_target
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
Result.new(post: @post, created: @created, records: records, label: LabelPolicy.for(@post),
|
|
155
|
+
findings: Findings.for(@post, context: { author_default_used: !@contract.attributes.key?(:author),
|
|
156
|
+
approval_incomplete: @contract.approval&.fetch(:facts_checked) == false }), dry_run: @contract.dry_run?)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module ApiFields
|
|
3
|
+
REPORT = %i[scope post page reach].freeze
|
|
4
|
+
POST_WRITE = (PostAttributes::CONTENT_FIELDS + PostAttributes::RECORD_FIELDS).freeze
|
|
5
|
+
POST_LIST = %i[status category tag author series q page per_page].freeze
|
|
6
|
+
POST_NESTED = {
|
|
7
|
+
author: %i[name type url], approval: %i[name facts_checked revision_identifier],
|
|
8
|
+
cover_image: %i[image_id signed_id url], social_image: %i[image_id signed_id url],
|
|
9
|
+
connections: %i[connections third_party_paid declared_by declared_on], faq: %i[question answer]
|
|
10
|
+
}.freeze
|
|
11
|
+
APPROVAL = %i[revision_identifier name facts_checked].freeze
|
|
12
|
+
CONNECTION = %i[connections third_party_paid declared_by declared_on].freeze
|
|
13
|
+
CATEGORY = %i[name slug description position].freeze
|
|
14
|
+
AUTHOR = %i[name slug type bio url profile_urls host_reference avatar].freeze
|
|
15
|
+
SERIES = %i[name slug description].freeze
|
|
16
|
+
REDIRECT = %i[old_path new_path occurred_on post_id].freeze
|
|
17
|
+
ADOPTION = (Adopt::Contract::CONTENT_FIELDS + Adopt::Contract::EXTRA_FIELDS).freeze
|
|
18
|
+
EXTRACTION = %i[body standalone_questions].freeze
|
|
19
|
+
PAGE = %i[page per_page].freeze
|
|
20
|
+
VIEWS = %i[from to].freeze
|
|
21
|
+
TOP_VIEWS = %i[days limit].freeze
|
|
22
|
+
SITE_PAGE = %i[title body status slug approved_by approved_on].freeze
|
|
23
|
+
IMAGE = %i[file url].freeze
|
|
24
|
+
REMOVE = %i[redirect_to].freeze
|
|
25
|
+
|
|
26
|
+
def self.validate_post_images!(attributes)
|
|
27
|
+
%i[cover_image social_image].each do |field|
|
|
28
|
+
value = attributes[field]
|
|
29
|
+
next if value.nil?
|
|
30
|
+
raise Error::ImageNotPermitted unless value.is_a?(Hash) && value.size == 1
|
|
31
|
+
if value.key?(:image_id)
|
|
32
|
+
raise Error::ImageNotPermitted unless value[:image_id].is_a?(Integer) && value[:image_id].positive?
|
|
33
|
+
else
|
|
34
|
+
key = value.keys.first
|
|
35
|
+
raise Error::ImageNotPermitted unless %i[signed_id url].include?(key) && value[key].is_a?(String)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Approve
|
|
3
|
+
def self.call(post, revision_identifier:, name:, facts_checked:, actor:, now: Time.current)
|
|
4
|
+
Operation.run(post: post, actor: actor, now: now, context: { approval_incomplete: facts_checked == false }) do |current, _created|
|
|
5
|
+
raise Error::NotFound unless current.persisted?
|
|
6
|
+
unless name.is_a?(String) && name.present? && [ true, false ].include?(facts_checked)
|
|
7
|
+
raise Error::ApprovalIncomplete
|
|
8
|
+
end
|
|
9
|
+
revision = current.public_revision
|
|
10
|
+
raise Error::RevisionMismatch unless revision && revision.identifier == revision_identifier
|
|
11
|
+
|
|
12
|
+
current.approvals.create!(revision: revision, kind: "sent", reviewer_name: name,
|
|
13
|
+
facts_checked: facts_checked, approved_at: now, recorded_by: actor)
|
|
14
|
+
{ revision: "same", publication: nil, approval: "sent" }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Authentication
|
|
3
|
+
def self.actor_for(request)
|
|
4
|
+
if (hook = OpenBlog.config.authenticate)
|
|
5
|
+
source = hook.call(request)
|
|
6
|
+
return unless source
|
|
7
|
+
scopes = source.respond_to?(:scopes) ? source.scopes : ApiToken::SCOPES
|
|
8
|
+
identity = source.respond_to?(:id) && source.id.present? ? source.id : source.name
|
|
9
|
+
return Actor.new(name: source.name, scopes: scopes, id: "hook:#{identity}")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
match = /\ABearer (ob_[1-9A-HJ-NP-Za-km-z]{40})\z/.match(request.authorization.to_s)
|
|
13
|
+
return unless match
|
|
14
|
+
token = ApiToken.authenticate(match[1])
|
|
15
|
+
Actor.new(name: token.name, scopes: token.scopes, id: "token:#{token.id}") if token
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "tailwindcss/ruby"
|
|
6
|
+
|
|
7
|
+
module OpenBlog
|
|
8
|
+
class BuildCss
|
|
9
|
+
COMPILER_VERSION = "4.3.3"
|
|
10
|
+
|
|
11
|
+
def self.render
|
|
12
|
+
unless Gem.loaded_specs.fetch("tailwindcss-ruby").version.to_s == COMPILER_VERSION
|
|
13
|
+
raise ConfigurationError, "Build stylesheets with tailwindcss-ruby #{COMPILER_VERSION}"
|
|
14
|
+
end
|
|
15
|
+
Dir.mktmpdir("open-blog-css") do |directory|
|
|
16
|
+
input = Pathname(directory).join("input.css")
|
|
17
|
+
output = Pathname(directory).join("output.css")
|
|
18
|
+
input.write(<<~CSS)
|
|
19
|
+
@import "tailwindcss" source(none);
|
|
20
|
+
@import #{theme_root.join("theme.css").to_s.to_json};
|
|
21
|
+
@source #{Engine.root.join("lib/generators/open_blog/install/templates/views").to_s.to_json};
|
|
22
|
+
CSS
|
|
23
|
+
_stdout, stderr, status = Open3.capture3(Tailwindcss::Ruby.executable.to_s, "--input", input.to_s,
|
|
24
|
+
"--output", output.to_s, "--minify", chdir: Engine.root.to_s)
|
|
25
|
+
raise ConfigurationError, "Stylesheet build failed: #{stderr.strip}" unless status.success?
|
|
26
|
+
output.binread
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.tokens
|
|
31
|
+
source = theme_root.join("theme.css").read
|
|
32
|
+
block = source.match(%r{/\* open-blog:tokens:start \*/(.*?)/\* open-blog:tokens:end \*/}m)
|
|
33
|
+
raise ConfigurationError, "The theme must contain its marked token block" unless block
|
|
34
|
+
"#{block[1].strip}\n"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.write(path: Engine.root.join("app/assets/builds/open_blog/blog.css"), tokens_path: theme_root.join("open_blog_theme.css"))
|
|
38
|
+
css = render
|
|
39
|
+
values = tokens
|
|
40
|
+
[ [ path, css ], [ tokens_path, values ] ].each do |destination, bytes|
|
|
41
|
+
destination = Pathname(destination)
|
|
42
|
+
FileUtils.mkdir_p(destination.dirname)
|
|
43
|
+
destination.binwrite(bytes)
|
|
44
|
+
end
|
|
45
|
+
Pathname(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.theme_root
|
|
49
|
+
Engine.root.join("lib/generators/open_blog/install/templates/theme")
|
|
50
|
+
end
|
|
51
|
+
private_class_method :theme_root
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
require "i18n"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
require "active_support/core_ext/numeric/bytes"
|
|
6
|
+
require "open_blog/configuration_error"
|
|
7
|
+
require "open_blog/page_views"
|
|
8
|
+
|
|
9
|
+
module OpenBlog
|
|
10
|
+
class Configuration
|
|
11
|
+
class Mcp
|
|
12
|
+
attr_accessor :enabled, :max_page_size
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@enabled = true
|
|
16
|
+
@max_page_size = 50
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_accessor :site_name, :public_base_url, :default_author, :publisher, :locale,
|
|
21
|
+
:blog_title, :blog_tagline, :layout, :parent_controller, :mount_path,
|
|
22
|
+
:route_segments, :posts_per_page, :primary_list_type, :call_to_action,
|
|
23
|
+
:default_social_image_url, :policy_urls, :sign_in_destinations, :body_formats,
|
|
24
|
+
:default_body_format, :markdown_hardbreaks, :ai_label, :require_approval,
|
|
25
|
+
:color_scheme, :syntax_theme, :feed_content, :feed_size, :serve_sitemap,
|
|
26
|
+
:page_views, :page_view_bot_pattern, :page_view_retention_days, :popular_posts,
|
|
27
|
+
:preview_expires_in, :storage_service, :image_delivery, :max_image_bytes,
|
|
28
|
+
:image_content_types, :image_fetch_policy, :api_rate_limit, :search_rate_limit
|
|
29
|
+
attr_reader :mcp, :authenticate, :before_publish
|
|
30
|
+
attr_writer :title_suffix, :rate_limit_store
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@locale = I18n.default_locale
|
|
34
|
+
@blog_title = "Blog".dup
|
|
35
|
+
@layout = "open_blog".dup
|
|
36
|
+
@parent_controller = "ActionController::Base".dup
|
|
37
|
+
@mount_path = "/blog".dup
|
|
38
|
+
@route_segments = { category: "category", tag: "tag", author: "author", series: "series" }.transform_values(&:dup)
|
|
39
|
+
@posts_per_page = 12
|
|
40
|
+
@primary_list_type = :categories
|
|
41
|
+
@policy_urls = { responsible_party: nil, corrections: nil, editorial: nil, ai_use: nil }
|
|
42
|
+
@body_formats = [ :markdown ]
|
|
43
|
+
@default_body_format = :markdown
|
|
44
|
+
@markdown_hardbreaks = true
|
|
45
|
+
@ai_label = :when_required
|
|
46
|
+
@require_approval = false
|
|
47
|
+
@color_scheme = :system
|
|
48
|
+
@syntax_theme = "github".dup
|
|
49
|
+
@feed_content = :summary
|
|
50
|
+
@feed_size = 20
|
|
51
|
+
@serve_sitemap = true
|
|
52
|
+
@page_views = true
|
|
53
|
+
@page_view_bot_pattern = PageViews::BOT_PATTERN
|
|
54
|
+
@popular_posts = { enabled: false, days: 30, limit: 5 }
|
|
55
|
+
@preview_expires_in = 7.days
|
|
56
|
+
@image_delivery = :redirect
|
|
57
|
+
@max_image_bytes = 10.megabytes
|
|
58
|
+
@image_content_types = %w[image/png image/jpeg image/webp image/gif image/avif].map(&:dup)
|
|
59
|
+
@image_fetch_policy = :public_only
|
|
60
|
+
@api_rate_limit = { to: 120, within: 1.minute }
|
|
61
|
+
@search_rate_limit = { to: 30, within: 1.minute }
|
|
62
|
+
@mcp = Mcp.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def title_suffix
|
|
66
|
+
defined?(@title_suffix) ? @title_suffix : " — #{site_name}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rate_limit_store
|
|
70
|
+
defined?(@rate_limit_store) ? @rate_limit_store : Rails.cache
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def authenticate=(hook)
|
|
74
|
+
validate_hook!(:authenticate, hook, 1)
|
|
75
|
+
@authenticate = hook
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def before_publish=(hook)
|
|
79
|
+
validate_hook!(:before_publish, hook, 2)
|
|
80
|
+
@before_publish = hook
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate!
|
|
84
|
+
%i[site_name default_author publisher].each do |key|
|
|
85
|
+
invalid!(key, "is required") if public_send(key).nil?
|
|
86
|
+
end
|
|
87
|
+
invalid!(:public_base_url, "is required in production") if Rails.env.production? && public_base_url.nil?
|
|
88
|
+
validate_structure!
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_structure!
|
|
92
|
+
invalid!(:site_name, "must be a nonblank string") unless site_name.nil? || nonblank_string?(site_name)
|
|
93
|
+
validate_identity!(:default_author, default_author)
|
|
94
|
+
validate_identity!(:publisher, publisher)
|
|
95
|
+
if default_author && !%i[person organization].include?(default_author[:type])
|
|
96
|
+
invalid!(:default_author, "type must be :person or :organization")
|
|
97
|
+
end
|
|
98
|
+
validate_base_url! unless public_base_url.nil?
|
|
99
|
+
unless body_formats.is_a?(Array) && body_formats.any? && (body_formats - %i[markdown rich_text]).empty?
|
|
100
|
+
invalid!(:body_formats, "must be a nonempty array containing :markdown or :rich_text")
|
|
101
|
+
end
|
|
102
|
+
invalid!(:default_body_format, "must be enabled in body_formats") unless body_formats.include?(default_body_format)
|
|
103
|
+
preview_duration
|
|
104
|
+
validate_view_settings!
|
|
105
|
+
invalid!(:mcp, "enabled must be true or false") unless [ true, false ].include?(mcp.enabled)
|
|
106
|
+
invalid!(:mcp, "max_page_size must be a positive integer") unless mcp.max_page_size.is_a?(Integer) && mcp.max_page_size.positive?
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def preview_duration
|
|
111
|
+
value = preview_expires_in
|
|
112
|
+
unless (value.is_a?(Numeric) || value.is_a?(ActiveSupport::Duration)) && value.to_f.finite? && value.to_f.positive?
|
|
113
|
+
invalid!(:preview_expires_in, "must be a positive finite duration")
|
|
114
|
+
end
|
|
115
|
+
value.to_f
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def validate_view_settings!
|
|
121
|
+
invalid!(:page_views, "must be true or false") unless [ true, false ].include?(page_views)
|
|
122
|
+
invalid!(:page_view_bot_pattern, "must be a regular expression") unless page_view_bot_pattern.is_a?(Regexp)
|
|
123
|
+
unless page_view_retention_days.nil? || (page_view_retention_days.is_a?(Integer) && page_view_retention_days.positive?)
|
|
124
|
+
invalid!(:page_view_retention_days, "must be nil or a positive integer")
|
|
125
|
+
end
|
|
126
|
+
unless popular_posts.is_a?(Hash) && [ true, false ].include?(popular_posts[:enabled])
|
|
127
|
+
invalid!(:popular_posts, "must include an enabled boolean")
|
|
128
|
+
end
|
|
129
|
+
{ days: 36500, limit: 100 }.each do |key, maximum|
|
|
130
|
+
value = popular_posts.fetch(key, key == :days ? 30 : 5)
|
|
131
|
+
invalid!(:popular_posts, "#{key} must be an integer from 1 to #{maximum}") unless value.is_a?(Integer) && value.between?(1, maximum)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_identity!(key, identity)
|
|
136
|
+
return if identity.nil?
|
|
137
|
+
unless identity.is_a?(Hash) && nonblank_string?(identity[:name])
|
|
138
|
+
invalid!(key, "must be a hash with a nonblank name")
|
|
139
|
+
end
|
|
140
|
+
%i[url logo_url].each do |field|
|
|
141
|
+
next if identity[field].nil?
|
|
142
|
+
invalid!(key, "#{field} must be an absolute HTTP(S) URL") unless http_url?(identity[field])
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_base_url!
|
|
147
|
+
unless http_url?(public_base_url)
|
|
148
|
+
invalid!(:public_base_url, "must be an absolute HTTP(S) origin")
|
|
149
|
+
end
|
|
150
|
+
uri = URI.parse(public_base_url)
|
|
151
|
+
unless [ "", "/" ].include?(uri.path) && uri.query.nil? && uri.fragment.nil?
|
|
152
|
+
invalid!(:public_base_url, "must contain only the scheme, host and optional port")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def http_url?(value)
|
|
157
|
+
return false unless value.is_a?(String)
|
|
158
|
+
uri = URI.parse(value)
|
|
159
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
|
|
160
|
+
rescue URI::InvalidURIError
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def nonblank_string?(value)
|
|
165
|
+
value.is_a?(String) && !value.strip.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_hook!(name, hook, argument_count)
|
|
169
|
+
return if hook.nil?
|
|
170
|
+
invalid!(name, "must be callable") unless hook.respond_to?(:call)
|
|
171
|
+
parameters = hook.is_a?(Proc) || hook.is_a?(Method) ? hook.parameters : hook.method(:call).parameters
|
|
172
|
+
required = parameters.count { |kind, _| kind == :req }
|
|
173
|
+
capacity = parameters.count { |kind, _| %i[req opt].include?(kind) }
|
|
174
|
+
rest = parameters.any? { |kind, _| kind == :rest }
|
|
175
|
+
keywords = parameters.any? { |kind, _| kind == :keyreq }
|
|
176
|
+
unless required <= argument_count && (rest || capacity >= argument_count) && !keywords
|
|
177
|
+
invalid!(name, "must accept #{argument_count} positional arguments without required keywords")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def invalid!(name, message)
|
|
182
|
+
raise ConfigurationError, "OpenBlog #{name} #{message}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module ContentGuard
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
STATE_KEY = :open_blog_content_parents
|
|
6
|
+
|
|
7
|
+
def self.with_parent(post)
|
|
8
|
+
previous = ActiveSupport::IsolatedExecutionState[STATE_KEY]
|
|
9
|
+
Post.lock.find(post.id) if post.persisted?
|
|
10
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = Array(previous) + [ post ]
|
|
11
|
+
yield
|
|
12
|
+
ensure
|
|
13
|
+
if previous
|
|
14
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = previous
|
|
15
|
+
else
|
|
16
|
+
ActiveSupport::IsolatedExecutionState.delete(STATE_KEY)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.active?(post)
|
|
21
|
+
return false unless post
|
|
22
|
+
Array(ActiveSupport::IsolatedExecutionState[STATE_KEY]).any? do |parent|
|
|
23
|
+
parent.equal?(post) || (parent.id && parent.id == post.id)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
around_save :guard_content_write, prepend: true
|
|
29
|
+
around_destroy :guard_content_write, prepend: true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update_columns(attributes)
|
|
33
|
+
reject_content_bypass if content_guard_applies? || content_guard_target?(attributes)
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete
|
|
38
|
+
reject_content_bypass if content_guard_applies?
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def increment!(attribute, by = 1, touch: nil)
|
|
43
|
+
reject_content_bypass if content_guard_applies?
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def touch(*names, time: nil)
|
|
48
|
+
if content_guard_applies? && (names.map(&:to_s) - %w[created_at updated_at]).any?
|
|
49
|
+
reject_content_bypass
|
|
50
|
+
end
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def content_guard_target?(_attributes)
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def guard_content_write
|
|
61
|
+
return yield unless content_guard_applies?
|
|
62
|
+
reject_content_bypass if content_guard_identity_changed?
|
|
63
|
+
parent = content_guard_parent
|
|
64
|
+
return yield if ContentGuard.active?(parent)
|
|
65
|
+
reject_content_bypass unless parent&.persisted?
|
|
66
|
+
|
|
67
|
+
fresh = Post.lock.find(parent.id)
|
|
68
|
+
result = yield
|
|
69
|
+
RecordRelease.call(fresh) if result
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reject_content_bypass
|
|
74
|
+
raise ActiveRecord::ReadOnlyRecord, "Save or destroy content through its existing post association"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Doctor
|
|
3
|
+
module Files
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def read(path)
|
|
7
|
+
file = @root.join(path)
|
|
8
|
+
file.file? ? file.read : ""
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def layout_source
|
|
12
|
+
read("app/views/layouts/#{@config.layout}.html.erb")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def views
|
|
16
|
+
layout = layout_source
|
|
17
|
+
content = view_tree("app/views/open_blog/posts/show.html.erb")
|
|
18
|
+
required = %w[open_blog_post_content open_blog_notices open_blog_byline open_blog_faq open_blog_responsible_party_link open_blog_cover]
|
|
19
|
+
absent = required.reject { |helper| content.match?(/\b#{helper}\b/) }
|
|
20
|
+
absent += %w[open_blog_head open_blog_structured_data].reject { |helper| layout.match?(/\b#{helper}\b/) }
|
|
21
|
+
absent << "original content cover (variant found)" if content.match?(/cover_image[^\n]*\.variant\b/)
|
|
22
|
+
absent.any? ? [ "error", "Copied views are missing required helpers: #{absent.join(', ')}." ] : [ "ok", "Copied layout and post partials call the reader helpers." ]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def view_tree(path, seen = [])
|
|
26
|
+
return "" if seen.include?(path)
|
|
27
|
+
seen << path
|
|
28
|
+
source = read(path)
|
|
29
|
+
partials = source.scan(/\brender\s*(?:\(?\s*)(?:partial:\s*)?["']([^"']+)["']/).flatten
|
|
30
|
+
source + partials.map do |partial|
|
|
31
|
+
directory, name = File.dirname(partial), File.basename(partial)
|
|
32
|
+
view_tree("app/views/#{directory}/_#{name}.html.erb", seen)
|
|
33
|
+
end.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def theme
|
|
37
|
+
layout = layout_source
|
|
38
|
+
tailwind = @root.join("app/assets/tailwind/application.css")
|
|
39
|
+
bundled = @root.join("app/assets/stylesheets/application.tailwind.css")
|
|
40
|
+
if !layout.include?("open_blog_stylesheets")
|
|
41
|
+
asset = layout[/stylesheet_link_tag\s*\(?\s*["']([^"']+)["']/, 1]
|
|
42
|
+
return [ "error", "The blog layout must load the installed stylesheet." ] unless %w[tailwind application].include?(asset)
|
|
43
|
+
entry = asset == "tailwind" ? tailwind : bundled
|
|
44
|
+
return [ "error", "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." ] unless entry.file?
|
|
45
|
+
source = entry.read
|
|
46
|
+
return [ "error", "Import open_blog/theme.css in #{entry.relative_path_from(@root)}." ] unless source.match?(/@import\s+["'][^"']*open_blog\/theme(?:\.css)?["']/)
|
|
47
|
+
absent = %w[theme blog syntax].reject { |name| @root.join("app/assets/tailwind/open_blog/#{name}.css").file? }
|
|
48
|
+
return [ "error", "Missing theme sources: #{absent.join(', ')}." ] if absent.any?
|
|
49
|
+
asset = "#{asset}.css"
|
|
50
|
+
else
|
|
51
|
+
return [ "error", "Install app/assets/stylesheets/open_blog_theme.css." ] unless @root.join("app/assets/stylesheets/open_blog_theme.css").file?
|
|
52
|
+
asset = "open_blog/blog.css"
|
|
53
|
+
return [ "error", "The blog layout must load open_blog_stylesheets." ] unless layout.include?("open_blog_stylesheets")
|
|
54
|
+
end
|
|
55
|
+
return [ "error", "Build the missing #{asset} stylesheet." ] unless @application.assets&.resolver&.resolve(asset)
|
|
56
|
+
all = Dir[@root.join("app/views/layouts/**/*")].select { |path| File.file?(path) }.any? { |path| File.read(path).match?(/stylesheet_link_tag\s*(?:\(?\s*):all\b/) }
|
|
57
|
+
all ? [ "warning", "Replace stylesheet_link_tag :all to avoid loading the blog reset on other pages." ] : [ "ok", "Theme sources and compiled stylesheet are available." ]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|