Contents

Hello, quarto.

Abstract

Things you get

  • A quick overview of how to integrate quarto with hugo.

Prerequisites

  • Basic Linux and bash knowledge.

Introduction

Writing scientific articles with reproducible code execution has been on my to-do list for quite some time. Recently, I came across Quarto, a tool that allows you to execute Python cells and save the output to markdown. This process, known as rendering, generates markdown files that can be used by Hugo to generate static HTML.

Quarto provides a seamless integration between Python and Hugo, making it easy to create interactive and dynamic content.

Setup

First, we need to install Quarto. This can be done via an official installer or by installing a specific release. For the latter the following script can be used.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# install quarto to ${HOME}/bin
QUARTO_VERSION=1.4.554

URL="https://github.com/quarto-dev/quarto-cli/releases/download/"\
"v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.tar.gz"

curl -o quarto.tar.gz -L $URL
tar -zxvf quarto.tar.gz
    --strip-components=1 \
    -C ${HOME}
rm quarto.tar.gz
Info
Make sure to use a release specific to your operating system. I use the linux-amd64 release.

Next, I needed to add a index.qmd file to the project root path to be able to call quarto render from that directory. This will render all *.qmd files in the project.

For the python runtime it is best practice to set up a virtual environment and install the basic jupyter requirement. I prefer poetry for dependency management and uv for creating virtual environments. So, for me that basic step is

1
uv venv --python 3.9 && source .venv/bin/activate && poetry install

where my pyproject.toml looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
[tool.poetry]
name = "homepage"
version = "0.1.0"
description = ""
authors = ["André Schemaitat <a.schemaitat@gmail.com>"]
readme = "README.md"
package-mode = false

[tool.poetry.dependencies]
python = "^3.9"
notebook = "^7.2.0"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

The pyproject.toml can also be generated by calling poetry init.

I added the package-mode = false setting, since I use poetry for dependency management only, i.e. housekeeping my venv.

Info
Don’t forget to activate your venv when calling quarto render.

Finally, you can (and it makes sense) add a configuration file _quarto.yml to the root path, which contains the global quarto configuration. Right now, I use the following setup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
project:
  type: hugo
      
format:
  hugo-md: 
    mermaid: 
      theme: forest

jupyter: python3

editor: 
  render-on-save: true

However, you can also add quarto settings per document. These will then be merged with the global settings.

Workflow

The main steps of creating content with quarto and hugo are depicted in Figure 1.

graph LR;
    A(qmd) --> 
    B(jupyter)
    --> C(md)
    --> D(Pandoc)
    --> E(hugo md)
    A -->|quarto render| E
    E -->|hugo serve| F(html)

Figure 1: Quarto & hugo workflow.

We first write some content, a quarto markdown file (*.qmd), that might contain diagrams (mermaid, graphviz, …) or python code. Next, quarto renders the file and generates output with the specified format. You can choose from many different output formats; we will need hugo-md, which is a hugo compatible markdown file.

You can then run quarto preview from the root path, which will run a hugo serve command for you and call quarto render on changes.

IDE

For VS Code you can install the Quarto extension. This gets you some basic shortcuts for running Quarto commands (e.g. quarto preview). Besides that, for me really useful is the preview functionality for diagrams. It lets you create mermaid diagrams or graphviz graphs on the fly and see the changes in a split screen live view !

Diagram preview

Deployment

For the deployment of this homepage, I use a simple Jenkins CI/CD Pipeline. Here CI means building the website html and CD means simply copying the html to a folder on the same machine that is served by nginx. When using quarto we have the additional quarto render step, that has to be executed before running hugo, which builds the html content. As described earlier we also need to install some binaries and a python environment to be able to run quarto render. Installing the required binaries into ${HOME}/bin on a CentOS machine could look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/bin/bash
# filename: install.sh

rm -rf ${HOME}/bin
mkdir -p ${HOME}/bin
mkdir -p ${HOME}/poetry

# install quarto
export QUARTO_VERSION=1.4.554

curl -o quarto.tar.gz -L \
    "https://github.com/quarto-dev/quarto-cli/releases/download/"\
