On January 12th, Flavio Corpa (kvothe) reached out on the #nix channel of the Elm Slack asking for help to make Nix and Parcel work together since he really wanted to use Simon Lydell's elm-safe-virtual-dom in his projects. Presumably, he read Juliano Solanho's (omnibs) post, on NoRedInk's blog, about adopting lydell/elm-safe-virtual-dom that got him excited about the prospects. He had some issues with browsers and auto translation of content that lydell/elm-safe-virtual-dom might be able to fix.
I had recently finished rewriting elm2nix and was on the lookout for opportunities to apply my freshly acquired knowledge so I felt it was fortuitous timing for me that his request was made when I was most able to help.
I approached my solution in two parts:
If you have Nix installed, you can try out the final result by executing:
nix run github:dwayne/elm-countries-quiz
Let's get into it.
Rewriting the project to use dwayne/elm2nix
In the PR, for this part, I highlighted all the noteworthy changes so I won't rehash them here but I'd make a few comments.
Name your development shell
nix develop doesn't give you any indication that you're in your development shell so to improve the situation you can give the shell a name and use it in your primary prompt string.
devShells.default = pkgs.mkShell {
name = "elm-countries-quiz";
shellHook = ''
export PS1="($name)\n$PS1"
'';
}
This way, when you enter your development shell the prompt changes and there's visual indication that you're in a different environment.
Use the programming language's package manager
In your development environment I found it's best to use Nix to install the programming language's package management tools and then use those tools to manage the project. In this way Nix becomes a way to manage your project's system dependencies while you continue to use the tools that are familiar to you from your programming language's ecosystem.
devShells.default = pkgs.mkShell {
packages = [
elm2nix.packages.${system}.default
pkgs.elmPackages.elm
pkgs.elmPackages.elm-format
pkgs.elmPackages.elm-json
pkgs.elmPackages.elm-test-rs
pkgs.elmPackages.elm-review
pkgs.nodejs_24
pkgs.pnpm
];
shellHook = ''
pnpm install --silent
'';
}
For e.g. anyone entering the development shell above gets access to elm2nix, elm, elm-format, elm-json, elm-test-rs, elm-review, node, npm, and pnpm.
In particular, you can now use pnpm to manage your project's dependencies like you would if you installed it in the traditional way. As a bonus, I run pnpm install --silent whenever you enter the development shell so that's one less thing you need to do to start working on the project.
Use aliases/functions to make it easier to perform common tasks
In your shellHook you can create Bash aliases or define Bash functions to provide shortcuts to the common tasks you find yourself repeating as you work on your project.
devShells.default = pkgs.mkShell {
shellHook = ''
export PROJECT_ROOT="$PWD"
f () {
elm-format \
"$PROJECT_ROOT"/src \
"$PROJECT_ROOT"/review/src \
"$PROJECT_ROOT"/tests \
--yes
}
r () {
elm-review
}
t () {
elm-test-rs "$PROJECT_ROOT"/tests/*.elm
}
c () {
rm -rf "$PROJECT_ROOT"/{.parcel-cache,dist,elm-stuff,node_modules}
}
s () {
pnpm start
}
echo "Type 'f' to run elm-format"
echo "Type 'r' to run elm-review"
echo "Type 't' to run elm-test-rs"
echo "Type 'c' to remove build artifacts"
echo "Type 's' to start the development server"
'';
}
N.B. I used to use aliases but after learning that for almost every purpose, shell functions are preferable to aliases I've switched to functions in my recent projects.
Building with pnpm and Parcel in Nix
pnpm and Parcel are used to build the application in nix/app.nix.
The file is structured using the conventional callPackage pattern. It uses stdenv.mkDerivation with specific attributes to be able to use pnpm and parcel.
I use file sets to specify the minimum files needed for the build to work. This ensures that rebuilds are only ever considered when those files change.
To work with pnpm I followed the advice in the pnpm section of the Nixpkgs Reference Manual. In particular, I used the fetchPnpmDeps function to create the pnpm store derivation corresponding to the project's pnpm-lock.yaml. The setup hook pnpmConfigHook prepares the build environment to install the pre-fetched dependencies.
To ensure the Elm compiler could work fully offline I use the prepareElmHomeScript function, from dwayne/elm2nix, to create a local Elm cache.
Finally, pnpm build invokes parcel build index.html which builds the project and places the generated files in the dist directory which I copy over to the current derivation's output directory to successfully complete the process.
Adopting lydell/elm-safe-virtual-dom
Simon Lydell does an excellent job explaining his project in its README. The tl;dr is that it's a robust virtual DOM for Elm that makes your application, among other things, resilient against browser extensions that mutate the DOM independent of Elm. One of the things this enables is browser-based translations, the very feature that kvothe wanted working with his project.
To adopt lydell/elm-safe-virtual-dom you have to patch core Elm libraries and get the Elm compiler to use the patched versions.
How it works
The first major thing I did was to create derivations of the patched core Elm libraries whose output directories were structured in a very specific way.
For e.g. lydell/browser is supposed to replace elm/browser 1.0.2 so the output produced by nix/elm-safe-virtual-dom/lydell-browser.nix looks as follows:
$ nix build github:dwayne/elm-countries-quiz#lydellBrowser -L
$ tree result
result
└── elm
└── browser
└── 1.0.2
├── elm.json
└── src
├── Browser
│ ├── AnimationManager.elm
│ ├── Dom.elm
│ ├── Events.elm
│ └── Navigation.elm
├── Browser.elm
├── Debugger
│ ├── Expando.elm
│ ├── History.elm
│ ├── Main.elm
│ ├── Metadata.elm
│ ├── Overlay.elm
│ └── Report.elm
└── Elm
└── Kernel
├── Browser.js
├── Browser.server.js
└── Debugger.js
i.e. it gets mapped to the core library it replaces and the directory structure is exactly what you'd find in the Elm cache under $ELM_HOME/0.19.1/packages. But, the contents are precisely that of lydell/browser.
nix/elm-safe-virtual-dom/lydell-virtual-dom.nix and nix/elm-safe-virtual-dom/lydell-html.nix work in a similar way. The pattern they all use was abstracted into a mkPatch builder in nix/elm-safe-virtual-dom/patch.nix.
Once you have derivations with outputs structured in that way and you know where to find the Elm cache it's a simple matter to remove the old package and replace it with the patched version in the Elm cache. That's what nix/elm-safe-virtual-dom/install-patch-script.nix does. One interesting thing to note is how I used the patched derivation's passthru attribute to get a hold of the path in which the package would be stored in the Elm cache. I use the path to remove the old package and to copy in the patched version.
nix/elm-safe-virtual-dom/patches.nix installs all the required patches. This is used by both nix/app.nix and nix/elm.nix, after the local Elm cache has been created, to install the patched versions of the packages.
N.B. The full details are available in this PR.
As you can imagine, this technique can be generalized to patch any published Elm package in any way you desire but I'm getting ahead of myself and will probably say more on that in a future post.
Further reading
Subscribe to my newsletter
If you're interested in improving your skills with Elm then I invite you to subscribe to my newsletter, Elm with Dwayne. To learn more about it, please read this announcement.
Top comments (0)