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.
Files changed (257) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +101 -0
  5. data/app/assets/builds/open_blog/blog.css +2 -0
  6. data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
  7. data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
  8. data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
  9. data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
  10. data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
  11. data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
  12. data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
  13. data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
  14. data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
  15. data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
  16. data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
  17. data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
  18. data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
  19. data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
  20. data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
  21. data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
  22. data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
  23. data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
  24. data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
  25. data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
  26. data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
  27. data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
  28. data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
  29. data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
  30. data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
  31. data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
  32. data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
  33. data/app/controllers/open_blog/application_controller.rb +5 -0
  34. data/app/controllers/open_blog/authors_controller.rb +14 -0
  35. data/app/controllers/open_blog/categories_controller.rb +15 -0
  36. data/app/controllers/open_blog/errors_controller.rb +7 -0
  37. data/app/controllers/open_blog/feeds_controller.rb +22 -0
  38. data/app/controllers/open_blog/mcp_controller.rb +38 -0
  39. data/app/controllers/open_blog/media_controller.rb +20 -0
  40. data/app/controllers/open_blog/pages_controller.rb +12 -0
  41. data/app/controllers/open_blog/posts_controller.rb +57 -0
  42. data/app/controllers/open_blog/previews_controller.rb +35 -0
  43. data/app/controllers/open_blog/search_controller.rb +32 -0
  44. data/app/controllers/open_blog/series_controller.rb +15 -0
  45. data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
  46. data/app/controllers/open_blog/tags_controller.rb +15 -0
  47. data/app/helpers/open_blog/content_helper.rb +79 -0
  48. data/app/helpers/open_blog/dates_helper.rb +18 -0
  49. data/app/helpers/open_blog/faq_helper.rb +59 -0
  50. data/app/helpers/open_blog/head_helper.rb +75 -0
  51. data/app/helpers/open_blog/icons_helper.rb +10 -0
  52. data/app/helpers/open_blog/notices_helper.rb +47 -0
  53. data/app/helpers/open_blog/pagination_helper.rb +18 -0
  54. data/app/helpers/open_blog/structured_data_helper.rb +63 -0
  55. data/app/helpers/open_blog/theme_helper.rb +52 -0
  56. data/app/helpers/open_blog/url_helper.rb +58 -0
  57. data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
  58. data/app/models/concerns/open_blog/immutable.rb +13 -0
  59. data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
  60. data/app/models/concerns/open_blog/previewable.rb +38 -0
  61. data/app/models/open_blog/api_token.rb +34 -0
  62. data/app/models/open_blog/application_record.rb +5 -0
  63. data/app/models/open_blog/approval.rb +19 -0
  64. data/app/models/open_blog/author.rb +11 -0
  65. data/app/models/open_blog/baseline.rb +26 -0
  66. data/app/models/open_blog/category.rb +10 -0
  67. data/app/models/open_blog/connection_declaration.rb +16 -0
  68. data/app/models/open_blog/faq.rb +22 -0
  69. data/app/models/open_blog/image.rb +45 -0
  70. data/app/models/open_blog/page.rb +23 -0
  71. data/app/models/open_blog/page_view.rb +7 -0
  72. data/app/models/open_blog/post.rb +132 -0
  73. data/app/models/open_blog/publication.rb +17 -0
  74. data/app/models/open_blog/redirect.rb +16 -0
  75. data/app/models/open_blog/revision.rb +12 -0
  76. data/app/models/open_blog/series.rb +10 -0
  77. data/app/models/open_blog/tag.rb +11 -0
  78. data/app/models/open_blog/tagging.rb +7 -0
  79. data/app/queries/open_blog/api_post_query.rb +54 -0
  80. data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
  81. data/app/serializers/open_blog/author_serializer.rb +10 -0
  82. data/app/serializers/open_blog/category_serializer.rb +7 -0
  83. data/app/serializers/open_blog/doctor_serializer.rb +7 -0
  84. data/app/serializers/open_blog/error_serializer.rb +7 -0
  85. data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
  86. data/app/serializers/open_blog/findings_serializer.rb +13 -0
  87. data/app/serializers/open_blog/image_serializer.rb +9 -0
  88. data/app/serializers/open_blog/page_serializer.rb +9 -0
  89. data/app/serializers/open_blog/post_serializer.rb +59 -0
  90. data/app/serializers/open_blog/preview_serializer.rb +11 -0
  91. data/app/serializers/open_blog/records_serializer.rb +28 -0
  92. data/app/serializers/open_blog/redirect_serializer.rb +7 -0
  93. data/app/serializers/open_blog/series_serializer.rb +7 -0
  94. data/app/serializers/open_blog/tag_serializer.rb +7 -0
  95. data/app/serializers/open_blog/write_result_serializer.rb +9 -0
  96. data/app/views/open_blog/attachments/_blob.html.erb +1 -0
  97. data/app/views/open_blog/feeds/atom.xml.builder +27 -0
  98. data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
  99. data/config/locales/open_blog.en.yml +95 -0
  100. data/config/routes.rb +52 -0
  101. data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
  102. data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
  103. data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
  104. data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
  105. data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
  106. data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
  107. data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
  108. data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
  109. data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
  110. data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
  111. data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
  112. data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
  113. data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
  114. data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
  115. data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
  116. data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
  117. data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
  118. data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
  119. data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
  120. data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
  121. data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
  122. data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
  123. data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
  124. data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
  125. data/lib/generators/open_blog/generator_support.rb +64 -0
  126. data/lib/generators/open_blog/install/install_generator.rb +188 -0
  127. data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
  128. data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
  129. data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
  130. data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
  131. data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
  132. data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
  133. data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
  134. data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
  135. data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
  136. data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
  137. data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
  138. data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
  139. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
  140. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
  141. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
  142. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
  143. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
  144. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
  145. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
  146. data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
  147. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
  148. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
  149. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
  150. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
  151. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
  152. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
  153. data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
  154. data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
  155. data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
  156. data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
  157. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
  158. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
  159. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
  160. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
  161. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
  162. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
  163. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
  164. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
  165. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
  166. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
  167. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
  168. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
  169. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
  170. data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
  171. data/lib/generators/open_blog/views/views_generator.rb +21 -0
  172. data/lib/open_blog/actor.rb +7 -0
  173. data/lib/open_blog/admin_suite.rb +73 -0
  174. data/lib/open_blog/adopt/contract.rb +152 -0
  175. data/lib/open_blog/adopt/snapshot.rb +35 -0
  176. data/lib/open_blog/adopt.rb +159 -0
  177. data/lib/open_blog/api_fields.rb +40 -0
  178. data/lib/open_blog/approve.rb +18 -0
  179. data/lib/open_blog/authentication.rb +18 -0
  180. data/lib/open_blog/build_css.rb +53 -0
  181. data/lib/open_blog/configuration.rb +185 -0
  182. data/lib/open_blog/configuration_error.rb +4 -0
  183. data/lib/open_blog/content_guard.rb +77 -0
  184. data/lib/open_blog/doctor/files.rb +61 -0
  185. data/lib/open_blog/doctor/javascript.rb +57 -0
  186. data/lib/open_blog/doctor.rb +177 -0
  187. data/lib/open_blog/engine.rb +45 -0
  188. data/lib/open_blog/errors.rb +40 -0
  189. data/lib/open_blog/faq_extraction.rb +184 -0
  190. data/lib/open_blog/findings/body_checks.rb +58 -0
  191. data/lib/open_blog/findings/check.rb +19 -0
  192. data/lib/open_blog/findings/link_check.rb +78 -0
  193. data/lib/open_blog/findings/record_checks.rb +94 -0
  194. data/lib/open_blog/findings/registry.rb +18 -0
  195. data/lib/open_blog/findings.rb +27 -0
  196. data/lib/open_blog/image_fetch.rb +115 -0
  197. data/lib/open_blog/image_import.rb +164 -0
  198. data/lib/open_blog/image_resolution.rb +171 -0
  199. data/lib/open_blog/instructions.rb +7 -0
  200. data/lib/open_blog/json_feed.rb +36 -0
  201. data/lib/open_blog/label_policy.rb +41 -0
  202. data/lib/open_blog/markdown_view.rb +57 -0
  203. data/lib/open_blog/mcp/definition.rb +28 -0
  204. data/lib/open_blog/mcp/dispatcher.rb +33 -0
  205. data/lib/open_blog/mcp/image_input.rb +30 -0
  206. data/lib/open_blog/mcp/registry.rb +139 -0
  207. data/lib/open_blog/mcp/schemas.rb +72 -0
  208. data/lib/open_blog/mcp.rb +24 -0
  209. data/lib/open_blog/not_found.rb +4 -0
  210. data/lib/open_blog/operation.rb +36 -0
  211. data/lib/open_blog/page_views.rb +72 -0
  212. data/lib/open_blog/pagination.rb +18 -0
  213. data/lib/open_blog/plain_text.rb +36 -0
  214. data/lib/open_blog/policy_url.rb +9 -0
  215. data/lib/open_blog/post_attributes.rb +172 -0
  216. data/lib/open_blog/post_identity.rb +28 -0
  217. data/lib/open_blog/publish.rb +7 -0
  218. data/lib/open_blog/reader_dates.rb +21 -0
  219. data/lib/open_blog/reader_page.rb +21 -0
  220. data/lib/open_blog/reader_queries.rb +90 -0
  221. data/lib/open_blog/record_release.rb +66 -0
  222. data/lib/open_blog/redirect_target.rb +58 -0
  223. data/lib/open_blog/remove.rb +43 -0
  224. data/lib/open_blog/renderer/highlighter.rb +44 -0
  225. data/lib/open_blog/renderer/markdown.rb +14 -0
  226. data/lib/open_blog/renderer/rich_text.rb +19 -0
  227. data/lib/open_blog/renderer/sanitizer.rb +72 -0
  228. data/lib/open_blog/renderer/scrubber.rb +35 -0
  229. data/lib/open_blog/renderer/transform.rb +115 -0
  230. data/lib/open_blog/renderer.rb +51 -0
  231. data/lib/open_blog/result.rb +11 -0
  232. data/lib/open_blog/revision_payload.rb +67 -0
  233. data/lib/open_blog/rich_text_guard.rb +26 -0
  234. data/lib/open_blog/sample.rb +56 -0
  235. data/lib/open_blog/save_draft.rb +7 -0
  236. data/lib/open_blog/search.rb +21 -0
  237. data/lib/open_blog/sitemap_entries.rb +66 -0
  238. data/lib/open_blog/surface_report/expected_content.rb +27 -0
  239. data/lib/open_blog/surface_report/http.rb +92 -0
  240. data/lib/open_blog/surface_report/page_checks.rb +303 -0
  241. data/lib/open_blog/surface_report/reach_checks.rb +141 -0
  242. data/lib/open_blog/surface_report/record_checks.rb +248 -0
  243. data/lib/open_blog/surface_report/robots.rb +49 -0
  244. data/lib/open_blog/surface_report.rb +223 -0
  245. data/lib/open_blog/syntax_css.rb +55 -0
  246. data/lib/open_blog/unpublish.rb +13 -0
  247. data/lib/open_blog/version.rb +3 -0
  248. data/lib/open_blog/write_post.rb +205 -0
  249. data/lib/open_blog.rb +91 -0
  250. data/lib/tasks/open_blog.rake +71 -0
  251. data/skills/open-blog-adopt/SKILL.md +12 -0
  252. data/skills/open-blog-install/SKILL.md +10 -0
  253. data/skills/open-blog-policy-pages/SKILL.md +10 -0
  254. data/skills/open-blog-publish/SKILL.md +14 -0
  255. data/skills/open-blog-report/SKILL.md +10 -0
  256. data/skills/open-blog-update/SKILL.md +16 -0
  257. metadata +515 -0
