Welcome to the OCaml blog creation workshop, brought to you in association with Ada Tech School! In this workshop, scheduled for the 22nd of September, 2023, we'll guide you step-by-step as you build a simple blog using OCaml.
Before you dive into the tutorial, ensure your environment is correctly set up by following the guide below. We also recommend acquainting yourself with OCaml through the recommended readings.
To provide a seamless workshop experience, we'll be using Dev Containers. To get started, install the following software:
- Docker Desktop: Download here
- Visual Studio Code (VSCode): Download here
- Dev Containers VSCode Extension: Download here
If you encounter any issues while installing the applications above, consult the Developing inside a Container guide from the VSCode documentation.
net localgroup docker-users "your-user-id" /ADD
Once you have VSCode and the Dev Containers extension ready, clone the workshop repository to your local machine using the following command:
git clone https://github.com/sabine/ocaml-blog-tutorial.git
Launch VSCode and execute the "Dev Containers: Open Folder in Container" command from the command palette. Select the ocaml-blog-tutorial
folder that you cloned in the previous step.
Initializing the environment for the first time might take a few minutes, as it involves installing necessary dependencies.
To ensure everything is set up correctly, run the following command in the VSCode terminal (Terminal > New Terminal):
dune exec main
When you see the "Hello World!" message printed in the terminal, your setup is successful!
Before attending the workshop, familiarize yourself with OCaml through the following resources:
- About OCaml: A high-level introduction to the OCaml language and why you might want to use it.
- A Tour of OCaml: Dive into the official OCaml documentation to understand the core concepts of the language. No need to be thorough since we'll cover this during the workshop, but you might want to skim through to prepare questions.
You are now all set for the workshop! Keep an eye on this repository for the tutorial, which we will delve into during the workshop session.
We look forward to building together with you at the Ada Tech School on the 22nd of September, 2023.
We've prepared a working directory in src/
it already contains an OCaml module, and a dune file that declares an executable. This is where you will be writing your code in.
When learning in a self-directed fashion, you can check the solutions in the solutions/
directory after you completed the corresponding step, or when you get too stuck to continue. However, when you get stuck, we'd be happy if you open an issue on this repository with your question, so we can clarify and improve the tutorial.
At any point, when you see a squiggly red underline saying that a module is not defined, you can run
dune build
or
dune exec main
to tell the build system Dune to build all the files, so that the editor plugin can pick up the type information to display.
To write our web application, we'll use Dream, OCaml's web framework.
Dream exposes all of its functions in its toplevel Dream
module. To run a Dream application, you can call the Dream.run
function. It only has one non-optional argument: a Handler.
In Dream, a Handler is a function that takes a Dream.request
as input, and returns a Dream.response
. If you've worked with other web frameworks, you might have come across the term "Controller". It's essentially the same thing.
Tasks:
- Update
main.ml
to implement a Dream application that always returns "Hello World!". You can replace theprint_endline "Hello World"
with this call to theDream.run
function:
Dream.run (fun _ -> Dream.html "Hello world!")
- In your terminal inside the dev container, run
dune exec main
- open http://localhost:8080 in your browser to confirm that "Hello world!" is shown.
Generally, a website consists of HTML pages and a common practice is to render HTML pages from templates:
Template files are processed to turn them into functions that may take some parameters and return a string that contains the rendered HTML.
Dream supports different HTML template engines. The one we're going to use is the default one. Every .eml.html
template is translated using the dream_eml
command to an OCaml file that provides the functions that render the HTML fragments defined in the corresponding .eml.html
template.
Tasks:
- Create a new file
template.eml.html
in the same folder asmain.ml
with this content:
let render param =
<html>
<body>
<h1>Hello <%s param %>! This is a HTML Template.</h1>
</body>
</html>
- Add this code to the
dune
file:
(rule
(targets template.ml)
(deps template.eml.html)
(action
(run %{bin:dream_eml} %{deps} --workspace %{workspace_root})))
This runs dream_eml
on the template.eml.html
template to generate the corresponding template.ml
OCaml file.
The function home
from template.eml.html
can now be used in your program like this: Template.home
.
- Change
main.ml
by replacing the string "Hello world!" with
(Template.home "world")
to use the new template.
- Run the code and check in your browser that the HTML template is returned under https://localhost:8080
So far, our web server returns the same HTML page for every request, independent of the URL. Generally, a web server serves different HTML pages under different URLs. So let's introduce different routes
Tasks:
- Use the
Dream.logger
middleware so that you can see the request log in your terminal when HTTP requests are processed by your Dream web server.
Here is an example of how Dream.logger
is used: https://github.com/aantron/dream/tree/master/example/2-middleware#files
- Serve the template only under the
"/"
URL using the HTTP Get method and make all other routes return a 404 response.
Hint: look at https://github.com/aantron/dream/tree/master/example/3-router#files and use Dream.router
and Dream.get
to serve the HTML template.
You can use
Dream.get "**" (fun _ -> Dream.html ~code:404 "404 Not Found")
at the end of the list of routes to to register a handler that returns a 404 response. Here, **
is a wildcard that matches and URL. Since routes are matched from top to bottom, this handler will only be called if none of the other routes matched.
- Run the code and check in your browser that http://localhost:8080 shows the "hello world" template, and http://localhost:8080/abc gives a 404 response.
Since we're creating a blog website, it's now a good time to work with some data that we want to display in our templates.
For now, we'll represent a blog post as a struct that has fields for the blog posts title
and html_body
, as well as a slug
field which we will use to refer to the blog post in a URL.
Tasks:
- Create a new file
post.ml
in the same directory asmain.ml
with the following content:
type t = { title : string; slug : string; html_body : string; image : string }
let all =
[
{
title = "Hello I am the first post!";
slug = "hello";
html_body = "<p>I just wanted to say hi!</p>";
image = "computer-4484282_1280.jpg";
};
{
title = "It's a nice day";
slug = "nice-day";
html_body = "<p>Don't you think so, too?</p>";
image = "man-791049_1280.jpg";
};
{
title = "Hello I am the third post!";
slug = "third-post";
html_body = "<p>See you later!</p>";
image = "work-3938876_1280.jpg";
};
]
This is, for now, a list of blog posts that have a title
, a slug
, a html_body
, and an image
.
Important: in this step, we only need to use the title
field. The slug
and html_body
field, we will use in step 5 of the tutorial, and the image
field we will use in step 6.
Now, Post.t
is a type that represents a single blog post, and Post.all : t list
is the list of all blog posts.
- Change the
home
function intemplate.eml.hml
to render a list of blog posts.
Instead of taking param
as parameter, the new home
function takes a parameter (posts: Post.t list)
.
Instead of showing Hello <%s param %>! This is a HTML Template.
, you can iterate over the list of posts using the List.iter
function like this:
<% posts |> List.iter (fun (p: Post.t) -> %>
... TODO: write HTML here ...
<% ); %>
Render a <ul>
and iterate over the list to render a <li>
tag that contains the title of the blog post.
Hint: <%s ... %> is used to render an OCaml string.
- Run the code and check in your browser that http://localhost:8080 shows list of blog posts
All we did in the last step was render a list of titles of the blog posts, but now we want to give every blog post its own HTML page under the URL /post/:slug
, where :slug
is the value of the slug
field of the particular blog post. This will allow people to link to or bookmark a specific blog post.
Tasks:
- Create a new function
post
intemplate.eml.html
that takes a parameter(post: Post.t)
and renders the following template:
let post (post : Post.t) =
<html>
<body>
<a href="/">all posts</a>
...
</body>
</html>
In the template (where ...
is), use <%s ... %>
to render the title
of the post inside an <h1>
tag, and use <%s! ... %>
to render the html_body
of the post as raw HTML.
s!
means that the OCaml expression in this block is a string that should be placed into the template without escaping special characters like <
, >
, and more. One must only use this raw string tag on sanitized HTML - otherwise, an XSS attack is possible.
- Create a new route for the URL
/post/:slug
and read the"slug"
parameter from the request usingDream.param
.
See an example here: https://aantron.github.io/dream/#routing
- Look up the post using the
slug
and render the new Template.
To find the post, use the function List.find : (Post.t -> bool) -> Post.t list -> Post.t
.
List.find
takes a predicate (a function returning true
or false
) that should be true
for the item we're looking for.
Notice that, when the blog post does not exist, we get an error.
3a. (optional) To avoid that, you can use List.find_opt : (Post.t -> bool) -> Post.t list -> Post.t option
instead, and you can use
match post with
| Some p -> ...
| None -> ...
to render Template.post
when a post was found, and return a 404 response when no post with the given slug was found.
- Modify
Template.home
so that every blog post has an<a>
tag around the title that links to the new route.
You will need to use <%s ... %>
again to render post.slug
as part of the URL.
- Run the code and check in your browser that http://localhost:8080 shows the list of blog posts and that you can follow the link to an individual blog post's page and from there back to http://localhost:8080.
So far, we're serving naked, unstyles HTML pages. Let's change that and add some CSS.
There already is a folder static
in this directory that contains a CSS stylesheet, as well as the images belonging to the posts.
Tasks:
- Serve the files from the
static
directory under the route/static/**
.
See https://aantron.github.io/dream/#static-files for an example of how to do this.
- Modify the templates in
template.eml.html
and add a HTML<head>
tag that contains the<link>
tag for the stylesheet:
<link rel="stylesheet" href="/static/chota.min.css">
- Add a
<div class="container">
right inside the body of both templates, to wrap the existing elements.
3a. (optional) Having the basic structure / layout of the page repeated in every template is repetitive. Can you see how to move the <html>, <head>, <link>, <body>, <div class="container">
to a separate function that can be used by both the post
and the home
template?
Hint: You can create a layout
function that takes a string parameter inner_html
and renders it inside the template with <%s! inner_html %>
.
Hint 2: It is possible to pass a HTML template to the layout
function like this:
let home =
layout (
<h1>All Posts</h1>
...
)
-
Add an
<img src="...">
tag to thepost
template intemplate.eml.html
to showpost.image
. -
Look at how much nicer it looks now.
There's a lot further we can take this from here. Ideas:
- Load blog posts from Markdown files (with YAML headers) instead
- Load blog posts from a database instead
- Add more fields like
author
,date_published
, etc. - Add TailwindCSS to the project
- Create a RSS feed that lists all the blog posts, for RSS readers to subscribe to
If you're interested in this, or have other ideas where to go with this tutorial, open an issue on the github repo.