Realizaremos una configuración básica de Surface UI, Tailwind y Sass en un proyecto Phoenix con LiveView.

A menos que se especifique explícitamente, los contenidos de ésta obra están bajo una Licencia Creative Commons Atribución-No-Comercial-Compartir-Igual 4.0 Internacional Licencia Creative Commons.

El código fuente está bajo la licencia BSD. https://github.com/elixircl/surface-tailwind-sass/

1. Creación de un nuevo proyecto

Vamos a crear un nuevo proyecto, aunque si ya tienes uno previo igual es útil, puesto que realizaremos las configuraciones de forma manual para entender como funciona todo.

Llamaremos al proyecto "miapp", como solo es una prueba no necesitaremos Ecto (base de datos) ni Mailer (envío de correos).

$ mix phx.new miapp --no-ecto --no-mailer

2. mix.exs

Vamos a nuestro archivo mix.exs y agregamos las deps y sus configuraciones.

Primero añadir :surface a la lista de compiladores

def project do
    # [ ...
        compilers: [:surface] ++ Mix.compilers(),
    # ] ...
end

Añadimos la función catalogues() para cargar los catálogos de Surface.

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(:dev), do: ["lib"] ++ catalogues()
defp elixirc_paths(_), do: ["lib"]

defp catalogues do
    [
        "priv/catalogue",
        "deps/surface/priv/catalogue"
    ]
end

Luego añadimos las dependencias (aprovechamos de añadir credo igual para tener un linter).

defp deps do
    # [...
        {:tailwind, "~> 0.1", runtime: Mix.env() == :dev},
        {:dart_sass, "~> 0.4", runtime: Mix.env() == :dev},
        {:surface, "~> 0.8"},
        {:surface_catalogue, "~> 0.5.1"},
        {:credo, "~> 1.6"},
    # ]
end

Finalmente configuramos los comandos a usar con mix

defp aliases do
    [
        setup: ["deps.get", "cmd npm --prefix=./assets i"],
        lint: ["format", "credo --strict"],
        test: ["test"],
        server: ["assets.deploy", "phx.server"],
        "assets.clean": ["phx.digest.clean --all"],
        "assets.deploy": [
            "sass default --no-source-map",
            "tailwind default --minify",
            "esbuild default --minify",
            "phx.digest priv/static -o priv/public"
        ]
    ]
end
  • setup: instala las dependencias de elixir y de javascript.

  • lint: formatea y evalúa que el código cumpla con los estándares de credo.

  • test: ejecuta las pruebas.

  • server: compila los assets y luego ejecuta el servidor.

  • assets.clean: limpia los archivos generados por phx.digest

  • assets.deploy: El órden es importante. Primero compila los archivos scss, luego los combina con tailwind, sigue el compilar los archivos javascript del proyecto. Finalmente se copia los archivos generados desde el directorio static al directorio public.

Cuando este configurado nuestro mix.exs podemos ejecutar mix deps.get para instalar las dependencias.

mix deps.get

3. .formatter.exs

Añadiremos la configuración para el comando mix format

[
    # import_deps: [:ecto, :phoenix, :surface],
    import_deps: [:phoenix, :surface],
    plugins: [Phoenix.LiveView.HTMLFormatter, Surface.Formatter.Plugin],
    inputs: ["*.{leex,heex,ex,exs,sface}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{leex,heex,ex,exs,sface}"],
    subdirectories: ["priv/*/migrations"]
]

4. .gitignore

Aprovechamos de agregar algunas reglas para ignorar ciertos archivos de surface a nuestro archivo .gitignore:

.DS_Store
_hooks/
_components.css

4.1. .vscode/settings.json

Si usas VSCode puedes añadir la siguiente configuración:

{
    "scss.lint.unknownAtRules": "ignore",
    "files.associations": {
        "*.css": "tailwindcss"
    }
}

5. Directorio assets/

Este directorio tendrá los archivos js y css que luego serán procesados por tailwind, sass y esbuild.

