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,63 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module StructuredDataHelper
|
|
3
|
+
def open_blog_structured_data(resource = nil)
|
|
4
|
+
page = open_blog_page(resource)
|
|
5
|
+
graph = [ open_blog_page_schema(page), open_blog_breadcrumb_schema(page) ]
|
|
6
|
+
if %i[post preview].include?(page.kind) && (entries = page.record.faq_list).any?
|
|
7
|
+
graph << { "@type" => "FAQPage", "mainEntity" => entries.map do |entry|
|
|
8
|
+
{ "@type" => "Question", "name" => entry[:question],
|
|
9
|
+
"acceptedAnswer" => { "@type" => "Answer", "text" => entry[:answer] } }
|
|
10
|
+
end }
|
|
11
|
+
end
|
|
12
|
+
json = ERB::Util.json_escape({ "@context" => "https://schema.org", "@graph" => graph }.to_json)
|
|
13
|
+
tag.script(json.html_safe, type: "application/ld+json")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def open_blog_page_schema(page)
|
|
17
|
+
case page.kind
|
|
18
|
+
when :post, :preview
|
|
19
|
+
post = page.record
|
|
20
|
+
publisher = OpenBlog.config.publisher || {}
|
|
21
|
+
data = { "@type" => "BlogPosting", "headline" => post.title, "description" => post.description.to_s,
|
|
22
|
+
"author" => open_blog_author_schema(post.author, name: post.author_name, base: page.base_url),
|
|
23
|
+
"publisher" => { "@type" => "Organization", "name" => publisher[:name], "url" => publisher[:url] }.compact,
|
|
24
|
+
"mainEntityOfPage" => open_blog_canonical_url(page), "wordCount" => post.word_count,
|
|
25
|
+
"keywords" => post.tags.map(&:name), "articleSection" => post.category&.name }
|
|
26
|
+
if publisher[:logo_url].present?
|
|
27
|
+
data["publisher"]["logo"] = { "@type" => "ImageObject", "url" => publisher[:logo_url] }
|
|
28
|
+
end
|
|
29
|
+
published, modified = ReaderDates.published_at(post), ReaderDates.modified_at(post)
|
|
30
|
+
data["datePublished"] = published.iso8601 if published
|
|
31
|
+
data["dateModified"] = modified.iso8601 if modified
|
|
32
|
+
image = open_blog_social_image_url(page)
|
|
33
|
+
data["image"] = image if image
|
|
34
|
+
data.compact
|
|
35
|
+
when :page
|
|
36
|
+
{ "@type" => "WebPage", "name" => page.record.title, "description" => open_blog_page_description(page),
|
|
37
|
+
"url" => open_blog_canonical_url(page), "dateModified" => page.record.updated_at.iso8601 }
|
|
38
|
+
when :author
|
|
39
|
+
{ "@type" => "ProfilePage", "name" => page.record.name, "url" => open_blog_canonical_url(page),
|
|
40
|
+
"mainEntity" => open_blog_author_schema(page.record, base: page.base_url) }
|
|
41
|
+
else
|
|
42
|
+
posts = page.pagination&.records || []
|
|
43
|
+
{ "@type" => page.kind == :index ? "Blog" : "CollectionPage", "name" => open_blog_page_title(page),
|
|
44
|
+
"description" => open_blog_page_description(page), "url" => open_blog_canonical_url(page),
|
|
45
|
+
"mainEntity" => { "@type" => "ItemList", "itemListElement" => posts.each_with_index.map do |post, index|
|
|
46
|
+
{ "@type" => "ListItem", "position" => index + 1, "url" => post.url(base: page.base_url), "name" => post.title }
|
|
47
|
+
end } }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def open_blog_author_schema(author, name: author.name, base: nil)
|
|
52
|
+
{ "@type" => author.author_type == "organization" ? "Organization" : "Person", "name" => name,
|
|
53
|
+
"url" => open_blog_absolute_url(open_blog_list_path(:author, author), base: base) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def open_blog_breadcrumb_schema(page)
|
|
57
|
+
{ "@type" => "BreadcrumbList", "itemListElement" => page.breadcrumbs.each_with_index.map do |crumb, index|
|
|
58
|
+
{ "@type" => "ListItem", "position" => index + 1, "name" => crumb[:name],
|
|
59
|
+
"item" => open_blog_absolute_url(crumb[:path], base: page.base_url) }
|
|
60
|
+
end }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module ThemeHelper
|
|
3
|
+
SURFACES = { "light" => "#ffffff", "dark" => "#0f172a" }.freeze
|
|
4
|
+
|
|
5
|
+
def open_blog_fixed_theme
|
|
6
|
+
OpenBlog.config.color_scheme.to_s if %w[light dark].include?(OpenBlog.config.color_scheme.to_s)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def open_blog_theme_attributes
|
|
10
|
+
open_blog_fixed_theme ? { data: { theme: open_blog_fixed_theme } } : {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def open_blog_theme_script
|
|
14
|
+
return "".html_safe if open_blog_fixed_theme
|
|
15
|
+
javascript_tag(nonce: content_security_policy_nonce, data: { open_blog_theme: true }) do
|
|
16
|
+
<<~JS.html_safe
|
|
17
|
+
(() => {
|
|
18
|
+
try {
|
|
19
|
+
const theme = localStorage.getItem("open-blog-theme");
|
|
20
|
+
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
|
21
|
+
} catch (_) {}
|
|
22
|
+
})();
|
|
23
|
+
JS
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def open_blog_theme_colors
|
|
28
|
+
safe_join(SURFACES.map do |scheme, surface|
|
|
29
|
+
tag.meta(name: "theme-color", media: "(prefers-color-scheme: #{scheme})", content: SURFACES.fetch(open_blog_fixed_theme, surface))
|
|
30
|
+
end, "\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def open_blog_stylesheets
|
|
34
|
+
stylesheets = [ stylesheet_link_tag("open_blog/blog") ]
|
|
35
|
+
stylesheets << stylesheet_link_tag("open_blog_theme") if open_blog_theme_override?
|
|
36
|
+
safe_join(stylesheets, "\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def open_blog_theme_override?
|
|
42
|
+
assets = Rails.application.assets if Rails.application.respond_to?(:assets)
|
|
43
|
+
if assets.respond_to?(:resolver)
|
|
44
|
+
assets.resolver.resolve("open_blog_theme.css").present?
|
|
45
|
+
elsif assets.respond_to?(:find_asset)
|
|
46
|
+
assets.find_asset("open_blog_theme.css").present?
|
|
47
|
+
elsif Rails.application.respond_to?(:assets_manifest)
|
|
48
|
+
Rails.application.assets_manifest&.assets&.key?("open_blog_theme.css")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
module UrlHelper
|
|
5
|
+
def open_blog_index_path
|
|
6
|
+
OpenBlog.mount_path.chomp("/").presence || "/"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def open_blog_list_path(kind, record)
|
|
10
|
+
segment = OpenBlog.config.route_segments.fetch(kind.to_s.singularize.to_sym)
|
|
11
|
+
"#{open_blog_index_path.chomp('/')}/#{segment}/#{record.slug}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def open_blog_page(resource = nil)
|
|
15
|
+
return resource if resource.is_a?(ReaderPage)
|
|
16
|
+
return @open_blog_page if @open_blog_page && (resource.nil? || resource == @open_blog_page.record)
|
|
17
|
+
kind = case resource
|
|
18
|
+
when Post then :post
|
|
19
|
+
when Page then :page
|
|
20
|
+
when Category then :category
|
|
21
|
+
when Tag then :tag
|
|
22
|
+
when Author then :author
|
|
23
|
+
when Series then :series
|
|
24
|
+
else :index
|
|
25
|
+
end
|
|
26
|
+
path = if %i[post page].include?(kind)
|
|
27
|
+
resource.path
|
|
28
|
+
elsif resource
|
|
29
|
+
open_blog_list_path(kind, resource)
|
|
30
|
+
else
|
|
31
|
+
open_blog_index_path
|
|
32
|
+
end
|
|
33
|
+
crumbs = [ { name: OpenBlog.config.blog_title, path: open_blog_index_path } ]
|
|
34
|
+
crumbs << { name: %i[post page].include?(kind) ? resource.title : resource.name, path: path } if resource
|
|
35
|
+
ReaderPage.new(kind: kind, record: resource, path: path, base_url: OpenBlog.config.public_base_url || request.base_url, breadcrumbs: crumbs)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def open_blog_absolute_url(path, base: nil)
|
|
39
|
+
return if path.blank?
|
|
40
|
+
base ||= @open_blog_page&.base_url || OpenBlog.config.public_base_url
|
|
41
|
+
base ||= request.base_url if respond_to?(:request) && request
|
|
42
|
+
uri = URI.join("#{base.to_s.chomp('/')}/", path.to_s)
|
|
43
|
+
uri.to_s if uri.is_a?(URI::HTTP) && uri.host.present? && uri.userinfo.nil?
|
|
44
|
+
rescue URI::InvalidURIError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def open_blog_canonical_url(resource = nil)
|
|
49
|
+
page = open_blog_page(resource)
|
|
50
|
+
path = %i[post preview].include?(page.kind) && page.record.canonical_url.presence || page.canonical_path
|
|
51
|
+
open_blog_absolute_url(path, base: page.base_url)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def open_blog_translate(key, **options)
|
|
55
|
+
I18n.t("open_blog.#{key}", locale: OpenBlog.config.locale, **options)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class PublishScheduledPostJob < ActiveJob::Base
|
|
3
|
+
def perform(post_id)
|
|
4
|
+
Post.transaction do
|
|
5
|
+
post = Post.lock.find_by(id: post_id)
|
|
6
|
+
now = Time.current
|
|
7
|
+
next unless post&.scheduled? && post.publish_at && post.publish_at <= now
|
|
8
|
+
result = WritePost.call({}, post: post, actor: nil, now: now, publish: :scheduled)
|
|
9
|
+
raise result.error unless result.success?
|
|
10
|
+
result
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Immutable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
before_destroy(prepend: true) { raise ActiveRecord::ReadOnlyRecord, "Stored records cannot be destroyed" if persisted? }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def readonly?
|
|
10
|
+
persisted? || super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module ImmutableImageAttachment
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
STRUCTURAL_ATTRIBUTES = %w[name blob_id record_type record_id].freeze
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_create :check_image_attachment_creation
|
|
9
|
+
before_update :check_image_attachment_changes
|
|
10
|
+
before_destroy :check_image_attachment_destruction, prepend: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update_columns(attributes)
|
|
14
|
+
if image_attachment?(attributes.stringify_keys["record_type"]) && (attributes.keys.map(&:to_s) & STRUCTURAL_ATTRIBUTES).any?
|
|
15
|
+
raise ActiveRecord::ReadOnlyRecord, "Stored image attachments cannot change"
|
|
16
|
+
end
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
def image_attachment?(target_type = nil)
|
|
22
|
+
[ record_type, record_type_in_database, target_type ].include?("OpenBlog::Image")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def check_image_attachment_creation
|
|
26
|
+
return unless image_attachment?
|
|
27
|
+
change = record&.attachment_changes&.fetch("file", nil)
|
|
28
|
+
unless record&.saved_change_to_id? && change&.attachment.equal?(self)
|
|
29
|
+
raise ActiveRecord::ReadOnlyRecord, "Image attachments must be created with their image"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def check_image_attachment_changes
|
|
34
|
+
if image_attachment? && (changes_to_save.keys & STRUCTURAL_ATTRIBUTES).any?
|
|
35
|
+
raise ActiveRecord::ReadOnlyRecord, "Stored image attachments cannot change"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check_image_attachment_destruction
|
|
40
|
+
raise ActiveRecord::ReadOnlyRecord, "Stored image attachments cannot be destroyed" if image_attachment?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Previewable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
TOKEN_LOCK = Mutex.new
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
def with_preview_tokens
|
|
8
|
+
TOKEN_LOCK.synchronize do
|
|
9
|
+
duration = OpenBlog.config.preview_duration
|
|
10
|
+
unless token_definitions[:preview]&.expires_in == duration
|
|
11
|
+
generates_token_for(:preview, expires_in: duration) { current_revision_identifier }
|
|
12
|
+
end
|
|
13
|
+
yield duration
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def find_by_preview_token(token)
|
|
18
|
+
return unless token.is_a?(String)
|
|
19
|
+
with_preview_tokens { find_by_token_for(:preview, token) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def previewable?
|
|
24
|
+
persisted? && (draft? || scheduled?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def preview_token
|
|
28
|
+
preview_credentials.fetch(:token)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def preview_credentials
|
|
32
|
+
raise Error::NotFound unless previewable?
|
|
33
|
+
self.class.with_preview_tokens do |duration|
|
|
34
|
+
{ token: generate_token_for(:preview), expires_at: Time.current + duration }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "active_support/core_ext/securerandom"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
class ApiToken < ApplicationRecord
|
|
6
|
+
SCOPES = %w[read write publish].freeze
|
|
7
|
+
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :token_digest, presence: true, uniqueness: true, format: { with: /\A[0-9a-f]{64}\z/ }
|
|
11
|
+
validates :token_prefix, length: { is: 8 }
|
|
12
|
+
validate do
|
|
13
|
+
errors.add(:scopes, "must contain only read, write or publish") unless scopes.is_a?(Array) && (scopes - SCOPES).empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.generate(name:, scopes: SCOPES, expires_at: nil)
|
|
17
|
+
secret = "ob_#{SecureRandom.base58(40)}"
|
|
18
|
+
record = create!(name: name, scopes: scopes, expires_at: expires_at,
|
|
19
|
+
token_digest: Digest::SHA256.hexdigest(secret), token_prefix: secret[0, 8])
|
|
20
|
+
[ record, secret ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.authenticate(secret)
|
|
24
|
+
return unless secret.is_a?(String) && secret.match?(/\Aob_[1-9A-HJ-NP-Za-km-z]{40}\z/)
|
|
25
|
+
digest = Digest::SHA256.hexdigest(secret)
|
|
26
|
+
record = active.find_by(token_digest: digest)
|
|
27
|
+
return unless record && ActiveSupport::SecurityUtils.secure_compare(record.token_digest, digest)
|
|
28
|
+
now = Time.current
|
|
29
|
+
return unless active.where(id: record.id).update_all(last_used_at: now) == 1
|
|
30
|
+
record.last_used_at = now
|
|
31
|
+
record
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Approval < ApplicationRecord
|
|
3
|
+
include Immutable
|
|
4
|
+
belongs_to :post
|
|
5
|
+
belongs_to :revision
|
|
6
|
+
|
|
7
|
+
validates :kind, inclusion: { in: %w[sent imported declared] }
|
|
8
|
+
validates :reviewer_name, :approved_at, presence: true
|
|
9
|
+
validates :facts_checked, inclusion: { in: [ true, false ] }
|
|
10
|
+
validates :declared_on, :declared_by, presence: true, if: -> { kind == "declared" }
|
|
11
|
+
validates :confirmed_by, :evidence, presence: true, if: -> { kind == "imported" }
|
|
12
|
+
validate :revision_belongs_to_post
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def revision_belongs_to_post
|
|
16
|
+
errors.add(:revision, "must belong to the post") if revision && revision.post != post
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Author < ApplicationRecord
|
|
3
|
+
has_many :posts, dependent: :restrict_with_exception
|
|
4
|
+
has_one_attached :avatar
|
|
5
|
+
|
|
6
|
+
before_validation { self.slug = name.to_s.parameterize if slug.blank? }
|
|
7
|
+
validates :name, :slug, presence: true
|
|
8
|
+
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
|
|
9
|
+
validates :author_type, inclusion: { in: %w[person organization] }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Baseline < ApplicationRecord
|
|
3
|
+
include Immutable
|
|
4
|
+
belongs_to :post
|
|
5
|
+
belongs_to :adopted_revision, class_name: "OpenBlog::Revision"
|
|
6
|
+
|
|
7
|
+
validates :post_id, uniqueness: true
|
|
8
|
+
validates :adopted_at, presence: true
|
|
9
|
+
validates :provenance, inclusion: { in: %w[ai_assisted human_written unknown] }
|
|
10
|
+
validates :source_id, uniqueness: { scope: :source_system }, allow_nil: true
|
|
11
|
+
validates :source_body_sha256, format: { with: /\A[0-9a-f]{64}\z/ }, allow_nil: true
|
|
12
|
+
validate :separate_declared_and_evidenced_dates
|
|
13
|
+
validate :revision_belongs_to_post
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
def separate_declared_and_evidenced_dates
|
|
17
|
+
if first_published_at && declared_first_published_at
|
|
18
|
+
errors.add(:declared_first_published_at, "cannot accompany an evidenced first publication date")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def revision_belongs_to_post
|
|
23
|
+
errors.add(:adopted_revision, "must belong to the post") if adopted_revision && adopted_revision.post != post
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Category < ApplicationRecord
|
|
3
|
+
has_many :posts, dependent: :nullify
|
|
4
|
+
|
|
5
|
+
before_validation { self.slug = name.to_s.parameterize if slug.blank? }
|
|
6
|
+
validates :name, :slug, presence: true, uniqueness: true
|
|
7
|
+
validates :slug, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
|
|
8
|
+
validates :position, numericality: { only_integer: true }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class ConnectionDeclaration < ApplicationRecord
|
|
3
|
+
include Immutable
|
|
4
|
+
belongs_to :post
|
|
5
|
+
validates :declared_by, :declared_on, presence: true
|
|
6
|
+
validates :third_party_paid, inclusion: { in: [ true, false ] }
|
|
7
|
+
validate :connections_are_named_relations
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
def connections_are_named_relations
|
|
11
|
+
unless connections.is_a?(Array) && connections.all? { |entry| entry.is_a?(Hash) && entry["party"].present? && entry["relation"].present? }
|
|
12
|
+
errors.add(:connections, "must be a list of named parties and relations")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Faq < ApplicationRecord
|
|
3
|
+
include ContentGuard
|
|
4
|
+
belongs_to :post, inverse_of: :faqs
|
|
5
|
+
validates :question, :answer, presence: true
|
|
6
|
+
validates :position, numericality: { only_integer: true, greater_than: 0 }, uniqueness: { scope: :post_id }
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def content_guard_applies?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def content_guard_identity_changed?
|
|
15
|
+
persisted? && will_save_change_to_post_id?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def content_guard_parent
|
|
19
|
+
post
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Image < ApplicationRecord
|
|
3
|
+
include Immutable
|
|
4
|
+
has_one_attached :file, service: ->(_) { OpenBlog.config.storage_service || Rails.application.config.active_storage.service }
|
|
5
|
+
|
|
6
|
+
validates :sha256, presence: true, uniqueness: true, format: { with: /\A[0-9a-f]{64}\z/ }
|
|
7
|
+
validates :filename, :content_type, presence: true
|
|
8
|
+
validates :byte_size, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
9
|
+
validates :width, :height, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
10
|
+
|
|
11
|
+
module ImmutableFile
|
|
12
|
+
%i[attach detach purge purge_later].each do |operation|
|
|
13
|
+
define_method(operation) do |*arguments, **options|
|
|
14
|
+
raise ActiveRecord::ReadOnlyRecord, "Stored image files cannot change" if record.persisted?
|
|
15
|
+
super(*arguments, **options)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_upload(io, filename:, content_type:, uploaded_by: nil)
|
|
21
|
+
ImageImport.prepare_upload(io, filename: filename, content_type: content_type).persist(uploaded_by: uploaded_by)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.from_signed_id(signed_id, uploaded_by: nil)
|
|
25
|
+
ImageImport.prepare(signed_id: signed_id).persist(uploaded_by: uploaded_by)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.from_url(url, uploaded_by: nil)
|
|
29
|
+
ImageImport.prepare(url: url).persist(uploaded_by: uploaded_by)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def path
|
|
33
|
+
"#{OpenBlog.mount_path.chomp("/")}/media/#{sha256}/#{ERB::Util.url_encode(filename)}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def file
|
|
37
|
+
super.extend(ImmutableFile)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def file=(attachable)
|
|
41
|
+
raise ActiveRecord::ReadOnlyRecord, "Stored image files cannot change" if persisted?
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Page < ApplicationRecord
|
|
3
|
+
DEFAULT_SLUGS = { "responsible_party" => "responsible-party", "corrections" => "corrections",
|
|
4
|
+
"editorial" => "editorial-policy", "ai_use" => "ai-use" }.freeze
|
|
5
|
+
KINDS = DEFAULT_SLUGS.keys.freeze
|
|
6
|
+
|
|
7
|
+
enum :status, { draft: "draft", published: "published" }, validate: true
|
|
8
|
+
before_validation { self.slug = DEFAULT_SLUGS[kind] if slug.blank? }
|
|
9
|
+
validates :kind, inclusion: { in: KINDS }, uniqueness: true
|
|
10
|
+
validates :title, :slug, presence: true
|
|
11
|
+
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/ }
|
|
12
|
+
validates :body_markdown, presence: true, if: :published?
|
|
13
|
+
|
|
14
|
+
def path
|
|
15
|
+
"#{OpenBlog.mount_path.chomp('/')}/policies/#{slug}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def url(base: nil)
|
|
19
|
+
origin = base || OpenBlog.config.public_base_url
|
|
20
|
+
"#{origin.chomp('/')}#{path}" if origin
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class Post < ApplicationRecord
|
|
5
|
+
include Previewable
|
|
6
|
+
RESERVED_SLUGS = %w[feed search preview media policies api mcp sitemap].freeze
|
|
7
|
+
SLUG_PATTERN = /\A[a-z0-9]+(?:[-_][a-z0-9]+)*\z/
|
|
8
|
+
|
|
9
|
+
belongs_to :author
|
|
10
|
+
belongs_to :category, optional: true
|
|
11
|
+
belongs_to :series, optional: true
|
|
12
|
+
belongs_to :cover_image, class_name: "OpenBlog::Image", optional: true
|
|
13
|
+
belongs_to :social_image, class_name: "OpenBlog::Image", optional: true
|
|
14
|
+
belongs_to :public_revision, class_name: "OpenBlog::Revision", optional: true
|
|
15
|
+
|
|
16
|
+
has_many :faqs, -> { order(:position) }, autosave: true, dependent: :destroy, inverse_of: :post
|
|
17
|
+
accepts_nested_attributes_for :faqs, allow_destroy: true, reject_if: ->(attributes) { attributes["id"].blank? && attributes["question"].blank? && attributes["answer"].blank? }
|
|
18
|
+
has_many :taggings, autosave: true, dependent: :destroy
|
|
19
|
+
has_many :tags, through: :taggings, autosave: false
|
|
20
|
+
has_many :page_views, dependent: :delete_all
|
|
21
|
+
has_many :revisions, dependent: :restrict_with_exception
|
|
22
|
+
has_many :approvals, dependent: :restrict_with_exception
|
|
23
|
+
has_many :publications, dependent: :restrict_with_exception
|
|
24
|
+
has_many :connection_declarations, dependent: :restrict_with_exception
|
|
25
|
+
has_one :baseline, dependent: :restrict_with_exception
|
|
26
|
+
has_rich_text :rich_body
|
|
27
|
+
|
|
28
|
+
attribute :body_format, :string, default: -> { OpenBlog.config.default_body_format.to_s }
|
|
29
|
+
enum :body_format, { markdown: "markdown", rich_text: "rich_text" }, validate: true
|
|
30
|
+
enum :status, { draft: "draft", scheduled: "scheduled", published: "published", archived: "archived" }, validate: true
|
|
31
|
+
enum :provenance, { ai_assisted: "ai_assisted", human_written: "human_written", unknown: "unknown" }, validate: true, prefix: true
|
|
32
|
+
|
|
33
|
+
validates :title, :author_name, presence: true
|
|
34
|
+
validates :slug, presence: true, uniqueness: true, format: { with: SLUG_PATTERN }
|
|
35
|
+
validates :external_id, uniqueness: true, allow_nil: true
|
|
36
|
+
validates :canonical_url, length: { maximum: 2048 }, allow_nil: true
|
|
37
|
+
validates :body_format, inclusion: { in: ->(_post) { OpenBlog.config.body_formats.map(&:to_s) } }
|
|
38
|
+
validates :series_position, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
39
|
+
validates :series_position, uniqueness: { scope: :series_id }, if: -> { series_id && series_position }
|
|
40
|
+
validate :slug_is_available
|
|
41
|
+
validate :public_revision_ownership
|
|
42
|
+
validate :canonical_url_is_absolute
|
|
43
|
+
|
|
44
|
+
scope :listed, -> { where(status: "published") }
|
|
45
|
+
|
|
46
|
+
attr_accessor :release_context, :release_records
|
|
47
|
+
|
|
48
|
+
before_save :resolve_body_images
|
|
49
|
+
before_save :compute_derived
|
|
50
|
+
after_save :record_release
|
|
51
|
+
around_save :guard_parent_content, prepend: true
|
|
52
|
+
around_destroy :guard_parent_content, prepend: true
|
|
53
|
+
|
|
54
|
+
def path
|
|
55
|
+
"#{OpenBlog.mount_path.chomp('/')}/#{slug}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def url(base: nil)
|
|
59
|
+
origin = base || OpenBlog.config.public_base_url
|
|
60
|
+
"#{origin.chomp('/')}#{path}" if origin
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def faq_list
|
|
64
|
+
faqs.to_a.reject(&:marked_for_destruction?).sort_by { |entry| entry.position || 0 }.map do |entry|
|
|
65
|
+
{ question: entry.question, answer: entry.answer }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def body_for_payload
|
|
70
|
+
return body_markdown.to_s if markdown?
|
|
71
|
+
|
|
72
|
+
if rich_body.will_save_change_to_body?
|
|
73
|
+
rich_body.read_attribute_for_database(:body).to_s
|
|
74
|
+
else
|
|
75
|
+
rich_body.read_attribute_before_type_cast(:body).to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def guard_parent_content(&block)
|
|
82
|
+
ContentGuard.with_parent(self, &block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record_release
|
|
86
|
+
republishing = saved_change_to_status? && status_before_last_save != "published"
|
|
87
|
+
self.release_records = RecordRelease.call(self, **(release_context || {}), republishing: republishing)
|
|
88
|
+
ensure
|
|
89
|
+
self.release_context = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def canonical_url_is_absolute
|
|
93
|
+
return if canonical_url.nil?
|
|
94
|
+
|
|
95
|
+
uri = URI.parse(canonical_url)
|
|
96
|
+
unless uri.is_a?(URI::HTTP) && uri.host.present?
|
|
97
|
+
errors.add(:canonical_url, "must be an absolute HTTP or HTTPS URL")
|
|
98
|
+
end
|
|
99
|
+
rescue URI::InvalidURIError
|
|
100
|
+
errors.add(:canonical_url, "must be an absolute HTTP or HTTPS URL")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve_body_images
|
|
104
|
+
ImageResolution.native!(self)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def compute_derived
|
|
108
|
+
self.current_revision_identifier = RevisionPayload.new(self).identifier
|
|
109
|
+
body_text = markdown? ? PlainText.from_markdown(body_markdown) : PlainText.from_html(body_for_payload)
|
|
110
|
+
faq_text = faq_list.flat_map { |entry| entry.values_at(:question, :answer) }.join("\n")
|
|
111
|
+
self.word_count = [ body_text, faq_text ].join("\n").split.length
|
|
112
|
+
self.reading_time_minutes = [ (word_count / 238.0).ceil, 1 ].max
|
|
113
|
+
self.search_text = [ title, description, body_text, faq_text ].join("\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def slug_is_available
|
|
117
|
+
if (RESERVED_SLUGS + OpenBlog.config.route_segments.values).include?(slug)
|
|
118
|
+
errors.add(:slug, "is reserved")
|
|
119
|
+
end
|
|
120
|
+
redirect = Redirect.find_by(old_path: path)
|
|
121
|
+
if redirect && (new_record? || redirect.post_id != id)
|
|
122
|
+
errors.add(:slug, "belongs to an existing redirect")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def public_revision_ownership
|
|
127
|
+
if public_revision && (new_record? || public_revision.post_id != id)
|
|
128
|
+
errors.add(:public_revision, "must belong to this post")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Publication < ApplicationRecord
|
|
3
|
+
include Immutable
|
|
4
|
+
belongs_to :post
|
|
5
|
+
belongs_to :revision
|
|
6
|
+
|
|
7
|
+
validates :entry_type, inclusion: { in: %w[first substantive correction maintenance adopted] }
|
|
8
|
+
validates :occurred_at, presence: true
|
|
9
|
+
validates :note, presence: true, if: -> { entry_type == "correction" }
|
|
10
|
+
validate :revision_belongs_to_post
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
def revision_belongs_to_post
|
|
14
|
+
errors.add(:revision, "must belong to the post") if revision && revision.post != post
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|