jekyll-og-image 2.1.0 → 3.0.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/README.md +41 -27
- data/assets/icon.png +0 -0
- data/assets/icon.svg +39 -0
- data/lib/jekyll_og_image/configuration.rb +35 -23
- data/lib/jekyll_og_image/element/base.rb +15 -16
- data/lib/jekyll_og_image/element/border.rb +12 -29
- data/lib/jekyll_og_image/element/canvas.rb +0 -2
- data/lib/jekyll_og_image/element/image.rb +7 -7
- data/lib/jekyll_og_image/element/text.rb +18 -6
- data/lib/jekyll_og_image/generator.rb +148 -97
- data/lib/jekyll_og_image/layout.rb +80 -0
- data/lib/jekyll_og_image/manifest.rb +43 -0
- data/lib/jekyll_og_image/version.rb +1 -1
- metadata +8 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a95a1847180a7d4565e7e951467ff2293fb88e7182b08a58f362aee77f4a1d9
|
|
4
|
+
data.tar.gz: f11ab1b7b52feec3c6052b46884da96dd1973fc863293d74ac36ac7c22142bd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e49a4a7fb8884c6f3898dae7ec54b8185809d1211544d16ad8cbe8b70a60aa4be81aff27a60f182873b6e62048f2fb9bc402dd92c1f4b3d8f8b1b8ceda411ec3
|
|
7
|
+
data.tar.gz: 5879cfbe269524ebf28e08dfd68f79d0e1782471b09f7dd74390133a7c62930a846957ac42e2351414f0a7c3d99738acc571905dc2449034771874fd5a6c2d6f
|
data/README.md
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/icon.svg" alt="Jekyll OG Image" width="128" height="128">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">Jekyll OG Image</h1>
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
A Jekyll plugin to automatically generate open graph images for posts.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://badge.fury.io/rb/jekyll-og-image"><img src="https://badge.fury.io/rb/jekyll-og-image.svg" alt="Gem Version"></a>
|
|
13
|
+
<a href="https://github.com/igor-alexandrov/jekyll-og-image/actions/workflows/lint.yml"><img src="https://github.com/igor-alexandrov/jekyll-og-image/actions/workflows/lint.yml/badge.svg?branch=main" alt="Lint"></a>
|
|
14
|
+
<a href="https://github.com/igor-alexandrov/jekyll-og-image/actions/workflows/tests.yml"><img src="https://github.com/igor-alexandrov/jekyll-og-image/actions/workflows/tests.yml/badge.svg?branch=main" alt="Tests"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="examples/1.png" alt="Example open graph image" width="600">
|
|
19
|
+
</p>
|
|
8
20
|
|
|
9
21
|
## Installation
|
|
10
22
|
|
|
11
23
|
Add this line to your site's Gemfile:
|
|
12
24
|
|
|
13
25
|
```ruby
|
|
26
|
+
gem 'jekyll-seo-tag'
|
|
14
27
|
gem 'jekyll-og-image'
|
|
15
28
|
```
|
|
16
29
|
|
|
@@ -22,13 +35,11 @@ plugins:
|
|
|
22
35
|
- jekyll-og-image
|
|
23
36
|
```
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
The plugin requires Ruby 3.2 or newer and [libvips](https://www.libvips.org/install.html). If you are using GitHub Pages to host your Jekyll site, don't forget to install `libvips` before running `jekyll build`. See the example below.
|
|
26
39
|
|
|
27
40
|
``` yaml
|
|
28
41
|
jobs:
|
|
29
42
|
build:
|
|
30
|
-
needs:
|
|
31
|
-
- lint
|
|
32
43
|
runs-on: ubuntu-latest
|
|
33
44
|
steps:
|
|
34
45
|
- name: Checkout
|
|
@@ -40,16 +51,6 @@ jobs:
|
|
|
40
51
|
ruby-version: .ruby-version
|
|
41
52
|
bundler-cache: true
|
|
42
53
|
|
|
43
|
-
- name: Set Node.js 20.x
|
|
44
|
-
uses: actions/setup-node@v3
|
|
45
|
-
with:
|
|
46
|
-
node-version: 20.x
|
|
47
|
-
|
|
48
|
-
- name: Run install
|
|
49
|
-
uses: borales/actions-yarn@v4
|
|
50
|
-
with:
|
|
51
|
-
cmd: install
|
|
52
|
-
|
|
53
54
|
- name: Update apt
|
|
54
55
|
env:
|
|
55
56
|
DEBIAN_FRONTEND: noninteractive
|
|
@@ -65,19 +66,28 @@ jobs:
|
|
|
65
66
|
uses: actions/configure-pages@v5
|
|
66
67
|
|
|
67
68
|
- name: Build with Jekyll
|
|
68
|
-
run:
|
|
69
|
+
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
|
|
69
70
|
env:
|
|
70
71
|
JEKYLL_ENV: production
|
|
71
72
|
|
|
72
73
|
- name: Upload artifact
|
|
73
74
|
# Automatically uploads an artifact from the './_site' directory by default
|
|
74
75
|
uses: actions/upload-pages-artifact@v3
|
|
75
|
-
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
## Usage
|
|
79
79
|
|
|
80
|
-
Jekyll OG Image works together with [jekyll-seo-tag](https://github.com/jekyll/jekyll-seo-tag) plugin. It
|
|
80
|
+
Jekyll OG Image works together with the [jekyll-seo-tag](https://github.com/jekyll/jekyll-seo-tag) plugin. It generates open graph images for posts and sets them as the `image` in each post's front matter, which jekyll-seo-tag turns into `og:image` and `twitter:image` meta tags. jekyll-seo-tag isn't installed automatically, so add it to your Gemfile yourself, as shown above.
|
|
81
|
+
|
|
82
|
+
### Regenerating images
|
|
83
|
+
|
|
84
|
+
An image is regenerated when anything that affects it changes: the document's title or metadata, the plugin configuration (site-wide or in front matter), the logo or background image files, or the plugin version. Otherwise the existing image is kept, so builds stay fast and `jekyll serve` doesn't rewrite unchanged files.
|
|
85
|
+
|
|
86
|
+
To track this, the plugin keeps a fingerprint of each image in `.jekyll-og-image.json` inside `output_dir`. Jekyll doesn't publish it. If you commit generated images to your repository, commit this file too; without it, every image is regenerated once.
|
|
87
|
+
|
|
88
|
+
### Upgrading from 2.x
|
|
89
|
+
|
|
90
|
+
Images used to be named after the document's title or slug, which let two documents with the same title share one image. They are now named after the source file (see `output_dir` below), so every image gets a new path and URL on the first build after upgrading. The old images are left in place. Delete them from `output_dir` if you committed them to your repository.
|
|
81
91
|
|
|
82
92
|
## Configuration
|
|
83
93
|
|
|
@@ -85,11 +95,15 @@ The plugin can be configured in the `_config.yml` file or in the post's front ma
|
|
|
85
95
|
|
|
86
96
|
The following configuration options are available:
|
|
87
97
|
|
|
98
|
+
Colors are hex values in `"#RRGGBB"` or `"#RGB"` form. Quote them in YAML, because an unquoted `#` starts a comment.
|
|
99
|
+
|
|
100
|
+
Paths to images (`image.path`, `canvas.background_image`) are relative to the site's source directory. If a file doesn't exist, the plugin logs a warning and generates the image without it.
|
|
101
|
+
|
|
88
102
|
* `collections` - An array specifying which types of collections to generate images for. Supports `"posts"`, `"pages"`, and the names of any custom collections. Default: `["posts"]`
|
|
89
103
|
|
|
90
|
-
* `output_dir` – The directory where the generated images will be saved. Images
|
|
104
|
+
* `output_dir` – The directory where the generated images will be saved. Images are placed in a subdirectory named after their collection type and named after their source file, e.g. `_posts/2024-02-15-hello.md` → `assets/images/og/posts/2024-02-15-hello.png` and `blog/index.md` → `assets/images/og/pages/blog/index.png`. Default: `assets/images/og`
|
|
91
105
|
|
|
92
|
-
* `force` – If set to `true`,
|
|
106
|
+
* `force` – If set to `true`, every image is regenerated on each build, even when it is up to date. Rarely needed, since images are regenerated automatically when they change (see [Regenerating images](#regenerating-images)). Under `jekyll serve`, it applies to the first build only. Default: `false`
|
|
93
107
|
* `verbose` – If set to `true`, the plugin will output additional information about the image generation process. Default: `false`
|
|
94
108
|
|
|
95
109
|
* `skip_drafts` – If set to `true`, the plugin will skip post drafts when generating images. Default: `true`
|
|
@@ -97,10 +111,10 @@ The following configuration options are available:
|
|
|
97
111
|
* `canvas` – The canvas configuration options:
|
|
98
112
|
* `background_color` – The background color of the canvas. Default: `#FFFFFF`
|
|
99
113
|
* `background_image` – The background image of the canvas. Default: `nil`
|
|
100
|
-
* `width` – The width of the generated image in pixels. Default: `1200`
|
|
114
|
+
* `width` – The width of the generated image in pixels. The layout is designed for a 1200px-wide canvas and scales with this width: margins, text sizes, and the logo and border sizes below are all given for 1200px. Default: `1200`
|
|
101
115
|
* `height` – The height of the generated image in pixels. Default: `600`
|
|
102
116
|
|
|
103
|
-
* `header` – The header configuration options
|
|
117
|
+
* `header` – The header configuration options. A title too long for the space above the metadata line is shrunk to fit.
|
|
104
118
|
* `font_family` – The font family of the header text. Default: `Helvetica, Bold`
|
|
105
119
|
* `color` – The color of the header text. Default: `#2f313d`
|
|
106
120
|
* `prefix` – Text to prepend to the title. Default: `""`
|
|
@@ -111,7 +125,7 @@ The following configuration options are available:
|
|
|
111
125
|
* `color` – The color of the content text. Default: `#535358`
|
|
112
126
|
|
|
113
127
|
* `border_bottom` – The border bottom configuration options:
|
|
114
|
-
* `width` – The width of the border bottom. Default: `
|
|
128
|
+
* `width` – The width of the border bottom. Default: `0`
|
|
115
129
|
* `fill` – The array of colors to fill the border bottom. Default: `["#000000"]`
|
|
116
130
|
|
|
117
131
|
* `domain` – The domain name to use in the image. Default: `nil`
|
|
@@ -122,7 +136,7 @@ The following configuration options are available:
|
|
|
122
136
|
* `height` – The height of the logo in pixels. Default: `150`
|
|
123
137
|
* `radius` – The radius for rounded corners on the logo. Default: `50`
|
|
124
138
|
* `position` – The position of the logo as `{x, y}` coordinates. Default: `{x: 80, y: 100}`
|
|
125
|
-
* `gravity` – The gravity anchor for logo positioning (nw,
|
|
139
|
+
* `gravity` – The gravity anchor for logo positioning (nw, ne, sw, se). Default: `ne`
|
|
126
140
|
|
|
127
141
|
* `metadata` – The metadata configuration options:
|
|
128
142
|
* `fields` – Array of metadata fields to display. Available options: `"date"`, `"tags"`, or any custom front matter field. Default: `["date", "tags"]`
|
data/assets/icon.png
ADDED
|
Binary file
|
data/assets/icon.svg
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#3a3d4c"/>
|
|
5
|
+
<stop offset="1" stop-color="#1f2029"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="logo" x1="0" y1="0" x2="1" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#EDA895"/>
|
|
9
|
+
<stop offset="1" stop-color="#D51F06"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<clipPath id="card">
|
|
12
|
+
<rect x="48" y="148" width="416" height="248" rx="24"/>
|
|
13
|
+
</clipPath>
|
|
14
|
+
</defs>
|
|
15
|
+
|
|
16
|
+
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
|
17
|
+
|
|
18
|
+
<rect x="96" y="108" width="320" height="248" rx="24" fill="#ffffff" opacity="0.18"/>
|
|
19
|
+
<rect x="72" y="128" width="368" height="248" rx="24" fill="#ffffff" opacity="0.36"/>
|
|
20
|
+
|
|
21
|
+
<g clip-path="url(#card)">
|
|
22
|
+
<rect x="48" y="148" width="416" height="248" fill="#ffffff"/>
|
|
23
|
+
|
|
24
|
+
<rect x="84" y="184" width="224" height="30" rx="15" fill="#2f313d"/>
|
|
25
|
+
<rect x="84" y="226" width="148" height="30" rx="15" fill="#2f313d"/>
|
|
26
|
+
|
|
27
|
+
<rect x="360" y="184" width="68" height="68" rx="18" fill="url(#logo)"/>
|
|
28
|
+
|
|
29
|
+
<rect x="84" y="290" width="112" height="12" rx="6" fill="#a4a4ab"/>
|
|
30
|
+
<rect x="84" y="316" width="168" height="12" rx="6" fill="#a4a4ab"/>
|
|
31
|
+
<rect x="360" y="290" width="68" height="12" rx="6" fill="#a4a4ab"/>
|
|
32
|
+
|
|
33
|
+
<rect x="48" y="364" width="83.2" height="32" fill="#820C02"/>
|
|
34
|
+
<rect x="131.2" y="364" width="83.2" height="32" fill="#A91401"/>
|
|
35
|
+
<rect x="214.4" y="364" width="83.2" height="32" fill="#D51F06"/>
|
|
36
|
+
<rect x="297.6" y="364" width="83.2" height="32" fill="#DE3F24"/>
|
|
37
|
+
<rect x="380.8" y="364" width="83.2" height="32" fill="#EDA895"/>
|
|
38
|
+
</g>
|
|
39
|
+
</svg>
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# require "anyway_config"
|
|
4
|
-
|
|
5
3
|
class JekyllOgImage::Configuration
|
|
6
4
|
Canvas = Data.define(:background_color, :background_image, :width, :height) do
|
|
7
5
|
def initialize(background_color: "#FFFFFF", background_image: nil, width: 1200, height: 600)
|
|
@@ -23,14 +21,16 @@ class JekyllOgImage::Configuration
|
|
|
23
21
|
|
|
24
22
|
Border = Data.define(:width, :fill) do
|
|
25
23
|
def initialize(width: 0, fill: nil)
|
|
26
|
-
|
|
27
|
-
super(width: width, fill: fill)
|
|
24
|
+
super(width: width, fill: Array(fill || "#000000"))
|
|
28
25
|
end
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
Image = Data.define(:path, :width, :height, :radius, :position, :gravity) do
|
|
32
|
-
def initialize(path: nil, width: 150, height: 150, radius: 50, position: {
|
|
33
|
-
|
|
29
|
+
def initialize(path: nil, width: 150, height: 150, radius: 50, position: {}, gravity: :ne)
|
|
30
|
+
# Values read from YAML have string keys and string gravity
|
|
31
|
+
position = { x: 80, y: 100 }.merge(position.transform_keys(&:to_sym))
|
|
32
|
+
|
|
33
|
+
super(path: path, width: width, height: height, radius: radius, position: position, gravity: gravity.to_sym)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -44,7 +44,7 @@ class JekyllOgImage::Configuration
|
|
|
44
44
|
@raw_config = raw_config
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def merge
|
|
47
|
+
def merge(other)
|
|
48
48
|
config = Jekyll::Utils.deep_merge_hashes(
|
|
49
49
|
@raw_config,
|
|
50
50
|
other.to_h
|
|
@@ -86,26 +86,22 @@ class JekyllOgImage::Configuration
|
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def canvas
|
|
89
|
-
|
|
89
|
+
build_section(Canvas, "canvas")
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def header
|
|
93
|
-
|
|
93
|
+
build_section(Header, "header")
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def content
|
|
97
|
-
|
|
97
|
+
build_section(Content, "content")
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def image
|
|
101
|
-
if
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Image.new(**Jekyll::Utils.symbolize_hash_keys(@raw_config["image"]))
|
|
106
|
-
else
|
|
107
|
-
Image.new
|
|
108
|
-
end
|
|
101
|
+
# Legacy support: if image is just a string, convert it to the new format
|
|
102
|
+
return Image.new(path: @raw_config["image"]) if @raw_config["image"].is_a?(String)
|
|
103
|
+
|
|
104
|
+
build_section(Image, "image")
|
|
109
105
|
end
|
|
110
106
|
|
|
111
107
|
def domain
|
|
@@ -113,15 +109,31 @@ class JekyllOgImage::Configuration
|
|
|
113
109
|
end
|
|
114
110
|
|
|
115
111
|
def border_bottom
|
|
116
|
-
|
|
112
|
+
build_section(Border, "border_bottom") if @raw_config["border_bottom"]
|
|
117
113
|
end
|
|
118
114
|
|
|
119
|
-
def
|
|
120
|
-
|
|
115
|
+
def metadata
|
|
116
|
+
build_section(Metadata, "metadata")
|
|
121
117
|
end
|
|
122
118
|
|
|
119
|
+
private
|
|
123
120
|
|
|
124
|
-
def
|
|
125
|
-
|
|
121
|
+
def build_section(klass, name)
|
|
122
|
+
value = @raw_config[name]
|
|
123
|
+
return klass.new unless value
|
|
124
|
+
|
|
125
|
+
unless value.is_a?(Hash)
|
|
126
|
+
raise Jekyll::Errors::InvalidConfigurationError, "og_image.#{name} must be a set of options, got #{value.inspect}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
options = Jekyll::Utils.symbolize_hash_keys(value)
|
|
130
|
+
unknown = options.keys - klass.members
|
|
131
|
+
|
|
132
|
+
if unknown.any?
|
|
133
|
+
raise Jekyll::Errors::InvalidConfigurationError,
|
|
134
|
+
"Unknown og_image.#{name} option: #{unknown.join(", ")}. Valid options: #{klass.members.join(", ")}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
klass.new(**options)
|
|
126
138
|
end
|
|
127
139
|
end
|
|
@@ -5,14 +5,22 @@ class JekyllOgImage::Element::Base
|
|
|
5
5
|
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
VALID_GRAVITY.each do |gravity|
|
|
9
|
+
define_method("gravity_#{gravity}?") do
|
|
10
|
+
@gravity == gravity
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
8
14
|
def hex_to_rgb(input)
|
|
9
15
|
case input
|
|
10
|
-
when String
|
|
11
|
-
input.match(/#(..)(..)(..)/)[1..3].map(&:hex)
|
|
12
16
|
when Array
|
|
13
17
|
input
|
|
18
|
+
when /\A#\h{3}\z/
|
|
19
|
+
input[1..].chars.map { |digit| (digit * 2).hex }
|
|
20
|
+
when /\A#\h{6}\z/
|
|
21
|
+
input[1..].scan(/\h\h/).map(&:hex)
|
|
14
22
|
else
|
|
15
|
-
raise ArgumentError, "
|
|
23
|
+
raise ArgumentError, "Invalid color #{input.inspect}, expected a hex color like \"#FFFFFF\" or \"#FFF\""
|
|
16
24
|
end
|
|
17
25
|
end
|
|
18
26
|
|
|
@@ -31,18 +39,9 @@ class JekyllOgImage::Element::Base
|
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def composite_with_gravity(canvas, overlay, x, y)
|
|
34
|
-
if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
canvas.composite(overlay, :over, x: [ x ], y: [ y ]).flatten
|
|
39
|
-
elsif gravity_sw?
|
|
40
|
-
y = canvas.height - overlay.height - y
|
|
41
|
-
canvas.composite(overlay, :over, x: [ x ], y: [ y ]).flatten
|
|
42
|
-
elsif gravity_se?
|
|
43
|
-
x = canvas.width - overlay.width - x
|
|
44
|
-
y = canvas.height - overlay.height - y
|
|
45
|
-
canvas.composite(overlay, :over, x: [ x ], y: [ y ]).flatten
|
|
46
|
-
end
|
|
42
|
+
x = canvas.width - overlay.width - x if gravity_ne? || gravity_se?
|
|
43
|
+
y = canvas.height - overlay.height - y if gravity_sw? || gravity_se?
|
|
44
|
+
|
|
45
|
+
canvas.composite(overlay, :over, x: [ x ], y: [ y ]).flatten
|
|
47
46
|
end
|
|
48
47
|
end
|
|
@@ -7,7 +7,7 @@ class JekyllOgImage::Element::Border < JekyllOgImage::Element::Base
|
|
|
7
7
|
def initialize(size, position: :bottom, fill: "#000000")
|
|
8
8
|
@size = size
|
|
9
9
|
@position = position
|
|
10
|
-
@fill = fill
|
|
10
|
+
@fill = Array(fill)
|
|
11
11
|
|
|
12
12
|
validate_position!
|
|
13
13
|
end
|
|
@@ -15,14 +15,11 @@ class JekyllOgImage::Element::Border < JekyllOgImage::Element::Base
|
|
|
15
15
|
def apply_to(canvas, &block)
|
|
16
16
|
border = Vips::Image.black(*dimensions(canvas))
|
|
17
17
|
|
|
18
|
-
parts(canvas).each
|
|
18
|
+
parts(canvas).each do |part|
|
|
19
19
|
image = Vips::Image.black(part.width, part.height).ifthenelse([ 0, 0, 0 ], part.rgb)
|
|
20
|
+
x, y = vertical? ? [ 0, part.offset ] : [ part.offset, 0 ]
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
border = border.composite(image, :over, x: [ 0 ], y: [ part.offset ]).flatten
|
|
23
|
-
else
|
|
24
|
-
border = border.composite(image, :over, x: [ part.offset ], y: [ 0 ]).flatten
|
|
25
|
-
end
|
|
22
|
+
border = border.composite(image, :over, x: [ x ], y: [ y ]).flatten
|
|
26
23
|
end
|
|
27
24
|
|
|
28
25
|
result = block.call(canvas, border) if block_given?
|
|
@@ -40,10 +37,6 @@ class JekyllOgImage::Element::Border < JekyllOgImage::Element::Base
|
|
|
40
37
|
|
|
41
38
|
private
|
|
42
39
|
|
|
43
|
-
def hex_to_rgb(hex)
|
|
44
|
-
hex.match(/#(..)(..)(..)/)[1..3].map { |x| x.hex }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
40
|
def dimensions(canvas)
|
|
48
41
|
if vertical?
|
|
49
42
|
[ @size, canvas.height ]
|
|
@@ -53,21 +46,15 @@ class JekyllOgImage::Element::Border < JekyllOgImage::Element::Base
|
|
|
53
46
|
end
|
|
54
47
|
|
|
55
48
|
def parts(canvas)
|
|
56
|
-
|
|
57
|
-
width, height = vertical? ? [ @size, (canvas.height / @fill.size) ] : [ (canvas.width / @fill.size), @size ]
|
|
58
|
-
|
|
59
|
-
@fill.map.with_index do |item, index|
|
|
60
|
-
Part.new(
|
|
61
|
-
rgb: hex_to_rgb(item),
|
|
62
|
-
width: width,
|
|
63
|
-
height: height,
|
|
64
|
-
offset: index * (vertical? ? height : width)
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
else
|
|
68
|
-
length = vertical? ? canvas.height : canvas.width
|
|
49
|
+
length = vertical? ? canvas.height : canvas.width
|
|
69
50
|
|
|
70
|
-
|
|
51
|
+
@fill.map.with_index do |color, index|
|
|
52
|
+
# Stripe edges are rounded down, so the last stripe absorbs any remainder
|
|
53
|
+
from = length * index / @fill.size
|
|
54
|
+
to = length * (index + 1) / @fill.size
|
|
55
|
+
width, height = vertical? ? [ @size, to - from ] : [ to - from, @size ]
|
|
56
|
+
|
|
57
|
+
Part.new(rgb: hex_to_rgb(color), width: width, height: height, offset: from)
|
|
71
58
|
end
|
|
72
59
|
end
|
|
73
60
|
|
|
@@ -75,10 +62,6 @@ class JekyllOgImage::Element::Border < JekyllOgImage::Element::Base
|
|
|
75
62
|
@position == :left || @position == :right
|
|
76
63
|
end
|
|
77
64
|
|
|
78
|
-
def horizontal?
|
|
79
|
-
@position == :top || @position == :bottom
|
|
80
|
-
end
|
|
81
|
-
|
|
82
65
|
def validate_position!
|
|
83
66
|
unless %i[left right top bottom].include?(@position)
|
|
84
67
|
raise ArgumentError, "Invalid position: #{@position.inspect}"
|
|
@@ -13,13 +13,19 @@ class JekyllOgImage::Element::Image < JekyllOgImage::Element::Base
|
|
|
13
13
|
|
|
14
14
|
def apply_to(canvas, &block)
|
|
15
15
|
image = Vips::Image.new_from_buffer(@source, "")
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
if image.bands != canvas.bands
|
|
18
|
+
image = image.flatten
|
|
19
|
+
end
|
|
17
20
|
|
|
18
21
|
if @width && @height
|
|
19
22
|
ratio = calculate_ratio(image, @width, @height, :min)
|
|
20
23
|
image = image.resize(ratio)
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# Rounded after resizing, so the radius is in output pixels whatever the source size
|
|
27
|
+
image = round_corners(image) if @radius
|
|
28
|
+
|
|
23
29
|
result = block.call(canvas, image) if block_given?
|
|
24
30
|
|
|
25
31
|
x, y = result ? [ result.fetch(:x, 0), result.fetch(:y, 0) ] : [ 0, 0 ]
|
|
@@ -29,12 +35,6 @@ class JekyllOgImage::Element::Image < JekyllOgImage::Element::Base
|
|
|
29
35
|
|
|
30
36
|
private
|
|
31
37
|
|
|
32
|
-
VALID_GRAVITY.each do |gravity|
|
|
33
|
-
define_method("gravity_#{gravity}?") do
|
|
34
|
-
@gravity == gravity
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
38
|
def round_corners(image)
|
|
39
39
|
mask = %(
|
|
40
40
|
<svg viewBox="0 0 #{image.width} #{image.height}">
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class JekyllOgImage::Element::Text < JekyllOgImage::Element::Base
|
|
4
|
-
|
|
4
|
+
MARKUP_ESCAPES = { "&" => "&", "<" => "<", ">" => ">" }.freeze
|
|
5
|
+
|
|
6
|
+
# Vips::Image.text parses its input as Pango markup, so plain text has to be escaped
|
|
7
|
+
def self.escape_markup(message)
|
|
8
|
+
message.to_s.gsub(/[&<>]/, MARKUP_ESCAPES)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(message, gravity: :nw, width: nil, height: nil, dpi: nil, color: "#000000", font: nil)
|
|
5
12
|
@message = message
|
|
6
13
|
@gravity = gravity
|
|
7
14
|
@width = width
|
|
15
|
+
@height = height
|
|
8
16
|
@dpi = dpi
|
|
9
17
|
@color = color
|
|
10
18
|
@font = font
|
|
@@ -22,7 +30,12 @@ class JekyllOgImage::Element::Text < JekyllOgImage::Element::Base
|
|
|
22
30
|
|
|
23
31
|
params[:wrap] = :word if wrap_supported?
|
|
24
32
|
|
|
25
|
-
text = Vips::Image.text(@message, **params)
|
|
33
|
+
text = Vips::Image.text(self.class.escape_markup(@message), **params)
|
|
34
|
+
|
|
35
|
+
# Too tall: given a width and height instead of a dpi, libvips picks the largest dpi that fits
|
|
36
|
+
if @height && text.height > @height && height_supported?
|
|
37
|
+
text = Vips::Image.text(self.class.escape_markup(@message), **params.except(:dpi), height: @height)
|
|
38
|
+
end
|
|
26
39
|
|
|
27
40
|
text = text
|
|
28
41
|
.new_from_image(hex_to_rgb(@color))
|
|
@@ -38,10 +51,9 @@ class JekyllOgImage::Element::Text < JekyllOgImage::Element::Base
|
|
|
38
51
|
|
|
39
52
|
private
|
|
40
53
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
end
|
|
54
|
+
def height_supported?
|
|
55
|
+
# Vips::Image.text fits text to a height since vips 8.8.0
|
|
56
|
+
Vips.at_least_libvips?(8, 8)
|
|
45
57
|
end
|
|
46
58
|
|
|
47
59
|
def wrap_supported?
|
|
@@ -1,60 +1,66 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
class JekyllOgImage::Generator < Jekyll::Generator
|
|
4
6
|
safe true
|
|
5
7
|
|
|
6
8
|
def generate(site)
|
|
7
9
|
config = JekyllOgImage.config
|
|
10
|
+
@missing_files = Set.new
|
|
11
|
+
@file_digests = {}
|
|
12
|
+
manifest = JekyllOgImage::Manifest.new(File.join(site.source, config.output_dir))
|
|
13
|
+
|
|
14
|
+
# `jekyll serve` reuses this generator for every rebuild. Forcing only the first
|
|
15
|
+
# build stops rewritten images from triggering the watcher in an endless loop.
|
|
16
|
+
@first_build = !@built
|
|
17
|
+
@built = true
|
|
8
18
|
|
|
9
19
|
config.collections.each do |type|
|
|
10
|
-
process_collection(site, type, config)
|
|
20
|
+
process_collection(site, type, config, manifest)
|
|
11
21
|
end
|
|
22
|
+
|
|
23
|
+
manifest.save
|
|
12
24
|
end
|
|
13
25
|
|
|
14
26
|
private
|
|
15
27
|
|
|
16
|
-
def process_collection(site, type, config)
|
|
28
|
+
def process_collection(site, type, config, manifest)
|
|
17
29
|
Jekyll.logger.info "Jekyll Og Image:", "Processing type: #{type}" if config.verbose?
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
return if items.empty?
|
|
21
|
-
|
|
22
|
-
base_output_dir = File.join(config.output_dir, type)
|
|
23
|
-
absolute_output_dir = File.join(site.config["source"], base_output_dir)
|
|
24
|
-
FileUtils.mkdir_p absolute_output_dir
|
|
25
|
-
|
|
26
|
-
items.each do |item|
|
|
31
|
+
get_items_for_collection(site, type).each do |item|
|
|
27
32
|
if item.respond_to?(:draft?) && item.draft? && config.skip_drafts?
|
|
28
33
|
Jekyll.logger.info "Jekyll Og Image:", "Skipping draft: #{item.data['title']}" if config.verbose?
|
|
29
34
|
next
|
|
30
35
|
end
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# rubocop:disable Layout/ElseAlignment # Disabled due to RuboCop error in v3.3.0
|
|
35
|
-
else
|
|
36
|
-
# rubocop:enable Layout/ElseAlignment
|
|
37
|
-
File.basename(item.name, File.extname(item.name))
|
|
38
|
-
end
|
|
37
|
+
item_config = config.merge(item.data["og_image"] || {})
|
|
38
|
+
next unless item_config.enabled?
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
absolute_image_path = File.join(
|
|
43
|
-
|
|
40
|
+
image_path = File.join(type, "#{image_basename(item)}.png")
|
|
41
|
+
site_image_path = File.join(config.output_dir, image_path)
|
|
42
|
+
absolute_image_path = File.join(site.source, site_image_path)
|
|
43
|
+
digest = image_digest(site, item, item_config)
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
force = @first_build && item_config.force?
|
|
46
|
+
|
|
47
|
+
if force || !File.exist?(absolute_image_path) || !manifest.fresh?(image_path, digest)
|
|
46
48
|
Jekyll.logger.info "Jekyll Og Image:", "Generating image #{absolute_image_path}" if config.verbose?
|
|
47
|
-
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(absolute_image_path))
|
|
50
|
+
generate_image_for_document(site, item, absolute_image_path, item_config)
|
|
48
51
|
else
|
|
49
|
-
Jekyll.logger.info "Jekyll Og Image:", "Skipping
|
|
52
|
+
Jekyll.logger.info "Jekyll Og Image:", "Skipping #{site_image_path}, it is up to date" if config.verbose?
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
next unless File.exist?(absolute_image_path)
|
|
56
|
+
|
|
57
|
+
manifest.record(image_path, digest)
|
|
58
|
+
register_static_file(site, site_image_path, config)
|
|
53
59
|
|
|
54
60
|
item.data["image"] ||= {
|
|
55
|
-
"path" =>
|
|
56
|
-
"width" =>
|
|
57
|
-
"height" =>
|
|
61
|
+
"path" => File.join("/", site_image_path), # Use leading slash for URL
|
|
62
|
+
"width" => item_config.canvas.width,
|
|
63
|
+
"height" => item_config.canvas.height,
|
|
58
64
|
"alt" => item.data["title"]
|
|
59
65
|
}
|
|
60
66
|
end
|
|
@@ -65,7 +71,7 @@ class JekyllOgImage::Generator < Jekyll::Generator
|
|
|
65
71
|
when "posts"
|
|
66
72
|
site.posts.docs
|
|
67
73
|
when "pages"
|
|
68
|
-
site.pages.
|
|
74
|
+
site.pages.select(&:html?)
|
|
69
75
|
else
|
|
70
76
|
if site.collections.key?(type)
|
|
71
77
|
site.collections[type].docs
|
|
@@ -76,104 +82,145 @@ class JekyllOgImage::Generator < Jekyll::Generator
|
|
|
76
82
|
end
|
|
77
83
|
end
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
# The source file's path without its collection directory and extension, which is
|
|
86
|
+
# unique within the site: "_posts/2024-01-01-hello.md" => "2024-01-01-hello",
|
|
87
|
+
# "blog/index.md" => "blog/index".
|
|
88
|
+
def image_basename(item)
|
|
89
|
+
path = item.relative_path.delete_prefix("/")
|
|
90
|
+
path = path.delete_prefix("#{item.collection.relative_directory}/") if item.respond_to?(:collection) && item.collection
|
|
91
|
+
path = path.delete_suffix(File.extname(path))
|
|
92
|
+
|
|
93
|
+
path.split("/").map { |segment| slugify_path_segment(segment) }.join("/")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def slugify_path_segment(segment)
|
|
97
|
+
slug = Jekyll::Utils.slugify(segment)
|
|
98
|
+
# A name made only of characters slugify removes, e.g. emoji
|
|
99
|
+
slug.empty? ? Digest::SHA256.hexdigest(segment)[0, 12] : slug
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Changes whenever anything that affects the rendered image changes
|
|
103
|
+
def image_digest(site, item, config)
|
|
104
|
+
inputs = [
|
|
105
|
+
JekyllOgImage::VERSION,
|
|
106
|
+
config.canvas.to_h,
|
|
107
|
+
config.header.to_h,
|
|
108
|
+
config.content.to_h,
|
|
109
|
+
config.image.to_h,
|
|
110
|
+
config.border_bottom&.to_h,
|
|
111
|
+
config.metadata.to_h,
|
|
112
|
+
config.domain,
|
|
113
|
+
item.data["title"],
|
|
114
|
+
config.metadata.fields.map { |field| metadata_value_for(item, field, config) },
|
|
115
|
+
file_digest(site, config.image.path),
|
|
116
|
+
file_digest(site, config.canvas.background_image)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
Digest::SHA256.hexdigest(JSON.generate(inputs))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def file_digest(site, path)
|
|
123
|
+
return unless path
|
|
124
|
+
|
|
125
|
+
absolute_path = File.join(site.source, path)
|
|
126
|
+
@file_digests[absolute_path] ||= Digest::SHA256.file(absolute_path).hexdigest if File.file?(absolute_path)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def register_static_file(site, site_image_path, config)
|
|
130
|
+
relative_path = File.join("/", site_image_path)
|
|
81
131
|
return if site.static_files.any? { |file| file.relative_path == relative_path }
|
|
82
132
|
|
|
133
|
+
# Leading slash in dir, as Jekyll's reader does, so relative_path matches the check above
|
|
83
134
|
static_file = Jekyll::StaticFile.new(
|
|
84
135
|
site,
|
|
85
136
|
site.source,
|
|
86
|
-
|
|
87
|
-
|
|
137
|
+
File.dirname(relative_path),
|
|
138
|
+
File.basename(relative_path)
|
|
88
139
|
)
|
|
89
140
|
|
|
90
141
|
site.static_files << static_file
|
|
91
|
-
Jekyll.logger.info "Jekyll Og Image:", "Added #{
|
|
142
|
+
Jekyll.logger.info "Jekyll Og Image:", "Added #{site_image_path} to static files" if config.verbose?
|
|
92
143
|
end
|
|
93
144
|
|
|
94
|
-
def generate_image_for_document(site, item, path,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return unless config.enabled?
|
|
145
|
+
def generate_image_for_document(site, item, path, config)
|
|
146
|
+
layout = JekyllOgImage::Layout.new(config)
|
|
98
147
|
|
|
99
148
|
canvas = generate_canvas(site, config)
|
|
100
|
-
canvas = add_border_bottom(canvas, config) if config.border_bottom
|
|
101
|
-
|
|
102
|
-
canvas =
|
|
103
|
-
canvas =
|
|
104
|
-
canvas =
|
|
149
|
+
canvas = add_border_bottom(canvas, config, layout) if config.border_bottom
|
|
150
|
+
logo = read_source_file(site, config.image.path, "og_image.image.path") if config.image.path
|
|
151
|
+
canvas = add_image(canvas, logo, config, layout) if logo
|
|
152
|
+
canvas = add_header(canvas, item, config, layout)
|
|
153
|
+
canvas = add_metadata(canvas, item, config, layout)
|
|
154
|
+
canvas = add_domain(canvas, config, layout) if config.domain
|
|
105
155
|
|
|
106
156
|
canvas.save(path)
|
|
107
157
|
end
|
|
108
158
|
|
|
109
159
|
def generate_canvas(site, config)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
File.exist?(bg_path) ? File.read(bg_path) : nil
|
|
160
|
+
if config.canvas.background_image
|
|
161
|
+
background_image = read_source_file(site, config.canvas.background_image, "og_image.canvas.background_image")
|
|
113
162
|
end
|
|
114
163
|
|
|
115
|
-
JekyllOgImage::Element::Canvas.new(
|
|
164
|
+
JekyllOgImage::Element::Canvas.new(config.canvas.width, config.canvas.height,
|
|
116
165
|
background_color: config.canvas.background_color,
|
|
117
166
|
background_image: background_image
|
|
118
167
|
)
|
|
119
168
|
end
|
|
120
169
|
|
|
121
|
-
def
|
|
122
|
-
|
|
170
|
+
def read_source_file(site, path, option)
|
|
171
|
+
absolute_path = File.join(site.source, path)
|
|
172
|
+
return File.binread(absolute_path) if File.file?(absolute_path)
|
|
173
|
+
|
|
174
|
+
# Warn once per build, not once per document
|
|
175
|
+
Jekyll.logger.warn "Jekyll Og Image:", "#{option} file not found: #{path}, skipping it" if @missing_files.add?(path)
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def add_border_bottom(canvas, config, layout)
|
|
180
|
+
canvas.border(layout.border_width,
|
|
123
181
|
position: :bottom,
|
|
124
182
|
fill: config.border_bottom.fill
|
|
125
183
|
)
|
|
126
184
|
end
|
|
127
185
|
|
|
128
|
-
def add_image(canvas, image_data, config)
|
|
129
|
-
image_config = config.image
|
|
186
|
+
def add_image(canvas, image_data, config, layout)
|
|
130
187
|
canvas.image(image_data,
|
|
131
|
-
gravity:
|
|
132
|
-
width:
|
|
133
|
-
height:
|
|
134
|
-
radius:
|
|
135
|
-
) { |_canvas,
|
|
188
|
+
gravity: config.image.gravity,
|
|
189
|
+
width: layout.logo_width,
|
|
190
|
+
height: layout.logo_height,
|
|
191
|
+
radius: layout.logo_radius
|
|
192
|
+
) { |_canvas, _image| layout.logo_position }
|
|
136
193
|
end
|
|
137
194
|
|
|
138
|
-
def add_header(canvas, item, config)
|
|
195
|
+
def add_header(canvas, item, config, layout)
|
|
139
196
|
title = item.data["title"] || "Untitled"
|
|
140
197
|
full_title = "#{config.header.prefix}#{title}#{config.header.suffix}"
|
|
141
198
|
|
|
142
|
-
# Calculate available width for header text to avoid overlap with image
|
|
143
|
-
header_width = if config.image.path
|
|
144
|
-
# Canvas width - left margin - right margin - image width - spacing
|
|
145
|
-
# 1200 - 80 - 80 - image_width - 30 (spacing)
|
|
146
|
-
1040 - config.image.width - 30
|
|
147
|
-
else
|
|
148
|
-
1040
|
|
149
|
-
end
|
|
150
|
-
|
|
151
199
|
canvas.text(full_title,
|
|
152
|
-
width: header_width,
|
|
200
|
+
width: layout.header_width,
|
|
201
|
+
height: layout.header_max_height(content_line_height(config, layout)),
|
|
153
202
|
color: config.header.color,
|
|
154
|
-
dpi:
|
|
203
|
+
dpi: layout.header_dpi,
|
|
155
204
|
font: config.header.font_family
|
|
156
|
-
) { |_canvas, _text| { x:
|
|
205
|
+
) { |_canvas, _text| { x: layout.margin, y: layout.header_top } }
|
|
157
206
|
end
|
|
158
207
|
|
|
159
|
-
def add_metadata(canvas, item, config)
|
|
160
|
-
metadata_text = metadata_text_for(item, config)
|
|
208
|
+
def add_metadata(canvas, item, config, layout)
|
|
209
|
+
metadata_text = metadata_text_for(item, config, layout)
|
|
161
210
|
return canvas if metadata_text.empty?
|
|
162
211
|
|
|
163
|
-
metadata_width = metadata_width_for(config)
|
|
164
|
-
|
|
165
212
|
canvas.text(metadata_text,
|
|
166
213
|
gravity: :sw,
|
|
167
|
-
width:
|
|
214
|
+
width: metadata_width_for(config, layout),
|
|
168
215
|
color: config.content.color,
|
|
169
|
-
dpi:
|
|
216
|
+
dpi: layout.content_dpi,
|
|
170
217
|
font: config.content.font_family
|
|
171
|
-
) { |_canvas, _text| { x:
|
|
218
|
+
) { |_canvas, _text| { x: layout.margin, y: layout.bottom } }
|
|
172
219
|
end
|
|
173
220
|
|
|
174
|
-
def metadata_text_for(item, config)
|
|
221
|
+
def metadata_text_for(item, config, layout)
|
|
175
222
|
metadata_parts = []
|
|
176
|
-
metadata_width = metadata_width_for(config)
|
|
223
|
+
metadata_width = metadata_width_for(config, layout)
|
|
177
224
|
|
|
178
225
|
config.metadata.fields.each do |field|
|
|
179
226
|
metadata_value = metadata_value_for(item, field, config)
|
|
@@ -181,7 +228,7 @@ class JekyllOgImage::Generator < Jekyll::Generator
|
|
|
181
228
|
|
|
182
229
|
if field == "description"
|
|
183
230
|
candidate_text = (metadata_parts + [ metadata_value ]).join(config.metadata.separator)
|
|
184
|
-
next unless metadata_text_fits_single_line?(candidate_text, metadata_width, config)
|
|
231
|
+
next unless metadata_text_fits_single_line?(candidate_text, metadata_width, config, layout)
|
|
185
232
|
end
|
|
186
233
|
|
|
187
234
|
metadata_parts << metadata_value
|
|
@@ -195,7 +242,7 @@ class JekyllOgImage::Generator < Jekyll::Generator
|
|
|
195
242
|
when "date"
|
|
196
243
|
item.respond_to?(:date) && item.date ? item.date.strftime(config.metadata.date_format) : nil
|
|
197
244
|
when "tags"
|
|
198
|
-
return nil unless item.data["tags"]
|
|
245
|
+
return nil unless item.data["tags"].is_a?(Array) && item.data["tags"].any?
|
|
199
246
|
|
|
200
247
|
item.data["tags"].map { |tag| "##{tag}" }.join(" ")
|
|
201
248
|
else
|
|
@@ -204,34 +251,38 @@ class JekyllOgImage::Generator < Jekyll::Generator
|
|
|
204
251
|
end
|
|
205
252
|
end
|
|
206
253
|
|
|
207
|
-
|
|
208
|
-
|
|
254
|
+
# The metadata line shares the bottom row with the domain
|
|
255
|
+
def metadata_width_for(config, layout)
|
|
256
|
+
return layout.content_width unless config.domain
|
|
257
|
+
|
|
258
|
+
layout.content_width - render_content_text(config.domain, config, layout).width - layout.gap
|
|
209
259
|
end
|
|
210
260
|
|
|
211
|
-
def metadata_text_fits_single_line?(text, metadata_width, config)
|
|
212
|
-
|
|
213
|
-
width: metadata_width,
|
|
214
|
-
dpi: 150,
|
|
215
|
-
font: config.content.font_family,
|
|
216
|
-
align: :low
|
|
217
|
-
}
|
|
218
|
-
options[:wrap] = :word if Vips.at_least_libvips?(8, 14)
|
|
261
|
+
def metadata_text_fits_single_line?(text, metadata_width, config, layout)
|
|
262
|
+
rendered_height = render_content_text(text, config, layout, width: metadata_width).height
|
|
219
263
|
|
|
220
|
-
|
|
221
|
-
|
|
264
|
+
rendered_height <= (content_line_height(config, layout) * 1.6)
|
|
265
|
+
end
|
|
222
266
|
|
|
223
|
-
|
|
267
|
+
def content_line_height(config, layout)
|
|
268
|
+
render_content_text("Ay", config, layout).height
|
|
224
269
|
end
|
|
225
270
|
|
|
271
|
+
def render_content_text(text, config, layout, width: nil)
|
|
272
|
+
options = { dpi: layout.content_dpi, font: config.content.font_family, align: :low }
|
|
273
|
+
options[:width] = width if width
|
|
274
|
+
options[:wrap] = :word if width && Vips.at_least_libvips?(8, 14)
|
|
275
|
+
|
|
276
|
+
Vips::Image.text(JekyllOgImage::Element::Text.escape_markup(text), **options)
|
|
277
|
+
end
|
|
226
278
|
|
|
227
|
-
def add_domain(canvas, _item, config)
|
|
228
|
-
y_pos = config.margin_bottom
|
|
229
279
|
|
|
280
|
+
def add_domain(canvas, config, layout)
|
|
230
281
|
canvas.text(config.domain,
|
|
231
282
|
gravity: :se,
|
|
232
283
|
color: config.content.color,
|
|
233
|
-
dpi:
|
|
284
|
+
dpi: layout.content_dpi,
|
|
234
285
|
font: config.content.font_family
|
|
235
|
-
) { |_canvas, _text| { x:
|
|
286
|
+
) { |_canvas, _text| { x: layout.margin, y: layout.bottom } }
|
|
236
287
|
end
|
|
237
288
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Positions and sizes for an image. Everything, including the pixel sizes in the
|
|
4
|
+
# configuration (logo, border), is designed for a 1200px-wide canvas and scaled
|
|
5
|
+
# with the canvas width, so any canvas size keeps the same proportions.
|
|
6
|
+
class JekyllOgImage::Layout
|
|
7
|
+
REFERENCE_WIDTH = 1200.0
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@scale = config.canvas.width / REFERENCE_WIDTH
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Left, right and bottom margin
|
|
15
|
+
def margin
|
|
16
|
+
scaled(80)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def header_top
|
|
20
|
+
scaled(100)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def header_dpi
|
|
24
|
+
scaled(400)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def content_dpi
|
|
28
|
+
scaled(150)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Space between the header and whatever is next to or below it
|
|
32
|
+
def gap
|
|
33
|
+
scaled(30)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def border_width
|
|
37
|
+
scaled(@config.border_bottom&.width || 0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Distance from the bottom edge to the metadata line, above the border
|
|
41
|
+
def bottom
|
|
42
|
+
margin + border_width
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def logo_width
|
|
46
|
+
scaled(@config.image.width)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def logo_height
|
|
50
|
+
scaled(@config.image.height)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def logo_radius
|
|
54
|
+
scaled(@config.image.radius) if @config.image.radius
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def logo_position
|
|
58
|
+
{ x: scaled(@config.image.position[:x]), y: scaled(@config.image.position[:y]) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def content_width
|
|
62
|
+
@config.canvas.width - 2 * margin
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Leaves room for the logo next to the header
|
|
66
|
+
def header_width
|
|
67
|
+
@config.image.path ? content_width - logo_width - gap : content_width
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# How tall the header can be without reaching the metadata line
|
|
71
|
+
def header_max_height(metadata_line_height)
|
|
72
|
+
@config.canvas.height - header_top - bottom - metadata_line_height - gap
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def scaled(value)
|
|
78
|
+
(value * @scale).round
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Remembers a digest of the inputs each image was generated from, so images are
|
|
6
|
+
# regenerated only when something that affects them changes.
|
|
7
|
+
#
|
|
8
|
+
# Stored as a dotfile in the output directory, which Jekyll never publishes.
|
|
9
|
+
class JekyllOgImage::Manifest
|
|
10
|
+
FILENAME = ".jekyll-og-image.json"
|
|
11
|
+
|
|
12
|
+
def initialize(dir)
|
|
13
|
+
@path = File.join(dir, FILENAME)
|
|
14
|
+
@previous = load
|
|
15
|
+
@current = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fresh?(image_path, digest)
|
|
19
|
+
@current.fetch(image_path) { @previous[image_path] } == digest
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record(image_path, digest)
|
|
23
|
+
@current[image_path] = digest
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Writes the images recorded during this build. Entries for images that weren't
|
|
27
|
+
# recorded (deleted or disabled documents) are dropped.
|
|
28
|
+
def save
|
|
29
|
+
entries = @current.sort.to_h
|
|
30
|
+
return if entries == @previous
|
|
31
|
+
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
33
|
+
File.write(@path, JSON.pretty_generate(entries))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def load
|
|
39
|
+
JSON.parse(File.read(@path))
|
|
40
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jekyll-og-image
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Alexandrov
|
|
@@ -9,20 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: jekyll-seo-tag
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '2.8'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '2.8'
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: zeitwerk
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,14 +29,14 @@ dependencies:
|
|
|
43
29
|
requirements:
|
|
44
30
|
- - "~>"
|
|
45
31
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: 2.2
|
|
32
|
+
version: '2.2'
|
|
47
33
|
type: :runtime
|
|
48
34
|
prerelease: false
|
|
49
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
36
|
requirements:
|
|
51
37
|
- - "~>"
|
|
52
38
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: 2.2
|
|
39
|
+
version: '2.2'
|
|
54
40
|
- !ruby/object:Gem::Dependency
|
|
55
41
|
name: jekyll
|
|
56
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -71,20 +57,6 @@ dependencies:
|
|
|
71
57
|
- - "<"
|
|
72
58
|
- !ruby/object:Gem::Version
|
|
73
59
|
version: '5.0'
|
|
74
|
-
- !ruby/object:Gem::Dependency
|
|
75
|
-
name: csv
|
|
76
|
-
requirement: !ruby/object:Gem::Requirement
|
|
77
|
-
requirements:
|
|
78
|
-
- - ">="
|
|
79
|
-
- !ruby/object:Gem::Version
|
|
80
|
-
version: '0'
|
|
81
|
-
type: :development
|
|
82
|
-
prerelease: false
|
|
83
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
-
requirements:
|
|
85
|
-
- - ">="
|
|
86
|
-
- !ruby/object:Gem::Version
|
|
87
|
-
version: '0'
|
|
88
60
|
email:
|
|
89
61
|
- igor.alexandrov@gmail.com
|
|
90
62
|
executables: []
|
|
@@ -97,6 +69,8 @@ files:
|
|
|
97
69
|
- LICENSE.txt
|
|
98
70
|
- README.md
|
|
99
71
|
- Rakefile
|
|
72
|
+
- assets/icon.png
|
|
73
|
+
- assets/icon.svg
|
|
100
74
|
- examples/1.png
|
|
101
75
|
- examples/2.png
|
|
102
76
|
- examples/3.png
|
|
@@ -110,6 +84,8 @@ files:
|
|
|
110
84
|
- lib/jekyll_og_image/element/image.rb
|
|
111
85
|
- lib/jekyll_og_image/element/text.rb
|
|
112
86
|
- lib/jekyll_og_image/generator.rb
|
|
87
|
+
- lib/jekyll_og_image/layout.rb
|
|
88
|
+
- lib/jekyll_og_image/manifest.rb
|
|
113
89
|
- lib/jekyll_og_image/version.rb
|
|
114
90
|
homepage: https://github.com/igor-alexandrov/jekyll-og-image
|
|
115
91
|
licenses:
|
|
@@ -130,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
130
106
|
- !ruby/object:Gem::Version
|
|
131
107
|
version: '0'
|
|
132
108
|
requirements: []
|
|
133
|
-
rubygems_version: 4.0.
|
|
109
|
+
rubygems_version: 4.0.20
|
|
134
110
|
specification_version: 4
|
|
135
111
|
summary: Jekyll plugin to generate GitHub-style open graph images
|
|
136
112
|
test_files: []
|