How I use Meteor, Elm and Tailwindcss together

anthonny

Anthonny QuΓ©rouil

Posted on June 17, 2020

How I use Meteor, Elm and Tailwindcss together

I have worked with Elm last year and honestly it was an great experience.
I really liked it and I would like to use Elm in all my new projects.

But I also like Meteor and three weeks ago I started using it again.
I discovered Meteor in 2015, I gave a talk about it and it still feels like "Wow!" when you start a project.

So in this post I will explain how I make the two work together and how to add Tailwindcss for the UI part (because I love Tailwindcss too 😍), from scratch.

I will also explain how to link an existing project to Meteor with a Todo app.

Prerequisite

You only need two things:

  • Your favourite IDE (VSCode with the Elm extension fits very well)
  • A browser

You don't need to install NPM, Node or Mongo.
All this stuff is already packaged in Meteor environment.

So to run a NPM command we will use meteor npm xxx, and for Mongo, we will use meteor mongo.

So let's begin 😁

Meteor

Install Meteor

Meteor install page

Here are the commands to install Meteor on OSX/Linux and Windows
OSX / Linux

# OSX/Linux
curl https://install.meteor.com/ | sh

# Windows
choco install meteor

For more information about the installation, please refer to the official documentation here https://www.meteor.com/install

Create a project

Let's start by creating an empty project

meteor create meteor-elm-app --bare
cd meteor-elm-app

This --bare option will create an empty project with static-html instead of blaze and without autopublish and insecure.

For more information about the command create, follow the link https://docs.meteor.com/commandline.html#meteorcreate

Add typescript

Adding Typescript is really simple.
Just run these two commands

# under meteor-elm-app
meteor add typescript
meteor npm i -D @types/meteor @types/mocha

Create a tsconfig.json under the directory meteor-elm-app

{
    "compilerOptions": {
      /* Basic Options */
      "target": "es2018",
      "module": "esNext",
      "lib": ["esnext", "dom"],
      "allowJs": true,
      "checkJs": false,
      "incremental": false,
      "noEmit": true,

      /* Strict Type-Checking Options */
      "strict": true,
      "noImplicitAny": true,
      "strictNullChecks": true,

      /* Additional Checks */
      "noUnusedLocals": true,
      "noUnusedParameters": true,
      "noImplicitReturns": false,
      "noFallthroughCasesInSwitch": false,

      /* Module Resolution Options */
      "baseUrl": ".",
      "paths": {
        /* Support absolute ~imports/* with a leading '/' */
        "/*": ["*"]
      },
      "moduleResolution": "node",
      "resolveJsonModule": true,
      "types": ["node", "mocha"],
      "esModuleInterop": true,
      "preserveSymlinks": true,
    },
    "exclude": [
      "./.meteor/**",
      "./packages/**"
    ]
  }

This configuration comes from the typescript template provided by Meteor.
I just removed the support of JSX.

Create the file structure

We will setup a simple file structure here.
For more complex projects, you should follow the guideline provided by Meteor https://guide.meteor.com/structure.html#javascript-structure

To initialise the file structure, run these commands

# under meteor-elm-app
mkdir client server imports/api
touch client/main.html client/main.ts client/main.css server/main.ts

Your project folder should look like this:

# under meteor-elm-app
❯ tree -I node_modules                                              
.
β”œβ”€β”€ client
β”‚   β”œβ”€β”€ main.css
β”‚   β”œβ”€β”€ main.html
β”‚   └── main.ts
β”œβ”€β”€ imports
β”‚   └── api
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ server
β”‚   └── main.ts
└── tsconfig.json

4 directories, 7 files

We will update the package.json file to define the main modules in our Meteor app:

"meteor": {
    "mainModule": {
        "client": "client/main.ts",
        "server": "server/main.ts"
    }
}

At this point your package.json file should be like:

{
  "name": "meteor-elm-app",
  "private": true,
  "scripts": {
    "start": "meteor run"
  },
  "meteor": {
    "mainModule": {
      "client": "client/main.ts",
      "server": "server/main.ts"
    }
  },
  "dependencies": {
    "@babel/runtime": "^7.8.3",
    "meteor-node-stubs": "^1.0.0"
  }
}

If you need more informations about this mainModule options, you can read the content of this pull request https://github.com/meteor/meteor/pull/9690

We now need to add some basic content to the main.html file:

<head>
  <title>meteor-elm-app</title>
</head>

<body>
  <div id="main">Elm app will be here</div>
</body>

Checkpoint

Lets check if everything is OK before starting with Elm.
Start your meteor server:

# under meteor-elm-app
meteor

