social_construct 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10cb6057e914282f135714277ab5459c5794e1892a4a2a0825c24cc64ec6259c
4
- data.tar.gz: 239310809089b66d572365265074e18a32d1ddc7c693374d60090276a38d793f
3
+ metadata.gz: 1a6da2405e4a0117c652f0273ba675242053926527418d9fdcb3acf01df55342
4
+ data.tar.gz: bb626f6d95fac62a0b0696b8c6ceb083f3109d03b74ad670f5496640f44eacb8
5
5
  SHA512:
6
- metadata.gz: f269fe451a28fae49d20e8c78953b810fe39c7db26559c80d8f555a8cc03ec9f0d80ce811ff6aa94466ba815fd6b4dfe4c19542477878729201b52f70dfbf4aa
7
- data.tar.gz: 868aa2dd22efbae1a7e105c8d10f0cfb2bbeee80f71386acdc44c1205b8672a279eefbca334255255230808863d5bde084de1b3ab5c936da39359d5da5dd4f32
6
+ metadata.gz: 29c1411422336bffdfb0fda48294395b8043dddab4ba44dd20b7bc8f3a367311a33dc1a484e79fb30403a5cfdf7c5b17d90512ac594151b7324d189a5be1a013
7
+ data.tar.gz: ca8995422185f360f50feeb4f0d0860f592e7d821b389305e8199936f388ff7b0d53672fcccc50772f34ba686b49eaa9efade2e864ab3057c2af6836eb8a24e3
data/README.md CHANGED
@@ -1,188 +1,153 @@
1
- # SocialConstruct
1
+ <img src="https://s3.brnbw.com/Stack-C9apVxz8Pg.webp" width="600">
2
2
 
3
- A Rails engine for generating social media preview cards (Open Graph images) with built-in preview functionality.
3
+ - Design using HTML/CSS
4
+ - Supports images, fonts, caching, ...
5
+ - Built-in previews in development (like mailers)
6
+ - Only requirement is Chrome(ium)
4
7
 
5
- ## Installation
8
+ ## Example
6
9
 
7
- Add to your Gemfile:
10
+ Create a card class:
8
11
 
9
12
  ```ruby
10
- gem "social_construct"
11
- ```
12
-
13
- Run the installation generator:
14
-
15
- ```bash
16
- bundle install
17
- bin/rails generate social_construct:install
18
- ```
19
-
20
- This will:
21
-
22
- - Create a configuration initializer
23
- - Set up ApplicationSocialCard base class
24
- - Create an example social card with template
25
- - Add a shared layout for social cards
26
- - Mount the preview interface in development
27
- - Create example preview classes
28
-
29
- ## Usage
30
-
31
- ### 1. Create your base social card class
32
-
33
- ```ruby
34
- # app/social_cards/application_social_card.rb
35
- class ApplicationSocialCard < SocialConstruct::BaseCard
36
- include SocialConstruct::CardConcerns
37
-
38
- # Set the logo path for your application
39
- self.logo_path = Rails.root.join("app/assets/images/logo.png")
40
- end
41
- ```
42
-
43
- ### 2. Create specific social card classes
44
-
45
- ```ruby
46
- # app/social_cards/item_social_card.rb
47
- class ItemSocialCard < ApplicationSocialCard
48
- def initialize(item)
49
- super()
50
- @item = item
13
+ class PostSocialCard < ApplicationSocialCard
14
+ def initialize(post)
15
+ @post = post
51
16
  end
52
17
 
53
- private
54
-
55
18
  def template_assigns
56
19
  {
57
- item: @item,
58
- cover_image_data_url: image_to_data_url(@item.cover_image, resize_to_limit: [480, 630], saver: {quality: 75}),
59
- logo_data_url: logo_data_url
20
+ title: @post.title,
21
+ author: @post.author_name,
22
+ avatar: image_to_data_url(@post.author.avatar)
60
23
  }
61
24
  end
62
25
  end
63
26
  ```
64
27
 
65
- ### 3. Create templates
66
-
67
- Templates go in `app/views/social_cards/` and should match your class names:
28
+ Create a template:
68
29
 
69
30
  ```erb
70
- <!-- app/views/social_cards/item_social_card.html.erb -->
31
+ <!-- app/views/social_cards/post_social_card.html.erb -->
32
+
71
33
  <div class="card">
72
- <h1><%= item.title %></h1>
73
- <!-- Your card HTML -->
34
+ <img src="<%= avatar %>" class="logo">
35
+ <h1><%= title %></h1>
36
+ <p>by <%= author %></p>
74
37
  </div>
75
38
  ```
