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,172 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class PostAttributes
|
|
5
|
+
CONTENT_FIELDS = %i[title description search_title search_description body body_format author cover_image cover_alt social_image faq category tags series series_position featured canonical_url slug external_id].freeze
|
|
6
|
+
RECORD_FIELDS = %i[provenance provenance_evidence made_by_ai approval change change_description note connections publish_at].freeze
|
|
7
|
+
TEXT_FIELDS = %i[title description search_title search_description body cover_alt canonical_url slug external_id provenance_evidence].freeze
|
|
8
|
+
|
|
9
|
+
def self.normalize(attributes)
|
|
10
|
+
unless attributes.is_a?(Hash) && attributes.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
11
|
+
raise Error::ValidationFailed.new(details: [ "attributes" ])
|
|
12
|
+
end
|
|
13
|
+
normalized = attributes.symbolize_keys
|
|
14
|
+
unknown = normalized.keys - CONTENT_FIELDS - RECORD_FIELDS
|
|
15
|
+
raise Error::UnknownField.new(details: unknown.map(&:to_s)) if unknown.any?
|
|
16
|
+
normalized
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.assign(post, attributes, actor:, now:)
|
|
20
|
+
new(post, normalize(attributes), actor: actor, now: now).assign
|
|
21
|
+
rescue ActiveRecord::RecordNotFound
|
|
22
|
+
raise Error::NotFound
|
|
23
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
24
|
+
raise Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(post, attributes, actor:, now:)
|
|
28
|
+
@post, @attributes, @actor, @now = post, attributes, actor, now
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def assign
|
|
32
|
+
validate_types
|
|
33
|
+
assign_scalars
|
|
34
|
+
assign_author if @attributes.key?(:author) || @post.author.nil?
|
|
35
|
+
%i[category series].each do |field|
|
|
36
|
+
@post.public_send("#{field}=", taxonomy(OpenBlog.const_get(field.to_s.classify), @attributes[field])) if @attributes.key?(field)
|
|
37
|
+
end
|
|
38
|
+
assign_faq if @attributes.key?(:faq)
|
|
39
|
+
assign_tags if @attributes.key?(:tags)
|
|
40
|
+
%i[cover_image social_image].each do |field|
|
|
41
|
+
@post.public_send("#{field}=", image(@attributes[field])) if @attributes.key?(field)
|
|
42
|
+
end
|
|
43
|
+
assign_provenance
|
|
44
|
+
PostIdentity.resolve({ slug: @post.slug }, post: @post)
|
|
45
|
+
@post
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def invalid(field)
|
|
51
|
+
raise Error::ValidationFailed.new(details: [ field.to_s ])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_types
|
|
55
|
+
TEXT_FIELDS.each { |field| invalid(field) if @attributes.key?(field) && !@attributes[field].nil? && !@attributes[field].is_a?(String) }
|
|
56
|
+
invalid(:featured) if @attributes.key?(:featured) && ![ true, false ].include?(@attributes[:featured])
|
|
57
|
+
if @attributes.key?(:series_position) && !@attributes[:series_position].nil?
|
|
58
|
+
invalid(:series_position) unless @attributes[:series_position].is_a?(Integer) && @attributes[:series_position].positive?
|
|
59
|
+
end
|
|
60
|
+
%i[category series].each do |field|
|
|
61
|
+
invalid(field) if @attributes.key?(field) && !@attributes[field].nil? && !@attributes[field].is_a?(String)
|
|
62
|
+
end
|
|
63
|
+
if @attributes.key?(:body_format)
|
|
64
|
+
format = @attributes[:body_format]
|
|
65
|
+
unless (format.is_a?(String) || format.is_a?(Symbol)) && OpenBlog.config.body_formats.map(&:to_s).include?(format.to_s)
|
|
66
|
+
raise Error::BodyFormatNotPermitted
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
if @attributes.key?(:provenance) && !%w[ai_assisted human_written unknown].include?(@attributes[:provenance].to_s)
|
|
70
|
+
invalid(:provenance)
|
|
71
|
+
end
|
|
72
|
+
if @attributes.key?(:tags)
|
|
73
|
+
invalid(:tags) unless @attributes[:tags].is_a?(Array) && @attributes[:tags].all? { |tag| tag.is_a?(String) && tag.present? }
|
|
74
|
+
end
|
|
75
|
+
if @attributes.key?(:faq)
|
|
76
|
+
invalid(:faq) unless @attributes[:faq].is_a?(Array)
|
|
77
|
+
@attributes[:faq].each do |entry|
|
|
78
|
+
invalid(:faq) unless entry.is_a?(Hash)
|
|
79
|
+
values = entry.stringify_keys
|
|
80
|
+
invalid(:faq) unless values.keys.sort == %w[answer question] && values.values.all? { |value| value.is_a?(String) && value.present? }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def assign_scalars
|
|
86
|
+
fields = TEXT_FIELDS - %i[body provenance_evidence]
|
|
87
|
+
(fields + %i[featured series_position body_format]).each do |field|
|
|
88
|
+
@post.public_send("#{field}=", @attributes[field]) if @attributes.key?(field)
|
|
89
|
+
end
|
|
90
|
+
%i[description search_title search_description cover_alt].each do |field|
|
|
91
|
+
@post.public_send("#{field}=", "") if @post.public_send(field).nil?
|
|
92
|
+
end
|
|
93
|
+
@post.canonical_url = nil if @post.canonical_url.blank?
|
|
94
|
+
if @post.slug.blank?
|
|
95
|
+
@post.slug = @post.title.to_s.parameterize.presence || "post-#{Digest::SHA256.hexdigest(@post.title.to_s)[0, 12]}"
|
|
96
|
+
end
|
|
97
|
+
if @attributes.key?(:body)
|
|
98
|
+
@post.public_send(@post.markdown? ? "body_markdown=" : "rich_body=", @attributes[:body])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assign_author
|
|
103
|
+
input = @attributes.key?(:author) ? @attributes[:author] : OpenBlog.config.default_author
|
|
104
|
+
author = case input
|
|
105
|
+
when Author then input
|
|
106
|
+
when Integer then Author.find(input)
|
|
107
|
+
when String
|
|
108
|
+
invalid(:author) if input.blank?
|
|
109
|
+
if input.match?(/\A[1-9]\d*\z/)
|
|
110
|
+
Author.find(input)
|
|
111
|
+
else
|
|
112
|
+
Author.find_by(slug: input) || Author.find_by(name: input) || Author.create!(name: input)
|
|
113
|
+
end
|
|
114
|
+
when Hash
|
|
115
|
+
fields = input.symbolize_keys
|
|
116
|
+
invalid(:author) unless (fields.keys - %i[name type url]).empty? && fields[:name].is_a?(String) && fields[:name].present?
|
|
117
|
+
invalid(:author) if fields.key?(:type) && !%w[person organization].include?(fields[:type].to_s)
|
|
118
|
+
invalid(:author) if fields.key?(:url) && !fields[:url].nil? && !fields[:url].is_a?(String)
|
|
119
|
+
Author.find_by(name: fields[:name]) || Author.create!(name: fields[:name], author_type: fields.fetch(:type, :person).to_s, url: fields[:url])
|
|
120
|
+
else invalid(:author)
|
|
121
|
+
end
|
|
122
|
+
@post.author = author
|
|
123
|
+
@post.author_name = author.name
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def taxonomy(model, value)
|
|
127
|
+
return if value.blank?
|
|
128
|
+
model.find_by(slug: value) || model.find_by(name: value) || model.create!(name: value)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def assign_faq
|
|
132
|
+
existing = @post.faqs.to_a.sort_by(&:position)
|
|
133
|
+
@attributes[:faq].each_with_index do |entry, index|
|
|
134
|
+
record = existing[index] || @post.faqs.build(position: (existing.last&.position || 0) + index + 1)
|
|
135
|
+
record.assign_attributes(entry.symbolize_keys)
|
|
136
|
+
end
|
|
137
|
+
existing.drop(@attributes[:faq].length).each(&:mark_for_destruction)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def assign_tags
|
|
141
|
+
tags = @attributes[:tags].uniq(&:downcase).map do |name|
|
|
142
|
+
Tag.where("LOWER(name) = ?", name.downcase).first || Tag.create!(name: name)
|
|
143
|
+
end
|
|
144
|
+
@post.taggings.each { |tagging| tagging.mark_for_destruction unless tags.any? { |tag| tag.id == tagging.tag_id } }
|
|
145
|
+
existing = @post.taggings.reject(&:marked_for_destruction?).map(&:tag_id)
|
|
146
|
+
tags.each { |tag| @post.taggings.build(tag: tag) unless existing.include?(tag.id) }
|
|
147
|
+
@post.association(:tags).target = tags
|
|
148
|
+
@post.association(:tags).loaded!
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def image(input)
|
|
152
|
+
return if input.nil?
|
|
153
|
+
return input if input.is_a?(Image) && input.persisted?
|
|
154
|
+
return Image.find(input) if input.is_a?(Integer)
|
|
155
|
+
if input.is_a?(Hash) && input.keys.map(&:to_s) == [ "image_id" ]
|
|
156
|
+
id = input[:image_id] || input["image_id"]
|
|
157
|
+
return Image.find(id) if id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[1-9]\d*\z/))
|
|
158
|
+
end
|
|
159
|
+
raise Error::ImageNotPermitted
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def assign_provenance
|
|
163
|
+
changed = @attributes.key?(:provenance) && @post.provenance != @attributes[:provenance].to_s
|
|
164
|
+
@post.provenance = @attributes[:provenance] if @attributes.key?(:provenance)
|
|
165
|
+
if @attributes.key?(:provenance_evidence)
|
|
166
|
+
@post.provenance_evidence = @attributes[:provenance_evidence]
|
|
167
|
+
elsif changed
|
|
168
|
+
@post.provenance_evidence = "Stated by #{@actor} in the call of #{@now.iso8601}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class PostIdentity
|
|
3
|
+
def self.resolve(attributes, post: nil)
|
|
4
|
+
raise Error::ValidationFailed.new(details: [ "attributes" ]) unless attributes.is_a?(Hash)
|
|
5
|
+
attributes = attributes.symbolize_keys
|
|
6
|
+
%i[external_id slug].each do |field|
|
|
7
|
+
if attributes.key?(field) && !attributes[field].nil? && !attributes[field].is_a?(String)
|
|
8
|
+
raise Error::ValidationFailed.new(details: [ field.to_s ])
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
unless post
|
|
12
|
+
external = Post.find_by(external_id: attributes[:external_id]) if attributes[:external_id].present?
|
|
13
|
+
slug = Post.find_by(slug: attributes[:slug]) if attributes[:slug].present?
|
|
14
|
+
raise Error::IdentityConflict if external && slug && external != slug
|
|
15
|
+
post = external || slug || Post.new
|
|
16
|
+
end
|
|
17
|
+
candidate = attributes[:slug].presence || post.slug
|
|
18
|
+
if candidate.present?
|
|
19
|
+
reserved = Post::RESERVED_SLUGS + OpenBlog.config.route_segments.values
|
|
20
|
+
redirect = Redirect.find_by(old_path: "#{OpenBlog.mount_path.chomp('/')}/#{candidate}")
|
|
21
|
+
if reserved.include?(candidate) || (redirect && (post.new_record? || redirect.post_id != post.id))
|
|
22
|
+
raise Error::SlugReserved
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
post
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module ReaderDates
|
|
3
|
+
def self.visible(time, now: Time.current)
|
|
4
|
+
time if time && time <= now
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def self.published_at(post, now: Time.current)
|
|
8
|
+
visible(post.published_at, now: now)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.modified_at(post, now: Time.current)
|
|
12
|
+
visible(post.modified_at, now: now)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.feed_updated_at(post, now: Time.current)
|
|
16
|
+
modified_at(post, now: now) ||
|
|
17
|
+
post.publications.where(entry_type: %w[first substantive correction adopted]).where("occurred_at <= ?", now).maximum(:occurred_at) ||
|
|
18
|
+
visible(post.created_at, now: now) || now
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class ReaderPage
|
|
3
|
+
attr_reader :kind, :record, :pagination, :path, :breadcrumbs, :base_url
|
|
4
|
+
|
|
5
|
+
def initialize(kind:, path:, base_url:, record: nil, pagination: nil, breadcrumbs: [])
|
|
6
|
+
@kind, @record, @pagination, @path, @breadcrumbs, @base_url = kind, record, pagination, path, breadcrumbs, base_url.chomp("/")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def page_number
|
|
10
|
+
pagination&.page || 1
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def path_for(number)
|
|
14
|
+
number > 1 ? "#{path}#{path.include?("?") ? "&" : "?"}page=#{number}" : path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def canonical_path
|
|
18
|
+
path_for(page_number)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
module ReaderQueries
|
|
6
|
+
def self.posts
|
|
7
|
+
Post.listed.includes(:author, :category, :tags, cover_image: { file_attachment: :blob }).order(Arel.sql("published_at IS NULL ASC"), published_at: :desc, id: :desc)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.featured
|
|
11
|
+
posts.where(featured: true).first
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.category_path(category)
|
|
15
|
+
"#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:category)}/#{category.slug}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.tag_path(tag)
|
|
19
|
+
"#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:tag)}/#{tag.slug}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.author_path(author)
|
|
23
|
+
"#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:author)}/#{author.slug}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.series_path(series)
|
|
27
|
+
"#{OpenBlog.mount_path.chomp('/')}/#{OpenBlog.config.route_segments.fetch(:series)}/#{series.slug}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.series_posts(series)
|
|
31
|
+
posts.where(series_id: series.id).reorder(Arel.sql("series_position IS NULL ASC"), series_position: :asc, id: :asc)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.series_neighbors(post)
|
|
35
|
+
return {} unless post.series_id
|
|
36
|
+
scope = series_posts(post.series).where.not(id: post.id)
|
|
37
|
+
if post.series_position
|
|
38
|
+
previous = scope.where("series_position < ?", post.series_position).reverse_order.first
|
|
39
|
+
following = scope.where("series_position > ? OR series_position IS NULL", post.series_position).first
|
|
40
|
+
else
|
|
41
|
+
previous = scope.where("series_position IS NOT NULL OR id < ?", post.id).reverse_order.first
|
|
42
|
+
following = scope.where(series_position: nil).where("id > ?", post.id).first
|
|
43
|
+
end
|
|
44
|
+
{ previous: previous, next: following }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.sidebar
|
|
48
|
+
category_counts = Post.listed.where.not(category_id: nil).group(:category_id).count
|
|
49
|
+
tag_counts = Tagging.joins(:post).merge(Post.listed).group(:tag_id).count
|
|
50
|
+
categories = Category.where(id: category_counts.keys).order(:position, :name).map { |category| { category: category, count: category_counts.fetch(category.id) } }
|
|
51
|
+
tags = Tag.where(id: tag_counts.keys).to_a.sort_by { |tag| [ -tag_counts.fetch(tag.id), tag.name, tag.id ] }.first(20).map { |tag| { tag: tag, count: tag_counts.fetch(tag.id) } }
|
|
52
|
+
{ categories: categories, tags: tags }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.sidebar_cache_key(sidebar)
|
|
56
|
+
fields = sidebar[:categories].map { |entry| [ entry[:category].attributes.slice("id", "name", "slug", "position"), entry[:count] ] } +
|
|
57
|
+
sidebar[:tags].map { |entry| [ entry[:tag].attributes.slice("id", "name", "slug"), entry[:count] ] }
|
|
58
|
+
[ "open_blog/sidebar", Post.listed.maximum(:modified_at), Post.listed.count,
|
|
59
|
+
OpenBlog.config.locale, OpenBlog.mount_path, Digest::SHA256.hexdigest(JSON.generate([ fields, OpenBlog.config.route_segments ])) ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.related_cache_key(post, related)
|
|
63
|
+
fields = related.map do |entry|
|
|
64
|
+
[ entry.id, entry.current_revision_identifier, entry.slug, entry.author.slug, entry.author.avatar.blob&.key,
|
|
65
|
+
entry.category&.attributes&.slice("name", "slug"), entry.cover_image&.path,
|
|
66
|
+
ReaderDates.published_at(entry), ReaderDates.modified_at(entry), entry.reading_time_minutes ]
|
|
67
|
+
end
|
|
68
|
+
[ "open_blog/related", post.id, Post.listed.maximum(:modified_at), Post.listed.count,
|
|
69
|
+
OpenBlog.config.locale, OpenBlog.mount_path, Digest::SHA256.hexdigest(JSON.generate([ fields, OpenBlog.config.route_segments ])) ]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.popular
|
|
73
|
+
settings = OpenBlog.config.popular_posts
|
|
74
|
+
return [] unless settings[:enabled]
|
|
75
|
+
days, limit = settings.fetch(:days, 30), settings.fetch(:limit, 5)
|
|
76
|
+
key = [ "open_blog/popular", days, limit, OpenBlog.config.locale, OpenBlog.mount_path, OpenBlog.config.route_segments ]
|
|
77
|
+
ids = Rails.cache.fetch(key, expires_in: 1.hour) do
|
|
78
|
+
PageViews.top(days: days, limit: limit, scope: Post.listed).fetch(:posts).map { |post| post.fetch(:id) }
|
|
79
|
+
end
|
|
80
|
+
current = Post.listed.where(id: ids).index_by(&:id)
|
|
81
|
+
ids.filter_map { |id| current[id] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.related(post)
|
|
85
|
+
scope = posts.where.not(id: post.id)
|
|
86
|
+
scope = scope.where(category_id: post.category_id) if post.category_id
|
|
87
|
+
scope.limit(3).to_a
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class RecordRelease
|
|
3
|
+
def self.call(post, change: nil, note: nil, description: nil, actor: nil, now: Time.current, made_by_ai: nil, republishing: false)
|
|
4
|
+
post.class.transaction do
|
|
5
|
+
stored = post.class.lock.find(post.id)
|
|
6
|
+
ImageResolution.native!(stored)
|
|
7
|
+
unless stored.published?
|
|
8
|
+
stored.send(:compute_derived)
|
|
9
|
+
post.update_columns(stored.attributes.slice("current_revision_identifier", "word_count", "reading_time_minutes", "search_text", "body_image_manifest", "body_image_source_digest"))
|
|
10
|
+
next { revision: nil, publication: nil, approval: nil }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
new(stored, post, change: change, note: note, description: description, actor: actor,
|
|
14
|
+
now: now, made_by_ai: made_by_ai, republishing: republishing).call
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(stored, original, **context)
|
|
19
|
+
@post, @original, @context = stored, original, context
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
@post.send(:compute_derived)
|
|
24
|
+
payload = RevisionPayload.new(@post)
|
|
25
|
+
changed = @post.public_revision&.identifier != payload.identifier
|
|
26
|
+
type = entry_type(changed)
|
|
27
|
+
revision = @post.revisions.find_by(identifier: payload.identifier)
|
|
28
|
+
revision_kind = revision ? "same" : "new"
|
|
29
|
+
revision ||= Revision.create!(post: @post, identifier: payload.identifier, payload: payload.to_json,
|
|
30
|
+
actor: @context[:actor], made_by_ai: @context[:made_by_ai], created_at: @context[:now])
|
|
31
|
+
if type
|
|
32
|
+
Publication.create!(post: @post, revision: revision, entry_type: type, occurred_at: @context[:now],
|
|
33
|
+
released_by: @context[:actor], note: @context[:note], description: description(changed))
|
|
34
|
+
end
|
|
35
|
+
attributes = @post.attributes.slice("current_revision_identifier", "word_count", "reading_time_minutes", "search_text", "body_image_manifest", "body_image_source_digest")
|
|
36
|
+
attributes.merge!(public_revision_id: revision.id, published_at: published_at, modified_at: modified_at)
|
|
37
|
+
@original.update_columns(attributes)
|
|
38
|
+
@original.association(:public_revision).reset
|
|
39
|
+
{ revision: revision_kind, publication: type, approval: nil }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def entry_type(changed)
|
|
45
|
+
return "first" unless @post.public_revision_id || @post.publications.exists?
|
|
46
|
+
return @context[:change].to_s if @context[:change].present?
|
|
47
|
+
return "substantive" if changed
|
|
48
|
+
"maintenance" if @context[:republishing]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def description(changed)
|
|
52
|
+
return @context[:description] if @context[:description].present?
|
|
53
|
+
"republished" if @context[:republishing] && !changed
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def published_at
|
|
57
|
+
@post.publications.find_by(entry_type: "first")&.occurred_at ||
|
|
58
|
+
@post.baseline&.first_published_at || @post.baseline&.declared_first_published_at
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def modified_at
|
|
62
|
+
@post.publications.where(entry_type: %w[substantive correction]).maximum(:occurred_at) ||
|
|
63
|
+
@post.baseline&.last_modified_at || published_at
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class RedirectTarget
|
|
5
|
+
LOCK_KEY = 1_867_541_239
|
|
6
|
+
|
|
7
|
+
def self.synchronize
|
|
8
|
+
Redirect.transaction do
|
|
9
|
+
connection = Redirect.connection
|
|
10
|
+
if connection.adapter_name == "PostgreSQL"
|
|
11
|
+
connection.execute("SELECT pg_advisory_xact_lock(#{LOCK_KEY})")
|
|
12
|
+
elsif connection.adapter_name == "SQLite"
|
|
13
|
+
connection.execute("UPDATE open_blog_redirects SET id = id WHERE 1 = 0")
|
|
14
|
+
end
|
|
15
|
+
yield
|
|
16
|
+
end
|
|
17
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
18
|
+
raise unless error.cause.class.name == "SQLite3::BusyException"
|
|
19
|
+
raise Error::ValidationFailed.new(details: [ "redirects" ])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.call(target, from:)
|
|
23
|
+
visited = [ from ]
|
|
24
|
+
while target
|
|
25
|
+
target, path = normalize_target(target)
|
|
26
|
+
raise Error::ValidationFailed.new(details: [ "redirect_to" ]) if visited.include?(path)
|
|
27
|
+
visited << path
|
|
28
|
+
redirect = Redirect.find_by(old_path: path)
|
|
29
|
+
break unless redirect
|
|
30
|
+
target = redirect.new_path
|
|
31
|
+
end
|
|
32
|
+
target
|
|
33
|
+
end
|
|
34
|
+
def self.normalize_target(target)
|
|
35
|
+
unless target.is_a?(String) && target.present? && !target.match?(/\s/)
|
|
36
|
+
raise Error::ValidationFailed.new(details: [ "redirect_to" ])
|
|
37
|
+
end
|
|
38
|
+
uri = URI.parse(target)
|
|
39
|
+
if target.start_with?("/") && !target.start_with?("//") && !uri.host && !uri.scheme
|
|
40
|
+
return [ target, uri.path ]
|
|
41
|
+
end
|
|
42
|
+
unless uri.is_a?(URI::HTTP) && uri.host.present? && !uri.userinfo
|
|
43
|
+
raise Error::ValidationFailed.new(details: [ "redirect_to" ])
|
|
44
|
+
end
|
|
45
|
+
origin = URI.parse(OpenBlog.config.public_base_url) if OpenBlog.config.public_base_url
|
|
46
|
+
if origin && [ origin.scheme, origin.host, origin.port ] == [ uri.scheme, uri.host, uri.port ]
|
|
47
|
+
target = uri.request_uri
|
|
48
|
+
target += "##{uri.fragment}" if uri.fragment
|
|
49
|
+
[ target, uri.path ]
|
|
50
|
+
else
|
|
51
|
+
[ target, target ]
|
|
52
|
+
end
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
raise Error::ValidationFailed.new(details: [ "redirect_to" ])
|
|
55
|
+
end
|
|
56
|
+
private_class_method :normalize_target
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class Remove
|
|
5
|
+
def self.call(post, redirect_to: nil, actor:, now: Time.current)
|
|
6
|
+
Operation.run(post: post, actor: actor, now: now) do |current, _created|
|
|
7
|
+
raise Error::NotFound unless current.persisted?
|
|
8
|
+
if (current.draft? || current.scheduled?) && !retained_records?(current)
|
|
9
|
+
current.destroy!
|
|
10
|
+
else
|
|
11
|
+
current.update!(status: "archived", publish_at: nil)
|
|
12
|
+
write_redirect(current, target: redirect_to, source: "removal", now: now)
|
|
13
|
+
end
|
|
14
|
+
{ revision: nil, publication: nil, approval: nil }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.write_redirect(post, target:, source:, now:)
|
|
19
|
+
RedirectTarget.synchronize do
|
|
20
|
+
target = RedirectTarget.call(target, from: post.path)
|
|
21
|
+
redirect = Redirect.find_by(old_path: post.path)
|
|
22
|
+
raise Error::SlugReserved if redirect && redirect.post_id != post.id
|
|
23
|
+
|
|
24
|
+
if redirect
|
|
25
|
+
redirect.update!(new_path: target)
|
|
26
|
+
else
|
|
27
|
+
Redirect.create!(old_path: post.path, new_path: target, source: source,
|
|
28
|
+
post: post, occurred_on: now.to_date)
|
|
29
|
+
end
|
|
30
|
+
Redirect.where(new_path: post.path).where.not(old_path: post.path).find_each do |incoming|
|
|
31
|
+
incoming.update!(new_path: target)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.retained_records?(post)
|
|
37
|
+
post.public_revision_id.present? || post.revisions.exists? || post.publications.exists? ||
|
|
38
|
+
post.approvals.exists? || post.connection_declarations.exists? || post.baseline.present? ||
|
|
39
|
+
Redirect.where(post_id: post.id).exists?
|
|
40
|
+
end
|
|
41
|
+
private_class_method :retained_records?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "rouge"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class Renderer
|
|
5
|
+
module Highlighter
|
|
6
|
+
def self.call(pre)
|
|
7
|
+
code = pre.at_css("code")
|
|
8
|
+
return unless code
|
|
9
|
+
language = pre["lang"].presence || code["lang"].presence
|
|
10
|
+
pre.remove_attribute("lang")
|
|
11
|
+
lexer = Rouge::Lexer.find(language) if language
|
|
12
|
+
code.inner_html = Rouge::Formatters::HTML.new.format(lexer.new.lex(code.text)) if lexer
|
|
13
|
+
pre["class"] = "ob-highlight"
|
|
14
|
+
pre["tabindex"] = "0"
|
|
15
|
+
if language
|
|
16
|
+
pre["data-lang"] = language
|
|
17
|
+
label = Nokogiri::XML::Node.new("span", pre.document)
|
|
18
|
+
label["class"] = "ob-code-language"
|
|
19
|
+
label["aria-hidden"] = "true"
|
|
20
|
+
label.content = language
|
|
21
|
+
pre.prepend_child(label)
|
|
22
|
+
end
|
|
23
|
+
pre["data-controller"] = "open-blog--code-copy"
|
|
24
|
+
code["data-open-blog--code-copy-target"] = "code"
|
|
25
|
+
pre["data-open-blog--code-copy-copied-label-value"] = translate(:copied)
|
|
26
|
+
pre["data-open-blog--code-copy-error-label-value"] = translate(:copy_failed)
|
|
27
|
+
button = Nokogiri::XML::Node.new("button", pre.document)
|
|
28
|
+
button["type"] = "button"
|
|
29
|
+
button["class"] = "ob-code-copy"
|
|
30
|
+
button["data-action"] = "open-blog--code-copy#copy"
|
|
31
|
+
button["data-open-blog--code-copy-target"] = "button"
|
|
32
|
+
button["hidden"] = "hidden"
|
|
33
|
+
button["aria-live"] = "polite"
|
|
34
|
+
button["aria-label"] = translate(:copy_label)
|
|
35
|
+
button.content = translate(:copy)
|
|
36
|
+
pre.add_child(button)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.translate(key)
|
|
40
|
+
I18n.t("open_blog.code.#{key}", locale: OpenBlog.config.locale)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "commonmarker"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class Renderer
|
|
5
|
+
module Markdown
|
|
6
|
+
def self.render(text)
|
|
7
|
+
Commonmarker.to_html(text.to_s.encode(Encoding::UTF_8), options: {
|
|
8
|
+
extension: { header_ids: nil, footnotes: true, alerts: true, table: true, tasklist: true, strikethrough: true, autolink: true },
|
|
9
|
+
render: { unsafe: false, hardbreaks: OpenBlog.config.markdown_hardbreaks }
|
|
10
|
+
}, plugins: { syntax_highlighter: nil })
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Renderer
|
|
3
|
+
module RichText
|
|
4
|
+
def self.render(text, post: nil)
|
|
5
|
+
ActionText::Content.new(text.to_s).render_attachments(with_full_attributes: false) do |attachment|
|
|
6
|
+
blob = attachment.attachable
|
|
7
|
+
image = ImageResolution.image_for_attachment(blob, post: post) if blob.is_a?(ActiveStorage::Blob)
|
|
8
|
+
if image
|
|
9
|
+
caption = PlainText.from_html(attachment.caption.to_s)
|
|
10
|
+
alt = attachment.node["alt"].presence || caption
|
|
11
|
+
ActionText::Content.render(partial: "open_blog/attachments/blob", locals: { image: image, alt: alt, caption: caption })
|
|
12
|
+
else
|
|
13
|
+
ERB::Util.html_escape(PlainText.from_html(attachment.caption.to_s))
|
|
14
|
+
end
|
|
15
|
+
end.to_html
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "rails/html/sanitizer"
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module OpenBlog
|
|
6
|
+
class Renderer
|
|
7
|
+
module Sanitizer
|
|
8
|
+
TAGS = %w[p h1 h2 h3 h4 h5 h6 ul ol li a strong em del code pre blockquote br hr img figure figcaption table thead tbody tr th td sup sub section div span input].freeze
|
|
9
|
+
ATTRIBUTES = %w[href rel src alt title width height lang align].freeze
|
|
10
|
+
TOKEN = "data-ob-render-token".freeze
|
|
11
|
+
Document = Struct.new(:fragment, :metadata, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def self.call(html, trusted: false)
|
|
14
|
+
fragment = Nokogiri::HTML5.fragment(html)
|
|
15
|
+
fragment.css("pre").each do |node|
|
|
16
|
+
code = Nokogiri::XML::Node.new("code", node.document)
|
|
17
|
+
code["lang"] = node.at_css("code")["lang"] if node.at_css("code")&.key?("lang")
|
|
18
|
+
code.content = node.text
|
|
19
|
+
node.children.remove
|
|
20
|
+
node.add_child(code)
|
|
21
|
+
end
|
|
22
|
+
fragment.css("code").each { |node| node.content = node.text }
|
|
23
|
+
metadata = trusted ? capture(fragment) : {}
|
|
24
|
+
output = Rails::HTML5::SafeListSanitizer.new.sanitize(fragment.to_html, scrubber: Scrubber.new(metadata))
|
|
25
|
+
Document.new(fragment: Nokogiri::HTML5.fragment(output), metadata: metadata)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.safe_url?(value, image: false)
|
|
29
|
+
return false unless value.is_a?(String) && value.present?
|
|
30
|
+
uri = URI.parse(value)
|
|
31
|
+
uri.scheme.nil? || (image ? %w[http https] : %w[http https mailto]).include?(uri.scheme.downcase)
|
|
32
|
+
rescue URI::InvalidURIError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.restore!(document)
|
|
37
|
+
document.fragment.css("[#{TOKEN}]").each do |node|
|
|
38
|
+
document.metadata.fetch(node[TOKEN], {}).each { |name, value| node[name] = value }
|
|
39
|
+
node.remove_attribute(TOKEN)
|
|
40
|
+
end
|
|
41
|
+
document.fragment
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.capture(fragment)
|
|
45
|
+
metadata = {}
|
|
46
|
+
fragment.traverse do |node|
|
|
47
|
+
next unless node.element?
|
|
48
|
+
attributes = {}
|
|
49
|
+
classes = node["class"].to_s.split.select do |name|
|
|
50
|
+
name.match?(/\A(?:markdown-alert(?:-(?:note|tip|important|warning|caution|title))?|footnotes?|footnote-(?:ref|backref)|task-list(?:-item)?)\z/)
|
|
51
|
+
end
|
|
52
|
+
attributes["class"] = classes.join(" ") if classes.any?
|
|
53
|
+
if node["id"].to_s.match?(/\Afn(?:ref)?-/)
|
|
54
|
+
attributes["id"] = "ob-footnote-#{node['id']}"
|
|
55
|
+
end
|
|
56
|
+
if node["href"].to_s.match?(/\A#fn(?:ref)?-/)
|
|
57
|
+
attributes["href"] = "#ob-footnote-#{node['href'].delete_prefix('#')}"
|
|
58
|
+
end
|
|
59
|
+
%w[data-footnote-ref data-footnotes data-footnote-backref data-footnote-backref-idx aria-label].each do |name|
|
|
60
|
+
attributes[name] = node[name] if node.key?(name)
|
|
61
|
+
end
|
|
62
|
+
next if attributes.empty?
|
|
63
|
+
token = metadata.length.to_s
|
|
64
|
+
metadata[token] = attributes
|
|
65
|
+
node[TOKEN] = token
|
|
66
|
+
end
|
|
67
|
+
metadata
|
|
68
|
+
end
|
|
69
|
+
private_class_method :capture
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|