open_blog 0.1.0 → 0.3.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -4
  3. data/README.md +65 -30
  4. data/app/assets/builds/open_blog/blog.css +1 -1
  5. data/app/assets/builds/open_blog/themes.css +403 -0
  6. data/app/assets/fonts/open_blog/OFL-dm-sans.txt +93 -0
  7. data/app/assets/fonts/open_blog/OFL-jetbrains-mono.txt +93 -0
  8. data/app/assets/fonts/open_blog/OFL-newsreader.txt +93 -0
  9. data/app/assets/fonts/open_blog/OFL-outfit.txt +93 -0
  10. data/app/assets/fonts/open_blog/OFL-space-grotesk.txt +93 -0
  11. data/app/assets/fonts/open_blog/dm-sans-italic-latin-ext.woff2 +0 -0
  12. data/app/assets/fonts/open_blog/dm-sans-italic-latin.woff2 +0 -0
  13. data/app/assets/fonts/open_blog/dm-sans-latin-ext.woff2 +0 -0
  14. data/app/assets/fonts/open_blog/dm-sans-latin.woff2 +0 -0
  15. data/app/assets/fonts/open_blog/jetbrains-mono-latin-ext.woff2 +0 -0
  16. data/app/assets/fonts/open_blog/jetbrains-mono-latin.woff2 +0 -0
  17. data/app/assets/fonts/open_blog/newsreader-italic-latin-ext.woff2 +0 -0
  18. data/app/assets/fonts/open_blog/newsreader-italic-latin.woff2 +0 -0
  19. data/app/assets/fonts/open_blog/newsreader-latin-ext.woff2 +0 -0
  20. data/app/assets/fonts/open_blog/newsreader-latin.woff2 +0 -0
  21. data/app/assets/fonts/open_blog/outfit-latin-ext.woff2 +0 -0
  22. data/app/assets/fonts/open_blog/outfit-latin.woff2 +0 -0
  23. data/app/assets/fonts/open_blog/space-grotesk-latin-ext.woff2 +0 -0
  24. data/app/assets/fonts/open_blog/space-grotesk-latin.woff2 +0 -0
  25. data/app/controllers/open_blog/api/v1/authors_controller.rb +1 -1
  26. data/app/controllers/open_blog/api/v1/pages_controller.rb +5 -5
  27. data/app/controllers/open_blog/media_controller.rb +20 -3
  28. data/app/helpers/open_blog/content_helper.rb +9 -2
  29. data/app/helpers/open_blog/faq_helper.rb +3 -1
  30. data/app/helpers/open_blog/theme_helper.rb +29 -5
  31. data/app/models/open_blog/image.rb +6 -0
  32. data/app/queries/open_blog/api_post_query.rb +5 -1
  33. data/config/routes.rb +2 -0
  34. data/db/migrate/20261003012512_create_open_blog_authors.rb +1 -1
  35. data/db/migrate/20261003012515_create_open_blog_images.rb +1 -1
  36. data/db/migrate/20261003012516_create_open_blog_posts.rb +4 -3
  37. data/db/migrate/20261020120000_align_open_blog_record_keys.rb +41 -0
  38. data/lib/generators/open_blog/generator_support.rb +5 -5
  39. data/lib/generators/open_blog/install/install_generator.rb +22 -1
  40. data/lib/generators/open_blog/install/templates/initializer.rb.tt +1 -0
  41. data/lib/generators/open_blog/install/templates/theme/blog.css +194 -93
  42. data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +17 -81
  43. data/lib/generators/open_blog/install/templates/theme/theme.css +3 -85
  44. data/lib/generators/open_blog/views/views_generator.rb +1 -1
  45. data/lib/open_blog/api_fields.rb +1 -1
  46. data/lib/open_blog/build_css.rb +51 -8
  47. data/lib/open_blog/configuration.rb +37 -1
  48. data/lib/open_blog/doctor/files.rb +50 -11
  49. data/lib/open_blog/doctor.rb +30 -1
  50. data/lib/open_blog/mcp/registry.rb +4 -4
  51. data/lib/open_blog/mcp/schemas.rb +5 -1
  52. data/lib/open_blog/post_attributes.rb +2 -2
  53. data/lib/open_blog/record_keys.rb +35 -0
  54. data/lib/open_blog/themes.rb +167 -0
  55. data/lib/open_blog/version.rb +1 -1
  56. data/lib/open_blog.rb +2 -0
  57. data/lib/tasks/open_blog.rake +1 -1
  58. data/skills/open-blog-install/SKILL.md +52 -4
  59. metadata +25 -2
@@ -27,17 +27,60 @@ module OpenBlog
27
27
  end
28
28
  end
29
29
 
30
+ OVERRIDE = <<~CSS
31
+ /*
32
+ Open Blog loads this file after the preset chosen with config.theme.
33
+ Put your token overrides here. Colours can also be set with config.theme_colors.
34
+
35
+ :root[data-ob-theme] {
36
+ --ob-accent: #1d4ed8;
37
+ --ob-font-display: Georgia, serif;
38
+ --ob-radius-card: 0.5rem;
39
+ }
40
+ :root[data-ob-theme][data-theme="dark"] {
41
+ --ob-accent: #93c5fd;
42
+ }
43
+ @media (prefers-color-scheme: dark) {
44
+ :root[data-ob-theme]:not([data-theme="light"]) {
45
+ --ob-accent: #93c5fd;
46
+ }
47
+ }
48
+ */
49
+ CSS
50
+
51
+ COLOR_SCHEME = <<~CSS
52
+ :root { color-scheme: light dark; }
53
+ :root[data-theme="light"] { color-scheme: light; }
54
+ :root[data-theme="dark"] { color-scheme: dark; }
55
+ CSS
56
+
30
57
  def self.tokens
31
- source = theme_root.join("theme.css").read
32
- block = source.match(%r{/\* open-blog:tokens:start \*/(.*?)/\* open-blog:tokens:end \*/}m)
33
- raise ConfigurationError, "The theme must contain its marked token block" unless block
34
- "#{block[1].strip}\n"
58
+ OVERRIDE
59
+ end
60
+
61
+ def self.themes
62
+ faces = Themes::FONTS.map do |font|
63
+ <<~CSS
64
+ @font-face {
65
+ font-family: "#{font.family}";
66
+ font-style: #{font.style};
67
+ font-weight: #{font.weight};
68
+ font-display: swap;
69
+ src: url("#{font.file}") format("woff2");
70
+ unicode-range: #{font.range};
71
+ }
72
+ CSS
73
+ end
74
+ presets = Themes.names.map do |name|
75
+ preset = Themes.fetch(name)
76
+ Themes.css(name, light: preset.light.merge(preset.tokens), dark: preset.dark)
77
+ end
78
+ [ *faces, COLOR_SCHEME, *presets ].join
35
79
  end
36
80
 
37
- def self.write(path: Engine.root.join("app/assets/builds/open_blog/blog.css"), tokens_path: theme_root.join("open_blog_theme.css"))
38
- css = render
39
- values = tokens
40
- [ [ path, css ], [ tokens_path, values ] ].each do |destination, bytes|
81
+ def self.write(path: Engine.root.join("app/assets/builds/open_blog/blog.css"), tokens_path: theme_root.join("open_blog_theme.css"),
82
+ themes_path: Pathname(path).dirname.join("themes.css"))
83
+ [ [ path, render ], [ tokens_path, tokens ], [ themes_path, themes ] ].each do |destination, bytes|
41
84
  destination = Pathname(destination)
42
85
  FileUtils.mkdir_p(destination.dirname)
43
86
  destination.binwrite(bytes)
@@ -5,6 +5,7 @@ require "active_support/core_ext/numeric/time"
5
5
  require "active_support/core_ext/numeric/bytes"
6
6
  require "open_blog/configuration_error"
7
7
  require "open_blog/page_views"
8
+ require "open_blog/themes"
8
9
 
9
10
  module OpenBlog
10
11
  class Configuration
@@ -17,12 +18,16 @@ module OpenBlog
17
18
  end
18
19
  end
19
20
 
21
+ THEMES = (Themes.names + [ :none ]).freeze
22
+ THEME_MODES = %i[light dark].freeze
23
+ THEME_COLOR_KEYS = Themes::COLOR_TOKENS.to_h { |token| [ token.tr("-", "_").to_sym, token ] }.freeze
24
+
20
25
  attr_accessor :site_name, :public_base_url, :default_author, :publisher, :locale,
21
26
  :blog_title, :blog_tagline, :layout, :parent_controller, :mount_path,
22
27
  :route_segments, :posts_per_page, :primary_list_type, :call_to_action,
23
28
  :default_social_image_url, :policy_urls, :sign_in_destinations, :body_formats,
24
29
  :default_body_format, :markdown_hardbreaks, :ai_label, :require_approval,
25
- :color_scheme, :syntax_theme, :feed_content, :feed_size, :serve_sitemap,
30
+ :color_scheme, :theme, :theme_colors, :faq_collapsed, :syntax_theme, :feed_content, :feed_size, :serve_sitemap,
26
31
  :page_views, :page_view_bot_pattern, :page_view_retention_days, :popular_posts,
27
32
  :preview_expires_in, :storage_service, :image_delivery, :max_image_bytes,
28
33
  :image_content_types, :image_fetch_policy, :api_rate_limit, :search_rate_limit
@@ -45,6 +50,9 @@ module OpenBlog
45
50
  @ai_label = :when_required
46
51
  @require_approval = false
47
52
  @color_scheme = :system
53
+ @theme = :signal
54
+ @theme_colors = {}
55
+ @faq_collapsed = true
48
56
  @syntax_theme = "github".dup
49
57
  @feed_content = :summary
50
58
  @feed_size = 20