76
39
 
77
- ### 4. Optional: Use a shared layout
78
-
79
- Create `app/views/layouts/social_cards.html.erb` for shared HTML structure:
40
+ Add a controller action:
80
41
 
81
- ```erb
82
- <!DOCTYPE html>
83
- <html>
84
- <head>
85
- <meta charset="utf-8">
86
- <style>
87
- /* Shared styles */
88
- </style>
89
- <%= yield :head %>
90
- </head>
91
- <body>
92
- <%= yield %>
93
- </body>
94
- </html>
95
- ```
42
+ ```ruby
43
+ class PostsController < ApplicationController
44
+ include SocialConstruct::Controller
96
45
 
97
- ### 5. Create preview classes for development
46
+ # ...
98
47
 
99
- ```ruby
100
- # app/social_cards/previews/item_social_card_preview.rb
101
- class ItemSocialCardPreview
102
- def default
103
- item = Item.first || Item.new(title: "Example Item")
104
- ItemSocialCard.new(item)
105
- end
48
+ def social_image
49
+ @post = Post.find(params[:id])
106
50
 
107
- def with_long_title
108
- item = Item.new(title: "This is a very long title that will test text wrapping")
109
- ItemSocialCard.new(item)
51
+ send_social_card(
52
+ PostSocialCard.new(@post),
53
+ cache_key: [@post.id, @post.updated_at]
54
+ )
110
55
  end
111
56
  end
112
57
  ```
113
58
 
114
- Visit `/rails/social_cards` in development to see all your previews.
59
+ ## Setup
115
60
 
116
- ### 6. Use in your controllers
61
+ ```sh
62
+ $ bundle add social_construct && bundle install
63
+ $ bin/rails generate social_construct:install
64
+ ```
117
65
 
118
- Include the controller concern:
66
+ ## Images
119
67
 
120
- ```ruby
121
- class ItemsController < ApplicationController
122
- include SocialConstruct::Controller
68
+ Convert ActiveStorage attachments to Base64 `data://` URLs:
123
69
 
124
- def og
125
- @item = Item.find(params[:id])
126
- render ItemSocialCard.new(@item)
127
- end
70
+ ```ruby
71
+ def template_assigns
72
+ {
73
+ cover_image: image_to_data_url(@post.cover_image),
74
+ avatar: image_to_data_url(@post.author.avatar, resize_to_limit: [200, 200])
75
+ }
128
76
  end
129
77
  ```
130
78
 
131
- The `render` method automatically handles both formats:
79
+ ## Fonts
132
80
 
133
- - `.png` - Generates the actual PNG image
134
- - `.html` - Shows the HTML preview (useful for debugging)
81
+ ### Remote fonts
135
82
 
136
- Or with caching:
83
+ Just import them normally and they should work.
137
84
 
138
- ```ruby
139
- def og
140
- @item = Item.find(params[:id])
85
+ ```erb
86
+ <style>
87
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
88
+ body { font-family: 'Inter', sans-serif; }
89
+ </style>
90
+ ```
141
91
 
142
- cache_key = [
143
- "social-cards",
144
- "item",
145
- @item.id,
146
- @item.updated_at.to_i
147
- ]
92
+ ### Local fonts
148
93
 
149
- render ItemSocialCard.new(@item), cache_key: cache_key
94
+ Store fonts in `app/assets/fonts/` and embed as `data://` URLs:
95
+
96
+ ```ruby
97
+ class LocalFontsCard < ApplicationSocialCard
98
+ def template_assigns
99
+ {
100
+ custom_font_css: generate_font_face(
101
+ "custom-font-name",
102
+ "Recursive_VF_1.085--subset-GF_latin_basic.woff2",
103
+ weight: "300 1000"
104
+ )
105
+ }
106
+ end
150
107
  end
151
108
  ```
152
109
 
153
- Alternative API:
110
+ And include in template:
154
111
 
155
- ```ruby
156
- def og
157
- @item = Item.find(params[:id])
158
- card = ItemSocialCard.new(@item)
159
-
160
- send_social_card(card,
161
- cache_key: ["social-cards", @item.id, @item.updated_at.to_i],
162
- expires_in: 7.days
163
- )
164
- end
112
+ ```erb
113
+ <style>
114
+ <%= custom_font_css %>
115
+
116
+ body {
117
+ font-family: 'custom-font-name', sans-serif;
118
+ }
119
+ </style>
165
120
  ```
166
121
 
167
- ## Configuration
122
+ ## Previews
168
123
 
169
- Configure in your initializer:
124
+ Mount the preview engine:
170
125
 
171
126
  ```ruby