5.1. package.json

Crearemos un archivo llamado package.json donde podremos incluir las dependencias de javascript que necesitemos en el proyecto.

Por el momento, solamente pondremos un archivo simple sin dependencias.

{
    "private": true,
    "devDependencies": {
        "autoprefixer": "^9.8.0"
    },
    "engines": {
        "npm": ">=6.0.0",
        "node": ">=14.0.0"
    }
}

5.2. postcss.config.js

El contenido puede ser similar a lo siguiente:

module.exports = {
    plugins: [
        require('tailwindcss'),
        require('autoprefixer'),
    ],
}

Más info en https://postcss.org/

5.3. tailwind.config.js

El contenido puede ser similar a lo siguiente:

module.exports = {
  important: true,
  content: [
    "../lib/**/*.{ex,leex,heex,eex,sface}",
    "./js/_hooks/**/*.js",
    "./js/app.js"
  ],
}

Para más detalles se puede ver la página https://tailwindcss.com/docs/configuration

5.4. css/app.scss

Cabe destacar que utilizar Sass es completamente opcional y hasta innecesario si se realiza una configuración apropiada de postcss. Revisar cómo usando acá https://tailwindcss.com/docs/using-with-preprocessors

Crearemos un archivo nuevo llamado css/app.scss que simplemente cambia el color del background.

$color: purple;
body {
    background-color: $color;
}

Aprovecharemos de eliminar los archivos:

  • app.css

  • phoenix.css

5.5. js/app.js

Vamos al archivo js/app.js y eliminamos la importación de los estilos css:

// import "../css/app.css"

Y agregamos los Hooks creados por Surface

import Hooks from "./_hooks"
// ...
let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, ... })

6. Directorio config/

Vamos a usar dos configuraciones específicas. Una será la de producción que guardará y aglomerará (digest) los archivos js y css. Guardará los archivos en el directorio priv/static. La otra guardará en un directorio llamado priv/public que será usado principalmente para desarrollo (para tener autoreload) y evitar el caché.

6.1. config/config.exs

Vamos a configurar las opciones predeterminadas. Esta configuración guardará los archivos en priv/static.

Primero añadimos que todos los assets serán entregados desde la ruta /static

config :miapp, MiappWeb.Endpoint,
  # ...
  static_url: [path: "/static"]

Ahora configuramos tanto Tailwind como Sass (debajo de la config de esbuild)