@@ -101,6 +109,7 @@ module OpenBlog
101
109
  end
102
110
  invalid!(:default_body_format, "must be enabled in body_formats") unless body_formats.include?(default_body_format)
103
111
  preview_duration
112
+ validate_theme!
104
113
  validate_view_settings!
105
114
  invalid!(:mcp, "enabled must be true or false") unless [ true, false ].include?(mcp.enabled)
106
115
  invalid!(:mcp, "max_page_size must be a positive integer") unless mcp.max_page_size.is_a?(Integer) && mcp.max_page_size.positive?
@@ -115,8 +124,35 @@ module OpenBlog
115
124
  value.to_f
116
125
  end
117
126
 
127
+ def theme_colors_for(mode)
128
+ validate_theme!
129
+ theme_colors.except(*THEME_MODES).merge(theme_colors.fetch(mode, {})).to_h do |key, color|
130
+ [ THEME_COLOR_KEYS.fetch(key), color ]
131
+ end
132
+ end
133
+
118
134
  private
119
135
 
136
+ def validate_theme!
137
+ invalid!(:theme, "must be #{THEMES.map(&:inspect).join(', ')}") unless THEMES.include?(theme)
138
+ invalid!(:faq_collapsed, "must be true or false") unless [ true, false ].include?(faq_collapsed)
139
+ invalid!(:theme_colors, "must be a hash") unless theme_colors.is_a?(Hash)
140
+ theme_colors.each do |key, value|
141
+ next validate_theme_color!(key, key, value) unless THEME_MODES.include?(key)
142
+ invalid!("theme_colors.#{key}", "must be a hash") unless value.is_a?(Hash)
143
+ value.each { |token, color| validate_theme_color!("#{key}.#{token}", token, color) }
144
+ end
145
+ end
146
+
147
+ def validate_theme_color!(path, token, color)
148
+ unless THEME_COLOR_KEYS.key?(token)
149
+ invalid!("theme_colors.#{path}", "is not a colour token; use #{THEME_COLOR_KEYS.keys.join(', ')}")
150
+ end
151
+ unless color.is_a?(String) && color.encoding.ascii_compatible? && color.valid_encoding? && color.match?(Themes::HEX)
152
+ invalid!("theme_colors.#{path}", "must be a hex colour such as #0f766e")
153
+ end
154
+ end
155
+
120
156
  def validate_view_settings!
121
157
  invalid!(:page_views, "must be true or false") unless [ true, false ].include?(page_views)
122
158
  invalid!(:page_view_bot_pattern, "must be a regular expression") unless page_view_bot_pattern.is_a?(Regexp)
@@ -34,27 +34,66 @@ module OpenBlog
34
34
  end
35
35
 
36
36
  def theme
