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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 324268599e7e9a136c090a233ac46619fd15c9d329292d96d12498213e163bdc
4
+ data.tar.gz: d598e2a23b0d553c81ae2640cf0812d2e302fa5f88fa4fca08de3612e53511b8
5
+ SHA512:
6
+ metadata.gz: 0c7b6d301c115edc1930b630c2e291570225454d2947808b482f5b195c604bfdc563e00940f92d553a6e6f8a2242699f198f20af5933c517b3f4c59e0f8912af
7
+ data.tar.gz: 4f8850a75fec631002c9496fdd024999892683c5fe96e80a8d92aceb9d31ce8c4700d8f72e51b8639f9e49b3489a88d57b00a18bd2c033e66e0d173bb2e084ec
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - Publish searchable public documentation with installation, configuration, publishing, API, MCP, and customization guides.
15
+ - Prepare release pull requests that update the version and dated changelog, then publish verified packages to RubyGems and synchronize GitHub Releases.
16
+
17
+ ### Changed
18
+
19
+ - Document supported Ruby, Rails, and database versions, every configuration setting with its default, and verified Ruby, API, MCP, and reader examples.
20
+
21
+ ### Added
22
+
23
+ - Initial gem package, runtime dependencies, tests, and continuous integration.
24
+ - Add a mountable engine with validated configuration and a PostgreSQL/SQLite host test matrix.
25
+ - Add content models, reversible database migrations, deterministic revision identifiers, and immutable publishing records.
26
+ - Add transactional draft, publish, approve, unpublish, and remove operations with publication history and scheduled enqueueing.
27
+ - Record direct FAQ and rich-text changes atomically and return advisory findings and AI notice labels from publishing operations.
28
+ - Import existing articles with source identity, historical dates, declared or imported approvals, redirects, safe repeat handling, and transactional dry runs.
29
+ - Reuse signed Active Storage blobs for imported images and extract proposed FAQ sections from Markdown with review findings.
30
+ - Render sanitized Markdown and rich text with heading links, syntax highlighting, image figures, body findings, durable image identities, and generated light/dark syntax colors.
31
+ - Add reader routes and copied templates for the layout, header, footer, sidebar, featured and ordinary cards, pagination, breadcrumbs, sharing, author box, call to action, related posts, post highlights, table of contents, tags, index, post, category, tag, author, and missing-page views.
32
+ - Add reader metadata, structured data, publication dates, notices, Atom feeds, sitemap entries, redirects, and stable original-image delivery.
33
+ - Add responsive light/dark themes, configurable design tokens, accessible syntax colors, and a reproducible compiled stylesheet.
34
+ - Add theme selection, link and code copying, device sharing, table-of-contents tracking, reading progress, and browser accessibility checks.
35
+ - Render ordered FAQ records as visible plain text and FAQ structured data, with a shared table-of-contents anchor and Markdown text output.
36
+ - Add install and views generators, an idempotent sample article, installation diagnostics, and fresh-application compatibility checks.
37
+ - Add scoped API tokens and authenticated JSON endpoints for posts, approvals, connection declarations, publishing history, findings, and installation checks.
38
+ - Add bounded remote image imports, deduplicated uploads, supporting-record APIs, adoption previews, FAQ extraction, redirect serialization, and one-time installer tokens.
39
+ - Add stateless MCP publishing tools, shared API validation, revision-bound preview links, and packaged agent workflows.
40
+ - Add editable policy pages with scoped API and MCP access, public rendering, footer and sitemap links, and live notice and diagnostic updates.
41
+ - Add series navigation, ranked reader and API search, live search suggestions, Markdown responses, and JSON Feed 1.1.
42
+ - Execute due publication schedules safely, count anonymous daily page views, expose view reports and a popular-post sidebar, and check internal blog links.
43
+ - Add page and publishing-record reports with JSON, text, MCP, and Rails-task access, plus optional bounded reach checks.
44
+ - Add an optional AdminSuite generator with isolated resources, nested FAQ editing, and read-only publishing-history panels.
45
+
46
+ ### Upgrade notes
47
+
48
+ - This is the first packaged release. Existing Git-source installations should run `bin/rails open_blog:install:migrations` and `bin/rails db:migrate` before restarting application and job processes.
49
+ - Reader templates are copied into the host. Run `bin/rails generate open_blog:views` to review updates; preserve local customizations and deliberately accept replacements. Refresh installed browser controllers with the install generator when upgrading from an earlier development checkout.
50
+ - Configure the public origin, publisher identity, policy pages, storage, and authentication for your deployment. The sample article is demonstration content and has no human approval record.
51
+ - Applications writing through bulk SQL must move to normal model saves or publishing operations to retain revision history.
52
+
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TechWright Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # Open Blog
2
+
3
+ A Rails blog engine with Markdown and rich-text articles, responsive reader pages, revision history, and publishing through Ruby, a JSON API, or MCP tools. Every publish, edit, approval, and removal leaves an immutable record, so a reader can trust the dates and notices on the page and an agent can publish without inventing a review.
4
+
5
+ [Documentation](https://techwright-lab.github.io/open-blog/) · [API reference](https://techwright-lab.github.io/open-blog/api/) · [Changelog](CHANGELOG.md)
6
+
7
+ ## What you get
8
+
9
+ - **Reader pages** with light and dark themes, categories, tags, authors, series, search, Atom and JSON feeds, a sitemap, and structured data. The templates are copied into your app so you can change them.
10
+ - **Publishing operations** in Ruby that compute a revision identifier from the content, record each release, and refuse a public edit that does not say whether it is substantive, a correction, or maintenance.
11
+ - **A JSON API and an MCP endpoint** with scoped tokens, so editorial tools and AI agents publish through the same guarded path. Six agent workflows ship in the gem.
12
+ - **Approvals, provenance, and labels.** A post carries who wrote it, who reviewed which revision, and whether facts were checked. The AI notice on the page follows those records.
13
+ - **Adoption** of an existing blog with its known history, dates, and redirects, without claiming a new first publication.
14
+ - **Policy pages, page views, diagnostics, and surface reports** that check what readers actually see against the stored records.
15
+ - **Optional AdminSuite resources** for a browser-based editor.
16
+
17
+ ## Requirements
18
+
19
+ | Requirement | Supported |
20
+ | --- | --- |
21
+ | Ruby | 3.2, 3.3, 3.4, or 4.0 |
22
+ | Rails | 8.0 or 8.1 |
23
+ | Database | PostgreSQL or SQLite |
24
+ | Images | Active Storage with libvips or ImageMagick |
25
+
26
+ Every combination of Ruby, Rails, and database above runs in CI.
27
+
28
+ ## Install
29
+
30
+ Install from GitHub until a version is published on RubyGems:
31
+
32
+ ```sh
33
+ bundle add open_blog --github techwright-lab/open-blog
34
+ bin/rails generate open_blog:install
35
+ ```
36
+
37
+ Start `bin/dev` (or `bin/rails server`) and open `/blog`. The generator installs tables, mounts the engine at `/blog`, copies the reader views and browser controllers, writes `config/initializers/open_blog.rb`, and publishes a sample article. If it prints an API token, save it: the secret is shown once.
38
+
39
+ Set your site name, publisher, and public origin in the initializer, then run `bin/rails open_blog:doctor` to check the setup. The [installation guide](https://techwright-lab.github.io/open-blog/installation/) lists every generator option, and the [configuration reference](https://techwright-lab.github.io/open-blog/configuration/) lists every setting with its default.
40
+
41
+ ![Default blog in the light theme](.github/images/reader-light.png)
42
+
43
+ ![Default blog in the dark theme](.github/images/reader-dark.png)
44
+
45
+ ## Publish a post
46
+
47
+ From Ruby:
48
+
49
+ ```ruby
50
+ result = OpenBlog::Publish.call(
51
+ { title: "Garden notes", body: "Today in the garden.", provenance: "human_written" },
52
+ actor: "Editor"
53
+ )
54
+ result.success? # => true
55
+ result.post.path # => "/blog/garden-notes"
56
+ result.records # => { revision: "new", publication: "first", approval: nil }
57
+ ```
58
+
59
+ From the JSON API, with a token from `bin/rails open_blog:token`:
60
+
61
+ ```sh
62
+ curl -s -X POST https://example.com/blog/api/v1/posts \
63
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
64
+ -d '{"title":"Garden notes","body":"Today in the garden.","publish":true}'
65
+ ```
66
+
67
+ From an agent, point an MCP client at `https://example.com/blog/mcp` with the same token. The `blog_save_draft` tool returns a preview link, and `blog_publish_post` records the release. Agents must show the exact text to a person and record only the approval they actually give; the [publishing instructions](https://techwright-lab.github.io/open-blog/mcp/#publishing-instructions) spell this out.
68
+
69
+ A later edit of a public post must carry `change: "substantive"`, `"correction"`, or `"maintenance"`. Without it the write is refused and nothing changes. Normal model saves and the operations keep this history; bulk SQL writes bypass it.
70
+
71
+ ## Guides
72
+
73
+ - [Configuration](https://techwright-lab.github.io/open-blog/configuration/): identity, routes, content gates, images, limits, and page views.
74
+ - [Publishing](https://techwright-lab.github.io/open-blog/publishing/): operations, previews, scheduling, approvals, and findings.
75
+ - [JSON API](https://techwright-lab.github.io/open-blog/api/) and [MCP](https://techwright-lab.github.io/open-blog/mcp/): endpoints, tools, examples, and error codes.
76
+ - [Reader pages and feeds](https://techwright-lab.github.io/open-blog/reader/): routes, search, feeds, dates, images, and policy pages.
77
+ - [Adoption](https://techwright-lab.github.io/open-blog/adoption/): import existing articles and preserve their known history.
78
+ - [Customization](https://techwright-lab.github.io/open-blog/customization/): copied templates, themes, and browser controllers.
79
+ - [AdminSuite](https://techwright-lab.github.io/open-blog/admin-suite/), [Diagnostics](https://techwright-lab.github.io/open-blog/diagnostics/), and [Page views](https://techwright-lab.github.io/open-blog/analytics/).
80
+
81
+ ## Development
82
+
83
+ The gem tests run through a dummy host application. Set `DB` to `sqlite` or `postgres`:
84
+
85
+ ```sh
86
+ bundle install
87
+ DB=sqlite RAILS_ENV=test bundle exec ruby bin/test
88
+ bundle exec rubocop
89
+ ```
90
+
91
+ CI runs the matrix of Ruby 3.2 to 4.0, Rails 8.0 and 8.1, and both databases, plus browser tests and a fresh-application installer check with the built gem.
92
+
93
+ The documentation is Markdown in `docs/`, built with Jekyll and Just the Docs from a separate bundle. See [working on the documentation](https://techwright-lab.github.io/open-blog/contributing/) for local preview and validation commands.
94
+
95
+ ## Releases
96
+
97
+ The **Prepare Release** workflow updates the version and changelog in a pull request. After review, merge, and successful checks, **Publish** publishes the verified package to RubyGems and creates or updates the GitHub Release. Merging ordinary changes does not publish a gem. See the [release guide](https://techwright-lab.github.io/open-blog/releases/) for the first-time Trusted Publishing setup.
98
+
99
+ ## License
100
+
101
+ Licensed under the [MIT License](LICENSE.txt).
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.hidden{display:none}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}}.ob-page{background:var(--ob-surface);color:var(--ob-text);font-family:var(--ob-font-body);overflow-wrap:anywhere;margin:0;line-height:1.6}.ob-page *,.ob-page :before,.ob-page :after{box-sizing:border-box}.ob-page [hidden]{display:none!important}.ob-page a{color:var(--ob-accent);text-underline-offset:.2em}.ob-page a:hover{color:var(--ob-accent-hover)}.ob-page a:focus-visible,.ob-page button:focus-visible,.ob-page input:focus-visible,.ob-page .ob-table:focus-visible,.ob-page .ob-prose pre:focus-visible{outline:3px solid var(--ob-accent);outline-offset:4px}.ob-page h1,.ob-page h2,.ob-page h3,.ob-page h4,.ob-page h5,.ob-page h6{color:var(--ob-heading);font-family:var(--ob-font-display);text-wrap:balance;font-weight:750;line-height:1.25}.ob-page h1{letter-spacing:-.035em;font-size:clamp(1.875rem,4vw,3rem)}.ob-page h2{font-size:1.5rem}.ob-page h3{font-size:1.25rem}.ob-page h4,.ob-page h5,.ob-page h6{font-size:1.125rem}.ob-page img{max-width:100%;height:auto}.ob-page button{cursor:pointer;font:inherit}.ob-page button:disabled{cursor:default}.ob-main{max-width:var(--ob-page-width);margin:auto;padding:3rem 1rem}.ob-header,.ob-footer{max-width:var(--ob-page-width);flex-wrap:wrap;align-items:center;gap:1rem;margin:auto;padding:1.25rem 1rem;display:flex}.ob-header{border-bottom:1px solid var(--ob-border)}.ob-header nav{margin-left:auto}.ob-site-name{font-size:1.125rem;font-weight:800;text-decoration:none}.ob-footer{border-top:1px solid var(--ob-border);font-size:.875rem}.ob-footer nav{flex-wrap:wrap;gap:1rem;display:flex}.ob-index-heading,.ob-list-heading{margin-bottom:1.5rem}.ob-index-heading h1{font-size:2.25rem;font-weight:800}.ob-tagline,.ob-list-description,.ob-lede{color:var(--ob-text-muted);margin-top:.75rem;font-size:1.125rem}.ob-list-layout{gap:2rem;display:grid}.ob-list-main,.ob-sidebar,.ob-post-card,.ob-featured-details{min-width:0}.ob-search{margin-bottom:1.5rem}.ob-search label{margin-bottom:.5rem;font-weight:700;display:block}.ob-search-controls{gap:.5rem;display:flex}.ob-search input{border:1px solid var(--ob-border-strong);background:var(--ob-surface);width:100%;min-width:0;color:var(--ob-text);font:inherit;border-radius:.5rem;padding:.5rem}.ob-search button{white-space:nowrap;background:var(--ob-accent);color:var(--ob-accent-contrast);border-radius:.5rem;flex-shrink:0;padding:.5rem .75rem}.ob-search-results{border:1px solid var(--ob-border);background:var(--ob-surface-raised);border-radius:.5rem;margin-top:.75rem}.ob-sidebar .ob-search-results a{border-radius:.5rem;display:block}.ob-search-results a:hover{background:var(--ob-accent-soft)}.ob-series-nav{border:1px solid var(--ob-border);border-radius:.75rem;margin:2rem 0;padding:1.25rem}.ob-series-nav h2{margin-bottom:.75rem;font-size:1.125rem}.ob-series-nav p+p{margin-top:.5rem}.ob-sidebar h2{margin-bottom:.75rem;font-size:1rem}.ob-sidebar-categories ul{gap:.5rem;padding-bottom:.5rem;display:flex;overflow-x:auto}.ob-sidebar-categories li{flex-shrink:0}.ob-sidebar a{border-radius:var(--ob-radius-pill);padding:.4rem .8rem;text-decoration:none;display:block}.ob-sidebar a[aria-current=page]{color:var(--ob-accent-contrast);background:var(--ob-accent)}.ob-sidebar-popular{margin-top:1.5rem}.ob-sidebar-popular li+li{margin-top:.25rem}.ob-sidebar-tags,.ob-count{display:none}.ob-post-grid{grid-template-columns:minmax(0,1fr);gap:2rem;display:grid}.ob-card-media{border-radius:var(--ob-radius-media);display:block;overflow:hidden}.ob-card-image,.ob-card-placeholder{aspect-ratio:16/9;object-fit:cover;width:100%}.ob-card-image{transition:transform .18s}.ob-card-media:hover .ob-card-image{transform:scale(1.05)}.ob-card-placeholder{background:linear-gradient(135deg, var(--ob-accent-soft), var(--ob-surface-sunken))}.ob-card-category,.ob-post-category{text-transform:uppercase;letter-spacing:.07em;margin-top:1rem;font-size:.8rem;font-weight:750;display:inline-block}.ob-card-title{margin:.5rem 0}.ob-card-title a,.ob-featured-details h2 a{text-decoration:none}.ob-card-title,.ob-card-description{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.ob-card-title{-webkit-line-clamp:2}.ob-card-description{-webkit-line-clamp:2;color:var(--ob-text-muted);margin:.75rem 0}.ob-card-author{align-items:center;gap:.5rem;margin-top:1rem;display:flex}.ob-card-author a{color:var(--ob-text)}.ob-avatar{object-fit:cover;vertical-align:middle;border-radius:50%;flex:0 0 2.5rem;justify-content:center;align-items:center;width:2.5rem;height:2.5rem;display:inline-flex}.ob-avatar--initials{background:var(--ob-accent-soft);color:var(--ob-heading);font-weight:750}.ob-date,.ob-reading-time{color:var(--ob-text-subtle);margin:.35rem .75rem .35rem 0;font-size:.875rem;display:inline-block}.ob-featured-card{border:1px solid var(--ob-border);border-top:4px solid var(--ob-accent);border-radius:var(--ob-radius-card);min-width:0;box-shadow:var(--ob-shadow);margin-bottom:2rem;display:grid;overflow:hidden}.ob-featured-card:hover{border-color:var(--ob-accent)}.ob-featured-card .ob-card-media{border-radius:0}.ob-featured-details{padding:1.25rem}.ob-featured-details .ob-card-description{-webkit-line-clamp:3}.ob-featured-label{color:var(--ob-accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem;font-size:.8rem;font-weight:750}.ob-pagination{flex-wrap:wrap;justify-content:center;gap:.5rem;margin-top:2.5rem;display:flex}.ob-pagination-link,.ob-pagination-current{border-radius:var(--ob-radius-pill);justify-content:center;align-items:center;min-width:2.5rem;min-height:2.5rem;text-decoration:none;display:inline-flex}.ob-pagination-current{color:var(--ob-accent-contrast);background:var(--ob-accent)}.ob-breadcrumbs ol{flex-wrap:wrap;gap:.5rem;margin-bottom:1.5rem;font-size:.875rem;display:flex}.ob-breadcrumbs li+li:before{content:"/";color:var(--ob-text-subtle);margin-right:.5rem}.ob-post{max-width:var(--ob-content-width);margin:auto}.ob-post-title{margin:.75rem 0 1rem;font-weight:800;line-height:1.15}.ob-post-category{margin-top:0}.ob-byline{margin:1rem 0 1.5rem}.ob-byline-author{font-weight:700}.ob-cover{margin:1.5rem -1rem}.ob-cover img{aspect-ratio:2;object-fit:cover;width:100%}.ob-highlights{border:1px solid var(--ob-border);border-radius:var(--ob-radius-card);background:var(--ob-surface-raised);margin:1.5rem 0;padding:1rem}.ob-highlights h2{margin-bottom:.5rem;font-size:1rem}.ob-highlights ul{flex-wrap:wrap;gap:.25rem 1rem;display:flex}.ob-notice{background:var(--ob-notice-bg);color:var(--ob-notice-text);border:1px solid var(--ob-notice-border);border-radius:var(--ob-radius-card);margin:1rem 0;padding:1rem 1.25rem}.ob-notice h2{color:inherit;margin-bottom:.5rem;font-size:1.125rem}.ob-notice ul{padding-left:1.25rem;list-style:outside}.ob-correction time{display:inline-block}.ob-toc{border-left:3px solid var(--ob-accent);background:var(--ob-surface-raised);border-radius:0 var(--ob-radius-card) var(--ob-radius-card) 0;margin:2rem 0;padding:1rem 1.25rem}.ob-toc h2{margin-bottom:.75rem;font-size:1rem}.ob-toc-item{margin:.35rem 0}.ob-toc-item--h3{padding-left:1rem}.ob-toc a[aria-current=location]{color:var(--ob-heading);font-weight:800;text-decoration-thickness:.15em}.ob-prose{font-size:var(--ob-prose-size);line-height:var(--ob-prose-leading)}.ob-prose>*{margin-block:1.25rem}.ob-prose h2{border-bottom:1px solid var(--ob-border);margin-top:2.5rem;padding-bottom:.5rem}.ob-prose h3,.ob-prose h4{margin-top:2rem}.ob-prose :is(h2,h3,h4,h5,h6){scroll-margin-top:2rem}.ob-heading-anchor{margin-left:.4em;font-size:.8em;text-decoration:none}.ob-prose ul,.ob-prose ol{padding-left:1.5rem}.ob-prose ul{list-style:outside}.ob-prose ol{list-style:decimal}.ob-prose li::marker{color:var(--ob-accent)}.ob-prose li+li{margin-top:.35rem}.ob-prose input[type=checkbox]{margin-right:.4rem}.ob-prose blockquote{border-left:3px solid var(--ob-accent);background:var(--ob-accent-soft);border-radius:0 var(--ob-radius-card) var(--ob-radius-card) 0;padding:.75rem 1.25rem}.ob-prose :not(pre)>code{background:var(--ob-surface-sunken);font-size:.9em;font-family:var(--ob-font-mono);border-radius:.3rem;padding:.15em .3em}.ob-prose pre{background:var(--ob-code-bg);border:1px solid var(--ob-code-border);border-radius:var(--ob-radius-card);overflow-wrap:normal;max-width:100%;padding:3.25rem 1rem 1rem;font-size:.9rem;line-height:1.6;position:relative;overflow-x:auto}.ob-prose .ob-highlight .w{background-color:#0000}.ob-prose pre code{width:max-content;min-width:100%;font-family:var(--ob-font-mono);display:block}.ob-code-language{color:var(--ob-text-subtle);font-family:var(--ob-font-body);font-size:.8rem;position:absolute;top:.65rem;left:1rem}.ob-code-copy{position:absolute;top:.5rem;right:.65rem}.ob-code-copy,.ob-theme-toggle,.ob-share-button{background:var(--ob-surface-raised);min-height:2.5rem;color:var(--ob-text);border:1px solid var(--ob-border-strong);border-radius:var(--ob-radius-pill);justify-content:center;align-items:center;gap:.4rem;padding:.4rem .75rem;display:inline-flex}.ob-icon{flex-shrink:0;width:1.125rem;height:1.125rem}.ob-prose hr{background:linear-gradient(to right, var(--ob-border), var(--ob-accent), var(--ob-border));border:0;height:1px;margin:2.5rem 0}.ob-figure{margin-inline:0}.ob-figure img{border-radius:var(--ob-radius-media)}.ob-figure figcaption{color:var(--ob-text-muted);margin-top:.5rem;font-size:.9rem}.ob-table{border:1px solid var(--ob-border);border-radius:var(--ob-radius-media);max-width:100%;overflow-x:auto}.ob-table table{border-collapse:collapse;width:max-content;min-width:100%;font-size:.95rem}.ob-table th,.ob-table td{text-align:left;border-bottom:1px solid var(--ob-border);padding:.65rem 1rem}.ob-table th{background:var(--ob-surface-raised);color:var(--ob-heading);font-weight:700}.ob-prose .markdown-alert{border-left:4px solid var(--ob-note);background:var(--ob-surface-raised);border-radius:0 var(--ob-radius-card) var(--ob-radius-card) 0;padding:1rem 1.25rem}.ob-prose .markdown-alert-title{color:var(--ob-note);margin-bottom:.5rem;font-weight:750}.ob-prose .markdown-alert-tip{border-color:var(--ob-tip)}.ob-prose .markdown-alert-tip .markdown-alert-title{color:var(--ob-tip)}.ob-prose .markdown-alert-warning,.ob-prose .markdown-alert-caution{border-color:var(--ob-warning)}.ob-prose .markdown-alert-warning .markdown-alert-title,.ob-prose .markdown-alert-caution .markdown-alert-title{color:var(--ob-warning)}.ob-prose--dropcap>p:first-of-type:first-letter{float:left;color:var(--ob-accent);padding-right:.1em;font-size:3.5em;line-height:.9}.ob-faq{border-top:1px solid var(--ob-border);min-width:0;margin-block:2.5rem;padding-top:2rem}.ob-faq>h2{margin-bottom:1.5rem;scroll-margin-top:2rem}.ob-faq-entry+.ob-faq-entry{border-top:1px solid var(--ob-border);margin-top:1.5rem;padding-top:1.5rem}.ob-faq-entry h3{margin-bottom:.75rem}.ob-faq-entry p{font-size:var(--ob-prose-size);line-height:var(--ob-prose-leading);overflow-wrap:anywhere}.ob-faq-entry p+p{margin-top:1rem}.ob-faq-entry a{text-decoration:underline}.ob-tags,.ob-author-profiles,.ob-share{flex-wrap:wrap;align-items:center;gap:.75rem;margin:1.5rem 0;display:flex}.ob-tags a{border-radius:var(--ob-radius-pill);background:var(--ob-accent-soft);padding:.25rem .75rem}.ob-share-status{color:var(--ob-text-muted);font-size:.875rem}.ob-author-box,.ob-call-to-action{background:var(--ob-surface-raised);border:1px solid var(--ob-border);border-radius:var(--ob-radius-card);margin-block:2rem;padding:1.5rem}.ob-author-box h2,.ob-call-to-action h2{margin-bottom:.75rem}.ob-author-bio{margin-top:.75rem}.ob-related-posts,.ob-recent-posts{margin-top:3rem}.ob-related-posts>h2,.ob-recent-posts>h2{margin-bottom:1.5rem}.ob-empty{color:var(--ob-text-muted);padding-block:2rem}.ob-reading-progress{z-index:50;background:var(--ob-border);pointer-events:none;height:3px;position:fixed;inset:0 0 auto}.ob-reading-progress-fill{background:var(--ob-accent);width:0;height:100%}@media (min-width:40rem){.ob-post-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.ob-featured-card{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ob-featured-card .ob-card-image,.ob-featured-card .ob-card-placeholder{height:100%}.ob-cover{margin-inline:0}.ob-cover img{border-radius:var(--ob-radius-media)}}@media (min-width:64rem){.ob-list-layout{grid-template-columns:14rem minmax(0,1fr)}.ob-sidebar{align-self:start;position:sticky;top:2rem}.ob-sidebar-categories ul{display:block;overflow:visible}.ob-sidebar-categories li{margin-bottom:.25rem}.ob-sidebar a{justify-content:space-between;gap:.75rem;display:flex}.ob-sidebar-tags{margin-top:1.5rem;display:block}.ob-sidebar-tags ul{flex-wrap:wrap;gap:.5rem;display:flex}.ob-count{display:inline}.ob-related-posts .ob-post-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-reduced-motion:reduce){.ob-card-image{transition:none}.ob-card-media:hover .ob-card-image{transform:none}}.ob-highlight table td{padding:5px}.ob-highlight table pre{margin:0}.ob-highlight,.ob-highlight .w{color:#24292f;background-color:#f6f8fa}.ob-highlight .k,.ob-highlight .kd,.ob-highlight .kn,.ob-highlight .kp,.ob-highlight .kr,.ob-highlight .kt,.ob-highlight .kv{color:#cf222e}.ob-highlight .gd{color:#82071e;background-color:#ffebe9}.ob-highlight .nb,.ob-highlight .nc,.ob-highlight .no,.ob-highlight .nn{color:#953800}.ob-highlight .sr,.ob-highlight .na,.ob-highlight .nt{color:#116329}.ob-highlight .gi{color:#116329;background-color:#dafbe1}.ob-highlight .ges{font-style:italic;font-weight:700}.ob-highlight .kc,.ob-highlight .l,.ob-highlight .ld,.ob-highlight .m,.ob-highlight .mb,.ob-highlight .mf,.ob-highlight .mh,.ob-highlight .mi,.ob-highlight .il,.ob-highlight .mo,.ob-highlight .mx,.ob-highlight .sb,.ob-highlight .bp,.ob-highlight .ne,.ob-highlight .nl,.ob-highlight .py,.ob-highlight .nv,.ob-highlight .vc,.ob-highlight .vg,.ob-highlight .vi,.ob-highlight .vm,.ob-highlight .o,.ob-highlight .ow{color:#0550ae}.ob-highlight .gh,.ob-highlight .gu{color:#0550ae;font-weight:700}.ob-highlight .s,.ob-highlight .sa,.ob-highlight .sc,.ob-highlight .dl,.ob-highlight .sd,.ob-highlight .s2,.ob-highlight .se,.ob-highlight .sh,.ob-highlight .sx,.ob-highlight .s1,.ob-highlight .ss{color:#0a3069}.ob-highlight .nd,.ob-highlight .nf,.ob-highlight .fm{color:#8250df}.ob-highlight .err{color:#f6f8fa;background-color:#82071e}.ob-highlight .ni,.ob-highlight .si{color:#24292f}.ob-highlight .ge{color:#24292f;font-style:italic}.ob-highlight .gs{color:#24292f;font-weight:700}[data-theme=dark] .ob-highlight table td{padding:5px}[data-theme=dark] .ob-highlight table pre{margin:0}[data-theme=dark] .ob-highlight,[data-theme=dark] .ob-highlight .w{color:#c9d1d9;background-color:#161b22}[data-theme=dark] .ob-highlight .k,[data-theme=dark] .ob-highlight .kd,[data-theme=dark] .ob-highlight .kn,[data-theme=dark] .ob-highlight .kp,[data-theme=dark] .ob-highlight .kr,[data-theme=dark] .ob-highlight .kt,[data-theme=dark] .ob-highlight .kv{color:#ff7b72}[data-theme=dark] .ob-highlight .gr{color:#f0f6fc}[data-theme=dark] .ob-highlight .gd{color:#ffdcd7;background-color:#67060c}[data-theme=dark] .ob-highlight .nb,[data-theme=dark] .ob-highlight .nc,[data-theme=dark] .ob-highlight .no,[data-theme=dark] .ob-highlight .nn{color:#ffa657}[data-theme=dark] .ob-highlight .sr,[data-theme=dark] .ob-highlight .na,[data-theme=dark] .ob-highlight .nt{color:#7ee787}[data-theme=dark] .ob-highlight .gi{color:#aff5b4;background-color:#033a16}[data-theme=dark] .ob-highlight .ges{font-style:italic;font-weight:700}[data-theme=dark] .ob-highlight .kc,[data-theme=dark] .ob-highlight .l,[data-theme=dark] .ob-highlight .ld,[data-theme=dark] .ob-highlight .m,[data-theme=dark] .ob-highlight .mb,[data-theme=dark] .ob-highlight .mf,[data-theme=dark] .ob-highlight .mh,[data-theme=dark] .ob-highlight .mi,[data-theme=dark] .ob-highlight .il,[data-theme=dark] .ob-highlight .mo,[data-theme=dark] .ob-highlight .mx,[data-theme=dark] .ob-highlight .sb,[data-theme=dark] .ob-highlight .bp,[data-theme=dark] .ob-highlight .ne,[data-theme=dark] .ob-highlight .nl,[data-theme=dark] .ob-highlight .py,[data-theme=dark] .ob-highlight .nv,[data-theme=dark] .ob-highlight .vc,[data-theme=dark] .ob-highlight .vg,[data-theme=dark] .ob-highlight .vi,[data-theme=dark] .ob-highlight .vm,[data-theme=dark] .ob-highlight .o,[data-theme=dark] .ob-highlight .ow{color:#79c0ff}[data-theme=dark] .ob-highlight .gh,[data-theme=dark] .ob-highlight .gu{color:#1f6feb;font-weight:700}[data-theme=dark] .ob-highlight .s,[data-theme=dark] .ob-highlight .sa,[data-theme=dark] .ob-highlight .sc,[data-theme=dark] .ob-highlight .dl,[data-theme=dark] .ob-highlight .sd,[data-theme=dark] .ob-highlight .s2,[data-theme=dark] .ob-highlight .se,[data-theme=dark] .ob-highlight .sh,[data-theme=dark] .ob-highlight .sx,[data-theme=dark] .ob-highlight .s1,[data-theme=dark] .ob-highlight .ss{color:#a5d6ff}[data-theme=dark] .ob-highlight .nd,[data-theme=dark] .ob-highlight .nf,[data-theme=dark] .ob-highlight .fm{color:#d2a8ff}[data-theme=dark] .ob-highlight .err{color:#f0f6fc;background-color:#8e1519}[data-theme=dark] .ob-highlight .c,[data-theme=dark] .ob-highlight .ch,[data-theme=dark] .ob-highlight .cd,[data-theme=dark] .ob-highlight .cm,[data-theme=dark] .ob-highlight .cp,[data-theme=dark] .ob-highlight .cpf,[data-theme=dark] .ob-highlight .c1,[data-theme=dark] .ob-highlight .cs,[data-theme=dark] .ob-highlight .gl,[data-theme=dark] .ob-highlight .gt{color:#8b949e}[data-theme=dark] .ob-highlight .ni,[data-theme=dark] .ob-highlight .si{color:#c9d1d9}[data-theme=dark] .ob-highlight .ge{color:#c9d1d9;font-style:italic}[data-theme=dark] .ob-highlight .gs{color:#c9d1d9;font-weight:700}@media (prefers-color-scheme:dark){html:not([data-theme=light]) .ob-highlight table td{padding:5px}html:not([data-theme=light]) .ob-highlight table pre{margin:0}html:not([data-theme=light]) .ob-highlight,html:not([data-theme=light]) .ob-highlight .w{color:#c9d1d9;background-color:#161b22}html:not([data-theme=light]) .ob-highlight .k,html:not([data-theme=light]) .ob-highlight .kd,html:not([data-theme=light]) .ob-highlight .kn,html:not([data-theme=light]) .ob-highlight .kp,html:not([data-theme=light]) .ob-highlight .kr,html:not([data-theme=light]) .ob-highlight .kt,html:not([data-theme=light]) .ob-highlight .kv{color:#ff7b72}html:not([data-theme=light]) .ob-highlight .gr{color:#f0f6fc}html:not([data-theme=light]) .ob-highlight .gd{color:#ffdcd7;background-color:#67060c}html:not([data-theme=light]) .ob-highlight .nb,html:not([data-theme=light]) .ob-highlight .nc,html:not([data-theme=light]) .ob-highlight .no,html:not([data-theme=light]) .ob-highlight .nn{color:#ffa657}html:not([data-theme=light]) .ob-highlight .sr,html:not([data-theme=light]) .ob-highlight .na,html:not([data-theme=light]) .ob-highlight .nt{color:#7ee787}html:not([data-theme=light]) .ob-highlight .gi{color:#aff5b4;background-color:#033a16}html:not([data-theme=light]) .ob-highlight .ges{font-style:italic;font-weight:700}html:not([data-theme=light]) .ob-highlight .kc,html:not([data-theme=light]) .ob-highlight .l,html:not([data-theme=light]) .ob-highlight .ld,html:not([data-theme=light]) .ob-highlight .m,html:not([data-theme=light]) .ob-highlight .mb,html:not([data-theme=light]) .ob-highlight .mf,html:not([data-theme=light]) .ob-highlight .mh,html:not([data-theme=light]) .ob-highlight .mi,html:not([data-theme=light]) .ob-highlight .il,html:not([data-theme=light]) .ob-highlight .mo,html:not([data-theme=light]) .ob-highlight .mx,html:not([data-theme=light]) .ob-highlight .sb,html:not([data-theme=light]) .ob-highlight .bp,html:not([data-theme=light]) .ob-highlight .ne,html:not([data-theme=light]) .ob-highlight .nl,html:not([data-theme=light]) .ob-highlight .py,html:not([data-theme=light]) .ob-highlight .nv,html:not([data-theme=light]) .ob-highlight .vc,html:not([data-theme=light]) .ob-highlight .vg,html:not([data-theme=light]) .ob-highlight .vi,html:not([data-theme=light]) .ob-highlight .vm,html:not([data-theme=light]) .ob-highlight .o,html:not([data-theme=light]) .ob-highlight .ow{color:#79c0ff}html:not([data-theme=light]) .ob-highlight .gh,html:not([data-theme=light]) .ob-highlight .gu{color:#1f6feb;font-weight:700}html:not([data-theme=light]) .ob-highlight .s,html:not([data-theme=light]) .ob-highlight .sa,html:not([data-theme=light]) .ob-highlight .sc,html:not([data-theme=light]) .ob-highlight .dl,html:not([data-theme=light]) .ob-highlight .sd,html:not([data-theme=light]) .ob-highlight .s2,html:not([data-theme=light]) .ob-highlight .se,html:not([data-theme=light]) .ob-highlight .sh,html:not([data-theme=light]) .ob-highlight .sx,html:not([data-theme=light]) .ob-highlight .s1,html:not([data-theme=light]) .ob-highlight .ss{color:#a5d6ff}html:not([data-theme=light]) .ob-highlight .nd,html:not([data-theme=light]) .ob-highlight .nf,html:not([data-theme=light]) .ob-highlight .fm{color:#d2a8ff}html:not([data-theme=light]) .ob-highlight .err{color:#f0f6fc;background-color:#8e1519}html:not([data-theme=light]) .ob-highlight .c,html:not([data-theme=light]) .ob-highlight .ch,html:not([data-theme=light]) .ob-highlight .cd,html:not([data-theme=light]) .ob-highlight .cm,html:not([data-theme=light]) .ob-highlight .cp,html:not([data-theme=light]) .ob-highlight .cpf,html:not([data-theme=light]) .ob-highlight .c1,html:not([data-theme=light]) .ob-highlight .cs,html:not([data-theme=light]) .ob-highlight .gl,html:not([data-theme=light]) .ob-highlight .gt{color:#8b949e}html:not([data-theme=light]) .ob-highlight .ni,html:not([data-theme=light]) .ob-highlight .si{color:#c9d1d9}html:not([data-theme=light]) .ob-highlight .ge{color:#c9d1d9;font-style:italic}html:not([data-theme=light]) .ob-highlight .gs{color:#c9d1d9;font-weight:700}}.ob-highlight .gr{color:#cf222e}.ob-highlight .c,.ob-highlight .ch,.ob-highlight .cd,.ob-highlight .cm,.ob-highlight .cp,.ob-highlight .cpf,.ob-highlight .c1,.ob-highlight .cs,.ob-highlight .gl,.ob-highlight .gt{color:#57606a}[data-theme=dark] .ob-highlight .gh,[data-theme=dark] .ob-highlight .gu{color:#79c0ff}@media (prefers-color-scheme:dark){html:not([data-theme=light]) .ob-highlight .gh,html:not([data-theme=light]) .ob-highlight .gu{color:#79c0ff}}:root{--ob-surface:#fff;--ob-surface-raised:#f8fafc;--ob-surface-sunken:#f1f5f9;--ob-heading:#0f172a;--ob-text:#334155;--ob-text-muted:#475569;--ob-text-subtle:#526176;--ob-border:#cbd5e1;--ob-border-strong:#64748b;--ob-accent:#1d4ed8;--ob-accent-hover:#1e40af;--ob-accent-soft:#dbeafe;--ob-accent-contrast:#fff;--ob-code-bg:#f6f8fa;--ob-code-border:#94a3b8;--ob-notice-bg:#eff6ff;--ob-notice-border:#60a5fa;--ob-notice-text:#1e3a8a;--ob-note:#1d4ed8;--ob-tip:#166534;--ob-warning:#854d0e;--ob-shadow:0 8px 24px #0f172a1a;--ob-font-display:ui-sans-serif, system-ui, sans-serif;--ob-font-body:ui-sans-serif, system-ui, sans-serif;--ob-font-mono:ui-monospace, SFMono-Regular, Consolas, monospace;--ob-radius-card:1rem;--ob-radius-media:.75rem;--ob-radius-pill:999px;--ob-prose-size:1.125rem;--ob-prose-leading:1.8;--ob-content-width:56rem;--ob-page-width:72rem}:root[data-theme=dark]{--ob-surface:#0f172a;--ob-surface-raised:#1e293b;--ob-surface-sunken:#020617;--ob-heading:#f8fafc;--ob-text:#e2e8f0;--ob-text-muted:#cbd5e1;--ob-text-subtle:#94a3b8;--ob-border:#475569;--ob-border-strong:#94a3b8;--ob-accent:#93c5fd;--ob-accent-hover:#bfdbfe;--ob-accent-soft:#172554;--ob-accent-contrast:#0f172a;--ob-code-bg:#161b22;--ob-code-border:#64748b;--ob-notice-bg:#172554;--ob-notice-border:#60a5fa;--ob-notice-text:#dbeafe;--ob-note:#93c5fd;--ob-tip:#86efac;--ob-warning:#fde047}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--ob-surface:#0f172a;--ob-surface-raised:#1e293b;--ob-surface-sunken:#020617;--ob-heading:#f8fafc;--ob-text:#e2e8f0;--ob-text-muted:#cbd5e1;--ob-text-subtle:#94a3b8;--ob-border:#475569;--ob-border-strong:#94a3b8;--ob-accent:#93c5fd;--ob-accent-hover:#bfdbfe;--ob-accent-soft:#172554;--ob-accent-contrast:#0f172a;--ob-code-bg:#161b22;--ob-code-border:#64748b;--ob-notice-bg:#172554;--ob-notice-border:#60a5fa;--ob-notice-text:#dbeafe;--ob-note:#93c5fd;--ob-tip:#86efac;--ob-warning:#fde047}}:root{color-scheme:light dark}:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}
@@ -0,0 +1,40 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["code", "button"]
5
+ static values = { copiedLabel: String, errorLabel: String }
6
+
7
+ connect() {
8
+ this.connected = true
9
+ this.label = this.buttonTarget.textContent
10
+ this.accessibleLabel = this.buttonTarget.getAttribute("aria-label")
11
+ this.buttonTarget.hidden = !navigator.clipboard?.writeText
12
+ }
13
+
14
+ disconnect() {
15
+ this.connected = false
16
+ clearTimeout(this.resetTimer)
17
+ this.restore()
18
+ this.buttonTarget.hidden = true
19
+ }
20
+
21
+ async copy() {
22
+ let label
23
+ try {
24
+ await navigator.clipboard.writeText(this.codeTarget.textContent)
25
+ label = this.copiedLabelValue
26
+ } catch (_) {
27
+ label = this.errorLabelValue
28
+ }
29
+ if (!this.connected) return
30
+ clearTimeout(this.resetTimer)
31
+ this.buttonTarget.textContent = label
32
+ this.buttonTarget.setAttribute("aria-label", label)
33
+ this.resetTimer = setTimeout(() => this.restore(), 2000)
34
+ }
35
+
36
+ restore() {
37
+ this.buttonTarget.textContent = this.label
38
+ this.buttonTarget.setAttribute("aria-label", this.accessibleLabel)
39
+ }
40
+ }
@@ -0,0 +1,36 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["progress"]
5
+ static values = { contentSelector: { type: String, default: "[data-open-blog-content]" } }
6
+
7
+ connect() {
8
+ this.content = document.querySelector(this.contentSelectorValue)
9
+ if (!this.content || !this.hasProgressTarget) return
10
+ this.schedule = () => {
11
+ if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = null; this.update() })
12
+ }
13
+ window.addEventListener("scroll", this.schedule, { passive: true })
14
+ window.addEventListener("resize", this.schedule)
15
+ this.observer = new ResizeObserver(this.schedule)
16
+ this.observer.observe(this.content)
17
+ this.update()
18
+ this.element.hidden = false
19
+ }
20
+
21
+ disconnect() {
22
+ window.removeEventListener("scroll", this.schedule)
23
+ window.removeEventListener("resize", this.schedule)
24
+ this.observer?.disconnect()
25
+ cancelAnimationFrame(this.frame)
26
+ this.frame = null
27
+ this.element.hidden = true
28
+ }
29
+
30
+ update() {
31
+ const rect = this.content.getBoundingClientRect()
32
+ const distance = rect.height - window.innerHeight
33
+ const progress = distance <= 0 ? (rect.bottom <= window.innerHeight ? 1 : 0) : Math.max(0, Math.min(1, -rect.top / distance))
34
+ this.progressTarget.style.width = `${progress * 100}%`
35
+ }
36
+ }
@@ -0,0 +1,49 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "results"]
5
+ static values = { url: String }
6
+
7
+ connect() { this.sequence = (this.sequence || 0) + 1 }
8
+
9
+ disconnect() {
10
+ this.sequence++
11
+ clearTimeout(this.timer)
12
+ this.request?.abort()
13
+ }
14
+
15
+ search() {
16
+ clearTimeout(this.timer)
17
+ this.request?.abort()
18
+ const sequence = ++this.sequence
19
+ const query = this.inputTarget.value.trim()
20
+ this.resultsTarget.replaceChildren()
21
+ this.resultsTarget.hidden = true
22
+ if (Array.from(query).length < 2 || Array.from(query).length > 100) return
23
+ this.timer = setTimeout(() => this.suggest(query, sequence), 200)
24
+ }
25
+
26
+ async suggest(query, sequence) {
27
+ this.request = new AbortController()
28
+ try {
29
+ const url = new URL(this.urlValue, window.location.href)
30
+ url.searchParams.set("q", query)
31
+ const response = await fetch(url, { signal: this.request.signal, headers: { Accept: "application/json" } })
32
+ if (!response.ok) return
33
+ const results = await response.json()
34
+ if (sequence !== this.sequence || !Array.isArray(results)) return
35
+ const items = results.map(result => {
36
+ const item = document.createElement("li")
37
+ const link = document.createElement("a")
38
+ link.href = result.url
39
+ link.textContent = result.title
40
+ item.append(link)
41
+ return item
42
+ })
43
+ this.resultsTarget.replaceChildren(...items)
44
+ this.resultsTarget.hidden = items.length === 0
45
+ } catch (_) {
46
+ // The ordinary GET form remains available when suggestions cannot load.
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["device", "copy", "status"]
5
+ static values = { url: String, title: String, copiedLabel: String, copyLabel: String, errorLabel: String }
6
+
7
+ connect() {
8
+ this.connected = true
9
+ if (this.hasDeviceTarget) this.deviceTarget.hidden = typeof navigator.share !== "function"
10
+ if (this.hasCopyTarget) this.copyTarget.hidden = !navigator.clipboard?.writeText
11
+ }
12
+
13
+ disconnect() {
14
+ this.connected = false
15
+ clearTimeout(this.resetTimer)
16
+ if (this.hasDeviceTarget) this.deviceTarget.hidden = true
17
+ if (this.hasCopyTarget) {
18
+ this.copyTarget.hidden = true
19
+ }
20
+ if (this.hasStatusTarget) this.statusTarget.textContent = ""
21
+ }
22
+
23
+ async share() {
24
+ try {
25
+ await navigator.share({ title: this.titleValue, url: this.urlValue })
26
+ } catch (error) {
27
+ if (error.name !== "AbortError") this.announce(this.errorLabelValue)
28
+ }
29
+ }
30
+
31
+ async copy() {
32
+ try {
33
+ await navigator.clipboard.writeText(this.urlValue)
34
+ this.announce(this.copiedLabelValue)
35
+ } catch (_) {
36
+ this.announce(this.errorLabelValue)
37
+ }
38
+ }
39
+
40
+ announce(label) {
41
+ if (!this.connected) return
42
+ clearTimeout(this.resetTimer)
43
+ if (this.hasStatusTarget) this.statusTarget.textContent = label
44
+ this.resetTimer = setTimeout(() => {
45
+ if (this.hasStatusTarget) this.statusTarget.textContent = ""
46
+ }, 2000)
47
+ }
48
+ }
@@ -0,0 +1,51 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["label"]
5
+ static values = { systemLabel: String, lightLabel: String, darkLabel: String }
6
+
7
+ connect() {
8
+ this.mode = "system"
9
+ try {
10
+ const stored = localStorage.getItem("open-blog-theme")
11
+ if (stored === "light" || stored === "dark") this.mode = stored
12
+ } catch (_) {}
13
+ this.media = window.matchMedia("(prefers-color-scheme: dark)")
14
+ this.onMediaChange = () => this.apply()
15
+ this.onStorage = (event) => {
16
+ if (event.key !== "open-blog-theme" && event.key !== null) return
17
+ this.mode = ["light", "dark"].includes(event.newValue) ? event.newValue : "system"
18
+ this.apply()
19
+ }
20
+ this.media.addEventListener("change", this.onMediaChange)
21
+ window.addEventListener("storage", this.onStorage)
22
+ this.apply()
23
+ this.element.hidden = false
24
+ }
25
+
26
+ disconnect() {
27
+ this.media?.removeEventListener("change", this.onMediaChange)
28
+ window.removeEventListener("storage", this.onStorage)
29
+ this.element.hidden = true
30
+ }
31
+
32
+ cycle() {
33
+ this.mode = { system: "light", light: "dark", dark: "system" }[this.mode]
34
+ try {
35
+ if (this.mode === "system") localStorage.removeItem("open-blog-theme")
36
+ else localStorage.setItem("open-blog-theme", this.mode)
37
+ } catch (_) {}
38
+ this.apply()
39
+ }
40
+
41
+ apply() {
42
+ const root = document.documentElement
43
+ if (this.mode === "system") delete root.dataset.theme
44
+ else root.dataset.theme = this.mode
45
+ const label = this[`${this.mode}LabelValue`]
46
+ if (this.hasLabelTarget) this.labelTarget.textContent = label
47
+ this.element.setAttribute("aria-label", label)
48
+ const surface = getComputedStyle(root).getPropertyValue("--ob-surface").trim()
49
+ if (surface) document.querySelectorAll('meta[name="theme-color"]').forEach(meta => meta.content = surface)
50
+ }
51
+ }
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.links = [...this.element.querySelectorAll('a[href^="#"]')]
6
+ this.headings = this.links.map(link => document.getElementById(decodeURIComponent(link.hash.slice(1))))
7
+ this.schedule = () => {
8
+ if (!this.frame) this.frame = requestAnimationFrame(() => { this.frame = null; this.update() })
9
+ }
10
+ window.addEventListener("scroll", this.schedule, { passive: true })
11
+ window.addEventListener("resize", this.schedule)
12
+ this.update()
13
+ }
14
+
15
+ disconnect() {
16
+ window.removeEventListener("scroll", this.schedule)
17
+ window.removeEventListener("resize", this.schedule)
18
+ cancelAnimationFrame(this.frame)
19
+ this.frame = null
20
+ this.links.forEach(link => link.removeAttribute("aria-current"))
21
+ }
22
+
23
+ update() {
24
+ let active = this.headings.findIndex(heading => heading)
25
+ this.headings.forEach((heading, index) => {
26
+ if (heading && heading.getBoundingClientRect().top <= 120) active = index
27
+ })
28
+ this.links.forEach((link, index) => {
29
+ if (index === active) link.setAttribute("aria-current", "location")
30
+ else link.removeAttribute("aria-current")
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,49 @@
1
+ module OpenBlog
2
+ module ReaderController
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ helper :all
7
+ layout -> { OpenBlog.config.layout }
8
+ around_action :use_blog_locale
9
+ rescue_from OpenBlog::NotFound, ActiveRecord::RecordNotFound, with: :render_not_found
10
+ end
11
+
12
+ private
13
+
14
+ def use_blog_locale(&block)
15
+ I18n.with_locale(OpenBlog.config.locale, &block)
16
+ end
17
+
18
+ def require_html
19
+ raise NotFound if params[:format].present? && params[:format] != "html"
20
+ request.format = :html
21
+ end
22
+
23
+ def origin
24
+ OpenBlog.config.public_base_url || request.base_url
25
+ end
26
+
27
+ def page_context(kind, path:, record: nil, pagination: nil, breadcrumbs: [])
28
+ @open_blog_page = ReaderPage.new(kind: kind, path: path, base_url: origin, record: record, pagination: pagination, breadcrumbs: breadcrumbs)
29
+ end
30
+
31
+ def paginate(scope)
32
+ Pagination.page(scope, page: params[:page], per_page: OpenBlog.config.posts_per_page)
33
+ end
34
+
35
+ def load_sidebar
36
+ @popular_posts = ReaderQueries.popular
37
+ sidebar = ReaderQueries.sidebar
38
+ @sidebar_categories, @sidebar_tags = sidebar.values_at(:categories, :tags)
39
+ @sidebar_cache_key = ReaderQueries.sidebar_cache_key(sidebar) + [ controller_name, params[:slug] ]
40
+ end
41
+
42
+ def render_not_found
43
+ request.format = :html
44
+ @posts = ReaderQueries.posts.limit(6).to_a
45
+ page_context(:not_found, path: request.path)
46
+ render "open_blog/errors/not_found", status: :not_found, formats: [ :html ], content_type: "text/html"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class AdoptionsController < BaseController
5
+ def create
6
+ require_scope!(:publish)
7
+ attributes = input_fields!(*ApiFields::ADOPTION)
8
+ ApiFields::POST_NESTED.except(:approval, :connections).each do |field, allowed|
9
+ values = field == :faq && attributes[field].is_a?(Array) ? attributes[field] : [ attributes[field] ]
10
+ values.each do |value|
11
+ next unless value.is_a?(Hash)
12
+ unknown = value.keys - allowed
13
+ raise Error::UnknownField.new(details: unknown.map { |key| "#{field}.#{key}" }) if unknown.any?
14
+ end
15
+ end
16
+ if attributes.key?(:author) && !attributes[:author].is_a?(String) && !attributes[:author].is_a?(Hash)
17
+ raise Error::ValidationFailed.new(details: [ "author" ])
18
+ end
19
+ ApiFields.validate_post_images!(attributes)
20
+ result = Adopt.call(attributes, actor: actor.name)
21
+ return render_error(result.error) unless result.success?
22
+ render json: AdoptionResultSerializer.call(result, base_url: request.base_url), status: result.created ? :created : :ok
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class ApprovalsController < BaseController
5
+ def create
6
+ require_scope!(:publish)
7
+ input = input_fields!(*ApiFields::APPROVAL)
8
+ result = Approve.call(find_post!, revision_identifier: input[:revision_identifier],
9
+ name: input[:name], facts_checked: input[:facts_checked], actor: actor.name)
10
+ render_result(result, status: :ok)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end