Skip to main content

lib/mix/tasks/astral.install.ex

Code.ensure_compiled(Igniter)

if Code.ensure_loaded?(Igniter) do
  defmodule Mix.Tasks.Astral.Install do
    @shortdoc "Install an Astral starter site"

    @moduledoc """
    #{@shortdoc}

    Creates the files needed for a small Astral site in the current Mix project.

    ## Example

        mix igniter.install astral
        mix astral.install

    The installer creates Astral config, starter pages, EEx layouts, TypeScript
    assets, public files, TypeScript configuration, and Volt formatter/linter
    configuration.
    """

    use Igniter.Mix.Task

    alias Igniter.Project.Config, as: ProjectConfig
    alias Igniter.Project.Formatter, as: ProjectFormatter
    alias Rewrite.Source

    @impl Igniter.Mix.Task
    def info(_argv, _parent) do
      %Igniter.Mix.Task.Info{
        group: :astral,
        example: "mix igniter.install astral"
      }
    end

    @impl Igniter.Mix.Task
    def igniter(igniter) do
      igniter
      |> create_site_files()
      |> configure_volt()
      |> configure_formatter()
      |> Igniter.add_notice("Run `mix astral.dev` to start the Astral development server.")
    end

    defp create_site_files(igniter) do
      Enum.reduce(site_files(), igniter, fn {path, content}, igniter ->
        Igniter.create_new_file(igniter, path, content, on_exists: :warning)
      end)
    end

    defp configure_volt(igniter) do
      igniter
      |> ProjectConfig.configure_group(
        "config.exs",
        :volt,
        [:format],
        [
          {:print_width, 100},
          {:semi, true},
          {:single_quote, false},
          {:trailing_comma, :all},
          {:arrow_parens, :always}
        ]
      )
      |> ProjectConfig.configure(
        "config.exs",
        :volt,
        [:lint],
        {:code,
         Sourceror.parse_string!("""
         [
           plugins: [:typescript],
           tsgolint: System.find_executable("tsgolint"),
           rules: %{
             "correctness" => :deny,
             "no-debugger" => :deny,
             "eqeqeq" => :deny,
             "typescript/no-explicit-any" => :warn
           }
         ]
         """)}
      )
    end

    defp configure_formatter(igniter) do
      igniter
      |> ProjectFormatter.add_formatter_plugin(Volt.Formatter)
      |> Igniter.create_or_update_file(".formatter.exs", formatter(), fn source ->
        Source.update(source, :content, &merge_formatter/1)
      end)
    end

    defp merge_formatter(content) do
      case Code.string_to_quoted(content) do
        {:ok, ast} when is_list(ast) ->
          ast
          |> ensure_formatter_plugin()
          |> ensure_formatter_input("assets/**/*.{js,ts,jsx,tsx}")
          |> Macro.to_string()
          |> Kernel.<>("\n")

        _ ->
          content
      end
    end

    defp ensure_formatter_plugin(ast) do
      Keyword.update(ast, :plugins, [volt_formatter_ast()], fn plugins ->
        prepend_unique_ast(List.wrap(plugins), volt_formatter_ast())
      end)
    end

    defp ensure_formatter_input(ast, input) do
      Keyword.update(ast, :inputs, [input], fn
        inputs when is_list(inputs) ->
          if input in inputs, do: inputs, else: inputs ++ [input]

        other ->
          other
      end)
    end

    defp prepend_unique_ast(items, item) do
      item_string = Macro.to_string(item)

      if Enum.any?(items, &(Macro.to_string(&1) == item_string)) do
        items
      else
        [item | items]
      end
    end

    defp volt_formatter_ast do
      quote(do: Volt.Formatter)
    end

    defp site_files do
      [
        {"astral.config.exs", astral_config()},
        {"pages/index.md", index_page()},
        {"pages/about.md", about_page()},
        {"layouts/default.html", default_layout()},
        {"assets/app.ts", app_ts()},
        {"assets/styles.css", styles_css()},
        {"public/robots.txt", robots_txt()},
        {"tsconfig.json", tsconfig()}
      ]
    end

    defp astral_config do
      """
      import Astral.Config

      root "."
      outdir "dist"

      layouts do
        default "default.html"
      end

      assets do
        entry "app.ts"
        url_prefix "/assets"
      end
      """
    end

    defp index_page do
      """
      ---
      title: Welcome to Astral
      ---

      # Welcome to Astral

      This page is rendered from Markdown with MDEx and wrapped in an EEx layout.
      """
    end

    defp about_page do
      """
      ---
      title: About
      ---

      # About

      Astral owns site semantics while Volt builds and serves frontend assets.
      """
    end

    defp default_layout do
      """
      <!doctype html>
      <html lang="en">
        <head>
          <meta charset="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          <title><%= @page.title || "Astral" %></title>
          <script type="module" src="<%= Astral.asset_path(@site, "app.ts") %>"></script>
        </head>
        <body>
          <header class="site-header">
            <a class="brand" href="/">Astral</a>
            <nav aria-label="Main navigation">
              <a href="/about/">About</a>
            </nav>
          </header>

          <main class="page" data-route="<%= @route %>">
            <%= @content %>
          </main>
        </body>
      </html>
      """
    end

    defp app_ts do
      """
      import "./styles.css";

      declare global {
        interface ImportMeta {
          readonly hot?: {
            accept(): void;
          };
        }
      }

      const status = document.createElement("p");
      status.className = "asset-status";
      status.textContent = "Volt assets loaded.";

      document.addEventListener("DOMContentLoaded", () => {
        document.body.appendChild(status);
      });

      if (import.meta.hot) {
        import.meta.hot.accept();
      }
      """
    end

    defp styles_css do
      """
      :root {
        color-scheme: light dark;
        font-family: Inter, ui-sans-serif, system-ui, sans-serif;
        line-height: 1.5;
      }

      body {
        margin: 0;
        background: #10131a;
        color: #f5f7fb;
      }

      a {
        color: #8bd3ff;
      }

      .site-header {
        display: flex;
        gap: 1rem;
        justify-content: space-between;
        align-items: center;
        padding: 1rem clamp(1rem, 5vw, 4rem);
        background: #171b25;
      }

      nav {
        display: flex;
        gap: 1rem;
      }

      .page {
        width: min(70ch, calc(100% - 2rem));
        margin: 4rem auto;
      }

      .asset-status {
        position: fixed;
        right: 1rem;
        bottom: 1rem;
        margin: 0;
        padding: 0.5rem 0.75rem;
        border-radius: 999px;
        background: #23304a;
      }
      """
    end

    defp robots_txt do
      """
      User-agent: *
      Allow: /
      """
    end

    defp tsconfig do
      """
      {
        "compilerOptions": {
          "target": "ES2022",
          "module": "ESNext",
          "moduleResolution": "Bundler",
          "strict": true,
          "noEmit": true,
          "lib": ["ES2022", "DOM", "DOM.Iterable"]
        },
        "include": ["assets/**/*.ts"]
      }
      """
    end

    defp formatter do
      """
      [
        plugins: [Volt.Formatter],
        inputs: [
          "{mix,.formatter}.exs",
          "{config,lib,test}/**/*.{ex,exs}",
          "assets/**/*.{js,ts,jsx,tsx}"
        ]
      ]
      """
    end
  end
else
  defmodule Mix.Tasks.Astral.Install do
    @moduledoc "Install an Astral starter site."
    @shortdoc @moduledoc

    use Mix.Task

    @impl Mix.Task
    def run(_argv) do
      Mix.shell().error("""
      The task 'astral.install' requires Igniter.

      Please install Igniter and try again:

          mix archive.install hex igniter_new
      """)

      exit({:shutdown, 1})
    end
  end
end