"v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.tar.gz"

tar -zxvf quarto.tar.gz \
    --strip-components=1 \
    -C ${HOME}
rm quarto.tar.gz

# install uv
export UV_RELEASE=0.1.44

curl -o uv.tar.gz -L \
    "https://github.com/astral-sh/uv/releases/download/"\
"${UV_RELEASE}/uv-x86_64-unknown-linux-gnu.tar.gz"

tar -zxvf uv.tar.gz \
    --strip-components=1 \
    -C ${HOME}/bin

rm uv.tar.gz

# install hugo
export HUGO_RELEASE=0.108.0

curl -o hugo.tar.gz -L \
    "https://github.com/gohugoio/hugo/releases/download/"\
"v${HUGO_RELEASE}/hugo_extended_${HUGO_RELEASE}_Linux-64bit.tar.gz"

tar -zxvf hugo.tar.gz -C ${HOME}/bin
rm hugo.tar.gz

# install poetry
curl -sSL https://install.python-poetry.org | POETRY_HOME=${HOME}/bin/poetry python3 -

# make all binaries executable
chmod -R +x ${HOME}/bin

My current Jenkinsfile looks like this:

pipeline{
    agent any

    environment{
        // look at local installation of hugo first if a 
        // installation with the wrong version exists
        PATH="${HOME}/bin:${HOME}/bin/poetry/bin:${WORKSPACE}:${PATH}"
    }

    stages {
        stage('Update submodules') {
            steps{
                sh "git submodule update --init --recursive"
            }
        }   

        stage('Install binaries'){
            steps{
                sh'''#!/bin/bash
                chmod +x ./scripts/install.sh
                ./scripts/install.sh
                '''
            }
        }

        stage('Create python venv and install packages'){
            steps{
                sh'''#!/bin/bash
                # as required by pyproject
                uv venv --python 3.9
                source .venv/bin/activate
                poetry install
                '''
            }
        }

        stage('Build static HTML') {
            steps{
                sh'''#!/bin/bash
                set -x
                sed -i "s/{{COMMIT}}/${GIT_COMMIT:0:6}/g" config.toml
                sed -i "s/{{DATE}}/$(date '+%A %e %B %Y')/g" config.toml
                '''
                sh "rm -rf public"
                sh "poetry run quarto render && hugo --cacheDir $HOME/hugo_cache"
            }
        }   

        stage("Update HTML"){
            steps{
                sh'''#!/bin/bash
                set -x
                rm -rf /usr/share/nginx/html/*
                cp -r public/* /usr/share/nginx/html
                '''
            }
        }
    }        
}

The relevant parts are adding ${HOME}/bin to the $PATH, installing the binaries with our magic script, creating the venv with the correct python version, installing the python project and running the quarto render hugo workflow.

Python Example

Finally, because it is so much fun, I want to showcase a simple python example. It uses the fantastic tool polars. The data file lives in the root path of this post and is called clean_data.csv.

Note
Since polars is my newly loved ❤️ and absolute favorite data processing library (thanks to Ritchie Vink), I will hopefully find some time to write a few posts about this tool and how a data scientist can benefit from it.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import polars as pl
import hvplot

hvplot.extension("matplotlib")

df = (
  pl.scan_csv("clean_data.csv")
  .select(["year", "month", "stateDescription", "price", "sales"])
  .rename({
    "stateDescription" : "state"
  })
  .filter(pl.col("state").str.contains("^(A|B|C)"))
  .with_columns(
    date=pl.date(pl.col("year"), pl.col("month"), 1),
  )
  .group_by_dynamic(
    "date",
    every="60d",
    group_by="state",
  )
  .agg(
    total_sales = pl.col("sales").sum(),
  )
)
1
df.collect().plot.line(x="date", y="total_sales", by="state")
Tip

Since we are running a jupyter python kernel we are also allowed to run inline shell scripts with the %%sh cell magic !

