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,188 @@
1
+ require "rails/generators"
2
+ require "bundler"
3
+ require_relative "../generator_support"
4
+
5
+ module OpenBlog
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include GeneratorSupport
9
+ source_root File.expand_path("templates", __dir__)
10
+ source_paths << OpenBlog::Engine.root.join("app/assets/javascripts").to_s
11
+
12
+ class_option :skip_tailwind, type: :boolean, default: false
13
+ class_option :body_format, type: :string, default: "markdown"
14
+ class_option :mount_at, type: :string, default: "/blog"
15
+ class_option :mount_position, type: :string, default: "last"
16
+ class_option :site_name, type: :string
17
+ class_option :author_name, type: :string
18
+ class_option :skip_sample, type: :boolean, default: false
19
+ class_option :skip_migrate, type: :boolean, default: false
20
+ class_option :admin_suite, type: :boolean, default: false
21
+
22
+ def detect
23
+ unless %w[markdown rich_text both].include?(options[:body_format])
24
+ raise Thor::Error, "--body-format must be markdown, rich_text or both"
25
+ end
26
+ unless %w[first last].include?(options[:mount_position])
27
+ raise Thor::Error, "--mount-position must be first or last"
28
+ end
29
+ unless options[:mount_at].match?(%r{\A/(?:[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)*)?\z})
30
+ raise Thor::Error, "--mount-at must be an absolute path without a query, fragment or trailing slash"
31
+ end
32
+ @javascript = javascript_setup
33
+ @css = css_setup
34
+ @install_stimulus = !File.file?(host_file("app/javascript/controllers/index.js"))
35
+ @install_tailwind = @css == :none
36
+ say "JavaScript: #{@javascript}; stylesheets: #{@css}"
37
+ say "Tailwind 3 detected; using the built blog stylesheet." if @css == :legacy
38
+ if @install_tailwind
39
+ say "Tailwind's installer changes the host layout, bin/dev and Procfile.dev. Use --skip-tailwind to keep your existing CSS setup."
40
+ end
41
+ say "The JavaScript installers may update the host entrypoint and controller manifest." if @install_stimulus || @javascript != :importmap
42
+ end
43
+
44
+ def configure_blog
45
+ @site_name = identity(:site_name, "Site name", "Example")
46
+ @author_name = identity(:author_name, "Author name", "Ada Example")
47
+ template "initializer.rb.tt", "config/initializers/open_blog.rb", **copy_options
48
+ end
49
+
50
+ def install_dependencies
51
+ processor = (Rails.application.config.active_storage.variant_processor || :vips).to_sym
52
+ backend = { vips: "ruby-vips", mini_magick: "mini_magick" }.fetch(processor) do
53
+ raise Thor::Error, "Unsupported Active Storage variant processor: #{processor}"
54
+ end
55
+ additions = [ "stimulus-rails", "image_processing", backend ]
56
+ additions << "importmap-rails" if @javascript == :none
57
+ additions << "tailwindcss-rails" if @install_tailwind
58
+ added = additions.reject { |name| host_read("Gemfile").match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/) }
59
+ added.each { |name| gem(name, *(name == "tailwindcss-rails" ? [ "~> 4.0" ] : [])) }
60
+ run_host_command("bundle", "install") if added.any?
61
+ if @javascript == :none
62
+ run_host_command("bin/rails", "importmap:install")
63
+ @javascript = :importmap
64
+ end
65
+ run_host_command("bin/rails", "stimulus:install") if @install_stimulus
66
+ if @install_tailwind
67
+ run_host_command("bin/rails", "tailwindcss:install")
68
+ @css = :rails
69
+ end
70
+ end
71
+
72
+ def install_migrations
73
+ run_host_command("bin/rails", "railties:install:migrations", "FROM=active_storage,action_text,open_blog")
74
+ run_host_command("bin/rails", "db:migrate") unless options[:skip_migrate]
75
+ end
76
+
77
+ def mount_engine
78
+ return if host_read("config/routes.rb").match?(/^\s*mount\s+OpenBlog::Engine\b/)
79
+ declaration = " mount OpenBlog::Engine, at: #{options[:mount_at].dump}\n"
80
+ if options[:mount_position] == "first"
81
+ unless host_read("config/routes.rb").match?(/Rails\.application\.routes\.draw do\s*\n/)
82
+ raise Thor::Error, "Cannot locate the routes draw block; mount OpenBlog::Engine manually."
83
+ end
84
+ inject_into_file "config/routes.rb", declaration, after: /Rails\.application\.routes\.draw do\s*\n/
85
+ else
86
+ unless host_read("config/routes.rb").match?(/^end\s*\z/)
87
+ raise Thor::Error, "Cannot locate the routes draw block; mount OpenBlog::Engine manually."
88
+ end
89
+ inject_into_file "config/routes.rb", declaration, before: /^end\s*\z/
90
+ end
91
+ end
92
+
93
+ def install_views
94
+ copy_reader_views(css: @css, javascript: @javascript)
95
+ end
96
+
97
+ def install_theme
98
+ if %i[rails bundler].include?(@css)
99
+ %w[theme.css blog.css syntax.css].each do |name|
100
+ copy_file "theme/#{name}", "app/assets/tailwind/open_blog/#{name}", **copy_options
101
+ end
102
+ entry, import = if @css == :rails
103
+ [ "app/assets/tailwind/application.css", '@import "./open_blog/theme.css";' ]
104
+ else
105
+ [ "app/assets/stylesheets/application.tailwind.css", '@import "../tailwind/open_blog/theme.css";' ]
106
+ end
107
+ append_once(entry, "\n#{import}\n")
108
+ else
109
+ copy_file "theme/open_blog_theme.css", "app/assets/stylesheets/open_blog_theme.css", **copy_options
110
+ end
111
+ end
112
+
113
+ def install_controllers
114
+ directory "open_blog/controllers", "app/javascript/controllers/open_blog", **copy_options
115
+ if @javascript == :bundler
116
+ say "Regenerating the host Stimulus controller manifest."
117
+ run_host_command("bin/rails", "stimulus:manifest:update")
118
+ else
119
+ append_once "config/importmap.rb", "\npin_all_from \"app/javascript/controllers\", under: \"controllers\"\n"
120
+ index = "app/javascript/controllers/index.js"
121
+ unless host_read(index).match?(/(?:eager|lazy)LoadControllersFrom\s*\(/)
122
+ controller_names.each do |name|
123
+ identifier = "open-blog--#{name.tr('_', '-')}"
124
+ next if host_read(index).match?(/application\.register\(\s*["']#{Regexp.escape(identifier)}["']/)
125
+ variable = "OpenBlog#{name.camelize}Controller"
126
+ append_to_file index, "\nimport #{variable} from \"controllers/open_blog/#{name}_controller\"\napplication.register(#{identifier.dump}, #{variable})\n"
127
+ end
128
+ end
129
+ end
130
+ say "Blog JavaScript entrypoint: application. Register these controllers there if you use another public entrypoint."
131
+ end
132
+
133
+ def install_text
134
+ copy_file OpenBlog::Engine.root.join("config/locales/open_blog.en.yml").to_s, "config/locales/open_blog.en.yml", **copy_options
135
+ end
136
+
137
+ def sample_and_report
138
+ if options[:skip_migrate]
139
+ say "Sample deferred: run db:migrate, then open_blog:sample when ready."
140
+ elsif !options[:skip_sample]
141
+ run_host_command("bin/rails", "open_blog:sample")
142
+ end
143
+ say "Blog: #{options[:mount_at]}"
144
+ if options[:skip_migrate]
145
+ say "API token deferred: run db:migrate, then open_blog:install_token."
146
+ else
147
+ run_host_command("bin/rails", "open_blog:install_token")
148
+ end
149
+ say "Publishing API: #{options[:mount_at].chomp('/')}/api/v1"
150
+ say "MCP: #{options[:mount_at].chomp('/')}/mcp"
151
+ invoke "open_blog:admin_suite", [], force: options[:force], skip: options[:skip] if options[:admin_suite]
152
+ say "Next: set your public base URL, review publisher details and policy links, and customize the theme."
153
+ run_host_command("bin/rails", "open_blog:doctor")
154
+ end
155
+
156
+ private
157
+
158
+ def run_host_command(*arguments)
159
+ say_status :run, arguments.join(" ")
160
+ return true if options[:pretend]
161
+ success = Bundler.with_unbundled_env { system(*arguments, chdir: destination_root) }
162
+ if !success && arguments == [ "bin/rails", "open_blog:doctor" ]
163
+ say "Doctor found setup issues; review the report above and run open_blog:doctor after completing setup."
164
+ elsif !success
165
+ raise Thor::Error, "Host command failed: #{arguments.join(' ')}"
166
+ end
167
+ success
168
+ end
169
+
170
+ def identity(key, prompt, placeholder)
171
+ return options[key] if options[key].present?
172
+ return ask("#{prompt}:", default: placeholder) if $stdin.tty?
173
+ say "Using placeholder #{prompt.downcase} #{placeholder.inspect}; update config/initializers/open_blog.rb."
174
+ placeholder
175
+ end
176
+
177
+ def controller_names
178
+ Dir[OpenBlog::Engine.root.join("app/assets/javascripts/open_blog/controllers/*_controller.js")].map do |path|
179
+ File.basename(path, "_controller.js")
180
+ end.sort
181
+ end
182
+
183
+ def body_formats
184
+ options[:body_format] == "both" ? %i[markdown rich_text] : [ options[:body_format].to_sym ]
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,16 @@
1
+ OpenBlog.configure do |config|
2
+ config.site_name = <%= @site_name.dump %>
3
+ config.public_base_url = ENV["OPEN_BLOG_PUBLIC_BASE_URL"].presence
4
+ config.default_author = { name: <%= @author_name.dump %>, type: :person }
5
+ config.publisher = { name: <%= @site_name.dump %>, url: ENV["OPEN_BLOG_PUBLIC_BASE_URL"].presence }
6
+ config.mount_path = <%= options[:mount_at].dump %>
7
+ config.body_formats = [ <%= body_formats.map { |format| ":#{format}" }.join(", ") %> ]
8
+ config.default_body_format = <%= ":#{body_formats.first}" %>
9
+ config.primary_list_type = :categories
10
+ config.sign_in_destinations = []
11
+ config.ai_label = :when_required
12
+ end
13
+ <% if @css == :bundler -%>
14
+
15
+ Rails.application.config.assets.excluded_paths << Rails.root.join("app/assets/tailwind")
16
+ <% end -%>
@@ -0,0 +1,54 @@
1
+ ## Make yourself at home
2
+
3
+ This sample is a place to try **bold ideas**, *quiet emphasis* and ~~an earlier draft~~. An article can be short and still be useful.
4
+
5
+ ![Blue and green blocks arranged as a simple landscape]({{sample_image}} "A simple landscape for a fresh start")
6
+
7
+ ### A few things to try
8
+
9
+ - Give your article a clear title.
10
+ - Add an image with a useful description.
11
+ - Keep paragraphs easy to read.
12
+
13
+ 1. Write a first draft.
14
+ 2. Read it aloud.
15
+ 3. Share it when you are ready.
16
+
17
+ - [x] Start with a sample.
18
+ - [ ] Write your next article.
19
+
20
+ > A small observation can be the beginning of a good story.
21
+
22
+ > [!NOTE]
23
+ > This article is sample content. You can change or remove it.
24
+
25
+ ## Show an example
26
+
27
+ Inline code such as `puts "hello"` fits naturally into a sentence. Longer examples have their own space:
28
+
29
+ ```ruby
30
+ def greeting(name)
31
+ "Hello, #{name}!"
32
+ end
33
+
34
+ puts greeting("reader")
35
+ ```
36
+
37
+ | Part | Purpose |
38
+ | --- | --- |
39
+ | Heading | Introduce the next idea |
40
+ | Image | Show a useful detail |
41
+ | Table | Compare a few related items |
42
+
43
+ Visit [Ruby's website](https://www.ruby-lang.org/) for more examples. A plain address such as https://www.ruby-lang.org/ is also a link.
44
+
45
+ ## Leave room for the reader
46
+
47
+ An article can include a reference without interrupting the main paragraph.[^note]
48
+
49
+ The questions below are stored separately from the article body, so they stay easy to read and update.
50
+
51
+ One line can lead to the next.
52
+ This line demonstrates a paragraph with a line break.
53
+
54
+ [^note]: This is an example footnote with a short explanation.
@@ -0,0 +1,193 @@
1
+ .ob-page {
2
+ margin: 0;
3
+ background: var(--ob-surface);
4
+ color: var(--ob-text);
5
+ font-family: var(--ob-font-body);
6
+ line-height: 1.6;
7
+ overflow-wrap: anywhere;
8
+ }
9
+ .ob-page *, .ob-page *::before, .ob-page *::after { box-sizing: border-box; }
10
+ .ob-page [hidden] { display: none !important; }
11
+ .ob-page a { color: var(--ob-accent); text-underline-offset: 0.2em; }
12
+ .ob-page a:hover { color: var(--ob-accent-hover); }
13
+ .ob-page a:focus-visible, .ob-page button:focus-visible, .ob-page input:focus-visible,
14
+ .ob-page .ob-table:focus-visible, .ob-page .ob-prose pre:focus-visible {
15
+ outline: 3px solid var(--ob-accent);
16
+ outline-offset: 4px;
17
+ }
18
+ .ob-page h1, .ob-page h2, .ob-page h3, .ob-page h4, .ob-page h5, .ob-page h6 {
19
+ color: var(--ob-heading);
20
+ font-family: var(--ob-font-display);
21
+ font-weight: 750;
22
+ line-height: 1.25;
23
+ text-wrap: balance;
24
+ }
25
+ .ob-page h1 { font-size: clamp(1.875rem, 4vw, 3rem); letter-spacing: -0.035em; }
26
+ .ob-page h2 { font-size: 1.5rem; }
27
+ .ob-page h3 { font-size: 1.25rem; }
28
+ .ob-page h4, .ob-page h5, .ob-page h6 { font-size: 1.125rem; }
29
+ .ob-page img { max-width: 100%; height: auto; }
30
+ .ob-page button { cursor: pointer; font: inherit; }
31
+ .ob-page button:disabled { cursor: default; }
32
+ .ob-main { max-width: var(--ob-page-width); margin: auto; padding: 3rem 1rem; }
33
+ .ob-header, .ob-footer {
34
+ max-width: var(--ob-page-width);
35
+ margin: auto;
36
+ padding: 1.25rem 1rem;
37
+ display: flex;
38
+ flex-wrap: wrap;
39
+ align-items: center;
40
+ gap: 1rem;
41
+ }
42
+ .ob-header { border-bottom: 1px solid var(--ob-border); }
43
+ .ob-header nav { margin-left: auto; }
44
+ .ob-site-name { font-size: 1.125rem; font-weight: 800; text-decoration: none; }
45
+ .ob-footer { border-top: 1px solid var(--ob-border); font-size: 0.875rem; }
46
+ .ob-footer nav { display: flex; flex-wrap: wrap; gap: 1rem; }
47
+ .ob-index-heading, .ob-list-heading { margin-bottom: 1.5rem; }
48
+ .ob-index-heading h1 { font-size: 2.25rem; font-weight: 800; }
49
+ .ob-tagline, .ob-list-description, .ob-lede { color: var(--ob-text-muted); font-size: 1.125rem; margin-top: 0.75rem; }
50
+ .ob-list-layout { display: grid; gap: 2rem; }
51
+ .ob-list-main, .ob-sidebar, .ob-post-card, .ob-featured-details { min-width: 0; }
52
+ .ob-search { margin-bottom: 1.5rem; }
53
+ .ob-search label { display: block; margin-bottom: 0.5rem; font-weight: 700; }
54
+ .ob-search-controls { display: flex; gap: 0.5rem; }
55
+ .ob-search input { min-width: 0; width: 100%; padding: 0.5rem; border: 1px solid var(--ob-border-strong); border-radius: 0.5rem; background: var(--ob-surface); color: var(--ob-text); font: inherit; }
56
+ .ob-search button { flex-shrink: 0; white-space: nowrap; padding: 0.5rem 0.75rem; border-radius: 0.5rem; background: var(--ob-accent); color: var(--ob-accent-contrast); }
57
+ .ob-search-results { margin-top: 0.75rem; border: 1px solid var(--ob-border); border-radius: 0.5rem; background: var(--ob-surface-raised); }
58
+ .ob-sidebar .ob-search-results a { display: block; border-radius: 0.5rem; }
59
+ .ob-search-results a:hover { background: var(--ob-accent-soft); }
60
+ .ob-series-nav { margin: 2rem 0; padding: 1.25rem; border: 1px solid var(--ob-border); border-radius: 0.75rem; }
61
+ .ob-series-nav h2 { font-size: 1.125rem; margin-bottom: 0.75rem; }
62
+ .ob-series-nav p + p { margin-top: 0.5rem; }
63
+ .ob-sidebar h2 { font-size: 1rem; margin-bottom: 0.75rem; }
64
+ .ob-sidebar-categories ul { display: flex; gap: 0.5rem; overflow-x: auto; padding-bottom: 0.5rem; }
65
+ .ob-sidebar-categories li { flex-shrink: 0; }
66
+ .ob-sidebar a { display: block; padding: 0.4rem 0.8rem; border-radius: var(--ob-radius-pill); text-decoration: none; }
67
+ .ob-sidebar a[aria-current="page"] { color: var(--ob-accent-contrast); background: var(--ob-accent); }
68
+ .ob-sidebar-popular { margin-top: 1.5rem; }
69
+ .ob-sidebar-popular li + li { margin-top: 0.25rem; }
70
+ .ob-sidebar-tags, .ob-count { display: none; }
71
+ .ob-post-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2rem; }
72
+ .ob-card-media { display: block; overflow: hidden; border-radius: var(--ob-radius-media); }
73
+ .ob-card-image, .ob-card-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
74
+ .ob-card-image { transition: transform 180ms ease; }
75
+ .ob-card-media:hover .ob-card-image { transform: scale(1.05); }
76
+ .ob-card-placeholder { background: linear-gradient(135deg, var(--ob-accent-soft), var(--ob-surface-sunken)); }
77
+ .ob-card-category, .ob-post-category { display: inline-block; margin-top: 1rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 750; }
78
+ .ob-card-title { margin: 0.5rem 0; }
79
+ .ob-card-title a, .ob-featured-details h2 a { text-decoration: none; }
80
+ .ob-card-title, .ob-card-description { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
81
+ .ob-card-title { -webkit-line-clamp: 2; }
82
+ .ob-card-description { -webkit-line-clamp: 2; color: var(--ob-text-muted); margin: 0.75rem 0; }
83
+ .ob-card-author { display: flex; align-items: center; gap: 0.5rem; margin-top: 1rem; }
84
+ .ob-card-author a { color: var(--ob-text); }
85
+ .ob-avatar { display: inline-flex; flex: 0 0 2.5rem; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: 50%; object-fit: cover; vertical-align: middle; }
86
+ .ob-avatar--initials { background: var(--ob-accent-soft); color: var(--ob-heading); font-weight: 750; }
87
+ .ob-date, .ob-reading-time { color: var(--ob-text-subtle); font-size: 0.875rem; display: inline-block; margin: 0.35rem 0.75rem 0.35rem 0; }
88
+ .ob-featured-card { display: grid; min-width: 0; margin-bottom: 2rem; border: 1px solid var(--ob-border); border-top: 4px solid var(--ob-accent); border-radius: var(--ob-radius-card); overflow: hidden; box-shadow: var(--ob-shadow); }
89
+ .ob-featured-card:hover { border-color: var(--ob-accent); }
90
+ .ob-featured-card .ob-card-media { border-radius: 0; }
91
+ .ob-featured-details { padding: 1.25rem; }
92
+ .ob-featured-details .ob-card-description { -webkit-line-clamp: 3; }
93
+ .ob-featured-label { color: var(--ob-accent); font-weight: 750; font-size: 0.8rem; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.08em; }
94
+ .ob-pagination { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 2.5rem; }
95
+ .ob-pagination-link, .ob-pagination-current { min-width: 2.5rem; min-height: 2.5rem; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--ob-radius-pill); text-decoration: none; }
96
+ .ob-pagination-current { color: var(--ob-accent-contrast); background: var(--ob-accent); }
97
+ .ob-breadcrumbs ol { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; font-size: 0.875rem; }
98
+ .ob-breadcrumbs li + li::before { content: "/"; margin-right: 0.5rem; color: var(--ob-text-subtle); }
99
+ .ob-post { max-width: var(--ob-content-width); margin: auto; }
100
+ .ob-post-title { line-height: 1.15; margin: 0.75rem 0 1rem; font-weight: 800; }
101
+ .ob-post-category { margin-top: 0; }
102
+ .ob-byline { margin: 1rem 0 1.5rem; }
103
+ .ob-byline-author { font-weight: 700; }
104
+ .ob-cover { margin: 1.5rem -1rem; }
105
+ .ob-cover img { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; }
106
+ .ob-highlights { padding: 1rem; border: 1px solid var(--ob-border); border-radius: var(--ob-radius-card); margin: 1.5rem 0; background: var(--ob-surface-raised); }
107
+ .ob-highlights h2 { font-size: 1rem; margin-bottom: 0.5rem; }
108
+ .ob-highlights ul { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; }
109
+ .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); padding: 1rem 1.25rem; margin: 1rem 0; }
110
+ .ob-notice h2 { color: inherit; font-size: 1.125rem; margin-bottom: 0.5rem; }
111
+ .ob-notice ul { list-style: disc; padding-left: 1.25rem; }
112
+ .ob-correction time { display: inline-block; }
113
+ .ob-toc { border-left: 3px solid var(--ob-accent); background: var(--ob-surface-raised); padding: 1rem 1.25rem; margin: 2rem 0; border-radius: 0 var(--ob-radius-card) var(--ob-radius-card) 0; }
114
+ .ob-toc h2 { font-size: 1rem; margin-bottom: 0.75rem; }
115
+ .ob-toc-item { margin: 0.35rem 0; }
116
+ .ob-toc-item--h3 { padding-left: 1rem; }
117
+ .ob-toc a[aria-current="location"] { color: var(--ob-heading); font-weight: 800; text-decoration-thickness: 0.15em; }
118
+ .ob-prose { font-size: var(--ob-prose-size); line-height: var(--ob-prose-leading); }
119
+ .ob-prose > * { margin-block: 1.25rem; }
120
+ .ob-prose h2 { border-bottom: 1px solid var(--ob-border); padding-bottom: 0.5rem; margin-top: 2.5rem; }
121
+ .ob-prose h3, .ob-prose h4 { margin-top: 2rem; }
122
+ .ob-prose :is(h2,h3,h4,h5,h6) { scroll-margin-top: 2rem; }
123
+ .ob-heading-anchor { margin-left: 0.4em; font-size: 0.8em; text-decoration: none; }
124
+ .ob-prose ul, .ob-prose ol { padding-left: 1.5rem; }
125
+ .ob-prose ul { list-style: disc; }
126
+ .ob-prose ol { list-style: decimal; }
127
+ .ob-prose li::marker { color: var(--ob-accent); }
128
+ .ob-prose li + li { margin-top: 0.35rem; }
129
+ .ob-prose input[type="checkbox"] { margin-right: 0.4rem; }
130
+ .ob-prose blockquote { border-left: 3px solid var(--ob-accent); padding: 0.75rem 1.25rem; background: var(--ob-accent-soft); border-radius: 0 var(--ob-radius-card) var(--ob-radius-card) 0; }
131
+ .ob-prose :not(pre) > code { background: var(--ob-surface-sunken); border-radius: 0.3rem; padding: 0.15em 0.3em; font-size: 0.9em; font-family: var(--ob-font-mono); }
132
+ .ob-prose pre { background: var(--ob-code-bg); position: relative; max-width: 100%; overflow-x: auto; border: 1px solid var(--ob-code-border); border-radius: var(--ob-radius-card); padding: 3.25rem 1rem 1rem; line-height: 1.6; font-size: 0.9rem; overflow-wrap: normal; }
133
+ .ob-prose .ob-highlight .w { background-color: transparent; }
134
+ .ob-prose pre code { display: block; width: max-content; min-width: 100%; font-family: var(--ob-font-mono); }
135
+ .ob-code-language { position: absolute; left: 1rem; top: 0.65rem; color: var(--ob-text-subtle); font-family: var(--ob-font-body); font-size: 0.8rem; }
136
+ .ob-code-copy { position: absolute; right: 0.65rem; top: 0.5rem; }
137
+ .ob-code-copy, .ob-theme-toggle, .ob-share-button { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; min-height: 2.5rem; padding: 0.4rem 0.75rem; background: var(--ob-surface-raised); color: var(--ob-text); border: 1px solid var(--ob-border-strong); border-radius: var(--ob-radius-pill); }
138
+ .ob-icon { width: 1.125rem; height: 1.125rem; flex-shrink: 0; }
139
+ .ob-prose hr { height: 1px; border: 0; background: linear-gradient(to right, var(--ob-border), var(--ob-accent), var(--ob-border)); margin: 2.5rem 0; }
140
+ .ob-figure { margin-inline: 0; }
141
+ .ob-figure img { border-radius: var(--ob-radius-media); }
142
+ .ob-figure figcaption { font-size: 0.9rem; color: var(--ob-text-muted); margin-top: 0.5rem; }
143
+ .ob-table { max-width: 100%; overflow-x: auto; border: 1px solid var(--ob-border); border-radius: var(--ob-radius-media); }
144
+ .ob-table table { border-collapse: collapse; min-width: 100%; width: max-content; font-size: 0.95rem; }
145
+ .ob-table th, .ob-table td { text-align: left; padding: 0.65rem 1rem; border-bottom: 1px solid var(--ob-border); }
146
+ .ob-table th { background: var(--ob-surface-raised); color: var(--ob-heading); font-weight: 700; }
147
+ .ob-prose .markdown-alert { padding: 1rem 1.25rem; border-left: 4px solid var(--ob-note); background: var(--ob-surface-raised); border-radius: 0 var(--ob-radius-card) var(--ob-radius-card) 0; }
148
+ .ob-prose .markdown-alert-title { color: var(--ob-note); font-weight: 750; margin-bottom: 0.5rem; }
149
+ .ob-prose .markdown-alert-tip { border-color: var(--ob-tip); }
150
+ .ob-prose .markdown-alert-tip .markdown-alert-title { color: var(--ob-tip); }
151
+ .ob-prose .markdown-alert-warning, .ob-prose .markdown-alert-caution { border-color: var(--ob-warning); }
152
+ .ob-prose .markdown-alert-warning .markdown-alert-title, .ob-prose .markdown-alert-caution .markdown-alert-title { color: var(--ob-warning); }
153
+ .ob-prose--dropcap > p:first-of-type::first-letter { float: left; font-size: 3.5em; line-height: 0.9; padding-right: 0.1em; color: var(--ob-accent); }
154
+ .ob-faq { margin-block: 2.5rem; padding-top: 2rem; border-top: 1px solid var(--ob-border); min-width: 0; }
155
+ .ob-faq > h2 { margin-bottom: 1.5rem; scroll-margin-top: 2rem; }
156
+ .ob-faq-entry + .ob-faq-entry { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ob-border); }
157
+ .ob-faq-entry h3 { margin-bottom: 0.75rem; }
158
+ .ob-faq-entry p { font-size: var(--ob-prose-size); line-height: var(--ob-prose-leading); overflow-wrap: anywhere; }
159
+ .ob-faq-entry p + p { margin-top: 1rem; }
160
+ .ob-faq-entry a { text-decoration: underline; }
161
+ .ob-tags, .ob-author-profiles, .ob-share { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin: 1.5rem 0; }
162
+ .ob-tags a { border-radius: var(--ob-radius-pill); padding: 0.25rem 0.75rem; background: var(--ob-accent-soft); }
163
+ .ob-share-status { color: var(--ob-text-muted); font-size: 0.875rem; }
164
+ .ob-author-box, .ob-call-to-action { margin-block: 2rem; padding: 1.5rem; background: var(--ob-surface-raised); border: 1px solid var(--ob-border); border-radius: var(--ob-radius-card); }
165
+ .ob-author-box h2, .ob-call-to-action h2 { margin-bottom: 0.75rem; }
166
+ .ob-author-bio { margin-top: 0.75rem; }
167
+ .ob-related-posts, .ob-recent-posts { margin-top: 3rem; }
168
+ .ob-related-posts > h2, .ob-recent-posts > h2 { margin-bottom: 1.5rem; }
169
+ .ob-empty { color: var(--ob-text-muted); padding-block: 2rem; }
170
+ .ob-reading-progress { position: fixed; inset: 0 0 auto; height: 3px; z-index: 50; background: var(--ob-border); pointer-events: none; }
171
+ .ob-reading-progress-fill { height: 100%; width: 0; background: var(--ob-accent); }
172
+ @media (min-width: 40rem) {
173
+ .ob-post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
174
+ .ob-featured-card { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
175
+ .ob-featured-card .ob-card-image, .ob-featured-card .ob-card-placeholder { height: 100%; }
176
+ .ob-cover { margin-inline: 0; }
177
+ .ob-cover img { border-radius: var(--ob-radius-media); }
178
+ }
179
+ @media (min-width: 64rem) {
180
+ .ob-list-layout { grid-template-columns: 14rem minmax(0, 1fr); }
181
+ .ob-sidebar { align-self: start; position: sticky; top: 2rem; }
182
+ .ob-sidebar-categories ul { display: block; overflow: visible; }
183
+ .ob-sidebar-categories li { margin-bottom: 0.25rem; }
184
+ .ob-sidebar a { display: flex; justify-content: space-between; gap: 0.75rem; }
185
+ .ob-sidebar-tags { display: block; margin-top: 1.5rem; }
186
+ .ob-sidebar-tags ul { display: flex; flex-wrap: wrap; gap: 0.5rem; }
187
+ .ob-count { display: inline; }
188
+ .ob-related-posts .ob-post-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
189
+ }
190
+ @media (prefers-reduced-motion: reduce) {
191
+ .ob-card-image { transition: none; }
192
+ .ob-card-media:hover .ob-card-image { transform: none; }
193
+ }
@@ -0,0 +1,82 @@
1
+ :root {
2
+ --ob-surface: #ffffff;
3
+ --ob-surface-raised: #f8fafc;
4
+ --ob-surface-sunken: #f1f5f9;
5
+ --ob-heading: #0f172a;
6
+ --ob-text: #334155;
7
+ --ob-text-muted: #475569;
8
+ --ob-text-subtle: #526176;
9
+ --ob-border: #cbd5e1;
10
+ --ob-border-strong: #64748b;
11
+ --ob-accent: #1d4ed8;
12
+ --ob-accent-hover: #1e40af;
13
+ --ob-accent-soft: #dbeafe;
14
+ --ob-accent-contrast: #ffffff;
15
+ --ob-code-bg: #f6f8fa;
16
+ --ob-code-border: #94a3b8;
17
+ --ob-notice-bg: #eff6ff;
18
+ --ob-notice-border: #60a5fa;
19
+ --ob-notice-text: #1e3a8a;
20
+ --ob-note: #1d4ed8;
21
+ --ob-tip: #166534;
22
+ --ob-warning: #854d0e;
23
+ --ob-shadow: 0 8px 24px #0f172a1a;
24
+ --ob-font-display: ui-sans-serif, system-ui, sans-serif;
25
+ --ob-font-body: ui-sans-serif, system-ui, sans-serif;
26
+ --ob-font-mono: ui-monospace, SFMono-Regular, Consolas, monospace;
27
+ --ob-radius-card: 1rem;
28
+ --ob-radius-media: 0.75rem;
29
+ --ob-radius-pill: 999px;
30
+ --ob-prose-size: 1.125rem;
31
+ --ob-prose-leading: 1.8;
32
+ --ob-content-width: 56rem;
33
+ --ob-page-width: 72rem;
34
+ }
35
+ :root[data-theme="dark"] {
36
+ --ob-surface: #0f172a;
37
+ --ob-surface-raised: #1e293b;
38
+ --ob-surface-sunken: #020617;
39
+ --ob-heading: #f8fafc;
40
+ --ob-text: #e2e8f0;
41
+ --ob-text-muted: #cbd5e1;
42
+ --ob-text-subtle: #94a3b8;
43
+ --ob-border: #475569;
44
+ --ob-border-strong: #94a3b8;
45
+ --ob-accent: #93c5fd;
46
+ --ob-accent-hover: #bfdbfe;
47
+ --ob-accent-soft: #172554;
48
+ --ob-accent-contrast: #0f172a;
49
+ --ob-code-bg: #161b22;
50
+ --ob-code-border: #64748b;
51
+ --ob-notice-bg: #172554;
52
+ --ob-notice-border: #60a5fa;
53
+ --ob-notice-text: #dbeafe;
54
+ --ob-note: #93c5fd;
55
+ --ob-tip: #86efac;
56
+ --ob-warning: #fde047;
57
+ }
58
+ @media (prefers-color-scheme: dark) {
59
+ :root:not([data-theme="light"]) {
60
+ --ob-surface: #0f172a;
61
+ --ob-surface-raised: #1e293b;
62
+ --ob-surface-sunken: #020617;
63
+ --ob-heading: #f8fafc;
64
+ --ob-text: #e2e8f0;
65
+ --ob-text-muted: #cbd5e1;
66
+ --ob-text-subtle: #94a3b8;
67
+ --ob-border: #475569;
68
+ --ob-border-strong: #94a3b8;
69
+ --ob-accent: #93c5fd;
70
+ --ob-accent-hover: #bfdbfe;
71
+ --ob-accent-soft: #172554;
72
+ --ob-accent-contrast: #0f172a;
73
+ --ob-code-bg: #161b22;
74
+ --ob-code-border: #64748b;
75
+ --ob-notice-bg: #172554;
76
+ --ob-notice-border: #60a5fa;
77
+ --ob-notice-text: #dbeafe;
78
+ --ob-note: #93c5fd;
79
+ --ob-tip: #86efac;
80
+ --ob-warning: #fde047;
81
+ }
82
+ }