Open http://localhost:3000 on your favorite browser
You should see this:

Checkpoint

Elm

Install Parcel

We will use Parcel to build our Elm application and we will use the result of this build in our Meteor application

To install Parcel, run this command

meteor npm i -D parcel

Create a Meteor package

This Meteor package will contain our Elm application and we will use this package inside the Meteor application.

We use a Package because it allows us to isolate our Elm application from the rest of the Meteor context.
It is also really useful if we want to remove our Elm application or if one we don't want to use Meteor anymore.

Let's start by creating some folders:

mkdir -p packages/elm-app/{app,dist}

The app folder will contain the sources of our Elm application (Elm, TS and CSS files).
The dist folder will contain the result of the build made by Parcel.

Because we will build with Parcel and not with Meteor, we will create a new file at the root of the meteor-elm-app called .meteorignore

#under meteor-elm-app
touch .meteorignore

Then add this line inside this new file:

/packages/elm-app/app/**/*

Because we don't want to push the dist and the elm-stuff folders on our repository, we will add them in the .gitignore located under the folder meteor-elm-app

dist
elm-stuff

Now, let's create a package.js file in our package:

#under meteor-elm-app/packages/elm-app
touch package.js

And add the following content in this file:

Package.describe({
    name: 'elm-app',
    version: '1.0.0',
    summary: 'elm app',
    documentation: 'add your elm app into meteor',
});

Package.onUse(function (api) {
    api.versionsFrom('1.10.2');
    api.use('modules');
    api.addFiles('dist/elm-app.css', 'client');
    api.mainModule('dist/elm-app.js', 'client');
});

Package.describe says that our package:

  • is called elm-app,
  • is in version 1.0.0

Package.onUse says that our package:

  • is implemented to be use with Meteor 1.10.2,
  • uses the modules package, so we will be able to use import {} from '',
  • will add the dist/elm-app.css file in the client when it will be loaded,
  • have a main js file for this package called dist/elm-app.js.

If you are using elm-css and if you don't need specific css classes in your app, you can remove api.addFiles('dist/elm-app.css', 'client'); from the package.js file.

For more informations about the Package.js file, see https://docs.meteor.com/api/packagejs.html

Create the app

We will create our Elm application under the folder packages/elm-app/app.

We need to install Elm:

meteor npm i -D elm elm-format

Elm-format is not mandatory but you should use it with your IDE to format on save and to avoid problem at compile time

Then we will initialise our app with the following command:

#under meteor-elm-app/packages/elm-app/app
meteor npx elm init

Validate the creation of the elm.json file and we are good πŸ‘.

At this step, your folder should be like this:

#under meteor-elm-app
❯ tree -I 'node_modules|.meteor' -a
.
β”œβ”€β”€ .gitignore
β”œβ”€β”€ .meteorignore
β”œβ”€β”€ client
β”‚   β”œβ”€β”€ main.css
β”‚   β”œβ”€β”€ main.html
β”‚   └── main.ts
β”œβ”€β”€ imports
β”‚   └── api
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ packages
β”‚   └── elm-app
β”‚       β”œβ”€β”€ app
β”‚       β”‚   β”œβ”€β”€ elm.json
β”‚       β”‚   └── src
β”‚       β”œβ”€β”€ dist
β”‚       └── package.js
β”œβ”€β”€ server
β”‚   └── main.ts
└── tsconfig.json

9 directories, 11 files

In a first time, we will create a simple Elm application.

Create a Main.elm file inside the folder packages/elm-app/app/src with this content:

module Main exposing(main)

import Browser
import Html exposing (Html, text)

type alias Model = String

main : Program () Model msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

init: () -> (Model, Cmd msg)
init _ =
    ("Hello from Elm app", Cmd.none)

view: Model -> Html msg
view model =
    text model

update: msg -> Model -> (Model, Cmd msg)
update _ model =
    (model, Cmd.none)

subscriptions : Model -> Sub msg
subscriptions _ =
    Sub.none

The CSS main file

In the folder meteor-elm-app/packages/elm-app/app, create an empty main.scss SCSS file (or CSS if you prefer) that we will use later to add some style in our Elm application.

NB: if you use elm-css and you don't need a stylesheet, skip this step and remove the line api.addFiles('dist/elm-app.css', 'client'); in the package.js file

The Package mainModule

In the folder meteor-elm-app/packages/elm-app/app, create a file index.ts that will mount our Elm application and export the ports.

A simple version could be:

import './main.scss'
const { Elm } = require('./src/Main.elm')

export const init = (configuration: any) => {
    const app = Elm.Main.init(configuration)
    return app.ports
}

