Skip to content

Commit

Permalink
Merge pull request phoenixframework#758 from laurocaetano/introduce-d…
Browse files Browse the repository at this point in the history
…igest-task

Introduce the digest task
  • Loading branch information
josevalim committed Apr 11, 2015
2 parents ba6d086 + 9f5b68c commit cf32704
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 0 deletions.
46 changes: 46 additions & 0 deletions lib/mix/tasks/phoenix.digest.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
defmodule Mix.Tasks.Phoenix.Digest do
use Mix.Task
@default_input_path "priv/static"

@shortdoc "Digests and compress static files."

@moduledoc """
Digests and compress static files.
mix phoenix.digest priv/static -o public/assets
The first argument is the path where the static files are located. The
`-o` option indicates the path that will be used to save the digested and
compressed files.
If no path is given, it will use `priv/static` as the input and output path.
The output folder will contain:
* the original file
* a compressed file with gzip
* a file containing the original file name and its digest
* a compressed file containing the file name and its digest
* a manifest file
Example of generated files:
* application.js.erb
* application.js.erb.gz
* application.js-eb0a5b9302e8d32828d8a73f137cc8f0.erb
* application.js-eb0a5b9302e8d32828d8a73f137cc8f0.erb.gz
* manifest.json
"""

@doc false
def run([input|args]) do
{args, _, _} = OptionParser.parse(args, aliases: [o: :output])
input_path = input || @default_input_path
output_path = args[:output] || input_path

case Phoenix.Digester.compile(input_path, output_path) do
:ok -> Mix.shell.info [:green, "Check your digested files at '#{output_path}'."]
{:error, :invalid_path} -> Mix.raise "The input path '#{input_path}' does not exist."
end
end
end
97 changes: 97 additions & 0 deletions lib/phoenix/digester.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
defmodule Phoenix.Digester do
@digested_file_regex ~r/(-[a-fA-F\d]{32})/

@moduledoc """
Digests and compress static files.
For each file under the given input path, Phoenix will generate a digest
and also compress in `.gz` format. The filename and its digest will be
used to generate the manifest file. It also avoid duplications checking
for already digested files.
"""

@doc """
Digests and compress the static files and save them in the given output path.
* `input_path` - The path where the assets are located
* `output_path` - The path where the compiled/compressed files will be saved
"""
@spec compile(String.t, String.t) :: :ok | {:error, :invalid_path}
def compile(input_path, output_path) do
if File.exists?(input_path) do
unless File.exists?(output_path), do: File.mkdir_p!(output_path)

input_path
|> filter_files
|> do_compile(output_path)
|> generate_manifest(output_path)
:ok
else
{:error, :invalid_path}
end
end

defp filter_files(input_path) do
input_path
|> Path.join("**")
|> Path.wildcard
|> Enum.filter(&(!File.dir?(&1) && !compiled_file?(&1)))
|> Enum.map(&(map_file(&1, input_path)))
end

defp do_compile(files, output_path) do
Enum.map(files, fn (file) ->
file
|> digest
|> compress
|> write_to_disk(output_path)
end)
end

defp generate_manifest(files, output_path) do
entries = Enum.reduce(files, %{}, fn (file, acc) ->
Map.put(acc, Path.join(file.relative_path, file.filename),
Path.join(file.relative_path, file.digested_filename))
end)

manifest_content = Poison.Encoder.encode(entries, [])
File.write!(Path.join(output_path, "manifest.json"), manifest_content)
end

defp compiled_file?(file_path) do
Regex.match?(@digested_file_regex, Path.basename(file_path)) ||
Path.extname(file_path) == ".gz"
end

defp map_file(file_path, input_path) do
%{absolute_path: file_path,
relative_path: Path.relative_to(file_path, input_path) |> Path.dirname,
filename: Path.basename(file_path),
content: File.read!(file_path)}
end

defp compress(file) do
Map.put(file, :compressed_content, :zlib.gzip(file.content))
end

defp digest(file) do
name = Path.rootname(file.filename)
extension = Path.extname(file.filename)
digest = Base.encode16(:erlang.md5(file.content), case: :lower)
Map.put(file, :digested_filename, "#{name}-#{digest}#{extension}")
end

defp write_to_disk(file, output_path) do
File.mkdir_p!(Path.join(output_path, file.relative_path))
path = Path.join(output_path, file.relative_path)

# compressed files
File.write!(Path.join(path, file.digested_filename <> ".gz"), file.compressed_content)
File.write!(Path.join(path, file.filename <> ".gz"), file.compressed_content)
# uncompressed files
File.write!(Path.join(path, file.digested_filename), file.content)
File.write!(Path.join(path, file.filename), file.content)

file
end
end
27 changes: 27 additions & 0 deletions test/mix/tasks/phoenix.digest_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule Mix.Tasks.Phoenix.DigestTest do
use ExUnit.Case, async: true

test "fails when the given paths are invalid" do
assert_raise Mix.Error, "The input path 'invalid_path' does not exist.", fn ->
Mix.Tasks.Phoenix.Digest.run(["invalid_path"])
end
end

test "digests and compress files" do
output_path = Path.join("tmp", "digested")
input_path = "priv/static"

File.mkdir_p!(output_path)

Mix.Tasks.Phoenix.Digest.run([input_path, "-o", output_path])
assert_received {:mix_shell, :info, ["Check your digested files at 'tmp/digested'."]}
end

test "uses the input path as output path when no outputh path is given" do
input_path = Path.join("tmp", "input_path")
File.mkdir_p!(input_path)

Mix.Tasks.Phoenix.Digest.run([input_path])
assert_received {:mix_shell, :info, ["Check your digested files at 'tmp/input_path'."]}
end
end
48 changes: 48 additions & 0 deletions test/phoenix/digester_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule Phoenix.DigesterTest do
use ExUnit.Case, async: true

test "fails when the given paths are invalid" do
assert {:error, :invalid_path} = Phoenix.Digester.compile("inexistent path", "/ ?? /path")
end

test "digests and compress files" do
output_path = Path.join("tmp", "phoenix_digest")
input_path = "priv/static/"

assert :ok = Phoenix.Digester.compile(input_path, output_path)

output_files = assets_files(output_path)

assert Enum.member?(output_files, "phoenix.png.gz")
assert Enum.member?(output_files, "phoenix.png")
assert Enum.member?(output_files, "manifest.json")
assert Enum.any?(output_files, &(String.match?(&1, ~r/(phoenix-[a-fA-F\d]{32}.png)/)))
assert Enum.any?(output_files, &(String.match?(&1, ~r/(phoenix-[a-fA-F\d]{32}.png.gz)/)))
end

test "doesn't duplicate files when digesting and compressing twice" do
input_path = Path.join("tmp", "phoenix_digest_twice")
input_file = Path.join(input_path, "file.js")
File.mkdir_p!(input_path)
File.write!(input_file, "console.log('test');")

assert :ok = Phoenix.Digester.compile(input_path, input_path)
assert :ok = Phoenix.Digester.compile(input_path, input_path)

output_files = assets_files(input_path)

duplicated_digested_file_regex = ~r/(file-[a-fA-F\d]{32}.[\w|\d]*.[-[a-fA-F\d]{32})/
assert Enum.any?(output_files, fn (f) ->
!String.match?(f, duplicated_digested_file_regex)
!String.match?(f, ~r/(file.js.gz.gz)/)
end)
end

defp assets_files(path) do
path
|> Path.join("**")
|> Path.wildcard
|> Enum.filter(&(!File.dir?(&1)))
|> Enum.map(&(Path.basename(&1)))
end
end

0 comments on commit cf32704

Please sign in to comment.