Skip to content

Commit 44426e9

Browse files
committed
Generate OG Image from frontmatter
1 parent 08dafcc commit 44426e9

File tree

5 files changed

+313
-3
lines changed

5 files changed

+313
-3
lines changed

Project.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ AbstractPlutoDingetjes = "6e696c72-6542-2067-7265-42206c756150"
88
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
99
BetterFileWatching = "c9fd44ac-77b5-486c-9482-9798bd063cc6"
1010
Configurations = "5218b696-f38b-4ac9-8b61-a12ec717816d"
11+
Deno_jll = "04572ae6-984a-583e-9378-9577a1c2574d"
1112
Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b"
1213
FromFile = "ff7dd447-1dcb-4ce3-b8ac-22a812192de7"
1314
Git = "d7ba0133-e1db-5d97-8f8c-041e4b3a1eb2"
@@ -19,6 +20,7 @@ Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
1920
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
2021
Pluto = "c3e4b0f8-55cb-11ea-2926-15256bba5781"
2122
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"
23+
Scratch = "6c6a2e73-6563-6170-7368-637461726353"
2224
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
2325
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
2426
TerminalLoggers = "5d786b92-1e48-4d6f-9151-6b4477ca9bed"
@@ -29,6 +31,7 @@ AbstractPlutoDingetjes = "1.1"
2931
Base64 = "1"
3032
BetterFileWatching = "^0.1.2"
3133
Configurations = "0.16, 0.17"
34+
Deno_jll = "1.33"
3235
Distributed = "1"
3336
FromFile = "0.1"
3437
Git = "1"
@@ -40,6 +43,7 @@ Logging = "1"
4043
Pkg = "1"
4144
Pluto = "0.19.18"
4245
SHA = "0.7, 1"
46+
Scratch = "1.2"
4347
Sockets = "1"
4448
TOML = "1"
4549
TerminalLoggers = "0.1"

src/Actions.jl

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ using FromFile
88
@from "./Configuration.jl" import PlutoDeploySettings, is_glob_match
99
@from "./FileHelpers.jl" import find_notebook_files_recursive
1010
@from "./PlutoHash.jl" import plutohash
11+
@from "./OGImage.jl" import generate_og_image, can_generate_og_image
1112

1213