37
+ colors = Configuration::THEME_MODES.index_with { |mode| @config.theme_colors_for(mode) }
38
+ preset = Themes.fetch(@config.theme) unless @config.theme == :none
39
+ problem = theme_files(preset)
40
+ return [ "error", problem ] if problem
41
+ warnings = []
42
+ warnings << "theme_colors has no effect with theme :none." if preset.nil? && colors.values.any?(&:any?)
43
+ low = preset ? low_contrast(preset, colors) : []
44
+ warnings << "Theme colours below #{Themes::MIN_CONTRAST}:1 contrast: #{low.join(', ')}." if low.any?
45
+ stale = stale_component_styles(preset)
46
+ warnings << stale if stale
47
+ if Dir[@root.join("app/views/layouts/**/*")].select { |path| File.file?(path) }.any? { |path| File.read(path).match?(/stylesheet_link_tag\s*(?:\(?\s*):all\b/) }
48
+ warnings << "Replace stylesheet_link_tag :all to avoid loading the blog reset on other pages."
49
+ end
50
+ return [ "warning", warnings.join(" ") ] if warnings.any?
51
+ [ "ok", "Theme #{@config.theme}: sources and compiled stylesheets are available." ]
52
+ rescue ConfigurationError => error
53
+ [ "error", error.message ]
54
+ end
55
+
56
+ def low_contrast(preset, colors)
57
+ colors.flat_map do |mode, overrides|
58
+ Themes.low_contrast(preset.public_send(mode).merge(overrides)).map do |foreground, background, ratio|
59
+ "#{mode} #{foreground.tr('-', '_')} on #{background.tr('-', '_')} #{ratio.floor(2)}:1"
60
+ end
61
+ end
62
+ end
63
+
64
+ def stale_component_styles(preset)
65
+ return if layout_source.include?("open_blog_stylesheets")
66
+ path = "app/assets/tailwind/open_blog/blog.css"
67
+ source = read(path)
68
+ absent = []
69
+ absent << "preset" if preset && !source.include?("data-ob-theme")
70
+ absent << "collapsed FAQ" if @config.faq_collapsed && !source.include?("details.ob-faq-entry")
71
+ return if absent.empty?
72
+ "#{path} has no #{absent.join(' or ')} rules. Delete it#{' and theme.css in the same directory' if preset}, then run bin/rails generate open_blog:install."
73
+ end
74
+
75
+ def theme_files(preset)
37
76
  layout = layout_source
38
77
  tailwind = @root.join("app/assets/tailwind/application.css")
39
78
  bundled = @root.join("app/assets/stylesheets/application.tailwind.css")
40
79
  if !layout.include?("open_blog_stylesheets")
41
80
  asset = layout[/stylesheet_link_tag\s*\(?\s*["']([^"']+)["']/, 1]
42
- return [ "error", "The blog layout must load the installed stylesheet." ] unless %w[tailwind application].include?(asset)
81
+ return "The blog layout must load the installed stylesheet." unless %w[tailwind application].include?(asset)
82
+ return "The blog layout must call open_blog_theme_stylesheets to load the #{preset.name} theme." if preset && !layout.include?("open_blog_theme_stylesheets")
43
83
  entry = asset == "tailwind" ? tailwind : bundled
44
- return [ "error", "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." ] unless entry.file?
84
+ return "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." unless entry.file?
45
85
  source = entry.read
46
- return [ "error", "Import open_blog/theme.css in #{entry.relative_path_from(@root)}." ] unless source.match?(/@import\s+["'][^"']*open_blog\/theme(?:\.css)?["']/)
86
+ return "Import open_blog/theme.css in #{entry.relative_path_from(@root)}." unless source.match?(/@import\s+["'][^"']*open_blog\/theme(?:\.css)?["']/)
47
87
  absent = %w[theme blog syntax].reject { |name| @root.join("app/assets/tailwind/open_blog/#{name}.css").file? }
48
- return [ "error", "Missing theme sources: #{absent.join(', ')}." ] if absent.any?
49
- asset = "#{asset}.css"
88
+ return "Missing theme sources: #{absent.join(', ')}." if absent.any?
89
+ assets = [ "#{asset}.css" ]
50
90
  else
51
- return [ "error", "Install app/assets/stylesheets/open_blog_theme.css." ] unless @root.join("app/assets/stylesheets/open_blog_theme.css").file?
52
- asset = "open_blog/blog.css"
53
- return [ "error", "The blog layout must load open_blog_stylesheets." ] unless layout.include?("open_blog_stylesheets")
91
+ return "Install app/assets/stylesheets/open_blog_theme.css." unless @root.join("app/assets/stylesheets/open_blog_theme.css").file?
92
+ assets = [ "open_blog/blog.css" ]
54
93
  end
55
- return [ "error", "Build the missing #{asset} stylesheet." ] unless @application.assets&.resolver&.resolve(asset)
56
- all = Dir[@root.join("app/views/layouts/**/*")].select { |path| File.file?(path) }.any? { |path| File.read(path).match?(/stylesheet_link_tag\s*(?:\(?\s*):all\b/) }
57
- all ? [ "warning", "Replace stylesheet_link_tag :all to avoid loading the blog reset on other pages." ] : [ "ok", "Theme sources and compiled stylesheet are available." ]
94
+ assets << "open_blog/themes.css" if preset
95
+ missing = assets.reject { |asset| @application.assets&.resolver&.resolve(asset) }
96
+ "Build the missing #{missing.join(', ')} stylesheet." if missing.any?
58
97
  end
59
98
  end
60
99
  end
@@ -11,7 +11,7 @@ module OpenBlog
11
11
 
12
12
  CHECKS = { "Configuration" => :configuration, "Migrations" => :migrations, "Route" => :route,
13
13
  "Views" => :views, "Theme" => :theme, "JavaScript" => :javascript, "Authentication" => :authentication,
14
- "Rate limit" => :rate_limit, "Jobs" => :jobs, "Storage" => :storage, "Policy pages" => :policy_pages,
14
+ "Rate limit" => :rate_limit, "Jobs" => :jobs, "Storage" => :storage, "Record keys" => :record_keys, "Policy pages" => :policy_pages,
15
15
  "Records" => :records, "Declarations" => :declarations }.freeze
16
16
  TABLES = %w[open_blog_authors open_blog_categories open_blog_series open_blog_images open_blog_posts
17
17
  open_blog_api_tokens open_blog_pages open_blog_page_views open_blog_faqs open_blog_tags open_blog_taggings open_blog_redirects open_blog_revisions
@@ -126,6 +126,35 @@ module OpenBlog
126
126
  [ "ok", "Active Storage and the configured image processor are available." ]
127
127
  end
128
128
 
129
+ def record_keys
130
+ if (missing_tables & %w[active_storage_attachments open_blog_images open_blog_authors open_blog_posts action_text_rich_texts]).any?
131
+ return [ "warning", "Record keys cannot be checked until all content tables are installed." ]
132
+ end
133
+ attachment = key_kind("active_storage_attachments", "record_id")
134
+ owners = %w[open_blog_images open_blog_authors].reject { |table| key_kind(table, "id") == attachment }
135
+ if owners.any?
136
+ return [ "error", "active_storage_attachments.record_id is #{attachment}, but #{owners.map { |table| "#{table}.id is #{key_kind(table, 'id')}" }.join(' and ')}, " \
137
+ "so blog images and avatars cannot attach. Install and run the latest migrations: " \
138
+ "bin/rails railties:install:migrations FROM=open_blog, then bin/rails db:migrate." ]
139
+ end
140
+ if @config.body_formats.include?(:rich_text)
141
+ problems = []
142
+ if key_kind("action_text_rich_texts", "record_id") != key_kind("open_blog_posts", "id")
143
+ problems << "action_text_rich_texts.record_id is #{key_kind('action_text_rich_texts', 'record_id')}, but open_blog_posts.id is #{key_kind('open_blog_posts', 'id')}, so rich-text bodies cannot save"
144
+ end
145
+ if key_kind("action_text_rich_texts", "id") != attachment
146
+ problems << "action_text_rich_texts.id is #{key_kind('action_text_rich_texts', 'id')}, but active_storage_attachments.record_id is #{attachment}, so rich-text bodies cannot embed images"
147
+ end
148
+ return [ "error", "#{problems.join('; ')}. Use config.body_formats = [ :markdown ] with these host tables." ] if problems.any?
149
+ end
150
+ [ "ok", "Blog image and author IDs match active_storage_attachments.record_id (#{attachment})." ]
151
+ end
152
+
153
+ def key_kind(table, column)
154
+ found = ActiveRecord::Base.connection.columns(table).find { |candidate| candidate.name == column }
155
+ { uuid: "uuid", integer: "integer" }.fetch(found.type, found.sql_type)
156
+ end
157
+
129
158
  def policy_pages
130
159
  problems = %i[responsible_party corrections editorial ai_use].filter_map do |key|
131
160
  url = @config.policy_urls[key]
@@ -22,12 +22,12 @@ module OpenBlog
22
22
  endpoint("remove_post", :publish, "posts", :destroy, fields: ApiFields::REMOVE, id: :required),
23
23
  upload_definition,
24
24
  endpoint("list_categories", :read, "categories", :index, fields: ApiFields::PAGE),
25
- endpoint("save_category", :write, "categories", :create, fields: ApiFields::CATEGORY, id: :optional, upsert: true),
25
+ endpoint("save_category", :write, "categories", :create, fields: ApiFields::CATEGORY, id: :optional, upsert: Category),
26
26
  endpoint("list_tags", :read, "tags", :index, fields: ApiFields::PAGE),
27
27
  endpoint("list_authors", :read, "authors", :index, fields: ApiFields::PAGE),
28
- endpoint("save_author", :write, "authors", :create, fields: ApiFields::AUTHOR, id: :optional, upsert: true),
28
+ endpoint("save_author", :write, "authors", :create, fields: ApiFields::AUTHOR, id: :optional, upsert: Author),
29
29
  endpoint("list_series", :read, "series", :index, fields: ApiFields::PAGE),
30
- endpoint("save_series", :write, "series", :create, fields: ApiFields::SERIES, id: :optional, upsert: true),
30
+ endpoint("save_series", :write, "series", :create, fields: ApiFields::SERIES, id: :optional, upsert: Series),
31
31
  endpoint("list_redirects", :read, "redirects", :index, fields: ApiFields::PAGE),
32
32
  endpoint("save_redirect", :publish, "redirects", :create, fields: ApiFields::REDIRECT, required: [ :old_path ]),
33
33
  site_page_definition("get_site_page", :read),
@@ -43,7 +43,7 @@ module OpenBlog
43
43
 
44
44
  def endpoint(name, scope, controller, action, fields: [], id: nil, required: [], force: {}, upsert: false, create_publish: false, properties: nil)
45
45
  properties ||= Schemas.fields(fields)
46
- properties = properties.merge("id" => upsert ? { type: "integer", minimum: 1 } : Schemas.identifier) if id
46
+ properties = properties.merge("id" => upsert ? Schemas.key(upsert) : Schemas.identifier) if id
47
47
  required = required + (id == :required ? [ :id ] : [])
48
48
  force.each { |key, value| properties[key.to_s] = properties.fetch(key.to_s).merge(const: value) if properties.key?(key.to_s) }
49
49
  definition(name, scope, Schemas.object(properties, required: required)) do |arguments, actor:, base_url:|
@@ -15,6 +15,10 @@ module OpenBlog
15
15
  { anyOf: [ { type: "integer", minimum: 1 }, { type: "string", minLength: 1 } ] }
16
16
  end
17
17
 
18
+ def key(model)
19
+ RecordKeys.uuid?(model) ? { type: "string", format: "uuid" } : { type: "integer", minimum: 1 }
20
+ end
21
+
18
22
  def fields(names)
19
23
  names.to_h { |name| [ name.to_s, field(name) ] }
20
24
  end
@@ -44,7 +48,7 @@ module OpenBlog
44
48
  object(fields(%i[reviewer_name approved_at facts_checked evidence confirmed_by]), required: %w[reviewer_name approved_at facts_checked evidence confirmed_by])
45
49
  when :old_slugs
46
50
  { type: "array", items: { anyOf: [ text, object(fields(%i[slug moved_on]), required: [ "slug" ]) ] } }
47
- when :image_id then { type: "integer", minimum: 1 }
51
+ when :image_id then key(Image)
48
52
  when :type then { type: "string", enum: %w[person organization] }
49
53
  when :body_format then { type: "string", enum: %w[markdown rich_text] }
50
54
  when :provenance then { type: "string", enum: %w[ai_assisted human_written unknown] }
@@ -106,7 +106,7 @@ module OpenBlog
106
106
  when Integer then Author.find(input)
107
107
  when String
108
108
  invalid(:author) if input.blank?
109
- if input.match?(/\A[1-9]\d*\z/)
109
+ if input.match?(/\A[1-9]\d*\z/) || RecordKeys.uuid_id?(Author, input)
110
110
  Author.find(input)
111
111
  else
112
112
  Author.find_by(slug: input) || Author.find_by(name: input) || Author.create!(name: input)
@@ -154,7 +154,7 @@ module OpenBlog
154
154
  return Image.find(input) if input.is_a?(Integer)
155
155
  if input.is_a?(Hash) && input.keys.map(&:to_s) == [ "image_id" ]
156
156
  id = input[:image_id] || input["image_id"]
157
- return Image.find(id) if id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[1-9]\d*\z/))
157
+ return Image.find(id) if id.is_a?(Integer) || (id.is_a?(String) && id.match?(/\A[1-9]\d*\z/)) || RecordKeys.uuid_id?(Image, id)
158
158
  end
159
159
  raise Error::ImageNotPermitted
160
160
  end
@@ -0,0 +1,35 @@
1
+ module OpenBlog
2
+ # Copied host migrations call these methods, so their names and arguments must stay stable.
3
+ module RecordKeys
4
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
5
+ ATTACHING_TABLES = %w[open_blog_images open_blog_authors].freeze
6
+
7
+ module_function
8
+
9
+ def attachment_type(connection)
10
+ if connection.data_source_exists?("active_storage_attachments")
11
+ column = connection.columns("active_storage_attachments").find { |candidate| candidate.name == "record_id" }
12
+ column&.type == :uuid ? :uuid : :bigint
13
+ else
14
+ generators = Rails.configuration.generators
15
+ generators.options.dig(generators.orm, :primary_key_type)&.to_sym == :uuid ? :uuid : :bigint
16
+ end
17
+ end
18
+
19
+ def table_options(connection)
20
+ attachment_type(connection) == :uuid ? { id: :uuid } : {}
21
+ end
22
+
23
+ def uuid?(model)
24
+ model.type_for_attribute(model.primary_key).type == :uuid
25
+ end
26
+
27
+ def id?(model, value)
28
+ uuid?(model) ? value.is_a?(String) && value.match?(UUID) : value.is_a?(Integer) && value.positive?
29
+ end
30
+
31
+ def uuid_id?(model, value)
32
+ uuid?(model) && value.is_a?(String) && value.match?(UUID)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,167 @@
1
+ module OpenBlog
2
+ module Themes
3
+ Preset = Data.define(:name, :light, :dark, :tokens)
4
+ Font = Data.define(:family, :style, :weight, :file, :range)
5
+
6
+ MIN_CONTRAST = 4.5
7
+
8
+ COLOR_TOKENS = %w[
9
+ surface surface-raised surface-sunken heading text text-muted text-subtle border border-strong
10
+ accent accent-hover accent-soft accent-contrast accent-2 accent-2-soft
11
+ code-bg code-text code-border notice-bg notice-border notice-text note tip warning
12
+ ].freeze
13
+
14
+ TOKENS = %w[
15
+ font-display font-body font-mono radius-card radius-media radius-pill prose-size prose-leading
16
+ content-width page-width shadow heading-weight heading-tracking heading-transform rule-width lede-style
17
+ ].freeze
18
+
19
+ CONTRAST_PAIRS = [
20
+ %w[heading surface], %w[text surface], %w[text-muted surface], %w[text-subtle surface],
21
+ %w[heading surface-raised], %w[text surface-raised], %w[text-muted surface-raised], %w[text-subtle surface-raised],
22
+ %w[heading surface-sunken], %w[text surface-sunken],
23
+ %w[accent surface], %w[accent-hover surface], %w[accent surface-raised], %w[accent-hover surface-raised], %w[accent accent-soft],
24
+ %w[heading accent-soft], %w[text accent-soft], %w[heading accent-2-soft],
25
+ %w[accent-contrast accent], %w[notice-text notice-bg], %w[code-text code-bg],
26
+ %w[note surface-raised], %w[tip surface-raised], %w[warning surface-raised]
27
+ ].map(&:freeze).freeze
28
+
29
+ SANS = "ui-sans-serif, system-ui, sans-serif"
30
+ MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace'
31
+
32
+ PRESETS = {
33
+ signal: {
34
+ light: %w[
35
+ #ffffff #f6fbfa #eaf4f2 #0b1f22 #2b3f42 #4d6165 #5f7579 #cfe0dd #5f8a84
36
+ #0f766e #115e59 #d9f0ec #ffffff #b45309 #fdebd3
37
+ #f6fbfa #0b1f22 #b3cfca #fff7e8 #f0b35b #6b3f0a #1d5fa8 #166534 #92400e
38
+ ],
39
+ dark: %w[
40
+ #071a1c #0d2528 #041214 #eaf6f4 #c9dedb #a3bdb9 #8ba8a4 #1f4145 #5f8a84
41
+ #5eead4 #99f6e4 #0f3d3a #06201f #fbbf24 #3d2a0a
42
+ #041214 #e6f1ef #2a4d51 #33250c #b07d2c #fde7bd #7cc4f8 #86efac #fcd34d
43
+ ],
44
+ tokens: [
45
+ %("Outfit", #{SANS}), %("DM Sans", #{SANS}), MONO, "1rem", "0.75rem", "999px", "1.125rem", "1.8",
46
+ "46rem", "72rem", "0 18px 40px -20px #0b1f2259", "750", "-0.02em", "none", "1px", "normal"
47
+ ]
48
+ },
49
+ editorial: {
50
+ light: %w[
51
+ #fbf7f1 #f4ede2 #ece3d4 #1f1a16 #3a322c #5c524a #6b6158 #dccfbd #8c7b66
52
+ #8a2d2d #6f2222 #f1dedb #ffffff #b7791f #f4e6c9
53
+ #f9f4ec #1f1a16 #c9b9a3 #f6efe4 #d0b58a #5a4420 #2c5282 #2f6b3a #8a5a0b
54
+ ],
55
+ dark: %w[
56
+ #1a1512 #241d18 #120e0c #f6eee2 #ddd2c3 #b9ab9a #a39483 #3d332b #8c7b66
57
+ #e9a09a #f4c2bd #43201e #1a1512 #e0b25a #3a2c10
58
+ #110d0b #f3ebdf #4a3e34 #2e2515 #8a7040 #f0e0be #9cc3e8 #a3d9a5 #ebc46a
59
+ ],
60
+ tokens: [
61
+ '"Newsreader", Georgia, "Times New Roman", serif', '"Newsreader", Georgia, "Times New Roman", serif', MONO,
62
+ "0.375rem", "0.25rem", "999px", "1.25rem", "1.75", "42rem", "72rem", "none", "500", "-0.015em", "none", "1px", "italic"
63
+ ]
64
+ },
65
+ ink: {
66
+ light: %w[
67
+ #ffffff #f5f5f4 #ececea #0a0a0a #262626 #525252 #636363 #d4d4d4 #0a0a0a
68
+ #1f3bd6 #172c9f #e3e8ff #ffffff #c6f432 #f1fbc9
69
+ #fafafa #0a0a0a #0a0a0a #fffbe6 #0a0a0a #0a0a0a #1f3bd6 #166534 #8a4b00
70
+ ],
71
+ dark: %w[
72
+ #000000 #111111 #1a1a1a #ffffff #e5e5e5 #b5b5b5 #9a9a9a #333333 #ffffff
73
+ #8ea2ff #b4c1ff #141c4d #000000 #c6f432 #2a3505
74
+ #0d0d0d #f5f5f5 #ffffff #1f1c05 #ffffff #ffffff #8ea2ff #c6f432 #facc15
75
+ ],
76
+ tokens: [
77
+ %("Space Grotesk", #{SANS}), %("DM Sans", #{SANS}), MONO, "0", "0", "0", "1.0625rem", "1.7",
78
+ "44rem", "72rem", "none", "700", "-0.03em", "uppercase", "2px", "normal"
79
+ ]
80
+ }
81
+ }.to_h do |name, values|
82
+ [ name, Preset.new(name: name, light: COLOR_TOKENS.zip(values[:light]).to_h.freeze,
83
+ dark: COLOR_TOKENS.zip(values[:dark]).to_h.freeze, tokens: TOKENS.zip(values[:tokens]).to_h.freeze) ]
84
+ end.freeze
85
+
86
+ RANGES = {
87
+ "latin" => "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, " \
88
+ "U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
89
+ "latin-ext" => "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, " \
90
+ "U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"
91
+ }.freeze
92
+
93
+ FONTS = [
94
+ [ "Outfit", "normal", "100 900", "outfit" ],
95
+ [ "DM Sans", "normal", "100 1000", "dm-sans" ],
96
+ [ "DM Sans", "italic", "100 1000", "dm-sans-italic" ],
97
+ [ "JetBrains Mono", "normal", "100 800", "jetbrains-mono" ],
98
+ [ "Newsreader", "normal", "200 800", "newsreader" ],
99
+ [ "Newsreader", "italic", "200 800", "newsreader-italic" ],
100
+ [ "Space Grotesk", "normal", "300 700", "space-grotesk" ]
101
+ ].flat_map do |family, style, weight, file|
102
+ RANGES.map { |subset, range| Font.new(family: family, style: style, weight: weight, file: "#{file}-#{subset}.woff2", range: range) }
103
+ end.freeze
104
+
105
+ HEX = /\A#(?:\h{3}|\h{6}|\h{8})\z/
106
+
107
+ class << self
108
+ def names
109
+ PRESETS.keys
110
+ end
111
+
112
+ def fetch(name)
113
+ PRESETS.fetch(name.to_s.to_sym) do
114
+ raise ConfigurationError, "Unknown theme #{name.inspect}; use #{names.join(', ')}"
115
+ end
116
+ end
117
+
118
+ def css(name, light: {}, dark: {})
119
+ root = %(:root[data-ob-theme="#{fetch(name).name}"])
120
+ system = "@media (prefers-color-scheme: dark) {\n#{rule(%(#{root}:not([data-theme="light"])), dark, " ")}}\n"
121
+ [
122
+ (rule(root, light) if light.any?),
123
+ (rule(%(#{root}[data-theme="dark"]), dark) if dark.any?),
124
+ (system if dark.any?)
125
+ ].compact.join
126
+ end
127
+
128
+ def contrast(foreground, background, backdrop = background)
129
+ under = blend(rgba(background), rgba(backdrop).first(3))
130
+ lighter, darker = [ blend(rgba(foreground), under), under ].map { |color| luminance(color) }.sort.reverse
131
+ (lighter + 0.05) / (darker + 0.05)
132
+ end
133
+
134
+ def low_contrast(colors)
135
+ CONTRAST_PAIRS.filter_map do |foreground, background|
136
+ ratio = contrast(colors.fetch(foreground), colors.fetch(background), colors.fetch("surface"))
137
+ [ foreground, background, ratio ] if ratio < MIN_CONTRAST
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def rule(selector, values, indent = "")
144
+ lines = values.map { |token, value| "#{indent} --ob-#{token.to_s.tr('_', '-')}: #{value};\n" }
145
+ "#{indent}#{selector} {\n#{lines.join}#{indent}}\n"
146
+ end
147
+
148
+ def rgba(color)
149
+ raise ArgumentError, "Expected a hex colour, got #{color.inspect}" unless color.is_a?(String) && color.match?(HEX)
150
+ digits = color.delete_prefix("#")
151
+ digits = digits.chars.map { |digit| digit * 2 }.join if digits.length == 3
152
+ digits.ljust(8, "f").scan(/../).map { |channel| channel.to_i(16) / 255.0 }
153
+ end
154
+
155
+ def blend(color, under)
156
+ *channels, alpha = color
157
+ channels.zip(under).map { |top, bottom| top * alpha + bottom * (1 - alpha) }
158
+ end
159
+
160
+ def luminance(channels)
161
+ channels.zip([ 0.2126, 0.7152, 0.0722 ]).sum do |value, weight|
162
+ weight * (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055)**2.4)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -1,3 +1,3 @@
1
1
  module OpenBlog
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/open_blog.rb CHANGED
@@ -40,6 +40,7 @@ module OpenBlog
40
40
  autoload :Renderer, "open_blog/renderer"
41
41
  autoload :SyntaxCss, "open_blog/syntax_css"
42
42
  autoload :BuildCss, "open_blog/build_css"
43
+ autoload :Themes, "open_blog/themes"
43
44
  autoload :ReaderPage, "open_blog/reader_page"
44
45
  autoload :ReaderDates, "open_blog/reader_dates"
45
46
  autoload :JsonFeed, "open_blog/json_feed"
@@ -57,6 +58,7 @@ module OpenBlog
57
58
  autoload :Mcp, "open_blog/mcp"
58
59
  autoload :ApiFields, "open_blog/api_fields"
59
60
  autoload :Authentication, "open_blog/authentication"
61
+ autoload :RecordKeys, "open_blog/record_keys"
60
62
 
61
63
  class << self
62
64
  def config
@@ -37,7 +37,7 @@ namespace :open_blog do
37
37
  task :syntax_css, [ :path ] => :environment do |_task, arguments|
38
38
  puts "Wrote #{OpenBlog::SyntaxCss.write(path: arguments[:path])}"
39
39
  end
40
- desc "Build the packaged stylesheet and token-only theme override"
40
+ desc "Build the packaged stylesheets and the theme override stub"
41
41
  task build_css: :environment do
42
42
  options = ENV["OUT"].present? ? { path: ENV["OUT"] } : {}
43
43
  puts "Wrote #{OpenBlog::BuildCss.write(**options)}"