open_blog 0.1.0 → 0.2.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 +27 -4
- data/README.md +60 -32
- 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/pages_controller.rb +5 -5
- data/app/helpers/open_blog/faq_helper.rb +3 -1
- data/app/helpers/open_blog/theme_helper.rb +29 -5
- 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/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/themes.rb +167 -0
- data/lib/open_blog/version.rb +1 -1
- data/lib/open_blog.rb +1 -0
- data/lib/tasks/open_blog.rake +1 -1
- data/skills/open-blog-install/SKILL.md +1 -1
- metadata +23 -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
|
|
@@ -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"
|
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)}"
|
|
@@ -3,7 +3,7 @@ name: open-blog-install
|
|
|
3
3
|
description: Install and configure Open Blog in a Rails host, customize its reader theme, and check setup.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Install the gem in the requested Rails application, then run `bin/rails generate open_blog:install`. Inspect the host's JavaScript, Tailwind, image processor, mount path, and authentication setup before selecting options. Use `--skip-tailwind` when the host should keep its CSS configuration; use the generated theme override for
|
|
6
|
+
Install the gem in the requested Rails application, then run `bin/rails generate open_blog:install`. Inspect the host's JavaScript, Tailwind, image processor, mount path, and authentication setup before selecting options. Use `--skip-tailwind` when the host should keep its CSS configuration; select a reader preset with `--theme=signal`, `--theme=editorial`, or `--theme=ink`. For branding, set hex colors in `config.theme_colors`, and use the generated theme override for fonts and shape.
|
|
7
7
|
|
|
8
8
|
Run `bin/rails open_blog:doctor` or `blog_doctor` and resolve relevant errors. Configure the actual publisher, author, public base URL, and policy links. Replace placeholders with user-supplied identities. Keep the initial API token secret; installation prints it once and repeat runs do not recover it. A host authentication hook takes precedence over tokens.
|
|
9
9
|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: open_blog
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- TechWright Labs
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: json
|
|
@@ -233,6 +233,26 @@ files:
|
|
|
233
233
|
- LICENSE.txt
|
|
234
234
|
- README.md
|
|
235
235
|
- app/assets/builds/open_blog/blog.css
|
|
236
|
+
- app/assets/builds/open_blog/themes.css
|
|
237
|
+
- app/assets/fonts/open_blog/OFL-dm-sans.txt
|
|
238
|
+
- app/assets/fonts/open_blog/OFL-jetbrains-mono.txt
|
|
239
|
+
- app/assets/fonts/open_blog/OFL-newsreader.txt
|
|
240
|
+
- app/assets/fonts/open_blog/OFL-outfit.txt
|
|
241
|
+
- app/assets/fonts/open_blog/OFL-space-grotesk.txt
|
|
242
|
+
- app/assets/fonts/open_blog/dm-sans-italic-latin-ext.woff2
|
|
243
|
+
- app/assets/fonts/open_blog/dm-sans-italic-latin.woff2
|
|
244
|
+
- app/assets/fonts/open_blog/dm-sans-latin-ext.woff2
|
|
245
|
+
- app/assets/fonts/open_blog/dm-sans-latin.woff2
|
|
246
|
+
- app/assets/fonts/open_blog/jetbrains-mono-latin-ext.woff2
|
|
247
|
+
- app/assets/fonts/open_blog/jetbrains-mono-latin.woff2
|
|
248
|
+
- app/assets/fonts/open_blog/newsreader-italic-latin-ext.woff2
|
|
249
|
+
- app/assets/fonts/open_blog/newsreader-italic-latin.woff2
|
|
250
|
+
- app/assets/fonts/open_blog/newsreader-latin-ext.woff2
|
|
251
|
+
- app/assets/fonts/open_blog/newsreader-latin.woff2
|
|
252
|
+
- app/assets/fonts/open_blog/outfit-latin-ext.woff2
|
|
253
|
+
- app/assets/fonts/open_blog/outfit-latin.woff2
|
|
254
|
+
- app/assets/fonts/open_blog/space-grotesk-latin-ext.woff2
|
|
255
|
+
- app/assets/fonts/open_blog/space-grotesk-latin.woff2
|
|
236
256
|
- app/assets/javascripts/open_blog/controllers/code_copy_controller.js
|
|
237
257
|
- app/assets/javascripts/open_blog/controllers/reading_progress_controller.js
|
|
238
258
|
- app/assets/javascripts/open_blog/controllers/search_controller.js
|
|
@@ -474,6 +494,7 @@ files:
|
|
|
474
494
|
- lib/open_blog/surface_report/record_checks.rb
|
|
475
495
|
- lib/open_blog/surface_report/robots.rb
|
|
476
496
|
- lib/open_blog/syntax_css.rb
|
|
497
|
+
- lib/open_blog/themes.rb
|
|
477
498
|
- lib/open_blog/unpublish.rb
|
|
478
499
|
- lib/open_blog/version.rb
|
|
479
500
|
- lib/open_blog/write_post.rb
|