Creating assignments and worksheets in Quarto and rendering them using Targets
In my teaching, I create worksheets and assignments for my students. These contain a number of questions based on a small number (usually two or three) scenarios. I like each scenario and its associated questions to live in a separate Quarto file (for ease of moving to another worksheet or assignment later, in case I don’t get as far in class as I had anticipated). I also want the worksheets to be available with and without solutions. (My students see a worksheet without solutions at their tutorial, and they get to try it with a TA available for help. After the tutorials are all done for the week, I post the solutions so that the students can see how they did).
This means that I need to navigate several things:
Let’s take these in turn. I’m going to create a baby worksheet called Worksheet 99 which has two scenarios with a couple of questions each, using one familiar and one possibly unfamiliar dataset.
The first scenario is based on the infamous mtcars
dataset. I put this in the file motor-trend.qmd
, which looks like this:
## Motor Trend cars
In 1974, the *Motor Trend* magazine collected data on fuel consumption
and other features of 32 different makes of car. The data are available
in the built-in dataset `mtcars`. The variables of interest to us are:
- `mpg`: fuel consumption in miles per US gallon
- `cyl`: number of cylinders in the engine
- `wt`: weight of car, in thousands of pounds.
(@) Make a suitable plot of fuel consumption against weight.
(@) Modify your plot to distinguish cars with different numbers of cylinders by colour.
This is not a self-contained Quarto file: it’s Quarto all right, but it’s designed to be included in another file (which it will be, later). In the Quarto documentation, they recommend giving a file like this a name with an underscore on the front, to make sure it doesn’t get rendered by accident (if, for example, the folder is a Quarto project and you render the whole folder). I’m going to control things with Targets, however, so I’m not going to worry about that.
The other notable feature here is how I label the two questions: the (@)
on the front, which will auto-number them from 1 upwards in the final worksheet.1
The second scenario is based on some data on making soap, which lives in soapy.qmd
:
## Making soap
A factory makes soap. There are two production lines, `a` and `b`.
These can be run at different speeds; running the production line faster
produces more soap, but it also produces more scrap (soap that cannot be
sold). Does the amount of scrap differ by production line? Answer the
questions below to find out. The data is in
<https://ritsokiguess.site/datafiles/soap.txt>.
(@) Read in and display some of the data.
(@) Make a suitable plot of the scrap produced and the production line. How do the production lines compare?
(@) Do you get a different story if you include speed in your plot?
This is structured the same way as the first file, and will have three numbered questions when it is rendered.
This, I have to admit, I stole more or less wholesale from Nicola Rennie, whose blog post you would do well to read. There are two key ideas:
In the YAML header of a Quarto document, you can have a section called params
which supplies some default values for parameters. The example in the Quarto documentation is this:
---
params:
alpha: 0.1
ratio: 0.1
---
which sets default values for the parameters alpha
and ratio
. You access them in the Quarto document through R like this, in an R code block:
params$alpha
You can supply different values by running quarto render
with the -P
option, like this:
quarto render myfile.qmd -P alpha:0.2 -P ratio:0.3
and then 0.2 and 0.3 will get passed down into your document.
Wait, you say, what YAML block? Neither of our files even have a YAML block. Well, when we get around to making the worksheet itself out of our two scenarios, we’ll have a proper “main” Quarto document that includes our two files, and not only will that have a YAML header with default parameter values in it, but also they will get passed down into our “child” documents with the scenarios in. The parameter we will use will be called hide_answers
and will be either true
or false
.
All right, now to conditional content. Here’s how you hide some content if you are creating an HTML document (from the Quarto docs):
::: {.content-hidden when-format="html"}
Will not appear in HTML.
:::
The :::
marks the beginning and end of a so-called “div block”. Inside the {}
on the top line is a class that the text has (being hidden) and an optional condition when it should be hidden (when the document format is HTML).
That’s all fine and wonderful, but we want to make our content hidden when something in R is true (namely, params$hide_answers
is TRUE). The way around this is to use inline R code to produce the top and bottom lines of our div block:
at the top, and
at the bottom. The way this works is if params$hide_answers
is TRUE, these lines create a div block with the content-hidden class (that is, the text between these two lines is hidden), but if params$hide_answers
is FALSE, no div block is created at all, and the text between these two lines is displayed.
Now we have the machinery to add some optionally-displayable text, that is to say, solutions, to our problems. What you do is to add the code that optionally starts the div block at the start of a solution, and the code that optionally ends it at the end. Thus, for example, the Motor Trend question file with solutions2 looks like this:
This process for adding solutions to a file of questions really ought to be called Renniefication.
Now that we have scenarios, questions and solutions, we can put together our Worksheet 99. This is how it goes together, with some comments below:
df-print: paged
to make dataframes (in the solutions) display nicely, and, as a pre-emptive strike, embed_resources: true
to make sure my output HTML doesn’t lose any of its graphs if the file gets moved around.params
block goes. I have one parameter here, the hide_answers
that I mentioned earlier, which I have set to true
here.include
Quarto “shortcode”. I think this is the cleanest way to do it, but you could also use R Markdown style “child documents” here. This works as if the file contents have been literally copied and pasted where the include
is, and has the effect that the parameters (that is, the value of hide_answers
) are passed down into the included files. When I refer to params$hide_answers
inside motor-trend.qmd
(as I did above), it uses the right value and correctly includes or excludes the solutions.This is, you might say, the end of the story. You render this file once with hide_answers: true
to make a worksheet to give to your students, and later you change true
to false
to make the solutions for them.
However, there is more human intervention here than you might like. Both the question document and the solutions document will be called worksheet_99.html
, and you’ll have to remember (or look) to find out whether it currently contains questions or solutions. It would be nice to make two html files, one with just the questions and the other with solutions as well, each with different names like worksheet_99_q.html
and worksheet_99_a.html
.
The other thing is how to keep everything up to date. If you change either of the included files, you want to be able to re-render worksheet_99.qmd
without having to remember to do so. Veteran Fortran programmers like me would solve this with a Makefile. The R way to do this is to use the targets
package, which we discuss shortly.
One way to supply parameter values is to put them in params
in the YAML block. But you can also supply them on the command line if you render that way, like this:
quarto render worksheet_99.qmd -P hide_answers:true
This puts the questions without solutions into worksheet_99.html
. But we can go one step further and set the name of the output file, like this:
quarto render worksheet_99.qmd -P hide_answers:true -o worksheet_99_q.html
Then, to make an HTML file with the solutions as well, you change hide_answers:true
to hide_answers:false
and change the -o
part to -o worksheet_99_a.html
.
There is enough repetitive stuff here that I wrote a function to do it:
renderify <- function(fname, ...) {
ans <- SplitPath(fname)
qq <- str_c(ans$filename, "_q.html")
aa <- str_c(ans$filename, "_a.html")
cmd <- str_c("quarto render ", ans$fullfilename)
cmd1 <- str_c(cmd, " -P hide_answers:true -o ", qq)
cmd2 <- str_c(cmd, " -P hide_answers:false -o ", aa)
olddir <- setwd(ans$dirname)
system(cmd1)
system(cmd2)
setwd(olddir)
fname
}
I wrote this just before reading Danielle Navarro’s excellent blog post on the fs
package, and now I realize that this would have been a great reason to learn about that package. I had, however, gotten this working using SplitPath
from the DescTools
package, so this is what you get. Also, I realize, now that I look at the code, that it would have benefitted greatly from using glue::glue
rather than str_c
from stringr
.3
Girt af.
Anyway: the function takes as input a filename (of a .qmd
file) and:
_q.html
and _a.html
onto the end of the base filenamequarto render
command. fullfilename
is the base filename plus its extension but not including its folder. This is important for reasons we see in a moment.quarto render
commands using the -P
and -o
options we saw above.So now I run cmd1
and cmd2
that I so laboriously constructed, right? Not so fast. When you run quarto render
from the command line, the file you’re rendering has to be in the same folder that you currently are. This is not usually the case for me: my project has worksheets and assignments in a subfolder assignments
, and when I am running targets
that is all controlled from the main project folder. So, very carefully, I change to the subfolder the .qmd
is in (saving my previous folder to go back to later), then run my commands, then go back to the folder I was in.
I hope in this way I am safe from having my computer set on fire, although I could undoubtedly stand to learn about the here
package too.
targets
The (very brief) idea behind targets
is that certain of your files (like documents) depend on certain other things (included files, here, or functions or datasets in general). The Targets book has a great intro walkthrough. What you do is to create “targets”, and then have functions that express how targets depend on each other. The definition of the targets lives in a file _targets.R
in the project folder. Here is the relevant bit of that:
worksheet99 <- list(
tar_target(worksheet_99_file, "assignments/worksheet_99.qmd", format = "file"),
tar_target(motor_trend, "assignments/motor-trend.qmd", format = "file"),
tar_target(soapy, "assignments/soapy.qmd", format = "file"),
tar_target(worksheet_99, renderify("assignments/worksheet_99.qmd",
worksheet_99_file,
motor_trend,
soapy))
)
My function renderify
is in a file R/functions.R
which has been source
d earlier in _targets.R
.
First, I create targets for each of my three files: the two question files, and the main worksheet file. Inside tar_target
, the first thing is the name of the target you’re making, the second is where the file lives, and the third thing is format = "file"
. Then, the last target is a function call. As we have seen, renderify
creates the two output files for the worksheet, but also serves the double duty of enforcing the dependency of the final worksheet on the targets made from the other three files. Targets knows this because those other three targets are also input to renderify
, so if any of those three targets have changed (meaning, any of the files from which those targets are made), the whole worksheet will be re-rendered. The relevant part of targets::tar_visnetwork
shows this: target worksheet_99
depends on targets worksheet_99_file
, motor_trend
, and soapy
.
The project from which the above was taken has targets for a whole bunch of worksheets, assignments, tests etc. If you look at targets
examples, you will usually see, at the end of the file, a list()
that defines every single one of the targets. But for me, this was getting out of hand, so I defined and saved a separate list()
for each worksheet or assignment, and then at the end of my _targets.R
, I glue them all together into one list with some code like this:
c(worksheet1, worksheet2, worksheet3, worksheet4,
worksheet5, worksheet6, worksheet7, worksheet8,
worksheet9, worksheet99)
Now, I can run targets::tar_make()
and my worksheet will be re-rendered (twice, to get the two output files) if and only if any of the files making it up have changed.
You might be thinking that renderify
doesn’t need those other inputs, and you would be quite right: the only thing the function uses is the filename that is the first input. I used ...
in my function code to allow arbitrary many other inputs, and these are used only to create the dependency, so that Targets knows what depends on what. This is the cleanest way I could think of.
So maybe now you want to see the result of all of this:
For attribution, please cite this work as
Butler (2024, Nov. 3). Ken's Blog: Targets for assignments and worksheets. Retrieved from http://ritsokiguess.site/blogg/posts/2024-11-02-assignments-worksheets-and-targets/
BibTeX citation
@misc{butler2024targets, author = {Butler, Ken}, title = {Ken's Blog: Targets for assignments and worksheets}, url = {http://ritsokiguess.site/blogg/posts/2024-11-02-assignments-worksheets-and-targets/}, year = {2024} }