social_construct 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +190 -0
- data/Rakefile +8 -0
- data/app/controllers/concerns/social_construct/controller.rb +66 -0
- data/app/controllers/social_construct/previews_controller.rb +61 -0
- data/app/models/social_construct/base_card.rb +211 -0
- data/app/views/social_construct/previews/index.html.erb +53 -0
- data/app/views/social_construct/previews/show.html.erb +56 -0
- data/config/routes.rb +7 -0
- data/lib/generators/social_construct/install/install_generator.rb +51 -0
- data/lib/generators/social_construct/install/templates/POST_INSTALL +34 -0
- data/lib/generators/social_construct/install/templates/application_social_card.rb +4 -0
- data/lib/generators/social_construct/install/templates/example_social_card.html.erb +69 -0
- data/lib/generators/social_construct/install/templates/example_social_card.rb +19 -0
- data/lib/generators/social_construct/install/templates/example_social_card_preview.rb +37 -0
- data/lib/generators/social_construct/install/templates/social_cards_layout.html.erb +49 -0
- data/lib/generators/social_construct/install/templates/social_construct.rb +10 -0
- data/lib/social_construct/engine.rb +14 -0
- data/lib/social_construct/version.rb +3 -0
- data/lib/social_construct.rb +12 -0
- metadata +104 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 10cb6057e914282f135714277ab5459c5794e1892a4a2a0825c24cc64ec6259c
|
4
|
+
data.tar.gz: 239310809089b66d572365265074e18a32d1ddc7c693374d60090276a38d793f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f269fe451a28fae49d20e8c78953b810fe39c7db26559c80d8f555a8cc03ec9f0d80ce811ff6aa94466ba815fd6b4dfe4c19542477878729201b52f70dfbf4aa
|
7
|
+
data.tar.gz: 868aa2dd22efbae1a7e105c8d10f0cfb2bbeee80f71386acdc44c1205b8672a279eefbca334255255230808863d5bde084de1b3ab5c936da39359d5da5dd4f32
|
data/README.md
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
# SocialConstruct
|
2
|
+
|
3
|
+
A Rails engine for generating social media preview cards (Open Graph images) with built-in preview functionality.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add to your Gemfile:
|
8
|
+
|
9
|
+
```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
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def template_assigns
|
56
|
+
{
|
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
|
60
|
+
}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
```
|
64
|
+
|
65
|
+
### 3. Create templates
|
66
|
+
|
67
|
+
Templates go in `app/views/social_cards/` and should match your class names:
|
68
|
+
|
69
|
+
```erb
|
70
|
+
<!-- app/views/social_cards/item_social_card.html.erb -->
|
71
|
+
<div class="card">
|
72
|
+
<h1><%= item.title %></h1>
|
73
|
+
<!-- Your card HTML -->
|
74
|
+
</div>
|
75
|
+
```
|
76
|
+
|
77
|
+
### 4. Optional: Use a shared layout
|
78
|
+
|
79
|
+
Create `app/views/layouts/social_cards.html.erb` for shared HTML structure:
|
80
|
+
|
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
|
+
```
|
96
|
+
|
97
|
+
### 5. Create preview classes for development
|
98
|
+
|
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
|
106
|
+
|
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)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
Visit `/rails/social_cards` in development to see all your previews.
|
115
|
+
|
116
|
+
### 6. Use in your controllers
|
117
|
+
|
118
|
+
Include the controller concern:
|
119
|
+
|
120
|
+
```ruby
|
121
|
+
class ItemsController < ApplicationController
|
122
|
+
include SocialConstruct::Controller
|
123
|
+
|
124
|
+
def og
|
125
|
+
@item = Item.find(params[:id])
|
126
|
+
render ItemSocialCard.new(@item)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
```
|
130
|
+
|
131
|
+
The `render` method automatically handles both formats:
|
132
|
+
|
133
|
+
- `.png` - Generates the actual PNG image
|
134
|
+
- `.html` - Shows the HTML preview (useful for debugging)
|
135
|
+
|
136
|
+
Or with caching:
|
137
|
+
|
138
|
+
```ruby
|
139
|
+
def og
|
140
|
+
@item = Item.find(params[:id])
|
141
|
+
|
142
|
+
cache_key = [
|
143
|
+
"social-cards",
|
144
|
+
"item",
|
145
|
+
@item.id,
|
146
|
+
@item.updated_at.to_i
|
147
|
+
]
|
148
|
+
|
149
|
+
render ItemSocialCard.new(@item), cache_key: cache_key
|
150
|
+
end
|
151
|
+
```
|
152
|
+
|
153
|
+
Alternative API:
|
154
|
+
|
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
|
165
|
+
```
|
166
|
+
|
167
|
+
## Configuration
|
168
|
+
|
169
|
+
Configure in your initializer:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
# config/initializers/social_construct.rb
|
173
|
+
|
174
|
+
# Template path (default: "social_cards")
|
175
|
+
Rails.application.config.social_construct.template_path = "custom_path"
|
176
|
+
|
177
|
+
# Enable debug logging (default: false)
|
178
|
+
SocialConstruct::BaseCard.debug = true
|
179
|
+
```
|
180
|
+
|
181
|
+
## Requirements
|
182
|
+
|
183
|
+
- Rails 7.0+
|
184
|
+
- Ferrum (headless Chrome driver)
|
185
|
+
- Marcel (MIME type detection)
|
186
|
+
|
187
|
+
## License
|
188
|
+
|
189
|
+
MIT
|
190
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
module SocialConstruct
|
2
|
+
module Controller
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
# Register social card mime type if not already registered
|
7
|
+
Mime::Type.register "image/png", :png unless Mime[:png]
|
8
|
+
end
|
9
|
+
|
10
|
+
# Render a social card as PNG with caching support
|
11
|
+
def send_social_card(card, cache_key: nil, expires_in: 7.days, cache_in_development: false)
|
12
|
+
# Build cache key if provided
|
13
|
+
if cache_key && (!Rails.env.development? || cache_in_development)
|
14
|
+
cache_key = Array(cache_key).join("-") if cache_key.is_a?(Array)
|
15
|
+
|
16
|
+
png_data = Rails.cache.fetch(cache_key, expires_in: expires_in) do
|
17
|
+
card.to_png
|
18
|
+
end
|
19
|
+
else
|
20
|
+
png_data = card.to_png
|
21
|
+
end
|
22
|
+
|
23
|
+
# Set caching headers
|
24
|
+
expires_in(1.day, public: true) unless Rails.env.development?
|
25
|
+
|
26
|
+
send_data(
|
27
|
+
png_data,
|
28
|
+
type: "image/png",
|
29
|
+
disposition: "inline",
|
30
|
+
filename: "#{controller_name.singularize}-social-card.png"
|
31
|
+
)
|
32
|
+
rescue => e
|
33
|
+
handle_social_card_error(e)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Allow using render with social cards
|
37
|
+
def render(*args)
|
38
|
+
if args.first.is_a?(SocialConstruct::BaseCard)
|
39
|
+
card = args.first
|
40
|
+
options = args.second || {}
|
41
|
+
|
42
|
+
respond_to do |format|
|
43
|
+
format.png { send_social_card(card, **options) }
|
44
|
+
format.html { render(html: card.render.html_safe, layout: false) }
|
45
|
+
end
|
46
|
+
else
|
47
|
+
super
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def handle_social_card_error(error)
|
54
|
+
Rails.logger.error("Social card generation failed: #{error.message}")
|
55
|
+
|
56
|
+
# Send a fallback 1x1 transparent PNG
|
57
|
+
send_data(
|
58
|
+
Base64.decode64(
|
59
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
60
|
+
),
|
61
|
+
type: "image/png",
|
62
|
+
disposition: "inline"
|
63
|
+
)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module SocialConstruct
|
2
|
+
class PreviewsController < ActionController::Base
|
3
|
+
def index
|
4
|
+
@preview_classes = find_preview_classes
|
5
|
+
end
|
6
|
+
|
7
|
+
def show
|
8
|
+
@preview_class = find_preview_class(params[:preview_name])
|
9
|
+
@preview_name = params[:preview_name]
|
10
|
+
|
11
|
+
unless @preview_class
|
12
|
+
redirect_to(previews_path, alert: "Preview not found")
|
13
|
+
return
|
14
|
+
end
|
15
|
+
|
16
|
+
@examples = @preview_class.instance_methods(false).sort
|
17
|
+
end
|
18
|
+
|
19
|
+
def preview
|
20
|
+
preview_class = find_preview_class(params[:preview_name])
|
21
|
+
example_name = params[:example_name]
|
22
|
+
|
23
|
+
unless preview_class && example_name
|
24
|
+
redirect_to(previews_path, alert: "Preview not found")
|
25
|
+
return
|
26
|
+
end
|
27
|
+
|
28
|
+
@card = preview_class.new.send(example_name)
|
29
|
+
|
30
|
+
respond_to do |format|
|
31
|
+
format.html { render(html: @card.render.html_safe, layout: false) }
|
32
|
+
format.png { send_data(@card.to_png, type: "image/png", disposition: "inline") }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def find_preview_classes
|
39
|
+
# Look for preview classes in the host app
|
40
|
+
preview_path = Rails.root.join("app/social_cards/previews")
|
41
|
+
return [] unless preview_path.exist?
|
42
|
+
|
43
|
+
Dir[preview_path.join("*_preview.rb")]
|
44
|
+
.map do |file|
|
45
|
+
require_dependency(file)
|
46
|
+
class_name = File.basename(file, ".rb").camelize
|
47
|
+
class_name.constantize if Object.const_defined?(class_name)
|
48
|
+
end
|
49
|
+
.compact
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_preview_class(name)
|
53
|
+
return nil unless name
|
54
|
+
|
55
|
+
class_name = "#{name.camelize}Preview"
|
56
|
+
class_name.constantize if Object.const_defined?(class_name)
|
57
|
+
rescue NameError
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,211 @@
|
|
1
|
+
require "base64"
|
2
|
+
require "tempfile"
|
3
|
+
|
4
|
+
module SocialConstruct
|
5
|
+
class BaseCard
|
6
|
+
include ActionView::Helpers
|
7
|
+
include Rails.application.routes.url_helpers
|
8
|
+
|
9
|
+
attr_reader :width, :height
|
10
|
+
|
11
|
+
# Class-level debug setting
|
12
|
+
cattr_accessor :debug, default: false
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@width = 1200
|
16
|
+
@height = 630
|
17
|
+
end
|
18
|
+
|
19
|
+
def render
|
20
|
+
ApplicationController.render(
|
21
|
+
template: template_name,
|
22
|
+
layout: layout_name,
|
23
|
+
locals: template_assigns.merge(
|
24
|
+
default_url_options: Rails.application.config.action_controller.default_url_options
|
25
|
+
)
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_png
|
30
|
+
log_debug("Starting PNG generation for #{self.class.name}")
|
31
|
+
|
32
|
+
# Use 1x resolution for better performance and reliability
|
33
|
+
browser_options = {
|
34
|
+
headless: true,
|
35
|
+
timeout: 30,
|
36
|
+
window_size: [@width, @height]
|
37
|
+
}
|
38
|
+
|
39
|
+
# Add Docker-specific options in production
|
40
|
+
if Rails.env.production? || ENV["DOCKER_CONTAINER"].present?
|
41
|
+
browser_options[:browser_options] = {
|
42
|
+
:"no-sandbox" => nil,
|
43
|
+
:"disable-dev-shm-usage" => nil,
|
44
|
+
:"disable-gpu" => nil,
|
45
|
+
:"disable-software-rasterizer" => nil,
|
46
|
+
:"disable-web-security" => nil,
|
47
|
+
:"force-color-profile" => "srgb"
|
48
|
+
}
|
49
|
+
log_debug("Using production browser options")
|
50
|
+
end
|
51
|
+
|
52
|
+
browser = Ferrum::Browser.new(browser_options)
|
53
|
+
|
54
|
+
html_content = render
|
55
|
+
log_debug("HTML content length: #{html_content.length} bytes")
|
56
|
+
log_debug("HTML encoding: #{html_content.encoding}")
|
57
|
+
|
58
|
+
# For large HTML content (with embedded images), use a temp file instead of data URL
|
59
|
+
# 1MB threshold
|
60
|
+
if html_content.length > 1_000_000
|
61
|
+
log_debug("HTML too large for data URL, using temp file")
|
62
|
+
|
63
|
+
require "tempfile"
|
64
|
+
|
65
|
+
temp_file = Tempfile.new(["social_card", ".html"])
|
66
|
+
temp_file.write(html_content)
|
67
|
+
temp_file.rewind
|
68
|
+
temp_file.close
|
69
|
+
|
70
|
+
begin
|
71
|
+
browser.goto("file://#{temp_file.path}")
|
72
|
+
ensure
|
73
|
+
# Will be deleted after browser loads it
|
74
|
+
temp_file.unlink
|
75
|
+
end
|
76
|
+
else
|
77
|
+
# Ensure UTF-8 encoding
|
78
|
+
html_content = html_content.force_encoding("UTF-8")
|
79
|
+
encoded_html = ERB::Util.url_encode(html_content)
|
80
|
+
|
81
|
+
browser.goto("data:text/html;charset=utf-8,#{encoded_html}")
|
82
|
+
end
|
83
|
+
|
84
|
+
browser.set_viewport(width: @width, height: @height)
|
85
|
+
|
86
|
+
# Wait for the page to fully load
|
87
|
+
browser.network.wait_for_idle
|
88
|
+
|
89
|
+
# Add extra wait for complex pages with large images
|
90
|
+
sleep(0.5)
|
91
|
+
|
92
|
+
# Log page readiness
|
93
|
+
if debug
|
94
|
+
page_ready = browser.evaluate("document.readyState")
|
95
|
+
log_debug("Page ready state: #{page_ready}")
|
96
|
+
|
97
|
+
# Log computed styles to check if CSS is applied
|
98
|
+
body_bg = browser.evaluate("window.getComputedStyle(document.body).backgroundColor")
|
99
|
+
log_debug("Body background color: #{body_bg}")
|
100
|
+
|
101
|
+
# Check if content is visible
|
102
|
+
has_title = browser.evaluate("!!document.querySelector('.title')")
|
103
|
+
title_text = browser.evaluate("document.querySelector('.title')?.textContent") if has_title
|
104
|
+
log_debug("Has title element: #{has_title}, Title text: #{title_text}")
|
105
|
+
|
106
|
+
# Check HTML body content
|
107
|
+
body_html_length = browser.evaluate("document.body.innerHTML.length")
|
108
|
+
log_debug("Body HTML length: #{body_html_length}")
|
109
|
+
end
|
110
|
+
|
111
|
+
screenshot = browser.screenshot(
|
112
|
+
encoding: :binary,
|
113
|
+
quality: 100,
|
114
|
+
full: false
|
115
|
+
)
|
116
|
+
|
117
|
+
log_debug("Screenshot generated, size: #{screenshot.bytesize} bytes")
|
118
|
+
|
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
|
+
screenshot
|
141
|
+
rescue => e
|
142
|
+
log_debug("Ferrum screenshot failed: #{e.message}", :error)
|
143
|
+
log_debug("Backtrace: #{e.backtrace.first(5).join("\n")}", :error) if debug
|
144
|
+
raise
|
145
|
+
ensure
|
146
|
+
browser&.quit
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
def template_name
|
152
|
+
# Use configured template path from engine
|
153
|
+
template_path = Rails.application.config.social_construct.template_path
|
154
|
+
"#{template_path}/#{self.class.name.demodulize.underscore}"
|
155
|
+
end
|
156
|
+
|
157
|
+
def layout_name
|
158
|
+
# Check if a social cards layout exists
|
159
|
+
layout_path = "layouts/#{Rails.application.config.social_construct.template_path}"
|
160
|
+
if template_exists?(layout_path)
|
161
|
+
layout_path
|
162
|
+
else
|
163
|
+
false
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def template_exists?(path)
|
168
|
+
ApplicationController.view_paths.any? do |resolver|
|
169
|
+
resolver.find_all(path, [], false, locale: [], formats: [:html], variants: [], handlers: [:erb]).any?
|
170
|
+
end
|
171
|
+
|
172
|
+
rescue
|
173
|
+
false
|
174
|
+
end
|
175
|
+
|
176
|
+
def template_assigns
|
177
|
+
{}
|
178
|
+
end
|
179
|
+
|
180
|
+
def image_to_data_url(attachment, variant_options = {})
|
181
|
+
return nil unless attachment.attached?
|
182
|
+
|
183
|
+
begin
|
184
|
+
# Ensure high quality defaults
|
185
|
+
options = {
|
186
|
+
saver: {quality: 90, strip: true}
|
187
|
+
}.deep_merge(variant_options)
|
188
|
+
|
189
|
+
variant = attachment.variant(options)
|
190
|
+
blob = variant.processed
|
191
|
+
|
192
|
+
content_type = blob.content_type || "image/jpeg"
|
193
|
+
image_data = blob.download
|
194
|
+
|
195
|
+
"data:#{content_type};base64,#{Base64.strict_encode64(image_data)}"
|
196
|
+
rescue => e
|
197
|
+
log_debug("Failed to convert image to data URL: #{e.message}", :error)
|
198
|
+
nil
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def template_path
|
203
|
+
Rails.application.config.social_construct.template_path
|
204
|
+
end
|
205
|
+
|
206
|
+
def log_debug(message, level = :info)
|
207
|
+
return unless debug
|
208
|
+
Rails.logger.send(level, "[SocialCard] #{message}")
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html class="h-full">
|
3
|
+
<head>
|
4
|
+
<title>Social Card Previews</title>
|
5
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
6
|
+
</head>
|
7
|
+
<body class="h-full bg-gray-50">
|
8
|
+
<div class="min-h-full">
|
9
|
+
<header class="bg-white shadow-sm">
|
10
|
+
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
11
|
+
<h1 class="text-2xl font-semibold leading-6 text-gray-900">Social Card Previews</h1>
|
12
|
+
</div>
|
13
|
+
</header>
|
14
|
+
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
15
|
+
|
16
|
+
<div class="overflow-hidden bg-white shadow rounded-lg">
|
17
|
+
<% if @preview_classes.any? %>
|
18
|
+
<ul role="list" class="divide-y divide-gray-200">
|
19
|
+
<% @preview_classes.each do |preview_class| %>
|
20
|
+
<li class="px-6 py-4 hover:bg-gray-50">
|
21
|
+
<% preview_name = preview_class.name.underscore.sub(/_preview$/, '') %>
|
22
|
+
<%= link_to social_construct.preview_path(preview_name), class: "block" do %>
|
23
|
+
<div class="flex items-center justify-between">
|
24
|
+
<div>
|
25
|
+
<p class="text-lg font-medium text-indigo-600 hover:text-indigo-500">
|
26
|
+
<%= preview_class.name.sub(/Preview$/, '').underscore.humanize %>
|
27
|
+
</p>
|
28
|
+
<p class="mt-1 text-sm text-gray-500">
|
29
|
+
<%= preview_class.name %>
|
30
|
+
</p>
|
31
|
+
</div>
|
32
|
+
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
33
|
+
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
|
34
|
+
</svg>
|
35
|
+
</div>
|
36
|
+
<% end %>
|
37
|
+
</li>
|
38
|
+
<% end %>
|
39
|
+
</ul>
|
40
|
+
<% else %>
|
41
|
+
<div class="px-6 py-12 text-center">
|
42
|
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
43
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
44
|
+
</svg>
|
45
|
+
<h3 class="mt-2 text-sm font-semibold text-gray-900">No preview classes</h3>
|
46
|
+
<p class="mt-1 text-sm text-gray-500">Create preview classes in <code class="font-mono text-xs">app/social_cards/previews/</code></p>
|
47
|
+
</div>
|
48
|
+
<% end %>
|
49
|
+
</div>
|
50
|
+
</main>
|
51
|
+
</div>
|
52
|
+
</body>
|
53
|
+
</html>
|
@@ -0,0 +1,56 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html class="h-full">
|
3
|
+
<head>
|
4
|
+
<title><%= @preview_class.name.sub(/Preview$/, '').underscore.humanize %> - Social Card Previews</title>
|
5
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
6
|
+
</head>
|
7
|
+
<body class="h-full bg-gray-50">
|
8
|
+
<div class="min-h-full">
|
9
|
+
<header class="bg-white shadow-sm">
|
10
|
+
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
11
|
+
<nav class="mb-4">
|
12
|
+
<%= link_to social_construct.previews_path, class: "inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-700" do %>
|
13
|
+
<svg class="mr-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
14
|
+
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
|
15
|
+
</svg>
|
16
|
+
Back to all previews
|
17
|
+
<% end %>
|
18
|
+
</nav>
|
19
|
+
<h1 class="text-2xl font-semibold leading-6 text-gray-900"><%= @preview_class.name.sub(/Preview$/, '').underscore.humanize %></h1>
|
20
|
+
</div>
|
21
|
+
</header>
|
22
|
+
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
23
|
+
|
24
|
+
<div class="overflow-hidden bg-white shadow rounded-lg">
|
25
|
+
<ul role="list" class="divide-y divide-gray-200">
|
26
|
+
<% @examples.each do |example| %>
|
27
|
+
<li class="px-6 py-4">
|
28
|
+
<div class="flex items-center justify-between">
|
29
|
+
<div>
|
30
|
+
<%= link_to social_construct.example_preview_path(@preview_name, example), class: "text-lg font-medium text-indigo-600 hover:text-indigo-500" do %>
|
31
|
+
<%= example.to_s.humanize %>
|
32
|
+
<% end %>
|
33
|
+
</div>
|
34
|
+
<div class="ml-4 flex flex-shrink-0 space-x-2">
|
35
|
+
<%= link_to social_construct.example_preview_path(@preview_name, example, format: :html), class: "inline-flex items-center rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" do %>
|
36
|
+
<svg class="-ml-0.5 mr-1.5 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
37
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
38
|
+
</svg>
|
39
|
+
HTML
|
40
|
+
<% end %>
|
41
|
+
<%= link_to social_construct.example_preview_path(@preview_name, example, format: :png), class: "inline-flex items-center rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" do %>
|
42
|
+
<svg class="-ml-0.5 mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
43
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
44
|
+
</svg>
|
45
|
+
PNG
|
46
|
+
<% end %>
|
47
|
+
</div>
|
48
|
+
</div>
|
49
|
+
</li>
|
50
|
+
<% end %>
|
51
|
+
</ul>
|
52
|
+
</div>
|
53
|
+
</main>
|
54
|
+
</div>
|
55
|
+
</body>
|
56
|
+
</html>
|
data/config/routes.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require "rails/generators/base"
|
2
|
+
|
3
|
+
module SocialConstruct
|
4
|
+
module Generators
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
7
|
+
|
8
|
+
desc "Creates SocialConstruct initializer, base classes, and example files"
|
9
|
+
|
10
|
+
def create_initializer_file
|
11
|
+
template("social_construct.rb", "config/initializers/social_construct.rb")
|
12
|
+
end
|
13
|
+
|
14
|
+
def create_application_social_card
|
15
|
+
template("application_social_card.rb", "app/social_cards/application_social_card.rb")
|
16
|
+
end
|
17
|
+
|
18
|
+
def create_example_social_card
|
19
|
+
template("example_social_card.rb", "app/social_cards/example_social_card.rb")
|
20
|
+
end
|
21
|
+
|
22
|
+
def create_example_template
|
23
|
+
template("example_social_card.html.erb", "app/views/social_cards/example_social_card.html.erb")
|
24
|
+
end
|
25
|
+
|
26
|
+
def create_social_cards_layout
|
27
|
+
template("social_cards_layout.html.erb", "app/views/layouts/social_cards.html.erb")
|
28
|
+
end
|
29
|
+
|
30
|
+
def create_example_preview
|
31
|
+
template("example_social_card_preview.rb", "app/social_cards/previews/example_social_card_preview.rb")
|
32
|
+
end
|
33
|
+
|
34
|
+
def add_route
|
35
|
+
route_string = <<-RUBY
|
36
|
+
|
37
|
+
# Social card previews (development only)
|
38
|
+
if Rails.env.development?
|
39
|
+
mount SocialConstruct::Engine, at: "/rails/social_cards"
|
40
|
+
end
|
41
|
+
RUBY
|
42
|
+
|
43
|
+
route(route_string)
|
44
|
+
end
|
45
|
+
|
46
|
+
def display_post_install
|
47
|
+
readme("POST_INSTALL") if behavior == :invoke
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
===============================================================================
|
2
|
+
|
3
|
+
SocialConstruct has been successfully installed! 🎉
|
4
|
+
|
5
|
+
Next steps:
|
6
|
+
|
7
|
+
1. Update the logo path in app/social_cards/application_social_card.rb
|
8
|
+
to point to your actual logo file.
|
9
|
+
|
10
|
+
2. Run your Rails server and visit:
|
11
|
+
http://localhost:3000/rails/social_cards
|
12
|
+
|
13
|
+
You should see the example social card previews.
|
14
|
+
|
15
|
+
3. Create your own social card classes:
|
16
|
+
- Inherit from ApplicationSocialCard
|
17
|
+
- Create matching templates in app/views/social_cards/
|
18
|
+
- Add preview classes in app/social_cards/previews/
|
19
|
+
|
20
|
+
4. Use in your controllers:
|
21
|
+
|
22
|
+
class YourController < ApplicationController
|
23
|
+
include SocialConstruct::Controller
|
24
|
+
|
25
|
+
def og
|
26
|
+
@model = Model.find(params[:id])
|
27
|
+
render YourSocialCard.new(@model)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
For more information, see:
|
32
|
+
https://github.com/brnbw/social_construct
|
33
|
+
|
34
|
+
===============================================================================
|
@@ -0,0 +1,69 @@
|
|
1
|
+
<% content_for :head do %>
|
2
|
+
<style>
|
3
|
+
body {
|
4
|
+
background-color: <%= background_color %>;
|
5
|
+
display: flex;
|
6
|
+
align-items: center;
|
7
|
+
justify-content: center;
|
8
|
+
color: white;
|
9
|
+
text-align: center;
|
10
|
+
padding: 60px;
|
11
|
+
}
|
12
|
+
|
13
|
+
.content {
|
14
|
+
max-width: 900px;
|
15
|
+
}
|
16
|
+
|
17
|
+
.title {
|
18
|
+
font-size: 72px;
|
19
|
+
font-weight: 800;
|
20
|
+
line-height: 1.1;
|
21
|
+
margin-bottom: 24px;
|
22
|
+
letter-spacing: -2px;
|
23
|
+
}
|
24
|
+
|
25
|
+
.subtitle {
|
26
|
+
font-size: 32px;
|
27
|
+
font-weight: 400;
|
28
|
+
opacity: 0.8;
|
29
|
+
line-height: 1.3;
|
30
|
+
}
|
31
|
+
|
32
|
+
.logo {
|
33
|
+
position: absolute;
|
34
|
+
bottom: 60px;
|
35
|
+
right: 60px;
|
36
|
+
}
|
37
|
+
|
38
|
+
.logo img {
|
39
|
+
height: 40px;
|
40
|
+
width: auto;
|
41
|
+
opacity: 0.9;
|
42
|
+
}
|
43
|
+
|
44
|
+
.decoration {
|
45
|
+
position: absolute;
|
46
|
+
top: 0;
|
47
|
+
left: 0;
|
48
|
+
right: 0;
|
49
|
+
bottom: 0;
|
50
|
+
background: linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.1) 100%);
|
51
|
+
pointer-events: none;
|
52
|
+
}
|
53
|
+
</style>
|
54
|
+
<% end %>
|
55
|
+
|
56
|
+
<div class="decoration"></div>
|
57
|
+
|
58
|
+
<div class="content">
|
59
|
+
<h1 class="title"><%= title %></h1>
|
60
|
+
<% if subtitle.present? %>
|
61
|
+
<p class="subtitle"><%= subtitle %></p>
|
62
|
+
<% end %>
|
63
|
+
</div>
|
64
|
+
|
65
|
+
<% if logo_data_url %>
|
66
|
+
<div class="logo">
|
67
|
+
<img src="<%= logo_data_url %>" alt="Logo">
|
68
|
+
</div>
|
69
|
+
<% end %>
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class ExampleSocialCard < ApplicationSocialCard
|
2
|
+
def initialize(title: "Hello World", subtitle: nil, background_color: "#1a1a1a")
|
3
|
+
super()
|
4
|
+
@title = title
|
5
|
+
@subtitle = subtitle
|
6
|
+
@background_color = background_color
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def template_assigns
|
12
|
+
{
|
13
|
+
title: @title,
|
14
|
+
subtitle: @subtitle,
|
15
|
+
background_color: @background_color,
|
16
|
+
logo_data_url: logo_data_url
|
17
|
+
}
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class ExampleSocialCardPreview
|
2
|
+
def default
|
3
|
+
ExampleSocialCard.new(
|
4
|
+
title: "Welcome to SocialConstruct",
|
5
|
+
subtitle: "Beautiful social cards for your Rails app"
|
6
|
+
)
|
7
|
+
end
|
8
|
+
|
9
|
+
def dark_theme
|
10
|
+
ExampleSocialCard.new(
|
11
|
+
title: "Dark Theme Example",
|
12
|
+
subtitle: "Perfect for modern applications",
|
13
|
+
background_color: "#0a0a0a"
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
def colorful
|
18
|
+
ExampleSocialCard.new(
|
19
|
+
title: "Colorful Background",
|
20
|
+
subtitle: "Make your cards stand out",
|
21
|
+
background_color: "#6366f1"
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
def long_title
|
26
|
+
ExampleSocialCard.new(
|
27
|
+
title: "This is a very long title that demonstrates how text wrapping works in social cards",
|
28
|
+
subtitle: "Subtitle remains readable"
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def no_subtitle
|
33
|
+
ExampleSocialCard.new(
|
34
|
+
title: "Simple and Clean"
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="<%= I18n.locale %>">
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
6
|
+
<style>
|
7
|
+
/* Reset and base styles for all social cards */
|
8
|
+
* {
|
9
|
+
margin: 0;
|
10
|
+
padding: 0;
|
11
|
+
box-sizing: border-box;
|
12
|
+
}
|
13
|
+
|
14
|
+
html {
|
15
|
+
width: 1200px;
|
16
|
+
height: 630px;
|
17
|
+
background: white;
|
18
|
+
}
|
19
|
+
|
20
|
+
body {
|
21
|
+
width: 1200px;
|
22
|
+
height: 630px;
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Arial', sans-serif;
|
24
|
+
margin: 0;
|
25
|
+
padding: 0;
|
26
|
+
position: relative;
|
27
|
+
overflow: hidden;
|
28
|
+
-webkit-font-smoothing: antialiased;
|
29
|
+
-moz-osx-font-smoothing: grayscale;
|
30
|
+
}
|
31
|
+
|
32
|
+
/* Ensure images don't have borders */
|
33
|
+
img {
|
34
|
+
border: 0;
|
35
|
+
max-width: 100%;
|
36
|
+
}
|
37
|
+
|
38
|
+
/* Default text rendering */
|
39
|
+
h1, h2, h3, h4, h5, h6, p {
|
40
|
+
font-weight: normal;
|
41
|
+
margin: 0;
|
42
|
+
}
|
43
|
+
</style>
|
44
|
+
<%= yield :head %>
|
45
|
+
</head>
|
46
|
+
<body>
|
47
|
+
<%= yield %>
|
48
|
+
</body>
|
49
|
+
</html>
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# SocialConstruct configuration
|
2
|
+
Rails.application.configure do
|
3
|
+
# Configure the template path for social card views
|
4
|
+
# Default: "social_cards"
|
5
|
+
# config.social_construct.template_path = "social_cards"
|
6
|
+
|
7
|
+
# Enable debug logging for social card generation
|
8
|
+
# Default: false
|
9
|
+
# SocialConstruct::BaseCard.debug = true
|
10
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module SocialConstruct
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace SocialConstruct
|
4
|
+
|
5
|
+
config.social_construct = ActiveSupport::OrderedOptions.new
|
6
|
+
config.social_construct.template_path = "social_cards"
|
7
|
+
|
8
|
+
# Ensure dependencies are loaded
|
9
|
+
config.before_initialize do
|
10
|
+
require "ferrum"
|
11
|
+
require "marcel"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require "social_construct/version"
|
2
|
+
require "social_construct/engine"
|
3
|
+
|
4
|
+
# Dependencies
|
5
|
+
require "ferrum"
|
6
|
+
require "marcel"
|
7
|
+
|
8
|
+
module SocialConstruct
|
9
|
+
# Configuration for template paths and other settings
|
10
|
+
mattr_accessor :template_path
|
11
|
+
@@template_path = "social_cards"
|
12
|
+
end
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: social_construct
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mikkel Malmberg
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: rails
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - ">="
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '7.0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - ">="
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '7.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: ferrum
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0.13'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0.13'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: marcel
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '1.0'
|
47
|
+
type: :runtime
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '1.0'
|
54
|
+
description: A flexible Rails engine for generating social media preview cards (Open
|
55
|
+
Graph images) with built-in preview functionality
|
56
|
+
email:
|
57
|
+
- mikkel@brnbw.com
|
58
|
+
executables: []
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files: []
|
61
|
+
files:
|
62
|
+
- README.md
|
63
|
+
- Rakefile
|
64
|
+
- app/controllers/concerns/social_construct/controller.rb
|
65
|
+
- app/controllers/social_construct/previews_controller.rb
|
66
|
+
- app/models/social_construct/base_card.rb
|
67
|
+
- app/views/social_construct/previews/index.html.erb
|
68
|
+
- app/views/social_construct/previews/show.html.erb
|
69
|
+
- config/routes.rb
|
70
|
+
- lib/generators/social_construct/install/install_generator.rb
|
71
|
+
- lib/generators/social_construct/install/templates/POST_INSTALL
|
72
|
+
- lib/generators/social_construct/install/templates/application_social_card.rb
|
73
|
+
- lib/generators/social_construct/install/templates/example_social_card.html.erb
|
74
|
+
- lib/generators/social_construct/install/templates/example_social_card.rb
|
75
|
+
- lib/generators/social_construct/install/templates/example_social_card_preview.rb
|
76
|
+
- lib/generators/social_construct/install/templates/social_cards_layout.html.erb
|
77
|
+
- lib/generators/social_construct/install/templates/social_construct.rb
|
78
|
+
- lib/social_construct.rb
|
79
|
+
- lib/social_construct/engine.rb
|
80
|
+
- lib/social_construct/version.rb
|
81
|
+
homepage: https://github.com/brnbw/social_construct
|
82
|
+
licenses: []
|
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
|
87
|
+
rdoc_options: []
|
88
|
+
require_paths:
|
89
|
+
- lib
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - ">="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '0'
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0'
|
100
|
+
requirements: []
|
101
|
+
rubygems_version: 3.6.9
|
102
|
+
specification_version: 4
|
103
|
+
summary: Rails engine for generating social media preview cards
|
104
|
+
test_files: []
|