Generate module and optimize LiveView diffing

This commit is contained in:
Chris McCord
2022-09-15 11:58:46 -04:00
parent 2ff8bca42e
commit 46c0c7237d
857 changed files with 8823 additions and 501 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
defmodule Heroicons.Cache do
@moduledoc """
An ETS-backed cache for icons. We cache both pre-compiled Phoenix Components and icon bodies as strings.
Uses the icon's path on disk as a key.
"""
use GenServer
@name __MODULE__
@doc false
def start_link(_), do: GenServer.start_link(__MODULE__, [], name: @name)
@doc "Fetch a icon's body and fingerprint from the cache or disk, given a `path`"
def fetch_icon(path) do
case :ets.lookup(@name, path) do
[{^path, icon_body, fingerprint}] ->
{icon_body, fingerprint}
[] ->
GenServer.call(@name, {:cache_icon, path})
end
end
@impl true
def init(_) do
:ets.new(@name, [:set, :protected, :named_table])
{:ok, []}
end
@impl true
def handle_call({:cache_icon, path}, _ref, state) do
{icon_body, fingerprint} = read_icon(path)
:ets.insert_new(@name, {path, icon_body, fingerprint})
{:reply, {icon_body, fingerprint}, state}
end
defp read_icon(path) do
icon =
Path.join(:code.priv_dir(:heroicons), path)
|> File.read!()
<<"<svg", icon_body::binary>> = icon
<<fingerprint::8*16>> = :erlang.md5(icon)
{icon_body, fingerprint}
end
end

View File

@ -1,142 +0,0 @@
defmodule Heroicons.Generator do
@moduledoc false
defmacro __using__(icon_dir: icon_dir) do
icon_paths =
Path.absname(icon_dir, :code.priv_dir(:heroicons))
|> Path.join("*.svg")
|> Path.wildcard()
require Phoenix.Component
recompile_quoted =
quote do
@paths_hash :erlang.md5(unquote(icon_paths))
def __mix_recompile__?() do
icon_paths =
Path.absname(unquote(icon_dir), :code.priv_dir(:heroicons))
|> Path.join("*.svg")
|> Path.wildcard()
:erlang.md5(icon_paths) != @paths_hash
end
end
icons_quoted =
for path <- icon_paths do
generate(path)
end
[recompile_quoted | icons_quoted]
end
@doc false
def generate(absolute_path) do
path = Path.relative_to(absolute_path, :code.priv_dir(:heroicons))
name = Heroicons.Generator.name(path)
quote do
@external_resource unquote(absolute_path)
@doc Heroicons.Generator.doc(unquote(name), unquote(path))
def unquote(name)(assigns_or_opts \\ [])
def unquote(name)(assigns) when is_map(assigns) do
Heroicons.Generator.icon_component(unquote(path), assigns)
end
def unquote(name)(opts) when is_list(opts) do
Heroicons.Generator.icon_function(unquote(path), opts)
end
end
end
@doc false
def name(path) do
Path.basename(path, ".svg")
|> String.replace("-", "_")
|> String.to_atom()
end
@doc false
def doc(name, path) do
"""
![](assets/#{path}) {: width=24px}
## Examples
Use as a `Phoenix.Component`
<.#{name} />
<.#{name} class="w-6 h-6 text-gray-500" />
or as a function
<%= #{name}() %>
<%= #{name}(class: "w-6 h-6 text-gray-500") %>
"""
end
if function_exported?(Phoenix.Component, :assigns_to_attributes, 2) do
@assign_mod Phoenix.Component
@assigns_to_attrs_mod Phoenix.Component
else
@assign_mod Phoenix.LiveView
@assigns_to_attrs_mod Phoenix.LiveView.Helpers
end
@doc false
def icon_component(path, assigns) when is_map(assigns) do
attrs = @assigns_to_attrs_mod.assigns_to_attributes(assigns)
assigns = @assign_mod.assign(assigns, :attrs, attrs)
dynamic = fn track_changes? ->
changed =
case assigns do
%{__changed__: changed} when track_changes? -> changed
_ -> nil
end
attrs =
case Phoenix.LiveView.Engine.changed_assign?(changed, :attrs) do
true -> elem(Phoenix.HTML.attributes_escape(assigns.attrs), 1)
false -> nil
end
[attrs]
end
{icon_body, fingerprint} = Heroicons.Cache.fetch_icon(path)
%Phoenix.LiveView.Rendered{
static: [
"<svg",
icon_body
],
dynamic: dynamic,
fingerprint: fingerprint,
root: true
}
end
@doc false
def icon_function(path, opts) when is_list(opts) do
attrs =
for {k, v} <- opts do
safe_k =
k |> Atom.to_string() |> String.replace("_", "-") |> Phoenix.HTML.Safe.to_iodata()
safe_v = v |> Phoenix.HTML.Safe.to_iodata()
{:safe, [?\s, safe_k, ?=, ?", safe_v, ?"]}
end
{icon_body, _fingerprint} = Heroicons.Cache.fetch_icon(path)
{:safe,
[
"<svg",
Phoenix.HTML.Safe.to_iodata(attrs),
icon_body
]}
end
end