But because we want to Type things as much as possible, let's create this index.ts like this:

import './main.scss'
const { Elm } = require('./src/Main.elm')

interface Flags {}

export interface Configuration {
    node: HTMLElement | null,
    flags: Flags
}

export interface Ports {}

export const init: (configuration: Configuration) => Ports = (configuration) => {
    const app = Elm.Main.init(configuration)
    return app.ports
}

With this definition, when we will need some flags or some ports, we will add the new stuff in our interface and the client will have to implement them.

If you are using CSS instead of SCSS then update the file import accordingly

Build with Parcel

Let's create a build script in our package.json file:

"elm:build": "parcel build packages/elm-app/app/index.ts -d packages/elm-app/dist --out-file elm-app.js --no-cache",

This script will build our application in a file elm-app.js (and elm-app.css) and put it in the folder packages/elm-app/dist (the one we added in our .gitignore)

We can test our script

#under meteor-elm-app
meteor npm run elm:build

If everything is ok, you should see these lines:

Add our package to Meteor

Now that we have a package, we have to add it in our Meteor configuration.

You must have run the previous build command before adding the package because without a dist folder, you will not be able to add it.

Execute this command to add the package

#under meteor-elm-app
meteor add elm-app

You should see

Post install

To avoid to have to compile manually each time someone clone the repository, we will add a postinstall script in the package.json file:

"postinstall": "meteor npm run elm:build",

Use the Elm application in our Meteor client

Now that we have our Elm application, it is time to import it in the client side of our Meteor application

In the client/main.ts file, add the following code:

import { init } from "meteor/elm-app";
import { Meteor } from 'meteor/meteor';

Meteor.startup(() => {
    const ports = init({
        node: document.getElementById("main"),
        flags: {}
    })
})

In this code, we import the init function from the package meteor/elm-app which is the package we have just created (you can see it in the file .meteor/packages).
Then we call it to mount our Elm application on the node document.getElementById("main") (the one we have created in the main.html file)

Now, if you start your meteor application by running the meteor command, on http://localhost:3000 you should see:

But...

The typing is not good

You should see that your import is underlined in red:

To fix that, we will add a declaration file:

#under meteor-elm-app
mkdir -p types/meteor
touch types/meteor/elm-app.d.ts

And add the following content

declare module 'meteor/elm-app' {
    export const init: (
        configuration: import('/packages/elm-app/app').Configuration,
    ) => import('/packages/elm-app/app').Ports;
}

Now each time we will change the definition of the type Flag or the type Port inside our Elm application, we will be sure to know if we have some stuff to fix in the Meteor client πŸ’ͺ.

Live Reload

Because we don't want to build manually our Elm application each time we make a change, we will setup the live reload

We will install some packages to help us

#under meteor-elm-app
meteor npm i -D concurrently wait-on rimraf

Then we will create an new script in our package.json file:

"elm:watch": "parcel watch packages/elm-app/app/index.ts -d packages/elm-app/dist --out-file elm-app.js",

With elm:watch, parcel will rebuild our app each time we make a change in Elm, TS or SCSS files under the folder packages/elm-app/app.

And because parcel watch create a .cache folder, we will add it to the .gitignore file.
The content of your .gitignore should be like this:

node_modules/
dist
elm-stuff
.cache

Now to run parcel and meteor in parallel, we will update the package.json file.
We will rename the script start to meteor:run, and redefine the script start:

"meteor:run": "meteor run",
"start": "rimraf \"./packages/elm-app/dist/*\" && concurrently -n \"parcel,meteor\" -c \"magenta,green\" \"meteor npm run elm:watch\" \"wait-on ./packages/elm-app/dist/elm-app.js && meteor npm run meteor:run\"",

The script start call rimraf to clean the dist folder, then we call concurrently to run two tasks:

  • the parcel one, that will be log in magenta and its command is meteor npm run elm:watch
  • the meteor one, that will be log in green and its command is wait-on ./packages/elm-app/dist/elm-app.js && meteor npm run meteor:run (the wait-on command is use to wait the build from Parcel)

Now each time we will change our content under packages/elm-app/app, Parcel will rebuild incrementally our application and update the content under the dist folder, so Meteor will detect a change and refresh the main application.

You can now start your application by running:

#under meteor-elm-app
meteor npm start

You can make some changes in your Main.elm file and see that everything will automatically refresh in your browser.

Tailwindcss

Tailwindcss is a npm package, so we will install it like this

meteor npm i -D tailwindcss

For more informations about Tailwindcss, see https://tailwindcss.com/docs/installation

We need to initialize Tailwincss:

#under meteor-elm-app/packages/elm-app/app
meteor npx tailwindcss init

This command will generate a file called tailwind.config.js

We can now edit the file main.scss inside our app (packages/elm-app/app/main.scss) to use Tailwindcss

@tailwind base;
@tailwind components;
@tailwind utilities;

We will configure postcss to use autoprefixer and the tailwind.config.js file.

#under meteor-elm-app/packages/elm-app/app
touch postcss.config.js

And add this content to this file

const path = require("path");

module.exports = {
  plugins: [
    require("tailwindcss")(path.join(__dirname, "tailwind.config.js")),
    require("autoprefixer"),
  ],
};

We can now edit our Main.elm to add a CSS class (text-green-500) from Tailwindcss:

view: Model -> Html msg
view model =
    div [class "text-green-500"] [text model]

Then if you (re)start your server, you should see this:

Congratulations πŸŽ‰! You made your first application with Elm, Meteor and Tailwindcss πŸ‘.

The Todos application

It is really awesome right? What? You don't want to use Meteor just to expose static file? Hmm ok, let's go with the Todos application

Because the goal of this post is not to learn how to code in Elm, we will start with an application I wrote for the occasion.

This application is not linked with Meteor yet, there is no ports defined.
The goal is to save each Todo in MongoDB and to be able to sync two browser.

Update the Main.elm

Replace the content of the Main.elm file with this gist https://gist.github.com/anthonny/1b6a73782a6ad94c611849b9a5d4cbbf

We will need to add elm/svg:

#under meteor-elm-app/packages/elm-app/app
meteor npx elm install elm/svg

Then start your application

meteor npm start

You can try the application, actually we can:

  • Add a Todo
  • Switch the status of a Todo
  • Filter Todos by status

We will keep the filtering part in the client, but we want to:

  • Load Todos from MongoDB
  • Save new Todos in MongoDB
  • Switch the status and save it in MongoDB

But let's start with the backend

Define the Todos collection and methods

Under the folder meteor-elm-app/imports/api, create a file todos.ts.

In this file we will define what is a Todo, and create the collection:

import { Mongo } from "meteor/mongo";
import { Meteor } from "meteor/meteor";

export interface Todo {
  _id?: string;
  value: string;
  status: "checked" | "unchecked";
  createdAt: Date;
}

export const TodosCollection = new Mongo.Collection<Todo>("todos");

Then in the same file, we will add two Meteor methods, one to add a Todo and another to switch the status of Todo with its ID:

Meteor.methods({
  "todos.addTodo"(value: string) {
    if (value !## "") {
      TodosCollection.insert({
        value,
        status: "unchecked",
        createdAt: new Date(),
      });
    }
  },
  "todos.toggleStatus"(todoId: string) {
    const todo = TodosCollection.findOne({ _id: todoId });
    if (!todo) {
      throw new Meteor.Error("Todo not found");
    }

    const newStatus = todo.status ### "checked" ? "unchecked" : "checked";

    TodosCollection.update({ _id: todoId }, { $set: { status: newStatus } });
  },
});

And at the end of the file, we will publish our collection on the server side:

if (Meteor.isServer) {
  Meteor.publish("todos", function todos() {
    return TodosCollection.find({}, { sort: { createdAt: -1 } });
  });
}

Finally we need to import this file in the file server/main.ts:

import "/imports/api/todos";

The server side in now ready.

Add ports to the Elm application

We will start by installing elm/json and NoRedInk/elm-json-decode-pipeline to decode our Todos:

#under meteor-elm-app/packages/elm-app/app
meteor npx elm install elm/json
meteor npx elm install NoRedInk/elm-json-decode-pipeline

So we will create 3 ports:

  • addTodo: port addTodo : String -> Cmd msg
  • toggleStatus: port toggleStatus : String -> Cmd msg
  • receiveTodos: port receiveTodos : (Decode.Value -> msg) -> Sub msg

Let's put these port at the end of our Main.elm file:

port module Main exposing(main)

import Json.Decode as Decode
import Json.Decode.Pipeline exposing (required)

...

port addTodo : String -> Cmd msg

port toggleStatus : String -> Cmd msg

port receiveTodos : (Decode.Value -> msg) -> Sub msg

We have to change the type of the Todo.id to use a String because of the id in Mongo:

type alias Todo =
    { id : String
    , value : String
    , status : TodoStatus
    }

type Msg
    = InputChanged String
    | AddTodo
    | ToggleStatus String -- ToggleStatus now need a String not a Int
    | FilterBy Filter

We need a new variant ReceiveTodos (List Todo) for Msg to receive todos:

type Msg
    = InputChanged String
    | AddTodo
    | ToggleStatus String
    | FilterBy Filter
    | ReceiveTodos (List Todo)

We also change the update function because we will not update the todos list anymore.
We will get the one we will receive from the port receiveTodos

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        InputChanged value ->
            ( { model | todo = value }, Cmd.none )

        AddTodo ->
            if String.isEmpty (String.trim model.todo) then
                ( model, Cmd.none )

            else
                ( { model | todo = "" }, addTodo model.todo )

        ToggleStatus todoId ->
            ( model, toggleStatus todoId )

        FilterBy selectedFilter ->
            ( { model | filter = selectedFilter }, Cmd.none )

        ReceiveTodos todos ->
            ( { model | todos = todos }, Cmd.none )

To finish with the Elm part, we need a subscription and some decoders to receive our Todos:

subscriptions : Model -> Sub Msg
subscriptions _ =
    receiveTodos
        (\value ->
            Decode.decodeValue decodeTodos value
                |> Result.withDefault []
                |> ReceiveTodos
        )


decodeTodo : Decode.Decoder Todo
decodeTodo =
    Decode.succeed Todo
        |> required "id" Decode.string
        |> required "value" Decode.string
        |> required "status" decodeStatus


decodeStatus : Decode.Decoder TodoStatus
decodeStatus =
    Decode.string
        |> Decode.andThen
            (\status ->
                case status of
                    "checked" ->
                        Decode.succeed Checked

                    _ ->
                        Decode.succeed Unchecked
            )


decodeTodos : Decode.Decoder (List Todo)
decodeTodos =
    Decode.list decodeTodo

If you remember, we have defined an interface Ports in the file meteor-elm-app/packages/elm-app/app/index.ts.
It is time to add some definitions:

interface Todo {
  id: string;
  value: string;
  status: "checked" | "unchecked";
}

export interface Ports {
  addTodo?: {
    subscribe: (fn: (todo: string) => void) => void;
  };
  toggleStatus?: {
    subscribe: (fn: (todoId: string) => void) => void;
  };
  receiveTodos?: {
    send: (todos: Todo[]) => void;
  };
}

Link ports to Meteor.methods and subscriptions

We have some piece of code in Elm and some piece of code in the server side.
Now we need to link them together, and we will do that in the file client/main.ts

We will need to import our TodosCollection and the Meteor Tracker

import { Tracker } from "meteor/tracker";
import { TodosCollection } from "/imports/api/todos";

Then we will subscribe to the output ports:

  ports.addTodo?.subscribe((todo) => {
    Meteor.call("todos.addTodo", todo, (err: Error) => {
      if (err) {
        // Maybe we should pass this error to Elm
        console.log("error", err);
        return;
      }
    });
  });

  ports.toggleStatus?.subscribe((todoId) => {
    Meteor.call("todos.toggleStatus", todoId, (err: Error) => {
      if (err) {
        // Maybe we should pass this error to Elm
        console.log("error", err);
        return;
      }
    });
  });

Here each time addTodo is called from Elm, we add a new Todo with a Meteor.call, same for the toggleStatus.

Of course we should manage the error, maybe it could be a good exercice 😁

Finally we need to send todos everytime the collection change.
To do that, we use Tracker.autorun that will run the callback when necessary.

  // We use the Tracker.autorun to send todos each time the fetch result
  // changes
  Tracker.autorun(() => {
    // Maybe one day we will need to manage the subscription
    const subscription = Meteor.subscribe("todos");

    const todos = TodosCollection.find({}, { sort: { createdAt: 1 } }).fetch();

    ports.receiveTodos?.send(
      todos.map((todo) => ({
        id: todo._id || "",
        value: todo.value,
        status: todo.status,
      }))
    );
  });

Now you can restart your server, open two browsers on http://localhost:3000 and see that everything is saved and sync πŸ‘.

Conclusion

I hope you enjoyed this content as much as I enjoyed writing it.
Three weeks ago I was sad because I could not use Meteor with Elm, so I started using it with React and Typescript 😳.

Today, I dropped React and I use Elm again and it is really pleasant.

If you liked this post, do not hesitate to share it on your favorite social networks and if you are interested by this kind of content, you can follow me on twitter @anthonny_q.

If you have any feedbacks, comments are open and you can find the sources of the project here https://github.com/anthonny/meteor-elm-todos.

Special thanks to ni-ko-o-kin, as I was very inspired by his post.

Big thanks to Yann Danthu for the review of this post 😘.

πŸ’– πŸ’ͺ πŸ™… 🚩
anthonny
Anthonny QuΓ©rouil

Posted on June 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related