1314
showall(xs) = Text(join(string.(xs), "\n"))
@@ -244,11 +245,19 @@ function generate_static_export(
244245
frontmatter = convert(
245246
Pluto.FrontMatter,
246247
get(
247-
() -> Pluto.FrontMatter(),
248-
get(() -> Dict{String,Any}(), original_state, "metadata"),
248+
Pluto.FrontMatter,
249+
get(Dict{String,Any}, original_state, "metadata"),
249250
"frontmatter",
250251
),
251252
)
253+
254+
if settings.Export.generate_og_image && !settings.Export.baked_state && can_generate_og_image(frontmatter)
255+
@debug "Generating OG image" export_statefile_path
256+
local og_image_path = generate_og_image(export_statefile_path)
257+
@debug "Generated OG image" og_image_path
258+
frontmatter["image"] = relpath(og_image_path, dirname(export_html_path))
259+
end
260+
252261
header_html = Pluto.frontmatter_html(frontmatter)
253262

254263
html_contents = Pluto.generate_html(;
@@ -302,4 +311,4 @@ function remove_static_export(path; settings, output_dir)
302311
!settings.Export.baked_notebookfile
303312
tryrm(export_jl_path)
304313
end
305-
end
314+
end

src/Configuration.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ end
5252
"Use the Pluto Featured GUI to display the notebooks on the auto-generated index page, using frontmatter for title, description, image, and more. The default is currently `false`, but it might change in the future. Set to `true` or `false` explicitly to fix a value."
5353
create_pluto_featured_index::Union{Nothing,Bool} = nothing
5454
pluto_cdn_root::Union{Nothing,String} = nothing
55+
"Toggle OG image generation"
56+
generate_og_image::Bool = false
5557
end
5658

5759
@option struct PlutoDeploySettings

src/OGImage.jl

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Deno_jll: deno
2+
using Scratch
3+
4+
"Is frontmatter complete enough to generate an OG image?"
5+
function can_generate_og_image(frontmatter)
6+
(haskey(frontmatter, "author") || haskey(frontmatter, "author_name")) &&
7+
haskey(frontmatter, "title") &&
8+
haskey(frontmatter, "image")
9+
end
10+
11+
"""
12+
Run the deno command with a [DENO_DIR](https://docs.deno.com/runtime/manual/basics/env_variables#special-environment-variables)
13+
tied to a Scratch.jl scratch space where the deps and cache files will be installed.
14+
"""
15+
deno_pss(args) = withenv("DENO_DIR" => get_scratch!(@__MODULE__, "deno_dir")) do
16+
buf = IOBuffer()
17+
run(`$(deno()) $(args)`, Base.DevNull(), buf)
18+
# ﬌ stdin ﬌ stdout
19+
String(take!(buf))
20+
end
21+
22+
function generate_og_image(path_to_pluto_state_file)
23+
deno_pss([
24+
"run",
25+
"--allow-all", # Do we need stricter permissions?
26+
joinpath(@__DIR__, "og_image_gen.jsx"),
27+
path_to_pluto_state_file,
28+
]) |> strip
29+
end

src/og_image_gen.jsx

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import satori from "npm:[email protected]";
2+
import React from "npm:[email protected]";
3+
import { Resvg } from "npm:@resvg/[email protected]";
4+
5+
import { encodeBase64 } from "https://deno.land/[email protected]/encoding/base64.ts";
6+
import { walk } from "https://deno.land/[email protected]/fs/walk.ts";
7+
import { join } from "https://deno.land/[email protected]/path/mod.ts";
8+
9+
// ES6 import for msgpack-lite, we use the fonsp/msgpack-lite fork to make it ES6-importable (without nodejs)
10+
import msgpack from "https://cdn.jsdelivr.net/gh/fonsp/[email protected]/dist/msgpack-es.min.mjs";
11+
12+
// based on https://github.com/kawanet/msgpack-lite/blob/5b71d82cad4b96289a466a6403d2faaa3e254167/lib/ext-packer.js
13+
const codec = msgpack.createCodec();
14+
const packTypedArray = (x) =>
15+
new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
16+
codec.addExtPacker(0x11, Int8Array, packTypedArray);
17+
codec.addExtPacker(0x12, Uint8Array, packTypedArray);
18+
codec.addExtPacker(0x13, Int16Array, packTypedArray);
19+
codec.addExtPacker(0x14, Uint16Array, packTypedArray);
20+
codec.addExtPacker(0x15, Int32Array, packTypedArray);
21+
codec.addExtPacker(0x16, Uint32Array, packTypedArray);
22+
codec.addExtPacker(0x17, Float32Array, packTypedArray);
23+
codec.addExtPacker(0x18, Float64Array, packTypedArray);
24+
25+
codec.addExtPacker(0x12, Uint8ClampedArray, packTypedArray);
26+
codec.addExtPacker(0x12, ArrayBuffer, (x) => new Uint8Array(x));
27+
codec.addExtPacker(0x12, DataView, packTypedArray);
28+
29+
// Pack and unpack dates. However, encoding a date does throw on Safari because it doesn't have BigInt64Array.
30+
// This isn't too much a problem, as Safari doesn't even support <input type=date /> yet...
31+
// But it does throw when I create a custom @bind that has a Date value...
32+
// For decoding I now also use a "Invalid Date", but the code in https://stackoverflow.com/a/55338384/2681964 did work in Safari.
33+
// Also there is no way now to send an "Invalid Date", so it just does nothing
34+
codec.addExtPacker(0x0d, Date, (d) => new BigInt64Array([BigInt(+d)]));
35+
codec.addExtUnpacker(0x0d, (uintarray) => {
36+
if ("getBigInt64" in DataView.prototype) {
37+
let dataview = new DataView(
38+
uintarray.buffer,
39+
uintarray.byteOffset,
40+
uintarray.byteLength,
41+
);
42+
let bigint = dataview.getBigInt64(0, true); // true here is "littleEndianes", not sure if this only Works On My Machine©
43+
if (bigint > Number.MAX_SAFE_INTEGER) {
44+
throw new Error(
45+
`Can't read too big number as date (how far in the future is this?!)`,
46+
);
47+
}
48+
return new Date(Number(bigint));
49+
} else {
50+
return new Date(NaN);
51+
}
52+
});
53+
54+
codec.addExtUnpacker(0x11, (x) => new Int8Array(x.buffer));
55+
codec.addExtUnpacker(0x12, (x) => new Uint8Array(x.buffer));
56+
codec.addExtUnpacker(0x13, (x) => new Int16Array(x.buffer));
57+
codec.addExtUnpacker(0x14, (x) => new Uint16Array(x.buffer));
58+
codec.addExtUnpacker(0x15, (x) => new Int32Array(x.buffer));
59+
codec.addExtUnpacker(0x16, (x) => new Uint32Array(x.buffer));
60+
codec.addExtUnpacker(0x17, (x) => new Float32Array(x.buffer));
61+
codec.addExtUnpacker(0x18, (x) => new Float64Array(x.buffer));
62+
63+
/** @param {any} x */
64+
export const pack = (x) => {
65+
return msgpack.encode(x, { codec: codec });
66+
};
67+
68+
/** @param {Uint8Array} x */
69+
export const unpack = (x) => {
70+
return msgpack.decode(x, { codec: codec });
71+
};
72+
73+
const fluentEmoji = (code) =>
74+
"https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" +
75+
code.toLowerCase() +
76+
"_color.svg";
77+
78+
const emojiCache = {};
79+
const loadEmoji = (type, code) => {
80+
const key = type + ":" + code;
81+
if (key in emojiCache) return emojiCache[key];
82+
83+
emojiCache[key] = fetch(fluentEmoji(code)).then((r) => r.text());
84+
return emojiCache[key];
85+
};
86+
87+
const loadDynamicAsset = async (type, code) => {
88+
if (type === "emoji") {
89+
const emojiSvg = await loadEmoji(type, code);
90+
return `data:image/svg+xml;base64,` + encodeBase64(emojiSvg);
91+
}
92+
93+
return null;
94+
};
95+
96+
const HeaderComponent = ({
97+
author,
98+
authorImage,
99+
title,
100+
description,
101+
imageUrl,
102+
}) => (
103+
<div
104+
style={{
105+
display: "flex",
106+
height: "100%",
107+
width: "100%",
108+
alignItems: "center",
109+
flexDirection: "column",
110+
letterSpacing: "-0.02em",
111+
fontWeight: 700,
112+
fontFamily: 'Roboto, "Material Icons"',
113+
background: "#8E7DBE",
114+
}}
115+
>
116+
<div
117+
style={{
118+
height: "62%",
119+
width: "100%",
120+
backgroundImage:
121+
"linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))",
122+
display: "flex",
123+
}}
124+
>
125+
{imageUrl && (
126+
<img
127+
style={{ objectFit: "cover" }}
128+
height="100%"
129+
width="100%"
130+
src={imageUrl}
131+
/>
132+
)}
133+
</div>
134+
<div
135+
style={{
136+
display: "flex",
137+
alignItems: "center",
138+
position: "absolute",
139+
right: "20px",
140+
top: "20px",
141+
background: "rgba(255,255,255,200)",
142+
padding: "5px",
143+
borderRadius: "30px",
144+
}}
145+
>
146+
<div
147+
style={{
148+
height: "25px",
149+
width: "25px",
150+
background: "salmon",
151+
backgroundImage: authorImage
152+
? `url(${authorImage})`
153+
: "url(https://avatars.githubusercontent.com/u/74617459?s=400&u=85ab12d22312806d5e577de6c5a8b6bf983c21a6&v=4)",
154+
backgroundClip: "border-box",
155+
backgroundSize: "25px 25px",
156+
borderRadius: "12px",
157+
}}
158+
>
159+
</div>
160+
<div
161+
style={{ display: "flex", marginLeft: "10px", marginRight: "10px" }}
162+
>
163+
{author}
164+
</div>
165+
</div>
166+
<div
167+
style={{
168+
position: "absolute",
169+
bottom: 0,
170+
display: "flex",
171+
flexDirection: "column",
172+
borderRadius: "30px 30px 0px 0px",
173+
width: "100%",
174+
height: "45%",
175+
padding: "20px",
176+
background: "white",
177+
}}
178+
>
179+
<div style={{ lineClamp: 1, fontSize: "2em", marginBottom: "15px" }}>
180+
{title}
181+
</div>
182+
<div style={{ lineClamp: 3, fontSize: "1.3em", color: "#aaa" }}>
183+
{description}
184+
</div>
185+
</div>
186+
</div>
187+
);
188+
189+
// TODO(paul): cache this and other files in DENO_DIR?
190+
const roboto = await (await fetch(
191+
"https://github.com/vercel/satori/raw/main/test/assets/Roboto-Regular.ttf",
192+
)).arrayBuffer();
193+
194+
const generateOgImage = async (pathToNotebook) => {
195+
const statefileBuf = await Deno.readFile(pathToNotebook + ".plutostate");
196+
const statefile = unpack(statefileBuf);
197+
198+
let authorName = statefile.metadata.frontmatter.author_name;
199+
let authorImage = statefile.metadata.frontmatter.author_image;
200+
201+
if (authorName === undefined) {
202+
authorName = statefile.metadata.frontmatter.author.map(({ name }) => name)
203+
.join(", ", " and ");
204+
}
205+
206+
if (authorImage === undefined) {
207+
authorImage = statefile.metadata.frontmatter.author.map(({ image }) =>
208+
image
209+
).findLast(() => true);
210+
}
211+
212+
if (!authorImage) {
213+
authorImage = statefile.metadata.frontmatter.author.find(() => true)?.url +
214+
".png?size=48";
215+
}
216+
217+
const svg = await satori(
218+
<HeaderComponent
219+
author={authorName}
220+
authorImage={authorImage}
221+
title={statefile.metadata.frontmatter.title ??
222+
pathToNotebook.split("/").findLast(() => true)}
223+
description={statefile.metadata.frontmatter.description}
224+
imageUrl={statefile.metadata.frontmatter.image}
225+
/>,
226+
{
227+
width: 600,
228+
height: 400,
229+
fonts: [
230+
{
231+
name: "Roboto",
232+
// Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
233+
data: roboto,
234+
weight: 400,
235+
style: "normal",
236+
},
237+
],
238+
loadAdditionalAsset: loadDynamicAsset,
239+
},
240+
);
241+
const opts = {
242+
background: "rgba(238, 235, 230, .9)",
243+
fitTo: {
244+
mode: "width",
245+
value: 1200,
246+
},
247+
};
248+
249+
// await Deno.writeTextFile("satori.svg", svg);
250+
251+
const resvg = new Resvg(svg, opts);
252+
const pngData = resvg.render();
253+
const pngBuffer = pngData.asPng();
254+
255+
const b64 = encodeBase64(pngBuffer);
256+
const dataUrl = `data:image/png;base64,${b64}`;
257+
258+
const pngPath = pathToNotebook + ".og-image.png";
259+
await Deno.writeFile(pngPath, pngBuffer);
260+
261+
console.log(pngPath);
262+
};
263+
264+
const plutostateFilePath = Deno.args[0]
265+
const pathToNotebook = plutostateFilePath.replace(".plutostate", "");
266+
await generateOgImage(pathToNotebook);

0 commit comments

Comments
 (0)