View File

@ -1,9 +0,0 @@
defmodule Heroicons.Mini do
@moduledoc """
Solid style icons drawn with fills, packaged as Phoenix Components.
For smaller elements like buttons, form elements, and to support text,
designed to be rendered at 20x20.
"""
use Heroicons.Generator, icon_dir: "icons/mini/"
end

View File

@ -1,9 +0,0 @@
defmodule Heroicons.Outline do
@moduledoc """
Outline style icons drawn with a stroke, packaged as Phoenix Components.
For primary navigation and marketing sections, with an outlined appearance,
designed to be rendered at 24x24.
"""
use Heroicons.Generator, icon_dir: "icons/outline/"
end

View File

@ -1,9 +0,0 @@
defmodule Heroicons.Solid do
@moduledoc """
Solid style icons drawn with fills, packaged as Phoenix Components.
For primary navigation and marketing sections, with a filled appearance,
designed to be rendered at 24x24.
"""
use Heroicons.Generator, icon_dir: "icons/solid/"
end

View File

@ -1,31 +0,0 @@
defmodule Mix.Tasks.Heroicons do
@moduledoc """
Invokes heroicons mix utilities.
Usage:
$ mix heroicons
"""
@shortdoc "Invokes heroicons mix utilities"
use Mix.Task
@impl true
def run(args) do
{_opts, args} = OptionParser.parse!(args, strict: [])
case args do
[] -> help()
_ -> Mix.raise("Invalid arguments, expected: mix heroicons")
end
end
defp help() do
Mix.Task.run("app.start")
Mix.shell().info("Heroicons v#{Application.spec(:heroicons, :vsn)}")
Mix.shell().info("Include Heroicons as SVG-strings in your Elixir/Phoenix project!")
Mix.shell().info("\nAvailable tasks:\n")
Mix.Tasks.Help.run(["--search", "heroicons."])
end
end

View File

@ -0,0 +1,35 @@
defmodule Mix.Tasks.Heroicons.Build do
# Builds a new lib/heroicons.ex with bundled icon set.
@moduledoc false
@shortdoc false
use Mix.Task
@target_file "lib/heroicons.ex"
def run(_args) do
vsn = Mix.Tasks.Heroicons.Update.vsn()
svgs_path = Mix.Tasks.Heroicons.Update.svgs_path()
outlined = Path.wildcard(Path.join(svgs_path, "outline/**/*.svg"))
solid = Path.wildcard(Path.join(svgs_path, "solid/**/*.svg"))
mini = Path.wildcard(Path.join(svgs_path, "mini/**/*.svg"))
ordered_icons = outlined ++ solid ++ mini
icons =
Enum.group_by(ordered_icons, &function_name(&1), fn file ->
for path <- file |> File.read!() |> String.split("\n"),
path = String.trim(path),
String.starts_with?(path, "<path"),
do: path
end)
Mix.Generator.copy_template("assets/heroicons.exs", @target_file, %{icons: icons, vsn: vsn},
force: true
)
Mix.Task.run("format")
end
defp function_name(file) do
file |> Path.basename() |> Path.rootname() |> String.split("-") |> Enum.join("_")
end
end

