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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -4
- data/README.md +65 -30
- data/app/assets/builds/open_blog/blog.css +1 -1
- data/app/assets/builds/open_blog/themes.css +403 -0
- data/app/assets/fonts/open_blog/OFL-dm-sans.txt +93 -0
- data/app/assets/fonts/open_blog/OFL-jetbrains-mono.txt +93 -0
- data/app/assets/fonts/open_blog/OFL-newsreader.txt +93 -0
- data/app/assets/fonts/open_blog/OFL-outfit.txt +93 -0
- data/app/assets/fonts/open_blog/OFL-space-grotesk.txt +93 -0
- data/app/assets/fonts/open_blog/dm-sans-italic-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/dm-sans-italic-latin.woff2 +0 -0
- data/app/assets/fonts/open_blog/dm-sans-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/dm-sans-latin.woff2 +0 -0
- data/app/assets/fonts/open_blog/jetbrains-mono-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/jetbrains-mono-latin.woff2 +0 -0
- data/app/assets/fonts/open_blog/newsreader-italic-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/newsreader-italic-latin.woff2 +0 -0
- data/app/assets/fonts/open_blog/newsreader-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/newsreader-latin.woff2 +0 -0
- data/app/assets/fonts/open_blog/outfit-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/outfit-latin.woff2 +0 -0
- data/app/assets/fonts/open_blog/space-grotesk-latin-ext.woff2 +0 -0
- data/app/assets/fonts/open_blog/space-grotesk-latin.woff2 +0 -0
- data/app/controllers/open_blog/api/v1/authors_controller.rb +1 -1
- data/app/controllers/open_blog/api/v1/pages_controller.rb +5 -5
- data/app/controllers/open_blog/media_controller.rb +20 -3
- data/app/helpers/open_blog/content_helper.rb +9 -2
- data/app/helpers/open_blog/faq_helper.rb +3 -1
- data/app/helpers/open_blog/theme_helper.rb +29 -5
- data/app/models/open_blog/image.rb +6 -0
- data/app/queries/open_blog/api_post_query.rb +5 -1
- data/config/routes.rb +2 -0
- data/db/migrate/20261003012512_create_open_blog_authors.rb +1 -1
- data/db/migrate/20261003012515_create_open_blog_images.rb +1 -1
- data/db/migrate/20261003012516_create_open_blog_posts.rb +4 -3
- data/db/migrate/20261020120000_align_open_blog_record_keys.rb +41 -0
- data/lib/generators/open_blog/generator_support.rb +5 -5
- data/lib/generators/open_blog/install/install_generator.rb +22 -1
- data/lib/generators/open_blog/install/templates/initializer.rb.tt +1 -0
- data/lib/generators/open_blog/install/templates/theme/blog.css +194 -93
- data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +17 -81
- data/lib/generators/open_blog/install/templates/theme/theme.css +3 -85
- data/lib/generators/open_blog/views/views_generator.rb +1 -1
- data/lib/open_blog/api_fields.rb +1 -1
- data/lib/open_blog/build_css.rb +51 -8
- data/lib/open_blog/configuration.rb +37 -1
- data/lib/open_blog/doctor/files.rb +50 -11
- data/lib/open_blog/doctor.rb +30 -1
- data/lib/open_blog/mcp/registry.rb +4 -4
- data/lib/open_blog/mcp/schemas.rb +5 -1
- data/lib/open_blog/post_attributes.rb +2 -2
- data/lib/open_blog/record_keys.rb +35 -0
- data/lib/open_blog/themes.rb +167 -0
- data/lib/open_blog/version.rb +1 -1
- data/lib/open_blog.rb +2 -0
- data/lib/tasks/open_blog.rake +1 -1
- data/skills/open-blog-install/SKILL.md +52 -4
- metadata +25 -2
data/lib/open_blog/build_css.rb
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
39
|
-
|
|
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
|
|
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
|
|
84
|
+
return "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." unless entry.file?
|
|
45
85
|
source = entry.read
|
|
46
|
-
return
|
|
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
|
|
49
|
-
|
|
88
|
+
return "Missing theme sources: #{absent.join(', ')}." if absent.any?
|
|
89
|
+
assets = [ "#{asset}.css" ]
|
|
50
90
|
else
|
|
51
|
-
return
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
data/lib/open_blog/doctor.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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 ?
|
|
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
|
|
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
|
data/lib/open_blog/version.rb
CHANGED
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
|
data/lib/tasks/open_blog.rake
CHANGED
|
@@ -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
|
|
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)}"
|