I wanted to share how I integrated Quarto into my website, so I’m going to do a series of posts on this topic. I am not an aspiring front-end developer so I kept things simple to achieve my goal of building a functional website with Quarto and Flask where I can create interesting technical content. I spent some time understanding Quarto’s HTML rendering and figuring out how to integrate it into my site, so I wanted to document my process in case it can help someone out there!
About Quarto
For those who are not familiar, Quarto is a program devoted to publishing academic or technical content in a variety of formats such as websites/blogs, dashboards, reports, and books. Quarto features powerful integrations including support for publishing dynamic Python content. Source content is generated with Pandoc markdown and stored as .qmd files that can be rendered to multiple formats such as PDF, HTML, etc.
For example, here’s a cool graph I can create and execute on the fly.
Since I wanted to build unique web pages such as calculator tools, I did not want to use a basic Quarto website. If you want to use Quarto with your own custom website it requires some housekeeping to integrate the rendered .html files.
Quarto natively supports pre-render and post-render Python scripts, including environment variables based on your Quarto project that allow for relatively straightforward integration with a custom website. These scripts are run every time you render. This post will focus on changes introduced in my post-render script, post-render.py, to integrate Quarto outputs with my site.
Quarto has a lot of options for styling including custom CSS themes and customization of themes using Sass. Maybe I will dive into this at some point in the future but currently I am on easy mode as far as styling. I use a preconfigured Bootswatch theme for my site and I’m able to render Quarto content to that theme very simply in my project configuration as you will see.
I keep all my Quarto source content in the quarto_posts directory; this is also my Quarto project root directory. I keep it simple by using one /images child directory that holds all images loaded in .qmd documents. This content is within my Flask /app directory, but it is ignored in the .git repository. I have a separate .git repository for my Quarto source content.
Quarto allows you to specify an output directory in a project _quarto.yml file stored within the project root directory. I set my output directory to /app/templates/posts since Flask expects all .html pages to be in /templates. This configuration file also allows you to set project-wide defaults that can be overridden for each post. Additionally, this is where pre- and post-render scripts are defined.
Note how simple it is to leverage Quarto’s built-in support for Bootswatch themes in the last line above.
Post-Render: Integrating the Structure
The first problem I came across when rendering files to the output directory is that the output includes a folder with static files for each .html file. Flask expects only .html files in its /templates directory and has a separate /static directory for static files, so I need to move these files. This folder has the same name as the .html file, but adds _files to the end. For example, post1.html has a corresponding post1_files directory as shown below.
The contents of each _files directory are specific to each post; however, so far I have found the libs files are all identical for every rendered HTML document (so long as your rendered stylings are the same, I guess). The figure-html directory contains images that are generated by code such as Python-based plots.
Using the post1.html example, the first task for a post-render.py script is moving all post1_files to the appropriate directory. The subfolders within post1_files can be copied to a destination directory shared for all posts, app/static/posts. Merging all posts’ static content into one directoroy is acceptable for my purposes.
Quarto provides some environment variables exclusively to the pre-render and post-render scripts. I can read the output directory and a list of output files with these environment variables for use in my post-render script.
Code
# list of files rendered output_files = os.getenv('QUARTO_PROJECT_OUTPUT_FILES').split('\n')# directory of rendered files output_dir = os.getenv('QUARTO_PROJECT_OUTPUT_DIR')
Here’s how I move the static files which are in a unique per-post directory (i.e. post1_files):
import osdef cut_paste_dir(source_dir, destination_dir):""" Recursively moves all contents from source_dir to destination_dir. Keeps existing content in destination_dir and overwrites files with the same name. If destination_dir does not exist, it is created. """ifnot os.path.exists(destination_dir): os.makedirs(destination_dir) # Create the destination directory if it doesn't existfor item in os.listdir(source_dir): source_item = os.path.join(source_dir, item) destination_item = os.path.join(destination_dir, item)if os.path.isfile(source_item):if os.path.isfile(destination_item): os.remove(destination_item) # Overwrite files with the same name shutil.move(source_item, destination_item) # Move the itemelif os.path.isdir(source_item): cut_paste_dir(source_item, destination_item) # Remove existing directory with the same name os.rmdir(source_dir) # Remove the source directory if it's empty
Here is an abbreviated tree for this Flask website /app directory after moving the post1_files folder structure to static/posts:
You may have noticed an additional posts/images directory. Images that are used in posts and stored in quarto_posts/images are also copied to the Quarto output directory (i.e templates/posts/images) upon rendering, so these need to get moved by the post-render script as well.
Image files are shared across posts so I can move them in one line.
After cleaning up the directories, the next step I will cover is to cleaning up links within HTML outputs.
Post-Render: Fixing Quarto HTML Links
Links to all static files in Quarto-generated HTML files have to be updated. These includes lines of HTML for styling for every file. For example, these links from a Quarto demo were all updated in my post-render script:
Fixing these static links is relatively straightforward (this is done per output file):
Code
# replace quarto static file location with Flask location updated_content = content.replace(f'{file_name_files_dir}', f'{posts_static_dir}')
Images included in Quarto post content have to be addressed separately since they are not part of the static directory for rendered HTML. This part is a bit more involved since only images from quarto_posts/images should get updated paths, not just any image file. This function needs to update the first line below (Quarto-rendered HTML) to the second line:
Note: I have used and will use {[ and ]} as my Jinja block characters within code blocks rather than double curly braces to avoid activating Jinja templating within posts.
I don’t plan on changing the directory structure except for in a significant overhaul, so I simply include the string literal images knowing this folder will be in my quarto_posts directory. Here’s the function I used to achieve this update to Quarto image links:
Code
def update_image_links(content): image_link_template ="{[ url_for('static', filename='') ]}" updated_content = content.splitlines()# match image links from quarto postsfor index, line, inenumerate(content.splitlines()):if line.startswith('<img'):print(f'image line found: {line}')# regex requires no spaces or double quotes in filenames match = re.search(r'src="(images/.*)" ', line)if match: src = match.group(1)print(f'match found: {src}') updated_src = image_link_template.replace("''", "'"+'posts/'+src+"'") updated_line = line.replace(src, updated_src)print(f'updated image line: {updated_line}') updated_content[index] = updated_line updated_content ='\n'.join(updated_content)return updated_content
In the function above, I find the string starting with src="images\, but I only get the portion of the string within the perenthesis per the regular expression on line 9, which is just the string that defines the file location. Then I can insert this location into my new image link template.
Post-Render: Merging Quarto Styling
The last topic to cover for integration of the Quarto render is the styling. First, I separated my Navbar from base.html and create a separate _navbar.html so that my Quarto posts can inherit this without disrupting other settings.
Despite having the same Bootswatch theme as my Quarto project, my Navbar font sizes and colors were different on my Quarto posts. After some investigating, I found I am able to apply a style override from my Flask site styling to the Quarto .html files. This saved me some time since I didn’t have to deep dive into how the JavaScript/CSS/HTML was working within the Quarto .html files.
In the end, I ended up with two code blocks I needed to insert into each .html post to impose my site’s Navbar and certain style characteristics that fix the discrepancies onto the rendered files. With Jinja templating I only need to insert two lines, each representing a file with the needed inclusions. Here’s the change:
Code
def apply_flask_styling(content):# define Jinja template lines include_navbar ='{[ include "_navbar.html" ]}' include_style_overrides ='{[ include "_post_styling.html" ]}' updated_content = content.splitlines()# insert Jinja template linesfor index, line, inenumerate(content.splitlines()):# assume styling appears first, insert style edits before existing quarto stylingif line.startswith('<style'): updated_content.insert(index, include_style_overrides) styling_inserted =True# insert navbar after body, with one offset for added style lineif line.startswith('<body') and styling_inserted: updated_content.insert(index +2 , include_navbar) navbar_inserted =Truebreakifnot styling_inserted:raiseValueError("No <style> tag found in the file.")ifnot navbar_inserted:raiseValueError("No <body> tag found in the file.") updated_content ='\n'.join(updated_content)return updated_content
Now my posts’ styling blend seamlessly with my standard Flask Bootswatch theme (Cosmo).
Summary
You can find the post-render.py file here that brings all these steps together. With these post-render modifications and the pre-render integrations, all I have to do to integrate a new post into my site is run quarto render in my Quarto project directory.
That’s it for the changes that are made to the output files. Part 2 of this series will explore the pre-render.py script and how that was used to integrate the Quarto content into the Python structure of Flask.