# #install_printer Yocaml.Path.pp ;;
# #install_printer Yocaml.Deps.pp ;;
# module Path = Yocaml.Path ;;
module Path = Yocaml.Path
This guide is essentially about establishing good practices, but it won’t teach you anything new that is specific to YOCaml.
The Three Contexts
Even though in the main guide we treated paths in a fairly uniform way, when generating a static site there are actually three contexts for our paths:
-
The source, which lets us fetch the documents (or assets) we’ll use to create targets. For example:
./content/articles
, describing the source of our articles. -
The target, which describes the paths where we’ll create artifacts. For example:
./_www/articles
, describing the destination where we’ll generate the articles. -
The server, which describes the paths relative to the server that serves our site. This is useful for computing article links (particularly for the index and the feed). For example:
/articles/my-first-article.html
, the URL (relative to our server) of an article.
We could imagine other contexts (like a cache, if we wanted to build more ambitious caching strategies). However, for the purposes of this guide, we’ll keep things simple.
While it’s possible to handle these paths in an ad-hoc way, as your generator grows more complex, centralizing path calculations in a module makes development much simpler. For example, this documentation site uses a resolver.
In this guide, we’ll see how to create a resolver that can be passed from one action to another, making our generator more modular and helping solve potential issues cleanly.
Note: There’s no single way to write a resolver. In this article, we’ll present an approach that’s both easy to adapt to your needs and not too intrusive.
An Annoying Problem
Beyond being a robust way to handle paths, using a resolver also
helps avoid a potentially frustrating issue. In our earlier examples,
as soon as we invoked the development
server, we assumed our site would always
be served from the root. Indeed, even if we pass a target
directory to the server (so it knows which folder to serve
statically), the server itself will always start from /
.
In practice, this isn’t always true. A common case is when deploying
to GitHub Pages, where the site root might actually be
/project-name/
.
By implementing a resolver strategically, we can handle this issue in an almost completely transparent way.
Resolver for the Simple Blog Tutorial
Building on the simple blog tutorial, we will
now create a basic resolver (and briefly see how to adapt our
actions). To get started, in a Resolver
module (touch bin/resolver.ml
), we’ll define a type (which can remain abstract)
that will describe the roots of our three contexts:
type t =
{ source: Path.t
; target: Path.t
; server: Path.t
}
We can now create a function to initialize our resolver, allowing us to provide default arguments:
let make
?(source_folder = Path.rel [])
?(target_folder = Path.rel ["_www"])
?(server_folder = Path.abs [])
()
=
{ source = source_folder
; target = target_folder
; server = server_folder
}
The general idea is to compute all of our paths from a value of type
t
. We can then define functions within submodules to resolve each
path.
For example, if we wanted to create a resolver for a site hosted on
the gh-pages
branch of the project my-project
, we might imagine
the following resolver:
# let my_resolver =
make ~server_folder:(Path.abs ["my-project"]) () ;;
val my_resolver : t = {source = ./; target = ./_www; server = /my-project}
Source
We’ll start with the simplest submodule, Source
. First, we define a
function that lets us easily access the root of the source:
module Source = struct
let source_root { source; _ } = source
end
Now we can rebuild all the variables we introduced earlier in the blog creation guide:
module Source = struct
let source_root { source; _ } = source
(* Assets *)
let assets res = Path.(source_root res / "assets")
let images res = Path.(assets res / "images")
let css res = Path.(assets res / "css")
let templates res = Path.(assets res / "templates")
(* Content *)
let content res = Path.(source_root res / "content")
let pages res = Path.(content res / "pages")
let articles res = Path.(content res / "articles")
let index res = Path.(content res / "index.md")
end
We’ve simply relocated the variables we defined on the fly throughout
the tutorial into a Resolver.Source
module. The main advantage is
that the source of our various files is now defined by the values we
provide to our resolver, allowing us, for example, to condition
the source of our generator via the CLI.
Target
We can perform the same exercise for the target. In the tutorial, we
calculated the target on the fly in each action. This time, we can
introduce a new submodule: Resolver.Target
:
module Target = struct
let target_root { target; server; _ } =
Path.relocate ~into:target server
end
Here there’s a subtlety! One might think that, just like the Source
module, the root of the target is simply the target
member.
However, if the server root isn’t /
, we want to build the path
relative to the server root.
For example, using the dummy resolver we created earlier (for the
project hosted on the GitHub Page my-project
):
# Target.target_root my_resolver ;;
- : Path.t = ./_www/my-project
This little hack allows us to remain consistent between the URLs on
our local server and how our site will be served in
production. Indeed, access to our site will be through
localhost:port/my-project
.
We can now complete our module with the ad-hoc targets we previously used in our actions.
module Target = struct
let target_root { target; server; _ } =
Path.relocate ~into:target server
let cache res =
Path.(target_root res / ".cache")
let images res =
Path.(target_root res / "images")
let style_css res =
Path.(target_root res / "style.css")
let page res source =
let into = target_root res in
source
|> Path.move ~into
|> Path.change_extension "html"
let article res source =
let into = Path.(target_root res / "articles") in
source
|> Path.move ~into
|> Path.change_extension "html"
let index res =
Path.(target_root res / "index.html")
let atom res =
Path.(target_root res / "atom.xml")
end
We can quickly verify that our resolver works by trying to calculate the target for the following article:
let my_first_article =
let open Path in
Source.articles my_resolver / "my-first-article.md"
We can use Target.article
and see that the calculated path correctly
takes the server root into account:
# let my_first_article_target =
Target.article my_resolver my_first_article ;;
val my_first_article_target : Path.t =
./_www/my-project/articles/my-first-article.html
We can now move on to the final step: the server.
Server
Resolving links for the server is much simpler. Indeed, we can
calculate a server path based on a standard target calculation. We’ll
use the function
Path.trim
,
which removes a prefix from a path:
module Server = struct
let server_root { server; _ } = server
let from_target res path =
let prefix = Target.target_root res in
let into = server_root res in
path
|> Path.trim ~prefix
|> Path.relocate ~into
end
We can test this using my_first_article_target
:
# Server.from_target my_resolver my_first_article_target ;;
- : Path.t = /my-project/articles/my-first-article.html
Now that we’ve finalized our resolver, we can start using it!
Using the Resolver
The first thing we’ll do is initialize our resolver in our program:
Note that it would also be possible to configure the target and server root via CLI arguments.
- let program () =
+ let program resolver () =
let open Eff in
let cache = Path.(www / ".cache") in
Action.restore_cache cache
>>= copy_image
>>= create_css
>>= create_pages
>>= create_articles
>>= create_index
>>= create_feed
>>= Action.store_cache cache
let () =
+ let resolver = Resolver.make () in
match Sys.argv.(1) with
| "server" ->
Yocaml_unix.serve
~level:`Info
~target:www
~port:8000
- program
+ (program resolver)
| _ | (exception _) ->
Yocaml_unix.run
~level:`Debug
- program
+ (program resolver)
Next, we can pass our resolver to our actions. We won’t do it for every action, because if you can do it for one action, you can do it for all:
-let create_css =
+let create_css resolver =
- let css_path = Path.(www / "style.css") in
+ let css_path = Resolver.Target.style_css resolver in
+ let css = Resolver.Source.css resolver in
let pipeline =
let open Task in
let+ () = track_binary
and+ content =
Pipeline.pipe_files ~separator:"\n"
Path.[ css / "foo.css"; css / "reset.css"; css / "style.css" ]
in
content
in
Action.Static.write_file css_path pipeline
let program resolver () =
let open Eff in
- let cache = Path.(www / ".cache") in
+ let cache = Resolver.Target.cache resolver in
Action.restore_cache cache
>>= copy_image
- >>= create_css
+ >>= create_css resolver
>>= create_pages
>>= create_articles
>>= create_index
>>= create_feed
>>= Action.store_cache cache
You can now update all your actions to use your freshly constructed resolver!
Conclusion
Even though this guide wasn’t particularly YOCaml-specific, we’ve seen how to centralize path definitions! This centralization makes our generator code more robust, potentially more configurable, and solves the issue of sites not always being generated at a server root.
In practice, creating a resolver is a common pattern among YOCaml users. If you have other approaches or encodings, don’t hesitate to share them with us!