1
2
3
4
5
6
7
%%sh
set -x
echo "Hello world from $(hostname) !"
pwd
ls -alh . | grep -i csv
curl wttr.in/Münster
set +x
++ hostname
+ echo 'Hello world from web-01 !'
+ pwd
+ grep -i csv
+ ls -alh .
+ curl wttr.in/Münster
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0100  8696  100  8696    0     0  43049      0 --:--:-- --:--:-- --:--:-- 43049
+ set +x

Hello world from web-01 !
/var/lib/jenkins/workspace/homepage_main/content/posts/quarto-intro
-rw-r--r--.  1 jenkins jenkins 5.0M Jul 13 07:16 clean_data.csv
Weather report: Münster

      \   /     Sunny
       .-.      +26(27) °C     
    ― (   ) ―   ↖ 9 km/h       
       `-’      10 km          
      /   \     0.0 mm         
                                                       ┌─────────────┐                                                       
┌──────────────────────────────┬───────────────────────┤  Sat 20 Jul ├───────────────────────┬──────────────────────────────┐
│            Morning           │             Noon      └──────┬──────┘     Evening           │             Night            │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│     \   /     Sunny          │     \   /     Sunny          │     \   /     Sunny          │     \   /     Sunny          │
│      .-.      +24(26) °C     │      .-.      29 °C          │      .-.      +29(31) °C     │      .-.      +24(26) °C     │
│   ― (   ) ―   ← 9-11 km/h    │   ― (   ) ―   ↑ 8-9 km/h     │   ― (   ) ―   ← 14-20 km/h   │   ― (   ) ―   ↖ 7-15 km/h    │
│      `-’      10 km          │      `-’      10 km          │      `-’      10 km          │      `-’      10 km          │
│     /   \     0.0 mm | 0%    │     /   \     0.0 mm | 0%    │     /   \     0.0 mm | 0%    │     /   \     0.0 mm | 0%    │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
                                                       ┌─────────────┐                                                       
┌──────────────────────────────┬───────────────────────┤  Sun 21 Jul ├───────────────────────┬──────────────────────────────┐
│            Morning           │             Noon      └──────┬──────┘     Evening           │             Night            │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│    \  /       Partly Cloudy  │  _`/"".-.     Patchy rain ne…│               Cloudy         │      .-.      Patchy light r…│
│  _ /"".-.     +24(26) °C     │   ,\_(   ).   +26(28) °C     │      .--.     22 °C          │     (   ).    20 °C          │
│    \_(   ).   ↗ 10-13 km/h   │    /(___(__)  → 10-13 km/h   │   .-(    ).   → 9-16 km/h    │    (___(__)   → 13-23 km/h   │
│    /(___(__)  10 km          │      ‘ ‘ ‘ ‘  9 km           │  (___.__)__)  10 km          │     ‘ ‘ ‘ ‘   9 km           │
│               0.0 mm | 0%    │     ‘ ‘ ‘ ‘   0.4 mm | 100%  │               0.0 mm | 0%    │    ‘ ‘ ‘ ‘    1.1 mm | 100%  │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
                                                       ┌─────────────┐                                                       
┌──────────────────────────────┬───────────────────────┤  Mon 22 Jul ├───────────────────────┬──────────────────────────────┐
│            Morning           │             Noon      └──────┬──────┘     Evening           │             Night            │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│               Overcast       │     \   /     Sunny          │    \  /       Partly Cloudy  │               Overcast       │
│      .--.     17 °C          │      .-.      22 °C          │  _ /"".-.     22 °C          │      .--.     20 °C          │
│   .-(    ).   → 15-20 km/h   │   ― (   ) ―   → 16-19 km/h   │    \_(   ).   → 11-17 km/h   │   .-(    ).   → 6-10 km/h    │
│  (___.__)__)  10 km          │      `-’      10 km          │    /(___(__)  10 km          │  (___.__)__)  10 km          │
│               0.0 mm | 0%    │     /   \     0.0 mm | 0%    │               0.0 mm | 0%    │               0.0 mm | 0%    │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
Location: Münster, Regierungsbezirk Münster, Nordrhein-Westfalen, Deutschland [51.9501317,7.61330165026119]

Follow @igor_chubin for wttr.in updates