Generate module and optimize LiveView diffing
This commit is contained in:
8595
lib/heroicons.ex
8595
lib/heroicons.ex
File diff suppressed because it is too large
Load Diff
@ -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
|
@ -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
|
||||
"""
|
||||
 {: 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
35
lib/mix/tasks/heroicons/build.ex
Normal file
35
lib/mix/tasks/heroicons/build.ex
Normal 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
|
@ -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
|
||||
|
Reference in New Issue
Block a user