lib/mix/tasks/compile/surface.ex

defmodule Mix.Tasks.Compile.Surface do
  @moduledoc """
  Generate CSS and JS/TS assets for components.

  ## Setup

  Update `mix.exs`, adding the `:surface` compiler to the list of compilers:

  ```elixir
  def project do
    [
      ...,
      compilers: [:phoenix] ++ Mix.compilers() ++ [:surface]
    ]
  end
  ```

  ## Configuration (optional)

  The Surface compiler provides some options for custom configuration in your `config/dev.exs`.

  ### Options

  * `generate_assets` - instructs the compiler to generate components' css and js files.
    Set it to `false` when developing a library of components that doesn't require any CSS
    style nor JS hooks. Default is `true`.

  * `hooks_output_dir` - defines the folder where the compiler generates the JS hooks files.
    Default is `./assets/js/_hooks/`.

  * `css_output_file` - defines the css file where the compiler generates the code.
    Default is `./assets/css/_components.css`.

  * `enable_variants` [experimental] - instructs the compiler to generate tailwind variants based
    on props/data. Currently, only Tailwind variants are supported. Default is `false`.
    See more in the "Enabling CSS variants" section below.

  * `variants_output_file` [experimental] - if `enable_variants` is `true`, defines the config file where
    the compiler generates the scoped variants. Currently, only Tailwind variants are supported.
    Default is `./assets/css/_variants.js`.

  * `variants_prefix` [experimental] - defines a prefix for all variants generated by the compiler.
    Default is `@`.

  ### Example

      config :surface, :compiler,
        hooks_output_dir: "assets/js/surface",
        css_output_file: "assets/css/surface.css",
        enable_variants: true,
        variants_prefix: "s-"

  ### Enabling CSS variants

  By setting `enable_variants` to `true`, we instruct the compiler to generate tailwind
  variants based on props/data. All variants are generated in the `variants_output_file`,
  which defaults to `./assets/css/_variants.js`.

  > **NOTE**: This feature is still experimental and available for feedback.
  > Therefore, the API might change in the next Surface minor version. It's also
  > currently only available for Tailwind.

  To make the generated variants available in your templates, you need to set up the
  project's `tailwind.config.js` to add the `variants_output_file` as
  a preset. Example:

      module.exports = {
        presets: [
          require('./css/_variants.js')
        ],
        ...
      }

  ## Defining CSS variants

  In order to define CSS variants for your templates, you can use the `css_variant`
  option, which is available for both, `prop` and `data`.

  ### Example

      prop loading, :boolean, css_variant: true
      prop size, :string, values: ["small", "medium", "large"], css_variant: true

  Depending on the type of the assign you're defining, a set of default variants will
  be automatically available in your tamplates and be used directly in any `class`
  attribute. By default, all variants names will start with the `@` prefix. If needed,
  you can change it by setting the `variants_prefix` option.

  ### Example

      <button class="@loading:opacity-75 @size-small:text-sm @size-medium:text-base @size-large:text-lg">
        Submit
      </button>

  ## Customizing variants' names

  As mentioned in the previous section, each variant name generated bu the compiler
  will start with a prefix (default is `@`) followed by its base name. Most of the time,
  you probably want to stick with the default base name, however there are cases when
  renaming it may be more intuitive. For instance:

      # Name it as `@inactive` instead of `@not-active`
      prop active, :boolean, css_variant: [false: "inactive"]

      # Name it `@valid` and `@invalid` instead of `@has-errors` and `@no-errors`
      data errors, :list, css_variant: [has_items: "invalid", no_items: "valid"]

      # Name it `@small`, `@medium` and `@large` instead of `@size-small`, `@size-medium` and `@size-large`
      prop size, :string, values: ["small", "medium", "large"], css_variant: [prefix: ""]

  As you can see, the value of `css_variant` option can be either a boolean or a keyword
  list of options.

  By passing `true`, the compiler generates variants according to the default values
  for each option based to the name and type of the related assign. All available options for each type
  are listed below.

  ### Options for `:boolean`

  * `:true` - the base name of the variant when the value is truthy. Default is the assign name.
  * `:false` - the base name of the variant when the value is falsy. Default is `not-[assign-name]`.

  ### Options for enumerables, e.g. `:list`, `:map` and `:mapset`

  * `:has_items` - the base name of the variant when the value list has items.
    Default is `has-[assign-name]`
  * `:no_items` - the base name of the variant when the value is empty or `nil`.
    Default is `no-[assign-name]`

  ### Options for `:string`, `:atom` and `:integer` defining `values` or `values!`

  * `:prefix` - the prefix of the variant's base name generated for each value listed in `values` or `values!`.
    Default is `[assign-name]-`.

  ### Options for other types

  * `:not_nil` - the base name of the variant when the value is not `nil`.
    Default is the assign name.
  * `:nil` - the base name of the variant when the value is `nil`.
    Default is `no-[assign-name]`.

  """

  use Mix.Task
  @recursive true

  alias Mix.Task.Compiler.Diagnostic

  @switches [
    return_errors: :boolean,
    warnings_as_errors: :boolean
  ]

  @assets_opts [
    :generate_assets,
    :hooks_output_dir,
    :css_output_file,
    :enable_variants,
    :variants_output_file,
    :variants_prefix
  ]

  @doc false
  def run(args) do
    # Do nothing if it's a dependency. We only have to run it once for the main project
    if "--from-mix-deps-compile" in args do
      {:noop, []}
    else
      {compile_opts, _argv, _err} = OptionParser.parse(args, switches: @switches)
      opts = Application.get_env(:surface, :compiler, [])
      asset_opts = Keyword.take(opts, @assets_opts)
      asset_components = Surface.components()
      project_components = Surface.components(only_current_project: true)

      [
        Mix.Tasks.Compile.Surface.ValidateComponents.validate(project_components),
        Mix.Tasks.Compile.Surface.AssetGenerator.run(asset_components, asset_opts)
      ]
      |> List.flatten()
      |> handle_diagnostics(compile_opts)
    end
  end

  @doc false
  def handle_diagnostics(diagnostics, compile_opts) do
    case diagnostics do
      [] ->
        {:noop, []}

      diagnostics ->
        if !compile_opts[:return_errors], do: print_diagnostics(diagnostics)
        status = status(compile_opts[:warnings_as_errors], diagnostics)

        {status, diagnostics}
    end
  end

  defp print_diagnostics(diagnostics) do
    for %Diagnostic{message: message, severity: severity, file: file, position: position} <- diagnostics do
      print_diagnostic(message, severity, file, position)
    end
  end

  if Version.match?(System.version(), ">= 1.14.0") do
    defp print_diagnostic(message, :warning, file, {line, col}) do
      IO.warn(message, file: file, line: line, column: col)
    end
  end

  # TODO: Remove this clause in Surface v0.13 and set required elixir to >= v1.14
  defp print_diagnostic(message, :warning, file, line) do
    rel_file = file |> Path.relative_to_cwd() |> to_charlist()
    IO.warn(message, [{nil, :__FILE__, 1, [file: rel_file, line: line]}])
  end

  defp print_diagnostic(message, :error, file, line) do
    error = IO.ANSI.format([:red, "error: "])

    stacktrace =
      "  #{file}" <>
        if(line, do: ":#{line}", else: "")

    IO.puts(:stderr, [error, message, ?\n, stacktrace])
  end

  defp status(warnings_as_errors, diagnostics) do
    cond do
      Enum.any?(diagnostics, &(&1.severity == :error)) -> :error
      warnings_as_errors && Enum.any?(diagnostics, &(&1.severity == :warning)) -> :error
      true -> :ok
    end
  end
end