View File

@ -1,33 +1,119 @@
defmodule Mix.Tasks.Heroicons.Update do
@moduledoc """
Update heroicons.
# Updates the icon set via download from github.
@moduledoc false
@shortdoc false
By default, it downloads the latest version but you can configure it
in your config files.
@vsn "2.0.10"
@tmp_dir_name "heroicons-elixir"
Example:
config :heroicons, :version, "#{Heroicons.latest_version()}"
Then update with
$ mix heroicons.update
"""
@shortdoc "Update heroicons assets"
# Updates the icons in the assets/icons directory
use Mix.Task
require Logger
def vsn, do: @vsn
def svgs_path, do: Path.join("assets", "icons")
@impl true
def run(args) do
{_opts, args} = OptionParser.parse!(args, strict: [])
def run(_args) do
version = @vsn
tmp_dir = Path.join(System.tmp_dir!(), @tmp_dir_name)
svgs_dir = Path.join([tmp_dir, "heroicons-#{version}", "optimized"])
case args do
[] ->
Heroicons.update()
File.rm_rf!(tmp_dir)
File.mkdir_p!(tmp_dir)
_ ->
Mix.raise("Invalid arguments, expected: mix heroicons.update")
url = "https://github.com/tailwindlabs/heroicons/archive/refs/tags/v#{version}.zip"
archive = fetch_body!(url)
case unpack_archive(".zip", archive, tmp_dir) do
:ok -> :ok
other -> raise "couldn't unpack archive: #{inspect(other)}"
end
# Copy icon styles, mini, outline and solid, to priv folder
svgs_dir
|> File.ls!()
|> Enum.each(fn size ->
case size do
"20" ->
copy_svg_files(Path.join([svgs_dir, size, "solid"]), "mini")
"24" ->
Path.join(svgs_dir, size)
|> File.ls!()
|> Enum.each(fn style -> copy_svg_files(Path.join([svgs_dir, size, style]), style) end)
_ ->
true
end
end)
end
defp copy_svg_files(src_dir, style) do
dest_dir = Path.join(svgs_path(), style)
File.rm_rf!(dest_dir)
File.mkdir_p!(dest_dir)
File.cp_r!(src_dir, dest_dir)
end
defp fetch_body!(url) do
url = String.to_charlist(url)
Logger.debug("Downloading heroicons from #{url}")
{:ok, _} = Application.ensure_all_started(:inets)
{:ok, _} = Application.ensure_all_started(:ssl)
if proxy = System.get_env("HTTP_PROXY") || System.get_env("http_proxy") do
Logger.debug("Using HTTP_PROXY: #{proxy}")
%{host: host, port: port} = URI.parse(proxy)
:httpc.set_options([{:proxy, {{String.to_charlist(host), port}, []}}])
end
if proxy = System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") do
Logger.debug("Using HTTPS_PROXY: #{proxy}")
%{host: host, port: port} = URI.parse(proxy)
:httpc.set_options([{:https_proxy, {{String.to_charlist(host), port}, []}}])
end
# https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets
cacertfile = CAStore.file_path() |> String.to_charlist()
http_options = [
ssl: [
verify: :verify_peer,
cacertfile: cacertfile,
depth: 2,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
],
versions: protocol_versions()
]
]
options = [body_format: :binary]
case :httpc.request(:get, {url, []}, http_options, options) do
{:ok, {{_, 200, _}, _headers, body}} ->
body
other ->
raise "couldn't fetch #{url}: #{inspect(other)}"
end
end
defp protocol_versions do
if otp_version() < 25, do: [:"tlsv1.2"], else: [:"tlsv1.2", :"tlsv1.3"]
end
defp otp_version, do: :erlang.system_info(:otp_release) |> List.to_integer()
defp unpack_archive(".zip", zip, cwd) do
with {:ok, _} <- :zip.unzip(zip, cwd: to_charlist(cwd)), do: :ok
end
defp unpack_archive(_, tar, cwd) do
:erl_tar.extract({:binary, tar}, [:compressed, cwd: to_charlist(cwd)])
end
end