svg preview renderer

This commit is contained in:
zack 2024-10-26 21:41:22 -04:00
parent 43a8412f06
commit faa9599849
No known key found for this signature in database
GPG key ID: 5F873416BCF59F35
29 changed files with 1027 additions and 254 deletions

View file

@ -1,6 +1,8 @@
defmodule ZoeyscomputerWeb.GistLive.FormComponent do
use ZoeyscomputerWeb, :live_component
import ZoeyscomputerWeb.GistLive.Select
alias Zoeyscomputer.Gists
@impl true
@ -11,7 +13,6 @@ defmodule ZoeyscomputerWeb.GistLive.FormComponent do
<%= @title %>
<:subtitle>Use this form to manage gist records in your database.</:subtitle>
</.header>
<.simple_form
for={@form}
id="gist-form"
@ -19,8 +20,22 @@ defmodule ZoeyscomputerWeb.GistLive.FormComponent do
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:code]} type="text" label="Code" />
<.input field={@form[:lang]} type="text" label="Lang" />
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:desc]} type="textarea" label="Description" />
<.input field={@form[:code]} type="textarea" label="Code" />
<.select
id="lang-select"
form={@form}
field={:lang}
label="Language"
options={[
{"JavaScript", "javascript"},
{"Python", "python"},
{"Elixir", "elixir"},
{"Ruby", "ruby"},
{"Rust", "rust"}
]}
/>
<:actions>
<.button phx-disable-with="Saving...">Save Gist</.button>
</:actions>
@ -49,6 +64,14 @@ defmodule ZoeyscomputerWeb.GistLive.FormComponent do
save_gist(socket, socket.assigns.action, gist_params)
end
# Updated to use lang instead of language
def handle_event("change", %{"value" => value}, socket) do
current_params = socket.assigns.form.params || %{}
updated_params = Map.put(current_params, "lang", value)
changeset = Gists.change_gist(socket.assigns.gist, updated_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
defp save_gist(socket, :edit, gist_params) do
case Gists.update_gist(socket.assigns.gist, gist_params) do
{:ok, gist} ->

View file

@ -11,7 +11,8 @@ defmodule ZoeyscomputerWeb.GistLive.Index do
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
live_action = socket.assigns.live_action || :index
{:noreply, apply_action(socket, live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
@ -23,7 +24,16 @@ defmodule ZoeyscomputerWeb.GistLive.Index do
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Gist")
|> assign(:gist, %Gist{})
|> assign(:gist, %Gist{
code: "",
lang: nil
})
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, "Show Gist")
|> assign(:gist, Gists.get_gist!(id))
end
defp apply_action(socket, :index, _params) do
@ -32,6 +42,13 @@ defmodule ZoeyscomputerWeb.GistLive.Index do
|> assign(:gist, nil)
end
# Catch-all clause for when live_action is nil
defp apply_action(socket, nil, _params) do
socket
|> assign(:page_title, "Listing Gists")
|> assign(:gist, nil)
end
@impl true
def handle_info({ZoeyscomputerWeb.GistLive.FormComponent, {:saved, gist}}, socket) do
{:noreply, stream_insert(socket, :gists, gist)}

View file

@ -12,7 +12,9 @@
rows={@streams.gists}
row_click={fn {_id, gist} -> JS.navigate(~p"/gists/#{gist}") end}
>
<:col :let={{_id, gist}} label="Code"><%= gist.code %></:col>
<:col :let={{_id, gist}} label="Code">
<p class="truncate max-w-72"><%= gist.title || gist.code %></p>
</:col>
<:col :let={{_id, gist}} label="Lang"><%= gist.lang %></:col>
<:action :let={{_id, gist}}>
<div class="sr-only">

View file

@ -0,0 +1,15 @@
defmodule ZoeyscomputerWeb.GistLive.Languages do
@languages [
%{name: "JavaScript", val: "javascript"},
%{name: "TypeScript", val: "typescript"},
%{name: "Rust", val: "rust"},
%{name: "Elixir", val: "elixir"}
]
def search_languages(name) do
@languages
|> Enum.filter(fn language ->
String.contains?(String.downcase(language.name), String.downcase(name))
end)
end
end

View file

@ -0,0 +1,243 @@
defmodule ZoeyscomputerWeb.GistLive.OgImage do
@moduledoc """
Generates OpenGraph preview images for Gists using SVG with syntax highlighting.
"""
require Logger
@external_resource "priv/static/fonts/WOFF2/Iosevka-Bold.woff2"
@external_resource "priv/static/fonts/WOFF2/Iosevka-Regular.woff2"
@font_bold File.read!("priv/static/fonts/WOFF2/Iosevka-Bold.woff2")
@font_regular File.read!("priv/static/fonts/WOFF2/Iosevka-Regular.woff2")
@line_height 36
@code_start_y 210
@code_start_x 80
@font_size 24
@char_width 12
defp font_face_styles do
"""
@font-face {
font-family: 'Iosevka';
src: url(data:font/woff2;charset=utf-8;base64,#{Base.encode64(@font_regular)}) format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Iosevka';
src: url(data:font/woff2;charset=utf-8;base64,#{Base.encode64(@font_bold)}) format('woff2');
font-weight: 700;
font-style: normal;
}
"""
end
def generate_preview(gist) do
code_lines =
gist.code
|> preview_code()
|> highlight_code(gist.lang)
|> render_code_lines()
"""
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1200" height="630" viewBox="0 0 1200 630"
xmlns="http://www.w3.org/2000/svg">
<defs>
<style type="text/css">
#{font_face_styles()}
* {
font-family: 'Iosevka' !important;
}
.background { fill: #0D1117; }
.title { fill: #ffffff; font-family: system-ui, -apple-system, sans-serif; }
.description { fill: #8B949E; font-family: system-ui, -apple-system, sans-serif; }
.code-background { fill: #161B22; }
/* Syntax Highlighting Colors */
.c, .cm, .cp, .c1, .cs { fill: #768390; } /* Comment */
.k, .kc, .kd, .kn, .kp, .kr, .kt { fill: #f47067; } /* Keyword */
.s, .sb, .sc, .sd, .s2, .se, .sh, .si, .sx, .sr, .s1, .ss { fill: #96d0ff; } /* String */
.n, .na, .nc, .no, .nd, .ni, .ne, .nf, .nl, .nn, .nx, .py, .nt, .nv, .vc, .vg, .vi { fill: #adbac7; } /* Name */
.nb, .bp { fill: #f69d50; } /* Built-in */
.m, .mf, .mh, .mi, .mo, .il { fill: #6cb6ff; } /* Number */
.o, .ow { fill: #f47067; } /* Operator */
.p { fill: #adbac7; } /* Punctuation */
.w { fill: #E6EDF3; } /* Whitespace */
.l { fill: #6cb6ff; } /* Literal */
.default { fill: #E6EDF3; } /* Default text color */
</style>
</defs>
<!-- Background -->
<rect class="background" width="1200" height="630" />
<!-- Content -->
<text x="60" y="80" class="title" font-size="40" font-weight="bold">
#{escape_text(gist.title || "Gist: #{gist.id}")}
</text>
<text x="60" y="130" class="description" font-size="24">
#{escape_text(gist.desc || "No description")}
</text>
<!-- Code Preview Background -->
<rect x="60" y="160" width="1080" height="420" class="code-background" rx="8"/>
<!-- Code Content -->
<g font-family="Iosevka" font-size="#{@font_size}">
#{code_lines}
</g>
</svg>
"""
end
defp render_code_lines(highlighted_html) do
highlighted_html
|> String.split("\n")
|> Enum.with_index()
|> Enum.map_join("\n", fn {line, index} ->
y_position = @code_start_y + index * @line_height
render_line(String.trim(line), y_position)
end)
end
defp render_line("", _y_position), do: ""
defp render_line(line, y_position) do
spans = parse_spans(line)
spans
|> Enum.reduce({[], 0}, fn {content, class}, {spans, x_offset} ->
span = render_span(content, class, x_offset, y_position)
{[span | spans], x_offset + String.length(content)}
end)
|> elem(0)
|> Enum.reverse()
|> Enum.join("\n")
end
defp parse_spans(""), do: []
defp parse_spans(line) do
# Handle non-span text first
line
|> String.split(~r{(<span.*?>|</span>)}, include_captures: true)
|> Enum.reduce({[], nil}, fn
# Opening span tag
<<"<span class=\"", rest::binary>>, {acc, nil} ->
class = rest |> String.replace_suffix("\">", "")
{acc, class}
# Closing span tag
"</span>", {acc, _class} ->
{acc, nil}
# Content within or outside spans
content, {acc, class} when byte_size(content) > 0 ->
{[{content, class || "default"} | acc], class}
_, acc_class ->
acc_class
end)
|> elem(0)
|> Enum.reverse()
end
defp render_span(content, class, x_offset, y_position) when byte_size(content) > 0 do
x = @code_start_x + x_offset * @char_width
"<text x=\"#{x}\" y=\"#{y_position}\" class=\"#{class}\">#{escape_text(content)}</text>"
end
defp render_span(_, _, _, _), do: ""
defp preview_code(content) do
content
|> String.split("\n")
|> Enum.take(15)
|> Enum.join("\n")
end
defp escape_text(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
end
defp highlight_code(code, language) when is_binary(language) do
lexer = get_lexer_for_language(language)
String.trim(
code
|> Makeup.highlight_inner_html(
lexer: lexer,
formatter_opts: [
highlight_tag: "span",
css_class: "makeup",
highlight_css_class: "hll"
]
)
|> to_string()
)
end
defp highlight_code(code, nil) do
escape_text(code)
end
defp get_lexer_for_language(language) do
case language do
"elixir" -> Makeup.Lexers.ElixirLexer
"erlang" -> Makeup.Lexers.ErlangLexer
"html" -> Makeup.Lexers.HtmlLexer
"javascript" -> Makeup.Lexers.JsLexer
"python" -> Makeup.Lexers.PythonLexer
"ruby" -> Makeup.Lexers.RubyLexer
"rust" -> Makeup.Lexers.RustLexer
end
end
def get_webp(gist) do
svg = generate_preview(gist)
tmp_svg_path = Path.join(System.tmp_dir(), "temp_#{:rand.uniform(999_999)}.svg")
Logger.debug(tmp_svg_path)
try do
File.write!(tmp_svg_path, svg)
Logger.debug("wrote svg")
try do
image =
tmp_svg_path
|> Mogrify.open()
|> Mogrify.custom("define", "svg:include-fonts=true")
|> Mogrify.format("webp")
|> Mogrify.save()
Logger.debug("wrote image #{image.path}")
{:ok, image_binary} = File.read(image.path)
Logger.debug("returning binary")
{:ok, image_binary}
rescue
e in Mogrify.Error ->
{
:error,
"Conversion failed: #{Exception.message(e)}"
}
end
rescue
e -> {:error, "File operation failed: #{Exception.message(e)}"}
after
File.rm(tmp_svg_path)
end
end
end

View file

@ -0,0 +1,125 @@
defmodule ZoeyscomputerWeb.GistLive.Select do
use Phoenix.Component
import Phoenix.HTML.Form, only: [input_value: 2]
attr :id, :string, required: true
attr :form, :any, required: true
attr :field, :atom, required: true
attr :options, :list, required: true
attr :label, :string, default: nil
attr :prompt, :string, default: "Select an option..."
attr :class, :string, default: nil
attr :disabled, :boolean, default: false
def select(assigns) do
selected_value = input_value(assigns.form, assigns.field)
assigns = assign(assigns, :selected_value, selected_value)
~H"""
<div class="relative" id={@id <> "-container"} phx-hook="SelectHook">
<label :if={@label} for={@id} class="block text-sm font-bold mb-2 text-ctp-overlay1">
<%= @label %>
</label>
<button
type="button"
id={@id <> "-trigger"}
phx-update="ignore"
class={[
"relative w-full cursor-default rounded-md py-1.5 pl-3 pr-10 text-left",
"bg-ctp-base text-ctp-text border-ctp-surface0 border",
"focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ctp-ring-lavender",
"transition-colors duration-200",
"disabled:cursor-not-allowed disabled:opacity-50"
]}
disabled={@disabled}
>
<span class="block truncate" id={@id <> "-selected"}>
<%= selected_option(@options, @selected_value) || @prompt %>
</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg class="h-5 w-5 ctp-text-overlay0" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
</svg>
</span>
</button>
<div
id={@id <> "-dropdown"}
phx-update="ignore"
class={
[
"absolute z-10 mt-1 w-full rounded-md py-1 shadow-lg",
"bg-ctp-base border-ctp-surface0 border",
# Initially hidden, toggled by JS
"hidden"
]
}
>
<div class="px-3 py-2">
<input
type="text"
id={@id <> "-search"}
placeholder="Search..."
class={[
"w-full rounded-md py-1.5 px-3",
"bg-ctp-mantle text-ctp-text placeholder-ctp-overlay0",
"focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-ctp-lavender",
"transition-colors duration-200"
]}
/>
</div>
<ul class="max-h-60 overflow-auto" id={@id <> "-options"} role="listbox" tabindex="-1">
<%= for {label, value} <- @options do %>
<li
class={[
"relative cursor-default select-none py-2 pl-3 pr-9",
"text-ctp-text hover:bg-ctp-surface0",
"transition-colors duration-200"
]}
role="option"
data-value={value}
aria-selected={to_string(value) == to_string(@selected_value)}
>
<span class="block truncate"><%= label %></span>
<%= if to_string(value) == to_string(@selected_value) do %>
<span class="absolute inset-y-0 right-0 flex items-center pr-4 ctp-text-lavender">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</span>
<% end %>
</li>
<% end %>
</ul>
</div>
<input
type="hidden"
name={input_name(@form, @field)}
id={input_id(@form, @field)}
value={@selected_value}
/>
</div>
"""
end
defp selected_option(_options, value) when is_nil(value), do: nil
defp selected_option(options, value) do
case Enum.find(options, fn {_label, val} -> to_string(val) == to_string(value) end) do
{label, _value} -> label
_ -> nil
end
end
defp input_id(%{id: id}, field) when is_atom(field), do: "#{id}_#{field}"
defp input_name(%{name: name}, field), do: "#{name}[#{field}]"
end

View file

@ -1,5 +1,6 @@
defmodule ZoeyscomputerWeb.GistLive.Show do
use ZoeyscomputerWeb, :live_view
import ZoeyscomputerWeb.CodeBlock
alias Zoeyscomputer.Gists

View file

@ -1,5 +1,5 @@
<.header>
Gist <%= @gist.id %>
<%= @gist.title || "Gist: #{@gist.id}" %>
<:subtitle>This is a gist record from your database.</:subtitle>
<:actions>
<.link patch={~p"/gists/#{@gist}/show/edit"} phx-click={JS.push_focus()}>
@ -8,10 +8,21 @@
</:actions>
</.header>
<.list>
<:item title="Code"><%= @gist.code %></:item>
<:item title="Lang"><%= @gist.lang %></:item>
</.list>
<meta property="og:title" content={@gist.title || "Gist: #{@gist.id}"} />
<meta property="og:description" content={@gist.desc} />
<meta property="og:image" content={url(~p"/gists/#{@gist.id}/preview")} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<.code_block
class="mt-4"
code={@gist.code}
language={@gist.lang}
title={@gist.title}
line_numbers={true}
highlighted_lines={[118, 119, 120]}
/>
<.back navigate={~p"/gists"}>Back to gists</.back>