@@ -0,0 +1,13 @@
1
+ <%= render "open_blog/shared/breadcrumbs" %>
2
+ <header class="ob-list-heading">
3
+ <h1><%= open_blog_translate("search.heading") %></h1>
4
+ <p class="ob-list-description"><%= open_blog_translate("search.description") %></p>
5
+ </header>
6
+ <div class="ob-list-layout">
7
+ <%= render "open_blog/shared/sidebar" %>
8
+ <div class="ob-list-main">
9
+ <% if @posts.empty? %><p class="ob-empty"><%= open_blog_translate("search.empty") %></p><% end %>
10
+ <div class="ob-post-grid"><%= render partial: "open_blog/shared/post_card", collection: @posts, as: :post %></div>
11
+ <%= render "open_blog/shared/pagination" %>
12
+ </div>
13
+ </div>
@@ -0,0 +1,13 @@
1
+ <%= render "open_blog/shared/breadcrumbs" %>
2
+ <header class="ob-list-heading">
3
+ <h1><%= @open_blog_page.record.name %></h1>
4
+ <p class="ob-list-description"><%= open_blog_page_description(@open_blog_page) %></p>
5
+ </header>
6
+ <div class="ob-list-layout">
7
+ <%= render "open_blog/shared/sidebar" %>
8
+ <div class="ob-list-main">
9
+ <% if @posts.empty? %><p class="ob-empty"><%= open_blog_translate("lists.empty") %></p><% end %>
10
+ <div class="ob-post-grid"><%= render partial: "open_blog/shared/post_card", collection: @posts, as: :post %></div>
11
+ <%= render "open_blog/shared/pagination" %>
12
+ </div>
13
+ </div>
@@ -0,0 +1,6 @@
1
+ <section class="ob-author-box" aria-labelledby="ob-author-heading">
2
+ <h2 id="ob-author-heading"><%= open_blog_translate("headings.about_author") %></h2>
3
+ <%= open_blog_avatar(post.author, name: post.author_name) %>
4
+ <%= link_to post.author_name, open_blog_list_path(:author, post.author) %>
5
+ <% if post.author.bio.present? %><p class="ob-author-bio"><%= post.author.bio %></p><% end %>
6
+ </section>
@@ -0,0 +1 @@
1
+ <%= open_blog_breadcrumbs(@open_blog_page) %>
@@ -0,0 +1,7 @@
1
+ <% if (cta = OpenBlog.config.call_to_action).present? && OpenBlog::Renderer::Sanitizer.safe_url?(cta[:url]) %>
2
+ <section class="ob-call-to-action" aria-labelledby="ob-call-to-action-heading">
3
+ <h2 id="ob-call-to-action-heading"><%= cta[:title] %></h2>
4
+ <p><%= cta[:text] %></p>
5
+ <%= link_to cta[:label], cta[:url] %>
6
+ </section>
7
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <article class="ob-featured-card">
2
+ <%= link_to post.path, aria: { label: post.title }, class: "ob-card-media" do %><%= open_blog_card_image(post) %><% end %>
3
+ <div class="ob-featured-details">
4
+ <p class="ob-featured-label"><%= open_blog_translate("cards.featured") %></p>
5
+ <h2><%= link_to post.title, post.path %></h2>
6
+ <p class="ob-card-description"><%= post.description %></p>
7
+ <div class="ob-card-author"><%= open_blog_avatar(post.author, name: post.author_name) %> <%= link_to post.author_name, open_blog_list_path(:author, post.author) %></div>
8
+ <%= open_blog_dates(post) %>
9
+ <%= open_blog_reading_time(post) %>
10
+ </div>
11
+ </article>
@@ -0,0 +1,11 @@
1
+ <footer class="ob-footer">
2
+ <nav aria-label="<%= open_blog_translate("navigation.footer") %>">
3
+ <% OpenBlog::Page::KINDS.each do |kind| %>
4
+ <% url = OpenBlog.policy_url(kind) %>
5
+ <% if url.present? && OpenBlog::Renderer::Sanitizer.safe_url?(url) %>
6
+ <%= link_to open_blog_translate("policies.#{kind}"), url %>
7
+ <% end %>
8
+ <% end %>
9
+ <%= link_to open_blog_translate("navigation.feed"), "#{open_blog_index_path.chomp('/')}/feed.xml" %>
10
+ </nav>
11
+ </footer>
@@ -0,0 +1,7 @@
1
+ <header class="ob-header">
2
+ <%= link_to OpenBlog.config.site_name, open_blog_index_path, class: "ob-site-name" %>
3
+ <nav aria-label="<%= open_blog_translate("navigation.blog") %>">
4
+ <%= link_to OpenBlog.config.blog_title, open_blog_index_path %>
5
+ </nav>
6
+ <%= render "open_blog/shared/theme_toggle" %>
7
+ </header>
@@ -0,0 +1 @@
1
+ <%= open_blog_paginate(@open_blog_page.pagination) %>
@@ -0,0 +1,9 @@
1
+ <article class="ob-post-card">
2
+ <%= link_to post.path, aria: { label: post.title }, class: "ob-card-media" do %><%= open_blog_card_image(post) %><% end %>
3
+ <% if post.category %><%= link_to post.category.name, open_blog_list_path(:category, post.category), class: "ob-card-category" %><% end %>
4
+ <%= content_tag(local_assigns.fetch(:heading, :h2), class: "ob-card-title") do %><%= link_to post.title, post.path %><% end %>
5
+ <p class="ob-card-description"><%= post.description %></p>
6
+ <div class="ob-card-author"><%= open_blog_avatar(post.author, name: post.author_name) %> <%= link_to post.author_name, open_blog_list_path(:author, post.author) %></div>
7
+ <%= open_blog_dates(post) %>
8
+ <%= open_blog_reading_time(post) %>
9
+ </article>
@@ -0,0 +1,10 @@
1
+ <% cache_unless @open_blog_page.kind == :preview, @related_cache_key do %>
2
+ <% if @related_posts.any? %>
3
+ <section class="ob-related-posts" aria-labelledby="ob-related-heading">
4
+ <h2 id="ob-related-heading"><%= open_blog_translate("headings.related") %></h2>
5
+ <div class="ob-post-grid">
6
+ <%= render partial: "open_blog/shared/post_card", collection: @related_posts, as: :post, locals: { heading: :h3 } %>
7
+ </div>
8
+ </section>
9
+ <% end %>
10
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% search_path = "#{OpenBlog.mount_path.chomp('/')}/search" %>
2
+ <form action="<%= search_path %>" method="get" role="search" class="ob-search" data-controller="open-blog--search" data-open-blog--search-url-value="<%= search_path %>.json">
3
+ <label for="open-blog-search"><%= open_blog_translate("search.label") %></label>
4
+ <div class="ob-search-controls">
5
+ <input id="open-blog-search" type="search" name="q" maxlength="100" value="<%= @query %>" data-open-blog--search-target="input" data-action="input->open-blog--search#search" autocomplete="off">
6
+ <button type="submit"><%= open_blog_translate("search.submit") %></button>
7
+ </div>
8
+ <ul class="ob-search-results" aria-label="<%= open_blog_translate("search.suggestions") %>" aria-live="polite" data-open-blog--search-target="results" hidden></ul>
9
+ </form>
@@ -0,0 +1,13 @@
1
+ <nav class="ob-share" aria-label="<%= open_blog_translate("share.heading") %>"
2
+ data-controller="open-blog--share"
3
+ data-open-blog--share-url-value="<%= open_blog_absolute_url(post.path) %>"
4
+ data-open-blog--share-title-value="<%= post.title %>"
5
+ data-open-blog--share-copy-label-value="<%= open_blog_translate("share.copy") %>"
6
+ data-open-blog--share-copied-label-value="<%= open_blog_translate("share.copied") %>"
7
+ data-open-blog--share-error-label-value="<%= open_blog_translate("share.failed") %>">
8
+ <button type="button" class="ob-share-button" hidden data-open-blog--share-target="device" data-action="open-blog--share#share"><%= open_blog_icon("share") %> <%= open_blog_translate("share.device") %></button>
9
+ <%= link_to open_blog_translate("share.x"), "https://twitter.com/intent/tweet?url=#{ERB::Util.url_encode(open_blog_absolute_url(post.path))}&text=#{ERB::Util.url_encode(post.title)}", rel: "noopener" %>
10
+ <%= link_to open_blog_translate("share.linkedin"), "https://www.linkedin.com/sharing/share-offsite/?url=#{ERB::Util.url_encode(open_blog_absolute_url(post.path))}", rel: "noopener" %>
11
+ <button type="button" class="ob-share-button" hidden data-open-blog--share-target="copy" data-action="open-blog--share#copy"><%= open_blog_icon("copy") %> <%= open_blog_translate("share.copy") %></button>
12
+ <span class="ob-share-status" data-open-blog--share-target="status" aria-live="polite"></span>
13
+ </nav>
@@ -0,0 +1,38 @@
1
+ <aside class="ob-sidebar">
2
+ <%= render "open_blog/shared/search" %>
3
+ <% cache @sidebar_cache_key do %>
4
+ <nav class="ob-sidebar-categories" aria-label="<%= open_blog_translate("navigation.categories") %>">
5
+ <h2><%= open_blog_translate("navigation.categories") %></h2>
6
+ <ul>
7
+ <li><%= link_to open_blog_translate("navigation.all_posts"), open_blog_index_path, aria: { current: (@open_blog_page.kind == :index ? "page" : nil) } %></li>
8
+ <% @sidebar_categories.each do |entry| %>
9
+ <li>
10
+ <%= link_to open_blog_list_path(:category, entry[:category]), aria: { current: (@open_blog_page.kind == :category && @open_blog_page.record == entry[:category] ? "page" : nil) } do %>
11
+ <span><%= entry[:category].name %></span> <span class="ob-count"><%= entry[:count] %></span>
12
+ <% end %>
13
+ </li>
14
+ <% end %>
15
+ </ul>
16
+ </nav>
17
+ <% if @sidebar_tags.any? %>
18
+ <nav class="ob-sidebar-tags" aria-label="<%= open_blog_translate("navigation.tags") %>">
19
+ <h2><%= open_blog_translate("navigation.tags") %></h2>
20
+ <ul>
21
+ <% @sidebar_tags.each do |entry| %>
22
+ <li><%= link_to entry[:tag].name, open_blog_list_path(:tag, entry[:tag]), aria: { current: (@open_blog_page.kind == :tag && @open_blog_page.record == entry[:tag] ? "page" : nil) } %></li>
23
+ <% end %>
24
+ </ul>
25
+ </nav>
26
+ <% end %>
27
+ <% end %>
28
+ <% if @popular_posts.any? %>
29
+ <nav class="ob-sidebar-popular" aria-label="<%= open_blog_translate("navigation.popular") %>">
30
+ <h2><%= open_blog_translate("navigation.popular") %></h2>
31
+ <ol>
32
+ <% @popular_posts.each do |post| %>
33
+ <li><%= link_to post.title, post.path %></li>
34
+ <% end %>
35
+ </ol>
36
+ </nav>
37
+ <% end %>
38
+ </aside>
@@ -0,0 +1,10 @@
1
+ <% unless open_blog_fixed_theme %>
2
+ <button type="button" class="ob-theme-toggle" hidden
3
+ data-controller="open-blog--theme" data-action="open-blog--theme#cycle"
4
+ data-open-blog--theme-system-label-value="<%= open_blog_translate("theme.system") %>"
5
+ data-open-blog--theme-light-label-value="<%= open_blog_translate("theme.light") %>"
6
+ data-open-blog--theme-dark-label-value="<%= open_blog_translate("theme.dark") %>">
7
+ <%= open_blog_icon("monitor") %>
8
+ <span data-open-blog--theme-target="label"><%= open_blog_translate("theme.system") %></span>
9
+ </button>
10
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= render "open_blog/shared/breadcrumbs" %>
2
+ <header class="ob-list-heading">
3
+ <h1><%= open_blog_translate("titles.tag", name: @open_blog_page.record.name) %></h1>
4
+ <p class="ob-list-description"><%= open_blog_page_description(@open_blog_page) %></p>
5
+ </header>
6
+ <div class="ob-list-layout">
7
+ <%= render "open_blog/shared/sidebar" %>
8
+ <div class="ob-list-main">
9
+ <% if @posts.empty? %><p class="ob-empty"><%= open_blog_translate("lists.empty") %></p><% end %>
10
+ <div class="ob-post-grid"><%= render partial: "open_blog/shared/post_card", collection: @posts, as: :post %></div>
11
+ <%= render "open_blog/shared/pagination" %>
12
+ </div>
13
+ </div>
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require_relative "../generator_support"
3
+
4
+ module OpenBlog
5
+ module Generators
6
+ class ViewsGenerator < Rails::Generators::Base
7
+ include GeneratorSupport
8
+ source_root File.expand_path("../install/templates", __dir__)
9
+ class_option :skip_tailwind, type: :boolean, default: nil
10
+
11
+ def copy_views
12
+ css = if options[:skip_tailwind].nil? && host_read("app/views/layouts/open_blog.html.erb").include?("open_blog_stylesheets")
13
+ :fallback
14
+ else
15
+ css_setup
16
+ end
17
+ copy_reader_views(css: css)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ module OpenBlog
2
+ Actor = Data.define(:name, :scopes, :id) do
3
+ def initialize(name:, scopes: %w[read write publish], id: nil)
4
+ super(name: name.to_s, scopes: Array(scopes).map(&:to_s).freeze, id: id || name.to_s)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,73 @@
1
+ require "admin_suite"
2
+ require "admin_suite/ui/field_renderer_registry"
3
+
4
+ module OpenBlog
5
+ module AdminSuite
6
+ RESOURCES = { "open_blog_posts" => "Post", "open_blog_categories" => "Category",
7
+ "open_blog_authors" => "Author", "open_blog_pages" => "Page" }.freeze
8
+ FAQ_FIELDS = %i[id question answer position _destroy].freeze
9
+
10
+ module ResourceParameters
11
+ private
12
+
13
+ def resource_config
14
+ name = RESOURCES[resource_name]
15
+ return super unless current_portal.to_s == "open_blog" && name
16
+ ensure_resources_loaded!
17
+ definition = "::Admin::Resources::OpenBlog::#{name}Resource".safe_constantize
18
+ return unless definition.is_a?(Class) && definition < ::Admin::Base::Resource
19
+ definition if definition.model_class == "::OpenBlog::#{name}".constantize
20
+ end
21
+
22
+ def resource_params
23
+ fields = resource_config&.form_config&.fields_list || []
24
+ nested = resource_class == ::OpenBlog::Post && fields.any? do |field|
25
+ field.respond_to?(:name) && field.name.to_sym == :faqs_attributes && field.type == :open_blog_faq
26
+ end
27
+ return super unless nested
28
+
29
+ original = params
30
+ key = resource_class.model_name.param_key
31
+ faq = original.require(key).slice(:faqs_attributes).permit(faqs_attributes: FAQ_FIELDS)
32
+ filtered = original.deep_dup
33
+ filtered[key].delete(:faqs_attributes)
34
+ self.params = filtered
35
+ super.merge(faq)
36
+ ensure
37
+ self.params = original if original
38
+ end
39
+ end
40
+
41
+ def self.install!
42
+ return if @installed
43
+ ::AdminSuite::UI::FieldRendererRegistry.register(:open_blog_faq) do |view, form, field, resource, field_class|
44
+ render_faqs(view, form, field, resource, field_class)
45
+ end
46
+ Rails.application.reloader.to_prepare { install_controller! }
47
+ install_controller!
48
+ @installed = true
49
+ end
50
+
51
+ def self.install_controller!
52
+ controller = ::AdminSuite::ResourcesController
53
+ controller.prepend(ResourceParameters) unless controller.ancestors.include?(ResourceParameters)
54
+ end
55
+
56
+ def self.render_faqs(view, form, field, resource, field_class)
57
+ entries = resource.faqs.to_a
58
+ next_position = entries.filter_map(&:position).max.to_i + 1
59
+ entries += [ ::OpenBlog::Faq.new(position: next_position) ] unless field.readonly
60
+ form.fields_for(:faqs, entries, include_id: false) do |nested|
61
+ view.tag.fieldset(class: "open-blog-faq-fields") do
62
+ view.safe_join([
63
+ view.tag.legend(nested.object.persisted? ? "FAQ #{nested.object.position}" : "Add a FAQ"),
64
+ nested.hidden_field(:id), nested.label(:position), nested.number_field(:position, min: 1, class: field_class, readonly: field.readonly),
65
+ nested.label(:question), nested.text_field(:question, class: field_class, readonly: field.readonly),
66
+ nested.label(:answer), nested.text_area(:answer, class: field_class, rows: 4, readonly: field.readonly),
67
+ nested.check_box(:_destroy, disabled: field.readonly), nested.label(:_destroy, "Remove this FAQ")
68
+ ])
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,152 @@
1
+ module OpenBlog
2
+ class Adopt
3
+ class Contract
4
+ CONTENT_FIELDS = (PostAttributes::CONTENT_FIELDS + %i[provenance provenance_evidence]).freeze
5
+ EXTRA_FIELDS = %i[source_system source_id source_body_sha256 old_slugs category_description first_published_at first_published_evidence last_modified_at last_modified_evidence imported_approval declaration connections dry_run].freeze
6
+ attr_reader :attributes, :baseline, :approval, :connections
7
+
8
+ def initialize(input, now: Time.current)
9
+ @now = now
10
+ invalid("attributes") unless input.is_a?(Hash) && input.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
11
+ @attributes = input.symbolize_keys
12
+ unknown = @attributes.keys - CONTENT_FIELDS - EXTRA_FIELDS
13
+ raise Error::UnknownField.new(details: unknown.map(&:to_s)) if unknown.any?
14
+ %i[source_system source_id slug title body_format].each { |field| required_text(@attributes, field, field.to_s) }
15
+ invalid("body") unless @attributes[:body].is_a?(String)
16
+ raise Error::SlugNotSupported.new(details: [ "slug" ]) unless Post::SLUG_PATTERN.match?(@attributes[:slug])
17
+ invalid("dry_run") if @attributes.key?(:dry_run) && ![ true, false ].include?(@attributes[:dry_run])
18
+ %i[category_description provenance_evidence first_published_evidence last_modified_evidence].each do |field|
19
+ invalid(field.to_s) if @attributes.key?(field) && !@attributes[field].nil? && !@attributes[field].is_a?(String)
20
+ end
21
+ if !@attributes[:source_body_sha256].nil? && !(@attributes[:source_body_sha256].is_a?(String) && @attributes[:source_body_sha256].match?(/\A[0-9a-f]{64}\z/))
22
+ invalid("source_body_sha256")
23
+ end
24
+ normalize_approval
25
+ normalize_baseline
26
+ normalize_connections
27
+ end
28
+
29
+ def dry_run?
30
+ @attributes[:dry_run] == true
31
+ end
32
+
33
+ def content
34
+ defaults = { description: "", search_title: "", search_description: "", cover_alt: "", faq: [], tags: [],
35
+ category: nil, series: nil, series_position: nil, featured: false, canonical_url: nil, external_id: nil,
36
+ cover_image: nil, social_image: nil, author: OpenBlog.config.default_author, provenance: "unknown", provenance_evidence: nil }
37
+ defaults.merge(@attributes.slice(*CONTENT_FIELDS)).merge(provenance: @baseline[:provenance], provenance_evidence: @baseline[:provenance_evidence])
38
+ end
39
+
40
+ def redirects(default_date:, current_slug:)
41
+ value = @attributes.fetch(:old_slugs, [])
42
+ invalid("old_slugs") unless value.is_a?(Array)
43
+ value.map do |item|
44
+ entry = item.is_a?(String) ? { slug: item } : nested(item, %i[slug moved_on], "old_slugs")
45
+ slug = required_text(entry, :slug, "old_slugs.slug")
46
+ unless Post::SLUG_PATTERN.match?(slug)
47
+ raise Error::SlugNotSupported.new(details: [ "old_slugs.slug" ])
48
+ end
49
+ invalid("old_slugs.slug") if slug == current_slug
50
+ { old_path: "#{OpenBlog.mount_path.chomp('/')}/#{slug}", new_path: "#{OpenBlog.mount_path.chomp('/')}/#{current_slug}",
51
+ occurred_on: entry[:moved_on].nil? ? default_date : date(entry[:moved_on], "old_slugs.moved_on") }
52
+ end.uniq.sort_by { |entry| entry[:old_path] }
53
+ end
54
+
55
+ private
56
+
57
+ def invalid(field)
58
+ raise Error::ValidationFailed.new(details: Array(field))
59
+ end
60
+
61
+ def required_text(input, field, path)
62
+ value = input[field]
63
+ invalid(path) unless value.is_a?(String) && value.present?
64
+ value
65
+ end
66
+
67
+ def nested(value, allowed, path)
68
+ invalid(path) unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
69
+ result = value.symbolize_keys
70
+ unknown = result.keys - allowed
71
+ raise Error::UnknownField.new(details: unknown.map { |field| "#{path}.#{field}" }) if unknown.any?
72
+ result
73
+ end
74
+
75
+ def timestamp(value, path)
76
+ return if value.nil?
77
+ invalid(path) unless value.is_a?(String) || value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) || value.is_a?(DateTime)
78
+ result = value.is_a?(String) ? Time.iso8601(value) : value.to_time
79
+ result.utc.floor(6)
80
+ rescue ArgumentError, TypeError
81
+ invalid(path)
82
+ end
83
+
84
+ def date(value, path)
85
+ invalid(path) unless value.is_a?(String) || value.is_a?(Date)
86
+ invalid(path) if value.is_a?(String) && !value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
87
+ value.is_a?(String) ? Date.iso8601(value) : value.to_date
88
+ rescue ArgumentError, TypeError
89
+ invalid(path)
90
+ end
91
+
92
+ def normalize_approval
93
+ if @attributes.key?(:declaration) && @attributes.key?(:imported_approval)
94
+ invalid(%w[declaration imported_approval])
95
+ end
96
+ key = @attributes.key?(:declaration) ? :declaration : (@attributes.key?(:imported_approval) ? :imported_approval : nil)
97
+ return unless key
98
+ allowed = %i[reviewer_name approved_at facts_checked] + (key == :declaration ? %i[declared_on declared_by declared_first_published_at] : %i[evidence confirmed_by])
99
+ value = nested(@attributes[key], allowed, key.to_s)
100
+ unless value[:reviewer_name].is_a?(String) && value[:reviewer_name].present? && [ true, false ].include?(value[:facts_checked])
101
+ raise Error::ApprovalIncomplete
102
+ end
103
+ invalid("#{key}.approved_at") unless value[:approved_at]
104
+ value[:approved_at] = timestamp(value[:approved_at], "#{key}.approved_at")
105
+ if key == :declaration
106
+ required_text(value, :declared_by, "declaration.declared_by")
107
+ value[:declared_on] = date(value[:declared_on], "declaration.declared_on")
108
+ @declared_first = timestamp(value.delete(:declared_first_published_at), "declaration.declared_first_published_at")
109
+ invalid("declaration.declared_first_published_at") if @attributes[:first_published_at] && @declared_first
110
+ else
111
+ required_text(value, :evidence, "imported_approval.evidence")
112
+ required_text(value, :confirmed_by, "imported_approval.confirmed_by")
113
+ end
114
+ @approval = value.merge(kind: key == :declaration ? "declared" : "imported")
115
+ end
116
+
117
+ def normalize_baseline
118
+ provenance = @attributes.fetch(:provenance, "unknown").to_s
119
+ invalid("provenance") unless %w[ai_assisted human_written unknown].include?(provenance)
120
+ evidence = @attributes[:provenance_evidence]
121
+ if evidence.blank? && @approval && @approval[:kind] == "declared"
122
+ evidence = "Declared by #{@approval[:declared_by]} on #{@approval[:declared_on].iso8601}"
123
+ end
124
+ invalid("provenance_evidence") if provenance != "unknown" && evidence.blank?
125
+ @baseline = @attributes.slice(:source_system, :source_id, :source_body_sha256).merge(
126
+ source_body_sha256: @attributes[:source_body_sha256], provenance: provenance,
127
+ provenance_evidence: evidence, declared_first_published_at: @declared_first)
128
+ %i[first_published last_modified].each do |name|
129
+ time = timestamp(@attributes[:"#{name}_at"], "#{name}_at")
130
+ evidence = @attributes[:"#{name}_evidence"].presence
131
+ @baseline[:"#{name}_at"] = evidence ? time : nil
132
+ @baseline[:"#{name}_evidence"] = evidence
133
+ end
134
+ end
135
+
136
+ def normalize_connections
137
+ return unless @attributes.key?(:connections)
138
+ value = nested(@attributes[:connections], %i[connections third_party_paid declared_by declared_on], "connections")
139
+ required_text(value, :declared_by, "connections.declared_by")
140
+ invalid("connections.third_party_paid") unless [ true, false ].include?(value[:third_party_paid])
141
+ value[:declared_on] = value.key?(:declared_on) ? date(value[:declared_on], "connections.declared_on") : @now.to_date
142
+ invalid("connections.connections") unless value[:connections].is_a?(Array)
143
+ value[:connections] = value[:connections].map do |entry|
144
+ normalized = nested(entry, %i[party relation], "connections.connections")
145
+ %i[party relation].each { |field| required_text(normalized, field, "connections.connections.#{field}") }
146
+ normalized.stringify_keys
147
+ end
148
+ @connections = value
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,35 @@
1
+ module OpenBlog
2
+ class Adopt
3
+ class Snapshot
4
+ BASELINE_FIELDS = %i[source_system source_id source_body_sha256 provenance provenance_evidence first_published_at first_published_evidence last_modified_at last_modified_evidence declared_first_published_at].freeze
5
+ APPROVAL_FIELDS = %i[kind reviewer_name facts_checked approved_at declared_on declared_by confirmed_by evidence].freeze
6
+ CONNECTION_FIELDS = %i[connections third_party_paid declared_by declared_on].freeze
7
+ POST_FIELDS = %w[slug body_format author_id category_id series_id series_position featured canonical_url external_id provenance provenance_evidence status].freeze
8
+
9
+ def self.stored(post, baseline, include_connections:)
10
+ return unless baseline
11
+ capture(post,
12
+ baseline: baseline.attributes.symbolize_keys.slice(*BASELINE_FIELDS),
13
+ approvals: post.approvals.where(revision_id: baseline.adopted_revision_id).order(:id).map { |record| record.attributes.symbolize_keys.slice(*APPROVAL_FIELDS) },
14
+ redirects: Redirect.where(post: post, source: "adoption").order(:old_path).map { |record| record.attributes.symbolize_keys.slice(:old_path, :new_path, :occurred_on) },
15
+ connections: include_connections ? latest_connections(post) : nil)
16
+ end
17
+
18
+ def self.candidate(post, contract, redirects)
19
+ approval = contract.approval && APPROVAL_FIELDS.index_with { |field| contract.approval[field] }
20
+ capture(post, baseline: contract.baseline, approvals: [ approval ].compact,
21
+ redirects: redirects, connections: contract.connections)
22
+ end
23
+
24
+ def self.latest_connections(post)
25
+ post.connection_declarations.order(:id).last&.attributes&.symbolize_keys&.slice(*CONNECTION_FIELDS)
26
+ end
27
+
28
+ def self.capture(post, baseline:, approvals:, redirects:, connections:)
29
+ { identifier: RevisionPayload.new(post).identifier, post: post.attributes.slice(*POST_FIELDS),
30
+ category_description: post.category&.description, tags: post.taggings.reject(&:marked_for_destruction?).map(&:tag_id).sort,
31
+ baseline: baseline, approvals: approvals, redirects: redirects, connections: connections }
32
+ end
33
+ end
34
+ end
35
+ end