172
- # config/initializers/social_construct.rb
127
+ Rails.application.routes.draw do
128
+ if Rails.env.development?
129
+ mount(SocialConstruct::Engine => "/rails/social_cards")
130
+ end
131
+ end
132
+ ```
173
133
 
174
- # Template path (default: "social_cards")
175
- Rails.application.config.social_construct.template_path = "custom_path"
134
+ Create preview classes:
176
135
 
177
- # Enable debug logging (default: false)
178
- SocialConstruct::BaseCard.debug = true
179
- ```
136
+ ```ruby
137
+ # app/social_cards/previews/post_social_card_preview.rb
138
+ class PostSocialCardPreview
139
+ def default
140
+ PostSocialCard.new(Post.first)
141
+ end
180
142
 
181
- ## Requirements
143
+ def long_title
144
+ post = Post.new(title: "A very long title that demonstrates text wrapping behavior")
145
+ PostSocialCard.new(post)
146
+ end
147
+ end
148
+ ```
182
149
 
183
- - Rails 7.0+
184
- - Ferrum (headless Chrome driver)
185
- - Marcel (MIME type detection)
150
+ Visit `http://localhost:3000/rails/social_cards` to preview all cards.
186
151
 
187
152
  ## License
188
153
 
@@ -6,14 +6,14 @@ module SocialConstruct
6
6
  include ActionView::Helpers
7
7
  include Rails.application.routes.url_helpers
8
8
 
9
- attr_reader :width, :height
10
-
11
- # Class-level debug setting
12
9
  cattr_accessor :debug, default: false
13
10
 
14
- def initialize
15
- @width = 1200
16
- @height = 630
11
+ def width
12
+ @width || 1200
13
+ end
14
+
15
+ def height
16
+ @height || 630
17
17
  end
18
18
 
19
19
  def render
@@ -33,7 +33,7 @@ module SocialConstruct
33
33
  browser_options = {
34
34
  headless: true,
35
35
  timeout: 30,
36
- window_size: [@width, @height]
36
+ window_size: [width, height]
37
37
  }
38
38
 
39
39
  # Add Docker-specific options in production
@@ -81,13 +81,38 @@ module SocialConstruct
81
81
  browser.goto("data:text/html;charset=utf-8,#{encoded_html}")
82
82
  end
83
83
 
84
- browser.set_viewport(width: @width, height: @height)
84
+ browser.set_viewport(width: width, height: height)
85
85
 
86
86
  # Wait for the page to fully load
87
87
  browser.network.wait_for_idle
88
88
 
89
- # Add extra wait for complex pages with large images
90
- sleep(0.5)
89
+ # Wait for all images and fonts to load
90
+ browser.execute(
91
+ <<~JS
92
+ return new Promise((resolve) => {
93
+ // Check if all images are loaded
94
+ const images = Array.from(document.querySelectorAll('img'));
95
+ const imagePromises = images.map(img => {
96
+ if (img.complete) return Promise.resolve();
97
+ return new Promise(res => {
98
+ img.addEventListener('load', res);
99
+ img.addEventListener('error', res);
100
+ });
101
+ });
102
+
103
+ // Check document fonts
104
+ const fontPromise = document.fonts?.ready || Promise.resolve();
105
+
106
+ // Wait for everything
107
+ Promise.all([...imagePromises, fontPromise]).then(() => {
108
+ // Small delay to ensure rendering is complete
109
+ requestAnimationFrame(() => {
110
+ setTimeout(resolve, 50);
111
+ });
112
+ });
113
+ });
114
+ JS
115
+ )
91
116
 
92
117
  # Log page readiness
93
118
  if debug
@@ -108,6 +133,21 @@ module SocialConstruct
108
133
  log_debug("Body HTML length: #{body_html_length}")
109
134
  end
110
135
 
