├── .devcontainer ├── devcontainer.json └── setup.sh ├── .formatter.exs ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── dart_sass.ex └── mix │ └── tasks │ ├── sass.ex │ └── sass.install.ex ├── mix.exs ├── mix.lock ├── priv └── dart_sass.bash └── test ├── dart_sass_test.exs ├── fixtures └── app.scss └── test_helper.exs /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. 2 | { 3 | "name": "dart_sass", 4 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 5 | "image": "elixir:1.17-otp-27-slim", 6 | 7 | // Features to add to the dev container. More info: https://containers.dev/features. 8 | "features": { 9 | "ghcr.io/devcontainers/features/common-utils:2": { 10 | "installOhMyZsh": false, 11 | "installOhMyZshConfig": false 12 | }, 13 | "ghcr.io/devcontainers/features/git:1": { 14 | "version": "latest" 15 | }, 16 | "ghcr.io/devcontainers/features/github-cli:1": {} 17 | }, 18 | 19 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 20 | // "forwardPorts": [], 21 | 22 | // Use 'postCreateCommand' to run commands after the container is created. 23 | "postCreateCommand": ".devcontainer/setup.sh", 24 | 25 | // Configure tool-specific properties. 26 | "customizations": { 27 | "vscode": { 28 | "extensions": [ 29 | "EditorConfig.EditorConfig", 30 | "JakeBecker.elixir-ls", 31 | "jasonnutter.vscode-codeowners" 32 | ] 33 | } 34 | } 35 | 36 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 37 | // "remoteUser": "root" 38 | } 39 | -------------------------------------------------------------------------------- /.devcontainer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | set -vx 6 | 7 | mix local.hex --force 8 | mix deps.get 9 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @CargoSense/elixir-reviewers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | reviewers: 9 | - "CargoSense/dx-reviewers" 10 | - package-ecosystem: "mix" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | reviewers: 15 | - "CargoSense/elixir-reviewers" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | workflow_dispatch: 9 | 10 | env: 11 | MIX_ENV: test 12 | 13 | jobs: 14 | test_macos: 15 | name: Elixir ${{ matrix.pair.elixir }} OTP ${{ matrix.pair.otp }} (macOS) 16 | runs-on: macos-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | pair: 21 | - elixir: "1.13" 22 | otp: "24.3.4.10" 23 | - elixir: "1.17" 24 | otp: "27.0.1" 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Generate .tool-versions file 28 | run: | 29 | echo "elixir ${{ matrix.pair.elixir }}" >> .tool-versions 30 | echo "erlang ${{ matrix.pair.otp }}" >> .tool-versions 31 | cat .tool-versions 32 | - uses: asdf-vm/actions/install@v3 33 | - name: Install Hex package manager 34 | run: mix local.hex --force 35 | - name: Install dependencies 36 | run: mix deps.get 37 | - run: mix test 38 | test_ubuntu: 39 | name: Elixir ${{ matrix.pair.elixir }} OTP ${{ matrix.pair.otp }} (Ubuntu) 40 | runs-on: ubuntu-latest 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | include: 45 | - pair: 46 | elixir: "1.13" 47 | otp: "24.3.4.10" 48 | - pair: 49 | elixir: "1.17" 50 | otp: "27.0.1" 51 | lint: lint 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: erlef/setup-beam@main 55 | with: 56 | otp-version: ${{ matrix.pair.otp }} 57 | elixir-version: ${{ matrix.pair.elixir }} 58 | version-type: strict 59 | - uses: actions/cache@v4 60 | with: 61 | path: deps 62 | key: mix-deps-${{ hashFiles('**/mix.lock') }} 63 | - run: mix deps.get --check-locked 64 | - run: mix format --check-formatted 65 | if: ${{ matrix.lint }} 66 | - run: mix deps.unlock --check-unused 67 | if: ${{ matrix.lint }} 68 | - run: mix deps.compile 69 | - run: mix compile --warnings-as-errors 70 | if: ${{ matrix.lint }} 71 | - run: mix test 72 | if: ${{ ! matrix.lint }} 73 | - run: mix test --warnings-as-errors 74 | if: ${{ matrix.lint }} 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | dart_sass-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.7.1 4 | 5 | * Update references to Dart Sass version to use latest `1.77.8` release 6 | * Update README to reflect minor version increase to `~> 0.7`, Dart Sass to `1.77.8`, edge Phoenix to `~> 1.7.14` 7 | * Fix Warning for elixir 1.17 8 | 9 | ## v0.7.0 (2023-06-27) 10 | 11 | * Require Elixir v1.11+ 12 | * Mark inets and ssl as optional apps 13 | * Ensure the install task only loads the runtime config with `--runtime-config` 14 | 15 | ## v0.6.0 (2023-04-19) 16 | 17 | **Potentially breaking change:** Due to a change in the upstream package structure, you must specify a `:version` >= 1.58.0 on Linux platforms. 18 | 19 | - Updates Sass version to `1.61.0`. 20 | - Renames DartSass.bin_path/0 to `DartSass.bin_paths/0`. 21 | - Supports installation of newer upstream packages on Linux platforms. (h/t @azizk) 22 | - Overriding `:path` disables version checking. 23 | - Explicitly depends on `inets` and `ssl`. (h/t @josevalim) 24 | 25 | ## v0.5.1 (2022-08-26) 26 | 27 | - Update Sass version to `1.54.5` 28 | - Skip platform check when given a custom path (h/t @jgelens) 29 | - Use only TLS 1.2 on OTP versions less than 25. 30 | 31 | ## v0.5.0 (2022-04-28) 32 | 33 | - Support upstream arm64 binaries 34 | - Update Sass version to `1.49.11` 35 | 36 | ## v0.4.0 (2022-01-19) 37 | 38 | - Update Sass version to `1.49.0` 39 | - Attach system target architecture to saved esbuild executable (h/t @cw789) 40 | - Use user cache directory (h/t @josevalim) 41 | - Add support for 32bit linux (h/t @derek-zhou) 42 | - Support `HTTP_PROXY/HTTPS_PROXY` to fetch esbuild (h/t @iaddict) 43 | - Fallback to \_build if Mix.Project is not available 44 | - Allow `config :dart_sass, :path, path` to configure the path to the Sass executable (or snapshot) 45 | - Support OTP 24 on Apple M1 architectures (via Rosetta2) 46 | 47 | ## v0.3.0 (2021-10-04) 48 | 49 | - Use Rosetta2 for Apple M1 architectures until dart-sass ships native 50 | 51 | ## v0.2.1 (2021-09-23) 52 | 53 | - Apply missing `--runtime-config` flag check to `mix sass.install` 54 | 55 | ## v0.2.0 (2021-09-21) 56 | 57 | - No longer load `config/runtime.exs` by default, instead support `--runtime-config` flag 58 | - Update initial `sass` version to `1.39.0` 59 | - `mix sass.install --if-missing` also checks version 60 | 61 | ## v0.1.2 (2021-08-23) 62 | 63 | - Fix target detection on FreeBSD (h/t @julp) 64 | - Extract archive with charlist cwd option (h/t @michallepicki) 65 | 66 | ## v0.1.1 (2021-07-30) 67 | 68 | - Fix installation path/unzip on windows 69 | - Add wrapper script to address zombie processes 70 | 71 | ## v0.1.0 (2021-07-25) 72 | 73 | - First release 74 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 CargoSense, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DartSass 2 | 3 | [![CI](https://github.com/CargoSense/dart_sass/actions/workflows/ci.yml/badge.svg)](https://github.com/CargoSense/dart_sass/actions/workflows/ci.yml) 4 | 5 | Mix tasks for installing and invoking [sass](https://github.com/sass/dart-sass/). 6 | 7 | ## Installation 8 | 9 | If you are going to build assets in production, then you add 10 | `dart_sass` as a dependency on all environments but only start it 11 | in dev: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:dart_sass, "~> 0.7", runtime: Mix.env() == :dev} 17 | ] 18 | end 19 | ``` 20 | 21 | However, if your assets are precompiled during development, 22 | then it only needs to be a dev dependency: 23 | 24 | ```elixir 25 | def deps do 26 | [ 27 | {:dart_sass, "~> 0.7", only: :dev} 28 | ] 29 | end 30 | ``` 31 | 32 | Once installed, change your `config/config.exs` to pick your 33 | dart_sass version of choice: 34 | 35 | ```elixir 36 | config :dart_sass, version: "1.77.8" 37 | ``` 38 | 39 | Now you can install dart-sass by running: 40 | 41 | ```bash 42 | $ mix sass.install 43 | ``` 44 | 45 | And invoke sass with: 46 | 47 | ```bash 48 | $ mix sass default assets/css/app.scss priv/static/assets/app.css 49 | ``` 50 | 51 | If you need additional load paths you may specify them: 52 | 53 | ```bash 54 | $ mix sass default assets/css/app.scss --load-path=assets/node_modules/bulma priv/static/assets/app.css 55 | ``` 56 | 57 | The executable may be kept at `_build/sass-TARGET`. However in most cases 58 | running dart-sass requires two files: the portable Dart VM is kept at 59 | `_build/dart-TARGET` and the Sass snapshot is kept at `_build/sass.snapshot-TARGET`. 60 | Where `TARGET` is your system target architecture. 61 | 62 | ## Profiles 63 | 64 | The first argument to `dart_sass` is the execution profile. 65 | You can define multiple execution profiles with the current 66 | directory, the OS environment, and default arguments to the 67 | `sass` task: 68 | 69 | ```elixir 70 | config :dart_sass, 71 | version: "1.77.8", 72 | default: [ 73 | args: ~w(css/app.scss ../priv/static/assets/app.css), 74 | cd: Path.expand("../assets", __DIR__) 75 | ] 76 | ``` 77 | 78 | When `mix sass default` is invoked, the task arguments will be appended 79 | to the ones configured above. 80 | 81 | ## Adding to Phoenix 82 | 83 | To add `dart_sass` to an application using Phoenix, you need only four steps. 84 | Note that installation requires that Phoenix watchers can accept `MFArgs` 85 | tuples – so you must have Phoenix > v1.5.9. 86 | 87 | First add it as a dependency in your `mix.exs`: 88 | 89 | ```elixir 90 | def deps do 91 | [ 92 | {:phoenix, "~> 1.7.14"}, 93 | {:dart_sass, "~> 0.7", runtime: Mix.env() == :dev} 94 | ] 95 | end 96 | ``` 97 | 98 | Now let's configure `dart_sass` to use `assets/css/app.scss` as the input file and 99 | compile CSS to the output location `priv/static/assets/app.css`: 100 | 101 | ```elixir 102 | config :dart_sass, 103 | version: "1.77.8", 104 | default: [ 105 | args: ~w(css/app.scss ../priv/static/assets/app.css), 106 | cd: Path.expand("../assets", __DIR__) 107 | ] 108 | ``` 109 | 110 | > Note: if you are using esbuild (the default from Phoenix v1.6), 111 | > make sure you remove the `import "../css/app.css"` line at the 112 | > top of assets/js/app.js so `esbuild` stops generating css files. 113 | 114 | > Note: make sure the "assets" directory from priv/static is listed 115 | > in the :only option for Plug.Static in your endpoint file at, 116 | > for instance `lib/my_app_web/endpoint.ex`. 117 | 118 | For development, we want to enable watch mode. So find the `watchers` 119 | configuration in your `config/dev.exs` and add: 120 | 121 | ```elixir 122 | sass: { 123 | DartSass, 124 | :install_and_run, 125 | [:default, ~w(--embed-source-map --source-map-urls=absolute --watch)] 126 | } 127 | ``` 128 | 129 | Note we are embedding source maps with absolute URLs and enabling the file system watcher. 130 | 131 | Finally, back in your `mix.exs`, make sure you have an `assets.deploy` 132 | alias for deployments, which will also use the `--style=compressed` option: 133 | 134 | ```elixir 135 | "assets.deploy": [ 136 | "esbuild default --minify", 137 | "sass default --no-source-map --style=compressed", 138 | "phx.digest" 139 | ] 140 | ``` 141 | 142 | ## FAQ 143 | 144 | ### Compatibility with Alpine Linux (`mix sass default` exited with 2) 145 | 146 | > Note: Using [glibc on Alpine Linux](https://ariadne.space/2021/08/26/there-is-no-such-thing-as-a-glibc-based-alpine-image/) is **not recommended**. Proceed at your own risk. 147 | 148 | Dart-native executables rely on [glibc](https://www.gnu.org/software/libc/) to be present. Because Alpine Linux uses [musl](https://musl.libc.org/) instead, you have to add the package [alpine-pkg-glibc](https://github.com/sgerrand/alpine-pkg-glibc) to your installation. Follow the installation guide in the README. 149 | 150 | For example, add the following to your Dockerfile before you 151 | run `mix sass`: 152 | 153 | ```Dockerfile 154 | ENV GLIBC_VERSION=2.34-r0 155 | RUN wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ 156 | wget -q -O /tmp/glibc.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-${GLIBC_VERSION}.apk && \ 157 | apk add /tmp/glibc.apk && \ 158 | rm -rf /tmp/glibc.apk 159 | ``` 160 | 161 | In case you get the error `../../runtime/bin/eventhandler_linux.cc: 412: error: Failed to start event handler thread 1`, it means that your Docker installation or the used Docker-in-Docker image, is using a version below Docker 20.10.6. This error is related to an [updated version of the musl library](https://about.gitlab.com/blog/2021/08/26/its-time-to-upgrade-docker-engine). It can be resolved by using the [alpine-pkg-glibc](https://github.com/sgerrand/alpine-pkg-glibc) with the version 2.33 instead of 2.34. 162 | 163 | Notes: The Alpine package gcompat vs libc6-compat will not work. 164 | 165 | ### Watchers and Bash 166 | 167 | In order to ensure graceful termination of the `sass` process 168 | when stdin closes, when the`--watch` option is given then the 169 | sass process will be invoked by a bash script that will 170 | handle the cleanup. 171 | 172 | ## Acknowledgements 173 | 174 | This package is based on the excellent [esbuild](https://github.com/phoenixframework/esbuild) by Wojtek Mach and José Valim. 175 | 176 | ## License 177 | 178 | Copyright (c) 2021 CargoSense, Inc. 179 | 180 | dart_sass source code is licensed under the [MIT License](LICENSE.md). 181 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :dart_sass, 4 | version: "1.77.8", 5 | another: [ 6 | args: ["--version"] 7 | ] 8 | -------------------------------------------------------------------------------- /lib/dart_sass.ex: -------------------------------------------------------------------------------- 1 | defmodule DartSass do 2 | @moduledoc """ 3 | DartSass is a installer and runner for [Sass](https://sass-lang.com/guide). 4 | 5 | ## Profiles 6 | 7 | You can define multiple configuration profiles. By default, there is a 8 | profile called `:default` which you can configure its args, current 9 | directory and environment: 10 | 11 | config :dart_sass, 12 | version: "1.77.8", 13 | default: [ 14 | args: ~w(css/app.scss ../priv/static/assets/app.css), 15 | cd: Path.expand("../assets", __DIR__) 16 | ] 17 | 18 | ## Dart Sass configuration 19 | 20 | There are two global configurations for the `dart_sass` application: 21 | 22 | * `:version` - the expected Sass version. 23 | 24 | * `:path` - the path to the Sass executable. By default 25 | it is automatically downloaded and placed inside the `_build` directory 26 | of your current app. Note that if your system architecture requires a 27 | separate Dart VM executable to run, then `:path` should be defined as a 28 | list of absolute paths. 29 | 30 | Overriding the `:path` is not recommended, as we will automatically 31 | download and manage `sass` for you. But in case you can't download 32 | it (for example, the GitHub releases are behind a proxy), you may want to 33 | set the `:path` to a configurable system location. 34 | 35 | For instance, you can install `sass` globally with `npm`: 36 | 37 | $ npm install -g sass 38 | 39 | Then the executable will be at: 40 | 41 | NPM_ROOT/sass/sass.js 42 | 43 | Where `NPM_ROOT` is the result of `npm root -g`. 44 | 45 | Once you find the location of the executable, you can store it in a 46 | `MIX_SASS_PATH` environment variable, which you can then read in 47 | your configuration file: 48 | 49 | config :dart_sass, path: System.get_env("MIX_SASS_PATH") 50 | 51 | Note that overriding `:path` disables version checking. 52 | """ 53 | 54 | use Application 55 | require Logger 56 | 57 | @doc false 58 | def start(_, _) do 59 | unless Application.get_env(:dart_sass, :path) do 60 | unless Application.get_env(:dart_sass, :version) do 61 | Logger.warning(""" 62 | dart_sass version is not configured. Please set it in your config files: 63 | 64 | config :dart_sass, :version, "#{latest_version()}" 65 | """) 66 | end 67 | 68 | configured_version = configured_version() 69 | 70 | case bin_version() do 71 | {:ok, ^configured_version} -> 72 | :ok 73 | 74 | {:ok, version} -> 75 | Logger.warning(""" 76 | Outdated dart-sass version. Expected #{configured_version}, got #{version}. \ 77 | Please run `mix sass.install` or update the version in your config files.\ 78 | """) 79 | 80 | :error -> 81 | :ok 82 | end 83 | end 84 | 85 | Supervisor.start_link([], strategy: :one_for_one) 86 | end 87 | 88 | @doc false 89 | # Latest known version at the time of publishing. 90 | def latest_version, do: "1.77.8" 91 | 92 | @doc """ 93 | Returns the configured Sass version. 94 | """ 95 | def configured_version do 96 | Application.get_env(:dart_sass, :version, latest_version()) 97 | end 98 | 99 | @doc """ 100 | Returns the configuration for the given profile. 101 | 102 | Returns nil if the profile does not exist. 103 | """ 104 | def config_for!(profile) when is_atom(profile) do 105 | Application.get_env(:dart_sass, profile) || 106 | raise ArgumentError, """ 107 | unknown dart_sass profile. Make sure the profile named #{inspect(profile)} is defined in your config files, such as: 108 | 109 | config :dart_sass, 110 | #{profile}: [ 111 | args: ~w(css/app.scss:../priv/static/assets/app.css), 112 | cd: Path.expand("../assets", __DIR__) 113 | ] 114 | """ 115 | end 116 | 117 | defp dest_bin_paths(platform, base_path) do 118 | target = target(platform) 119 | ["dart", "sass.snapshot"] |> Enum.map(&Path.join(base_path, "#{&1}-#{target}")) 120 | end 121 | 122 | @doc """ 123 | Returns the path to the `dart` VM executable and to the `sass` executable. 124 | """ 125 | def bin_paths do 126 | cond do 127 | env_path = Application.get_env(:dart_sass, :path) -> 128 | List.wrap(env_path) 129 | 130 | Code.ensure_loaded?(Mix.Project) -> 131 | dest_bin_paths(platform(), Path.dirname(Mix.Project.build_path())) 132 | 133 | true -> 134 | dest_bin_paths(platform(), "_build") 135 | end 136 | end 137 | 138 | # TODO: Remove when dart-sass will exit when stdin is closed. 139 | @doc false 140 | def script_path() do 141 | Path.join(:code.priv_dir(:dart_sass), "dart_sass.bash") 142 | end 143 | 144 | @doc """ 145 | Returns the version of the Sass executable. 146 | 147 | Returns `{:ok, version_string}` on success or `:error` when the executable 148 | is not available. 149 | """ 150 | def bin_version do 151 | paths = bin_paths() 152 | 153 | with true <- paths_exist?(paths), 154 | {result, 0} <- cmd(paths, ["--version"]) do 155 | {:ok, String.trim(result)} 156 | else 157 | _ -> :error 158 | end 159 | end 160 | 161 | defp cmd([command_path | bin_paths], extra_args, opts \\ []) do 162 | System.cmd(command_path, bin_paths ++ extra_args, opts) 163 | end 164 | 165 | @doc """ 166 | Runs the given command with `args`. 167 | 168 | The given args will be appended to the configured args. 169 | The task output will be streamed directly to stdio. It 170 | returns the status of the underlying call. 171 | """ 172 | def run(profile, extra_args) when is_atom(profile) and is_list(extra_args) do 173 | config = config_for!(profile) 174 | config_args = config[:args] || [] 175 | 176 | opts = [ 177 | cd: config[:cd] || File.cwd!(), 178 | env: config[:env] || %{}, 179 | into: IO.stream(:stdio, :line), 180 | stderr_to_stdout: true 181 | ] 182 | 183 | args = config_args ++ extra_args 184 | 185 | # TODO: Remove when dart-sass will exit when stdin is closed. 186 | # Link: https://github.com/sass/dart-sass/pull/1411 187 | paths = 188 | if "--watch" in args and platform() != :windows, 189 | do: [script_path() | bin_paths()], 190 | else: bin_paths() 191 | 192 | paths 193 | |> cmd(args, opts) 194 | |> elem(1) 195 | end 196 | 197 | @doc """ 198 | Installs, if not available, and then runs `sass`. 199 | 200 | Returns the same as `run/2`. 201 | """ 202 | def install_and_run(profile, args) do 203 | unless paths_exist?(bin_paths()) do 204 | install() 205 | end 206 | 207 | run(profile, args) 208 | end 209 | 210 | @doc """ 211 | Installs Sass with `configured_version/0`. 212 | """ 213 | def install do 214 | platform = platform() 215 | version = configured_version() 216 | 217 | if platform == :linux and Version.match?(version, "< 1.58.0") do 218 | raise "Installing dart_sass on Linux platforms requires version >= 1.58.0, got: #{inspect(version)}" 219 | end 220 | 221 | tmp_opts = if System.get_env("MIX_XDG"), do: %{os: :linux}, else: %{} 222 | 223 | tmp_dir = 224 | freshdir_p(:filename.basedir(:user_cache, "cs-sass", tmp_opts)) || 225 | freshdir_p(Path.join(System.tmp_dir!(), "cs-sass")) || 226 | raise "could not install sass. Set MIX_XDG=1 and then set XDG_CACHE_HOME to the path you want to use as cache" 227 | 228 | name = "dart-sass-#{version}-#{target_extname(platform)}" 229 | url = "https://github.com/sass/dart-sass/releases/download/#{version}/#{name}" 230 | archive = fetch_body!(url) 231 | 232 | case unpack_archive(Path.extname(name), archive, tmp_dir) do 233 | :ok -> :ok 234 | other -> raise "couldn't unpack archive: #{inspect(other)}" 235 | end 236 | 237 | [dart, snapshot] = bin_paths() 238 | 239 | bin_suffix = if platform == :windows, do: ".exe", else: "" 240 | 241 | for {src_name, dest_path} <- [{"dart#{bin_suffix}", dart}, {"sass.snapshot", snapshot}] do 242 | File.rm(dest_path) 243 | File.cp!(Path.join([tmp_dir, "dart-sass", "src", src_name]), dest_path) 244 | end 245 | end 246 | 247 | @doc false 248 | def platform do 249 | case :os.type() do 250 | {:unix, :darwin} -> :macos 251 | {:unix, :linux} -> :linux 252 | {:unix, osname} -> raise "dart_sass is not available for osname: #{inspect(osname)}" 253 | {:win32, _} -> :windows 254 | end 255 | end 256 | 257 | defp paths_exist?(paths) do 258 | Enum.all?(paths, &File.exists?/1) 259 | end 260 | 261 | defp freshdir_p(path) do 262 | with {:ok, _} <- File.rm_rf(path), 263 | :ok <- File.mkdir_p(path) do 264 | path 265 | else 266 | _ -> nil 267 | end 268 | end 269 | 270 | defp unpack_archive(".zip", zip, cwd) do 271 | with {:ok, _} <- :zip.unzip(zip, cwd: to_charlist(cwd)), do: :ok 272 | end 273 | 274 | defp unpack_archive(_, tar, cwd) do 275 | :erl_tar.extract({:binary, tar}, [:compressed, cwd: to_charlist(cwd)]) 276 | end 277 | 278 | defp target_extname(platform) do 279 | target = target(platform) 280 | 281 | case platform do 282 | :windows -> "#{target}.zip" 283 | _ -> "#{target}.tar.gz" 284 | end 285 | end 286 | 287 | # Available targets: https://github.com/sass/dart-sass/releases 288 | defp target(:windows) do 289 | case :erlang.system_info(:wordsize) * 8 do 290 | 32 -> "windows-ia32" 291 | 64 -> "windows-x64" 292 | end 293 | end 294 | 295 | defp target(platform) do 296 | arch_str = :erlang.system_info(:system_architecture) 297 | [arch | _] = arch_str |> List.to_string() |> String.split("-") 298 | 299 | # TODO: remove "arm" when we require OTP 24 300 | case arch do 301 | "amd64" -> "#{platform}-x64" 302 | "aarch64" -> "#{platform}-arm64" 303 | "arm" -> "#{platform}-arm64" 304 | "x86_64" -> "#{platform}-x64" 305 | "i686" -> "#{platform}-ia32" 306 | "i386" -> "#{platform}-ia32" 307 | _ -> raise "dart_sass not available for architecture: #{arch_str}" 308 | end 309 | end 310 | 311 | defp fetch_body!(url) do 312 | url = String.to_charlist(url) 313 | Logger.debug("Downloading dart-sass from #{url}") 314 | 315 | {:ok, _} = Application.ensure_all_started(:inets) 316 | {:ok, _} = Application.ensure_all_started(:ssl) 317 | 318 | if proxy = System.get_env("HTTP_PROXY") || System.get_env("http_proxy") do 319 | Logger.debug("Using HTTP_PROXY: #{proxy}") 320 | %{host: host, port: port} = URI.parse(proxy) 321 | :httpc.set_options([{:proxy, {{String.to_charlist(host), port}, []}}]) 322 | end 323 | 324 | if proxy = System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") do 325 | Logger.debug("Using HTTPS_PROXY: #{proxy}") 326 | %{host: host, port: port} = URI.parse(proxy) 327 | :httpc.set_options([{:https_proxy, {{String.to_charlist(host), port}, []}}]) 328 | end 329 | 330 | # https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets 331 | cacertfile = cacertfile() |> String.to_charlist() 332 | 333 | http_options = [ 334 | autoredirect: false, 335 | ssl: [ 336 | verify: :verify_peer, 337 | cacertfile: cacertfile, 338 | depth: 2, 339 | customize_hostname_check: [ 340 | match_fun: :public_key.pkix_verify_hostname_match_fun(:https) 341 | ], 342 | versions: protocol_versions() 343 | ] 344 | ] 345 | 346 | case :httpc.request(:get, {url, []}, http_options, []) do 347 | {:ok, {{_, 302, _}, headers, _}} -> 348 | {~c"location", download} = List.keyfind(headers, ~c"location", 0) 349 | options = [body_format: :binary] 350 | 351 | case :httpc.request(:get, {download, []}, http_options, options) do 352 | {:ok, {{_, 200, _}, _, body}} -> 353 | body 354 | 355 | other -> 356 | raise "couldn't fetch #{download}: #{inspect(other)}" 357 | end 358 | 359 | other -> 360 | raise "couldn't fetch #{url}: #{inspect(other)}" 361 | end 362 | end 363 | 364 | defp protocol_versions do 365 | if otp_version() < 25 do 366 | [:"tlsv1.2"] 367 | else 368 | [:"tlsv1.2", :"tlsv1.3"] 369 | end 370 | end 371 | 372 | defp otp_version do 373 | :erlang.system_info(:otp_release) |> List.to_integer() 374 | end 375 | 376 | defp cacertfile() do 377 | Application.get_env(:dart_sass, :cacerts_path) || CAStore.file_path() 378 | end 379 | end 380 | -------------------------------------------------------------------------------- /lib/mix/tasks/sass.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Sass do 2 | @moduledoc """ 3 | Invokes sass with the given args. 4 | 5 | Usage: 6 | 7 | $ mix sass TASK_OPTIONS PROFILE SASS_ARGS 8 | 9 | Example: 10 | 11 | $ mix sass default assets/css/app.scss priv/static/assets/app.css 12 | 13 | If dart-sass is not installed, it is automatically downloaded. 14 | Note the arguments given to this task will be appended 15 | to any configured arguments. 16 | 17 | ## Options 18 | 19 | * `--runtime-config` - load the runtime configuration before executing 20 | command 21 | 22 | Note flags to control this Mix task must be given before the profile: 23 | 24 | $ mix sass --runtime-config default assets/css/app.scss 25 | """ 26 | 27 | @shortdoc "Invokes sass with the profile and args" 28 | @compile {:no_warn_undefined, Mix} 29 | 30 | use Mix.Task 31 | 32 | @impl true 33 | def run(args) do 34 | switches = [runtime_config: :boolean] 35 | {opts, remaining_args} = OptionParser.parse_head!(args, switches: switches) 36 | 37 | if function_exported?(Mix, :ensure_application!, 1) do 38 | Mix.ensure_application!(:inets) 39 | Mix.ensure_application!(:ssl) 40 | end 41 | 42 | if opts[:runtime_config] do 43 | Mix.Task.run("app.config") 44 | else 45 | Application.ensure_all_started(:dart_sass) 46 | end 47 | 48 | Mix.Task.reenable("sass") 49 | install_and_run(remaining_args) 50 | end 51 | 52 | defp install_and_run([profile | args] = all) do 53 | case DartSass.install_and_run(String.to_atom(profile), args) do 54 | 0 -> :ok 55 | status -> Mix.raise("`mix sass #{Enum.join(all, " ")}` exited with #{status}") 56 | end 57 | end 58 | 59 | defp install_and_run([]) do 60 | Mix.raise("`mix sass` expects the profile as argument") 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/mix/tasks/sass.install.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Sass.Install do 2 | @moduledoc """ 3 | Installs dart-sass under `_build`. 4 | 5 | ```bash 6 | $ mix sass.install 7 | $ mix sass.install --if-missing 8 | ``` 9 | 10 | By default, it installs #{DartSass.latest_version()} but you 11 | can configure it in your config files, such as: 12 | 13 | config :dart_sass, :version, "#{DartSass.latest_version()}" 14 | 15 | ## Options 16 | 17 | * `--runtime-config` - load the runtime configuration 18 | before executing command 19 | 20 | * `--if-missing` - install only if the given version 21 | does not exist 22 | 23 | """ 24 | 25 | @shortdoc "Installs dart-sass under _build" 26 | @compile {:no_warn_undefined, Mix} 27 | 28 | use Mix.Task 29 | 30 | @impl true 31 | def run(args) do 32 | valid_options = [runtime_config: :boolean, if_missing: :boolean] 33 | 34 | case OptionParser.parse_head!(args, strict: valid_options) do 35 | {opts, []} -> 36 | if opts[:runtime_config], do: Mix.Task.run("app.config") 37 | 38 | if opts[:if_missing] && latest_version?() do 39 | :ok 40 | else 41 | if function_exported?(Mix, :ensure_application!, 1) do 42 | Mix.ensure_application!(:inets) 43 | Mix.ensure_application!(:ssl) 44 | end 45 | 46 | DartSass.install() 47 | end 48 | 49 | {_, _} -> 50 | Mix.raise(""" 51 | Invalid arguments to sass.install, expected one of: 52 | 53 | mix sass.install 54 | mix sass.install --runtime-config 55 | mix sass.install --if-missing 56 | """) 57 | end 58 | end 59 | 60 | defp latest_version?() do 61 | version = DartSass.configured_version() 62 | match?({:ok, ^version}, DartSass.bin_version()) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DartSass.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.7.1" 5 | @source_url "https://github.com/CargoSense/dart_sass" 6 | 7 | def project do 8 | [ 9 | app: :dart_sass, 10 | version: @version, 11 | elixir: "~> 1.11", 12 | deps: deps(), 13 | description: "Mix tasks for installing and invoking sass", 14 | package: [ 15 | links: %{ 16 | "GitHub" => @source_url, 17 | "dart-sass" => "https://sass-lang.com/dart-sass" 18 | }, 19 | licenses: ["MIT"] 20 | ], 21 | docs: [ 22 | main: "DartSass", 23 | source_url: @source_url, 24 | source_ref: "v#{@version}", 25 | extras: ["CHANGELOG.md"] 26 | ], 27 | aliases: [test: ["sass.install --if-missing", "test"]] 28 | ] 29 | end 30 | 31 | def application do 32 | [ 33 | extra_applications: [:logger, inets: :optional, ssl: :optional], 34 | mod: {DartSass, []}, 35 | env: [default: []] 36 | ] 37 | end 38 | 39 | defp deps do 40 | [ 41 | {:castore, ">= 0.0.0"}, 42 | {:ex_doc, ">= 0.0.0", only: :docs} 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 4 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 5 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 9 | } 10 | -------------------------------------------------------------------------------- /priv/dart_sass.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script was taken from the Elixir Port guide. It is used to ensure 4 | # graceful termination of the `sass` process when it detects that stdin 5 | # has been closed. 6 | # Link: https://hexdocs.pm/elixir/Port.html#module-zombie-operating-system-processes 7 | # 8 | # This script is required until dart-sass supports listening on stdin and 9 | # gracefully terminating when stdin is closed. There is currently a PR for 10 | # this behaviour: https://github.com/sass/dart-sass/pull/1411 11 | # 12 | # Start the program in the background 13 | exec "$@" & 14 | pid1=$! 15 | 16 | # Silence warnings from here on 17 | exec >/dev/null 2>&1 18 | 19 | # Read from stdin in the background and 20 | # kill running program when stdin closes 21 | exec 0<&0 $( 22 | while read; do :; done 23 | kill -KILL $pid1 24 | ) & 25 | pid2=$! 26 | 27 | # Clean up 28 | wait $pid1 29 | ret=$? 30 | kill -KILL $pid2 31 | exit $ret 32 | -------------------------------------------------------------------------------- /test/dart_sass_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DartSassTest do 2 | use ExUnit.Case, async: true 3 | 4 | @version DartSass.latest_version() 5 | 6 | test "run on default" do 7 | assert ExUnit.CaptureIO.capture_io(fn -> 8 | assert DartSass.run(:default, ["--version"]) == 0 9 | end) =~ @version 10 | end 11 | 12 | test "run on profile" do 13 | assert ExUnit.CaptureIO.capture_io(fn -> 14 | assert DartSass.run(:another, []) == 0 15 | end) =~ @version 16 | end 17 | 18 | test "updates on install" do 19 | Application.put_env(:dart_sass, :version, "1.58.0") 20 | 21 | Mix.Task.rerun("sass.install", ["--if-missing"]) 22 | 23 | assert ExUnit.CaptureIO.capture_io(fn -> 24 | assert DartSass.run(:default, ["--version"]) == 0 25 | end) =~ "1.58.0" 26 | 27 | Application.delete_env(:dart_sass, :version) 28 | 29 | Mix.Task.rerun("sass.install", ["--if-missing"]) 30 | 31 | assert ExUnit.CaptureIO.capture_io(fn -> 32 | assert DartSass.run(:default, ["--version"]) == 0 33 | end) =~ @version 34 | end 35 | 36 | test "errors on invalid profile" do 37 | assert_raise ArgumentError, 38 | ~r, 39 | fn -> 40 | assert DartSass.run(:"assets/css/app.scss", ["../priv/static/assets/app.css"]) 41 | end 42 | end 43 | 44 | @tag platform: :linux 45 | test "errors on older Linux package version" do 46 | Application.put_env(:dart_sass, :version, "1.57.1") 47 | 48 | assert_raise RuntimeError, ~r/requires version >= 1.58.0, got: "1.57.1"/, fn -> 49 | Mix.Task.rerun("sass.install", ["--if-missing"]) 50 | end 51 | 52 | Application.delete_env(:dart_sass, :version) 53 | end 54 | 55 | @tag :tmp_dir 56 | test "compiles", %{tmp_dir: dir} do 57 | dest = Path.join(dir, "app.css") 58 | Mix.Task.rerun("sass", ["default", "--no-source-map", "test/fixtures/app.scss", dest]) 59 | assert File.read!(dest) == "body > p {\n color: green;\n}\n" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/fixtures/app.scss: -------------------------------------------------------------------------------- 1 | body { 2 | &>p { 3 | color: green; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure(exclude: :platform, include: [platform: DartSass.platform()]) 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------