Generating a static site with Middleman
I needed a site as a landing page for my professional life. Making that decision was the easy part. The complexity of the modern web can be incredibly overwhelming, with so many options to choose from. You can get decision paralysis even before writing a single line of code, let alone any content!
I want to be able to focus on the content, with the freedom to structure and style my site the way I want. My needs and tastes are quite simple. I don’t need hundreds of lines of javascript and css just to host some links and a blog.
I did not want to be forced into making certain design decisions, based on the opinion or convention of the specific tool or framework. I realise in retrospect that compromise is inevitable with any framework. What I did want was the ability to do my own thing, and not feel forced into a particular way of doing things.
I didn’t want to depend on templates provided by others. Although this would have made things a lot easier to start off with, it would have made things more complex over time. If I design something that is simple and easy to understand, this will allow me to support it over a longer period of time. If an upstream template change breaks a complex template I barely understand, this would be a huge headache and reduce my efficiency.
With the above decisions made, I thought it would be best to use a Static Site Generator.
Choosing a Static Site Generator
My experience with static site generators in the past were not positive. My main issues with static site generators are:
- They can be overly opinionated
- They can rely on too much “magic”
- You must understand their abstractions, and conform to their way of doing things
I selected Middleman as my static site generator for the following reasons:
-
Allows for ERB and HAML templating
I wanted the flexibility of using Ruby in my templates, and ERB and HAML allow me to do this. Middleman also allows for nested layouts, which are incredibly powerful.
-
Allows arbitrary data to be stored and used easily
I like the idea of using structured data files, such as YAML, to control certain aspects of the site. This reduces the need to update the individual pages or layouts, and reduces duplication.
-
Allows re-usable components and modular design
Middleman implements functionality similar to Rails Partials, which is very flexible and powerful.
Middleman is reminiscent of Rails, however just focusing on the “View” part of “MVC”. As I’m already familiar with Ruby on Rails, Middleman’s workflow, structure, and concepts seemed very familiar already.
There are of course other options that implement the same or similar features, however my familiarity with Ruby and Rails allowed me to understand Middleman easier, which became a large factor in choosing it.
For those familiar with Rails and Middleman, you might be thinking I’m being hypocritical about my dislikes of other static site generators, with regards to Middleman.
-
They can be overly opinionated
Rails is known for being an opinionated framework. After all, one of their core philosophies is “Convention over Configuration”. Middleman inherits similar Rails-isms, and some may say this is opinionated. It so happens that I agree with these opinions, and they don’t get in my way nearly as much as other SSGs.
-
They can rely on too much “magic”
For something to be useful or simple to use, some form of “magic” is required. For me to use something effectively, I need to be able to understand it. I need to know what it does, and how, and possibly why. I don’t need to understand it entirely, but I do need enough knowledge to use it properly, and to get out of trouble if needed.
I understand that what is considered “magic” is can be very subjective. “Magic” is just a process that you don’t understand, where there’s some obfuscation involved to ensure you don’t understand it.
I feel that I understand Middleman enough to implement my own workflow the way I want.
-
You must understand their abstractions, and conform to their way of doing things
SSGs use magic and they introduce abstractions to help simplify the process. I don’t like not having agency and some level of control when it comes to magic. I also need to properly understand the abstractions.
I tried the Hugo way, the Jekyll way, the Nanoc way, and the Bridgetown way. Each of these had their advantages, but I found myself struggling to fully understand how they worked. I’m sure I would have a much better understanding if I spent a lot more time reading the documentation. I found myself spending more time on the workflow than the actual work.
My workflow
I use vim to edit my files, and git for version control. I test locally using middleman server. I use GitLab for repository hosting, as well as building and deploying the static site using CI/CD and GitLab Pages.
My .gitlab-ci.yml CI/CD pipeline builds the site using middleman build, and either deploys to a review app for Merge Request pipelines, or deploys to Pages for commits to the default branch.
I wanted to share the .gitlab-ci.yml file I use, but my site project is private. I publish the .gitlab-ci.yml file as an artifact to GitLab Pages via GitLab CI/CD. I wrote a helper function in Middleman’s config.rb to read files via URL and render them, so below is the most recent .gitlab-ci.yml file for master branch:
stages:
- prepare
- build
- review
- deploy
variables:
REVIEW_URL_HTTP_PREFIX: "/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/public"
PAGES_URL: "https://$CI_PROJECT_NAMESPACE.gitlab.io"
default:
image: ruby:3.3.10
interruptible: true # All jobs are interruptible by default
# The following 'retry' configuration settings may help avoid false build failures
# during brief problems with CI/CD infrastructure availability
retry:
max: 2 # This is confusing but this means "3 runs at max".
when:
- unknown_failure
- api_failure
- runner_system_failure
- job_execution_timeout
- stuck_or_timeout_failure
.update-install-nodejs: &update-install-nodejs
- apt-get update -qq && apt-get install -y -qq nodejs
bundle-install:
stage: prepare
cache:
- key:
files:
- Gemfile.lock
paths:
- vendor/
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- *update-install-nodejs
- gem install bundler --no-document
- bundle config set path 'vendor'
- bundle install --quiet --jobs 4
.build:
script:
- *update-install-nodejs
- bundle config set path 'vendor'
- bundle exec middleman build --bail
- mkdir -p public/data
- cp .gitlab-ci.yml public/data/.gitlab-ci.yml
build:
extends: .build
stage: build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
artifacts:
paths:
- public/
cache:
- key:
files:
- Gemfile.lock
paths:
- vendor/
build:review:
extends: .build
stage: build
before_script:
- |
REVIEW_URL_HTTP_PREFIX_ESCAPED=$(echo $REVIEW_URL_HTTP_PREFIX | sed 's/\//\\\//g')
echo $REVIEW_URL_HTTP_PREFIX_ESCAPED
if [[ ! -z "$REVIEW_URL_HTTP_PREFIX_ESCAPED" ]];
then
sed -e "s/#set :http_prefix/set :http_prefix/g" \
-e "s/<http_prefix>/$REVIEW_URL_HTTP_PREFIX_ESCAPED/g" -i config.rb
fi
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
artifacts:
paths:
- public/
cache:
- key:
files:
- Gemfile.lock
paths:
- vendor/
pages:review:
stage: review
script:
- ls -la public
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
environment:
name: pages/$CI_COMMIT_REF_NAME
url: "${PAGES_URL}${REVIEW_URL_HTTP_PREFIX}/index.html"
artifacts:
paths:
- public/
pages:
stage: deploy
script:
- ls -laR public/
artifacts:
paths:
- public/
rules:
# Only run this job from pipelines for the default branch
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH