diff --git a/Project.toml b/Project.toml index d671ec9..e3e7fae 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ AbstractPlutoDingetjes = "6e696c72-6542-2067-7265-42206c756150" Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" BetterFileWatching = "c9fd44ac-77b5-486c-9482-9798bd063cc6" Configurations = "5218b696-f38b-4ac9-8b61-a12ec717816d" +Deno_jll = "04572ae6-984a-583e-9378-9577a1c2574d" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" FromFile = "ff7dd447-1dcb-4ce3-b8ac-22a812192de7" Git = "d7ba0133-e1db-5d97-8f8c-041e4b3a1eb2" @@ -19,6 +20,7 @@ Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" Pluto = "c3e4b0f8-55cb-11ea-2926-15256bba5781" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +Scratch = "6c6a2e73-6563-6170-7368-637461726353" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" TerminalLoggers = "5d786b92-1e48-4d6f-9151-6b4477ca9bed" @@ -29,6 +31,7 @@ AbstractPlutoDingetjes = "1.1" Base64 = "1" BetterFileWatching = "^0.1.2" Configurations = "0.16, 0.17" +Deno_jll = "1.33" Distributed = "1" FromFile = "0.1" Git = "1" @@ -40,6 +43,7 @@ Logging = "1" Pkg = "1" Pluto = "0.19.18" SHA = "0.7, 1" +Scratch = "1.2" Sockets = "1" TOML = "1" TerminalLoggers = "0.1" diff --git a/src/Actions.jl b/src/Actions.jl index df6ca1b..ef14e77 100644 --- a/src/Actions.jl +++ b/src/Actions.jl @@ -8,6 +8,7 @@ using FromFile @from "./Configuration.jl" import PlutoDeploySettings, is_glob_match @from "./FileHelpers.jl" import find_notebook_files_recursive @from "./PlutoHash.jl" import plutohash +@from "./OGImage.jl" import generate_og_image, can_generate_og_image showall(xs) = Text(join(string.(xs), "\n")) @@ -244,11 +245,19 @@ function generate_static_export( frontmatter = convert( Pluto.FrontMatter, get( - () -> Pluto.FrontMatter(), - get(() -> Dict{String,Any}(), original_state, "metadata"), + Pluto.FrontMatter, + get(Dict{String,Any}, original_state, "metadata"), "frontmatter", ), ) + + if settings.Export.generate_og_image && !settings.Export.baked_state && can_generate_og_image(frontmatter) + @debug "Generating OG image" export_statefile_path + local og_image_path = generate_og_image(export_statefile_path) + @debug "Generated OG image" og_image_path + frontmatter["image"] = relpath(og_image_path, dirname(export_html_path)) + end + header_html = Pluto.frontmatter_html(frontmatter) html_contents = Pluto.generate_html(; @@ -302,4 +311,4 @@ function remove_static_export(path; settings, output_dir) !settings.Export.baked_notebookfile tryrm(export_jl_path) end -end \ No newline at end of file +end diff --git a/src/Configuration.jl b/src/Configuration.jl index 529c5c5..b1d0125 100644 --- a/src/Configuration.jl +++ b/src/Configuration.jl @@ -52,6 +52,8 @@ end "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." create_pluto_featured_index::Union{Nothing,Bool} = nothing pluto_cdn_root::Union{Nothing,String} = nothing + "Toggle OG image generation" + generate_og_image::Bool = false end @option struct PlutoDeploySettings diff --git a/src/OGImage.jl b/src/OGImage.jl new file mode 100644 index 0000000..3337162 --- /dev/null +++ b/src/OGImage.jl @@ -0,0 +1,29 @@ +using Deno_jll: deno +using Scratch + +"Is frontmatter complete enough to generate an OG image?" +function can_generate_og_image(frontmatter) + (haskey(frontmatter, "author") || haskey(frontmatter, "author_name")) && + haskey(frontmatter, "title") && + haskey(frontmatter, "image") +end + +""" +Run the deno command with a [DENO_DIR](https://docs.deno.com/runtime/manual/basics/env_variables#special-environment-variables) +tied to a Scratch.jl scratch space where the deps and cache files will be installed. +""" +deno_pss(args) = withenv("DENO_DIR" => get_scratch!(@__MODULE__, "deno_dir")) do + buf = IOBuffer() + run(`$(deno()) $(args)`, Base.DevNull(), buf) + # stdin stdout + String(take!(buf)) +end + +function generate_og_image(path_to_pluto_state_file) + deno_pss([ + "run", + "--allow-all", # Do we need stricter permissions? + joinpath(@__DIR__, "og_image_gen.jsx"), + path_to_pluto_state_file, + ]) |> strip +end diff --git a/src/og_image_gen.jsx b/src/og_image_gen.jsx new file mode 100644 index 0000000..db23c0b --- /dev/null +++ b/src/og_image_gen.jsx @@ -0,0 +1,266 @@ +import satori from "npm:satori@0.10"; +import React from "npm:react@18.2"; +import { Resvg } from "npm:@resvg/resvg-js@2.6"; + +import { encodeBase64 } from "https://deno.land/std@0.207.0/encoding/base64.ts"; +import { walk } from "https://deno.land/std@0.202.0/fs/walk.ts"; +import { join } from "https://deno.land/std@0.202.0/path/mod.ts"; + +// ES6 import for msgpack-lite, we use the fonsp/msgpack-lite fork to make it ES6-importable (without nodejs) +import msgpack from "https://cdn.jsdelivr.net/gh/fonsp/msgpack-lite@0.1.27-es.1/dist/msgpack-es.min.mjs"; + +// based on https://github.com/kawanet/msgpack-lite/blob/5b71d82cad4b96289a466a6403d2faaa3e254167/lib/ext-packer.js +const codec = msgpack.createCodec(); +const packTypedArray = (x) => + new Uint8Array(x.buffer, x.byteOffset, x.byteLength); +codec.addExtPacker(0x11, Int8Array, packTypedArray); +codec.addExtPacker(0x12, Uint8Array, packTypedArray); +codec.addExtPacker(0x13, Int16Array, packTypedArray); +codec.addExtPacker(0x14, Uint16Array, packTypedArray); +codec.addExtPacker(0x15, Int32Array, packTypedArray); +codec.addExtPacker(0x16, Uint32Array, packTypedArray); +codec.addExtPacker(0x17, Float32Array, packTypedArray); +codec.addExtPacker(0x18, Float64Array, packTypedArray); + +codec.addExtPacker(0x12, Uint8ClampedArray, packTypedArray); +codec.addExtPacker(0x12, ArrayBuffer, (x) => new Uint8Array(x)); +codec.addExtPacker(0x12, DataView, packTypedArray); + +// Pack and unpack dates. However, encoding a date does throw on Safari because it doesn't have BigInt64Array. +// This isn't too much a problem, as Safari doesn't even support yet... +// But it does throw when I create a custom @bind that has a Date value... +// For decoding I now also use a "Invalid Date", but the code in https://stackoverflow.com/a/55338384/2681964 did work in Safari. +// Also there is no way now to send an "Invalid Date", so it just does nothing +codec.addExtPacker(0x0d, Date, (d) => new BigInt64Array([BigInt(+d)])); +codec.addExtUnpacker(0x0d, (uintarray) => { + if ("getBigInt64" in DataView.prototype) { + let dataview = new DataView( + uintarray.buffer, + uintarray.byteOffset, + uintarray.byteLength, + ); + let bigint = dataview.getBigInt64(0, true); // true here is "littleEndianes", not sure if this only Works On My Machine© + if (bigint > Number.MAX_SAFE_INTEGER) { + throw new Error( + `Can't read too big number as date (how far in the future is this?!)`, + ); + } + return new Date(Number(bigint)); + } else { + return new Date(NaN); + } +}); + +codec.addExtUnpacker(0x11, (x) => new Int8Array(x.buffer)); +codec.addExtUnpacker(0x12, (x) => new Uint8Array(x.buffer)); +codec.addExtUnpacker(0x13, (x) => new Int16Array(x.buffer)); +codec.addExtUnpacker(0x14, (x) => new Uint16Array(x.buffer)); +codec.addExtUnpacker(0x15, (x) => new Int32Array(x.buffer)); +codec.addExtUnpacker(0x16, (x) => new Uint32Array(x.buffer)); +codec.addExtUnpacker(0x17, (x) => new Float32Array(x.buffer)); +codec.addExtUnpacker(0x18, (x) => new Float64Array(x.buffer)); + +/** @param {any} x */ +export const pack = (x) => { + return msgpack.encode(x, { codec: codec }); +}; + +/** @param {Uint8Array} x */ +export const unpack = (x) => { + return msgpack.decode(x, { codec: codec }); +}; + +const fluentEmoji = (code) => + "https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + + code.toLowerCase() + + "_color.svg"; + +const emojiCache = {}; +const loadEmoji = (type, code) => { + const key = type + ":" + code; + if (key in emojiCache) return emojiCache[key]; + + emojiCache[key] = fetch(fluentEmoji(code)).then((r) => r.text()); + return emojiCache[key]; +}; + +const loadDynamicAsset = async (type, code) => { + if (type === "emoji") { + const emojiSvg = await loadEmoji(type, code); + return `data:image/svg+xml;base64,` + encodeBase64(emojiSvg); + } + + return null; +}; + +const HeaderComponent = ({ + author, + authorImage, + title, + description, + imageUrl, +}) => ( +