136
+ # Ensure content is painted before screenshot
137
+ browser.execute(
138
+ <<~JS
139
+ // Force layout and paint
140
+ document.body.offsetHeight;
141
+ // Check if we have visible content
142
+ const hasContent = document.body.textContent.trim().length > 0 ||
143
+ document.querySelectorAll('img').length > 0;
144
+ if (!hasContent) {
145
+ console.warn('Page appears to have no visible content');
146
+ }
147
+ return hasContent;
148
+ JS
149
+ )
150
+
111
151
  screenshot = browser.screenshot(
112
152
  encoding: :binary,
113
153
  quality: 100,
@@ -116,27 +156,6 @@ module SocialConstruct
116
156
 
117
157
  log_debug("Screenshot generated, size: #{screenshot.bytesize} bytes")
118
158
 
119
- # Check if screenshot might be blank (very small file size indicates mostly white/single color)
120
- # Less than 10KB usually means it's mostly one color
121
- if screenshot.bytesize < 10_000
122
- log_debug("Screenshot seems too small, might be blank. Retrying with delay...", :warn)
123
-
124
- # Wait a bit more and try again
125
- sleep(1)
126
-
127
- # Force a paint
128
- browser.execute(
129
- "document.body.style.display = 'none'; document.body.offsetHeight; document.body.style.display = 'flex';"
130
- )
131
-
132
- screenshot = browser.screenshot(
133
- encoding: :binary,
134
- quality: 100,
135
- full: false
136
- )
137
- log_debug("Retry screenshot size: #{screenshot.bytesize} bytes")
138
- end
139
-
140
159
  screenshot
141
160
  rescue => e
142
161
  log_debug("Ferrum screenshot failed: #{e.message}", :error)
@@ -191,14 +210,93 @@ module SocialConstruct
191
210
 
192
211
  content_type = blob.content_type || "image/jpeg"
193
212
  image_data = blob.download
213
+ file_size = image_data.bytesize
214
+
215
+ # Check image size limitations
216
+ # 2MB hard limit
217
+ if file_size > 2_000_000
218
+ log_debug("Image is #{file_size} bytes (#{file_size / 1024 / 1024}MB), exceeds 2MB data URL limit", :error)
219
+ return nil
220
+ # 500KB warning threshold
221
+ elsif file_size > 500_000
222
+ log_debug(
223
+ "Image is #{file_size} bytes (#{file_size / 1024}KB), consider optimizing for better performance",
224
+ :warn
225
+ )
226
+ end
194
227
 
195
- "data:#{content_type};base64,#{Base64.strict_encode64(image_data)}"
228
+ encoded_data = Base64.strict_encode64(image_data)
229
+ log_debug("Image loaded: #{file_size} bytes (#{encoded_data.bytesize} bytes encoded)")
230
+
231
+ "data:#{content_type};base64,#{encoded_data}"
196
232
  rescue => e
197
233
  log_debug("Failed to convert image to data URL: #{e.message}", :error)
198
234
  nil
199
235
  end
200
236
  end
201
237
 
238
+ # Local font helper - converts font file to data URL
239
+ def font_to_data_url(font_path)
240
+ full_path = if font_path.start_with?("/")
241
+ font_path
242
+ else
243
+ Rails.root.join("app", "assets", "fonts", font_path)
244
+ end
245
+
246
+ return nil unless File.exist?(full_path)
247
+
248
+ begin
249
+ font_data = File.read(full_path)
250
+ file_size = font_data.bytesize
251
+
252
+ # Check file size limitations
253
+ # Most browsers have data URL limits around 2MB, but performance degrades after ~500KB
254
+ # 2MB hard limit
255
+ if file_size > 2_000_000
256
+ log_debug(
257
+ "Font file #{font_path} is #{file_size} bytes (#{file_size / 1024 / 1024}MB), exceeds 2MB data URL limit",
258
+ :error
259
+ )
260
+ return nil
261
+ # 500KB warning threshold
262
+ elsif file_size > 500_000
263
+ log_debug(
264
+ "Font file #{font_path} is #{file_size} bytes (#{file_size / 1024}KB), consider optimizing for better performance",
265
+ :warn
266
+ )
267
+ end
268
+
269
+ content_type = font_content_type(full_path)
270
+ encoded_data = Base64.strict_encode64(font_data)
271
+
272
+ # Base64 encoding increases size by ~33%
273
+ encoded_size = encoded_data.bytesize
274
+ log_debug("Font #{font_path} loaded: #{file_size} bytes (#{encoded_size} bytes encoded)")
275
+
276
+ "data:#{content_type};base64,#{encoded_data}"
277
+ rescue => e
278
+ log_debug("Failed to convert font to data URL: #{e.message}", :error)
279
+ nil
280
+ end
281
+ end
282
+
283
+ # Generate @font-face declaration for local fonts
284
+ def generate_font_face(family_name, font_path, weight: "normal", style: "normal", display: "swap")
285
+ data_url = font_to_data_url(font_path)
286
+ return "" unless data_url
287
+
288
+ <<~CSS
289
+ @font-face {
290
+ font-family: '#{family_name}';
291
+ src: url('#{data_url}');
292
+ font-weight: #{weight};
293
+ font-style: #{style};
294
+ font-display: #{display};
295
+ }
296
+ CSS
297
+ .html_safe
298
+ end
299
+
202
300
  def template_path
203
301
  Rails.application.config.social_construct.template_path
204
302
  end
@@ -207,5 +305,84 @@ module SocialConstruct
207
305
  return unless debug
208
306
  Rails.logger.send(level, "[SocialCard] #{message}")
209
307
  end
308
+
309
+ # Local image helper - converts image file to data URL
310
+ def image_data_url(image_path)
311
+ full_path = if image_path.start_with?("/")
312
+ image_path
313
+ else
314
+ Rails.root.join("app", "assets", "images", image_path)
315
+ end
316
+
317
+ return nil unless File.exist?(full_path)
318
+
319
+ begin
320
+ image_data = File.read(full_path)
321
+ file_size = image_data.bytesize
322
+
323
+ # Check file size limitations
324
+ if file_size > 2_000_000
325
+ log_debug(
326
+ "Image file #{image_path} is #{file_size} bytes (#{file_size / 1024 / 1024}MB), exceeds 2MB data URL limit",
327
+ :error
328
+ )
329
+ return nil
330
+ elsif file_size > 500_000
331
+ log_debug(
332
+ "Image file #{image_path} is #{file_size} bytes (#{file_size / 1024}KB), consider optimizing for better performance",
333
+ :warn
334
+ )
335
+ end
336
+
337
+ content_type = image_content_type(full_path)
338
+ encoded_data = Base64.strict_encode64(image_data)
339
+ log_debug("Image #{image_path} loaded: #{file_size} bytes (#{encoded_data.bytesize} bytes encoded)")
340
+
341
+ "data:#{content_type};base64,#{encoded_data}"
342
+ rescue => e
343
+ log_debug("Failed to convert image to data URL: #{e.message}", :error)
344
+ nil
345
+ end
346
+ end
347
+
348
+ # Determine MIME type for image files
349
+ def image_content_type(image_path)
350
+ extension = File.extname(image_path).downcase
351
+ case extension
352
+ when ".png"
353
+ "image/png"
354
+ when ".jpg", ".jpeg"
355
+ "image/jpeg"
356
+ when ".gif"
357
+ "image/gif"
358
+ when ".svg"
359
+ "image/svg+xml"
360
+ when ".webp"
361
+ "image/webp"
362
+ else
363
+ # fallback
364
+ "image/png"
365
+ end
366
+ end
367
+
368
+ # Determine MIME type for font files
369
+ def font_content_type(font_path)
370
+ extension = File.extname(font_path).downcase
371
+ case extension
372
+ when ".woff2"
373
+ "font/woff2"
374
+ when ".woff"
375
+ "font/woff"
376
+ when ".ttf"
377
+ "font/truetype"
378
+ when ".otf"
379
+ "font/opentype"
380
+ when ".eot"
381
+ "application/vnd.ms-fontobject"
382
+ else
383
+ # fallback
384
+ "font/truetype"
385
+ end
386
+ end
210
387
  end
211
388
  end
@@ -46,4 +46,5 @@
46
46
  <body>
47
47
  <%= yield %>
48
48
  </body>
49
- </html>
49
+ </html>
50
+
@@ -9,6 +9,7 @@ module SocialConstruct
9
9
  config.before_initialize do
10
10
  require "ferrum"
11
11
  require "marcel"
12
+
12
13
  end
13
14
  end
14
15
  end
@@ -1,3 +1,3 @@
1
1
  module SocialConstruct
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: social_construct
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikkel Malmberg
@@ -78,12 +78,12 @@ files:
78
78
  - lib/social_construct.rb
79
79
  - lib/social_construct/engine.rb
80
80
  - lib/social_construct/version.rb
81
- homepage: https://github.com/brnbw/social_construct
81
+ homepage: https://github.com/mikker/social_construct
82
82
  licenses: []
83
83
  metadata:
84
- homepage_uri: https://github.com/brnbw/social_construct
85
- source_code_uri: https://github.com/brnbw/social_construct
86
- changelog_uri: https://github.com/brnbw/social_construct/blob/main/CHANGELOG.md
84
+ homepage_uri: https://github.com/mikker/social_construct
85
+ source_code_uri: https://github.com/mikker/social_construct
86
+ changelog_uri: https://github.com/mikker/social_construct/blob/main/CHANGELOG.md
87
87
  rdoc_options: []
88
88
  require_paths:
89
89
  - lib