# esbuild
config :esbuild,
  version: "0.14.29",
  default: [
    args:
      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

# sass
config :dart_sass,
    version: "1.39.0",
    default: [
        args: ~w(--load-path=./node_modules css/app.scss ../priv/static/assets/app-raw.css),
        cd: Path.expand("../assets", __DIR__)
    ]

# tailwind
config :tailwind,
    version: "3.0.7",
        default: [
            args: ~w(
                --config=tailwind.config.js
                --input=../priv/static/assets/app-raw.css
                --output=../priv/static/assets/app.css
            ),
            cd: Path.expand("../assets", __DIR__)
        ]

6.2. config/dev.exs

Ésta configuración guardará los archivos en priv/public. Sobre escribe las configuraciones y rutas de config.exs.

config :esbuild,
  version: "0.14.29",
  default: [
    args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/public/assets),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ],
  catalogue: [
    args: ~w(../deps/surface_catalogue/assets/js/app.js --bundle --target=es2017 --minify --outdir=../priv/public/assets/catalogue),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

# agregamos la configuracion de los catálogos de surface
# para que pueda encontrar los estilos, ya que modificamos
# la ruta de fábrica para los assets.
config :surface_catalogue,
  assets_path: "/static/assets/catalogue/"

config :dart_sass,
  version: "1.39.0",
  default: [
    args: ~w(--load-path=./node_modules css/app.scss ../priv/public/assets/app-raw.css),
    cd: Path.expand("../assets", __DIR__)
  ]

config :tailwind,
  version: "3.0.7",
  default: [
    args: ~w(
    --config=tailwind.config.js
    --input=../priv/public/assets/app-raw.css
    --output=../priv/public/assets/app.css
  ),
    cd: Path.expand("../assets", __DIR__)
  ]

Luego configuramos el arreglo de watchers para verificar cuando se ha cambiado un archivo y volver a compilarlo. Incluyendo los archivos javascript, sass y surface.

config :miapp, MiappWeb.Endpoint,
    # ...
    watchers: [
        esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
        esbuild: {Esbuild, :install_and_run, [:catalogue, ~w(--sourcemap=inline --watch)]},
        sass: {
            DartSass,
            :install_and_run,
            [:default, ~w(--embed-source-map --source-map-urls=absolute --watch)]
        },
        tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
    ]

Finalmente configuramos los formatos de archivo que serán recompilados

config :miapp, MiappWeb.Endpoint,
    reloadable_compilers: [:phoenix, :elixir, :surface],
    live_reload: [
        patterns: [
            # ...
            ~r"priv/public/.*(js|css|png|jpeg|jpg|gif|svg)$",
            ~r"priv/catalogue/.*(ex)$",
            ~r"lib/miapp_web/(live|views|components)/.*(ex|js)$",
            ~r"lib/miapp_web/live/.*(sface)$",
            # ...
        ]
]

7. Directorio lib/miapp_web

En este directorio irán las configuraciones de los sistemas que sirven los requests desde el navegador y renderizan html.

7.1. endpoint.ex

Necesitamos configurar el archivo endpoint.ex para permitir que los assets sean servidos desde nuestro directorio especial.

    # ...
    socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
    # ...

    plug Plug.Static,
        at: "/",
        from: :miapp,
        gzip: false,
        only: ~w(favicon.ico robots.txt)

    plug Plug.Static,
        at: "/static",
        from: {:miapp, "priv/public"},
        gzip: false,
        only: ~w(assets fonts images)

7.2. router.ex

Importamos las funciones de Surface para utilizarlas en nuestras rutas.

defmodule MiappWeb.Router do
    use MiappWeb, :router
    import Surface.Catalogue.Router
    # ...

Luego añadimos la ruta a nuestra página de index llamada live/home.ex.

# ...
scope "/", MiappWeb do
    pipe_through :browser

    live_session :default do
        live "/", Live.Home, :index
    end
end
# ...

Finalmente añadimos la ruta para acceder a los catálogos de Surface, solamente cuando estemos en ambiente de desarollo.

if Mix.env() == :dev do
    scope "/" do
        pipe_through :browser
        surface_catalogue "/catalogue"
    end
end

7.3. live/home.ex

Crearemos un archivo llamado live/home.ex para renderizar un html simple usando el siguiente contenido:

defmodule MiappWeb.Live.Home do
  use MiappWeb, :surface_live_view

  @impl true
  def render(assigns) do
    ~F"""
    <div class={"bg-slate-100"}>
      <div class={"text-sky-500"}>
        <h1 class={"text-lg", "font-medium"}>Esta es Mi App</h1>
      </div>
    </div>
    """
  end
end

7.4. miapp_web.ex

Como podemos notar estamos llamando a surface_live_view para importar un código global. Ésto nos permitirá simplificar el código, reutilizando la importación.

Añadimos lo siguiente a miapp_web.ex:

def surface_live_view do
    quote do
        use Surface.LiveView,
            layout: {MiappWeb.LayoutView, "live.html"}
        unquote(view_helpers())
    end
end

8. Probando

Si todo sale como esta previsto, solo bastaría ejecutar el comando

mix server

para hacer el deploy de los assets y ejecutar el servidor.

Al cual podremos acceder desde http://localhost:4000

8.1. Página Principal

194343605 0871c3ae fcd6 4417 82f2 c1aa26e9743a

8.2. Catálogo de Surface

194342378 40b55e29 0828 4674 a671 c42986a7a1ad

9. Creditos

Made with by Ninjas.cl.