---
title: "Translating learnr Tutorials"
author: David Kane
vignette: >
  %\VignetteEngine{quarto::html}
  %\VignetteIndexEntry{Translating learnr Tutorials}
  %\VignetteEncoding{UTF-8}
---

This vignette is the mechanics reference for **learnr2** tutorials. It covers how a classic [**learnr**](https://rstudio.github.io/learnr/) or [**tutorial.helpers**](https://ppbds.github.io/tutorial.helpers/) tutorial turns into a **learnr2** one, and the rules every **learnr2** tutorial follows whether it was translated or written fresh. It assumes you have read [Tutorials in the Age of AI](ai.html), which covers what a tutorial is *for* and how it should teach. This one covers how the file is put together so that it renders and runs.

The single most important difference is that a **learnr2** tutorial needs nothing installed to view. There is no Shiny app and no R server. A tutorial renders once to a static HTML page, and its code cells run in the reader's browser via WebR. Most of the rules below follow from that.

## Where things go

Classic tutorials live at `inst/tutorials/<name>/tutorial.Rmd` in their package. Translate one into `inst/tutorials/<name>/<name>.qmd`, either in **learnr2** itself for a bundled tutorial or in a separate content package that depends on **learnr2**. `create_tutorial()` also writes `<name>.qmd`. A file named `tutorial.qmd` works too, because `available_tutorials()` and `run_tutorial()` take the first `.qmd` in the directory, but prefer `<name>.qmd`.

Keep the source's `images/` and `data/` directories next to the `.qmd`, exactly as they were. Relative paths resolve against the `.qmd`'s own directory.

## Quick reference

| learnr or tutorial.helpers source | learnr2 equivalent |
| --- | --- |
| `output: learnr::tutorial`, `runtime: shiny_prerendered` | `format: live-html` and `engine: knitr` |
| `library(learnr)` or `library(tutorial.helpers)` in a setup chunk | delete it, and call functions as `learnr2::fn()` |
| `knitr::opts_chunk$set(echo = FALSE)` in setup | `#| echo: false` on every widget chunk, never a document-wide setting |
| the `info_section.Rmd` child document | a chunk calling `learnr2::student_info()` |
| the `download_answers.Rmd` child document | a minutes `question()` chunk and a `learnr2::download_answers_button()` chunk |
| an `exercise = TRUE` chunk | a `{webr}` cell with `label`, `exercise` and `persist: true` |
| an `ex1-setup` chunk | a `{webr}` cell with `setup: true` and the same `exercise`, or the setup inlined |
| `ex1-hint`, `ex1-hint-1`, ... chunks | `.hint` fenced divs |
| an `ex1-solution` chunk, ungraded | a `.solution` fenced div |
| an `ex1-solution` chunk, graded | a `{webr}` cell with `solution: true` |
| an `ex1-check` chunk with **gradethis** | a `{webr}` cell with `check: true`, plus the `_gradethis.qmd` include |
| `question("...", answer("a", correct = TRUE))` | `learnr2::question("...", learnr2::answer("a", correct = TRUE))` |
| `question_text(...)`, with or without a model answer | `learnr2::question(..., type = "reflection")` |
| `question_numeric(...)` (the minutes question) | `learnr2::question(..., type = "reflection_editable", validate = "integer")` |
| a bare `###` divider | keep it, since it becomes a Continue stop with no heading |
| `allow_skip: yes` in the YAML | `learnr2::tutorial_options(allow_skip = TRUE)` |
| `knitr::include_graphics("images/x.png")` | `![](images/x.png)` |
| prose sending the reader to RStudio or a local console to configure it | rewrite around the page, or drop it (see below) |

The sections that follow expand on the rows that aren't obvious.

## The YAML header

Replace the **learnr** header:

```yaml
---
title: Some Title
output:
  learnr::tutorial:
    progressive: yes
    allow_skip: yes
runtime: shiny_prerendered
---
```

with:

```yaml
---
title: "Some Title"
subtitle: "A short description, if the source had one"
format: live-html
engine: knitr
toc: true
---
```

Drop `tutorial: id:`, `output:`, `runtime:`, `progressive:` and `allow_skip:` entirely. **learnr2** reveals sections progressively on its own. If the source had `allow_skip: yes` and you want to keep that behaviour, see *Sections and the Continue button* below. Update `title:` and `subtitle:` so neither still describes content you have changed, such as "Tutorials in RStudio" for a tutorial that no longer needs RStudio.

Immediately after the header, include the quarto-live runtime:

````{verbatim}
{{</* include _extensions/r-wasm/live/_knitr.qmd */>}}
````

Add `{{</* include _extensions/r-wasm/live/_gradethis.qmd */>}}` as well if any exercise is graded.

## Setup chunks and boilerplate

Delete the **learnr** setup chunk, with its `library()` calls and `knitr::opts_chunk$set()`. **learnr2** functions are called with the `learnr2::` prefix instead.

The two **tutorial.helpers** child documents become plain chunks. At the top, right after any introductory prose:

````{verbatim}
```{r}
#| label: student-information-1
#| echo: false
learnr2::student_info()
```
````

At the bottom:

````{verbatim}
```{r}
#| label: your-answers-1
#| echo: false
learnr2::question(
  "How many minutes, approximately, did it take you to complete this
  tutorial? For example, an hour and a half would be 90 minutes.",
  type = "reflection_editable",
  validate = "integer"
)
```

```{r}
#| label: your-answers-2
#| echo: false
learnr2::download_answers_button(filename_prefix = "<name>")
```
````

**learnr2** has no numeric question type. `validate = "integer"` blocks Submit until the response is a whole number, without grading it against any one value, since any honest count is acceptable. There is no `answer()` because there is no correct number of minutes. This is the one question that stays `reflection_editable`, because there is no right answer to copy. After Submit it locks behind an Edit button, and Edit reopens it until the next Submit, the same cycle as `student_info()`.

## Chunk labels

Every chunk needs a unique `#| label:` on its own line inside the chunk, never the old inline `{r some-label}` form. That applies to `{r}` chunks and to every `{webr}` cell, including plain demonstration cells.

For a question, the label is load-bearing. With no explicit `id =`, a question's id is its chunk's label, and that id is the key its saved answer is stored under and the name it appears under in the downloaded file. Renaming a question's chunk resets that question for every student.

Follow the **tutorial.helpers** convention. Take the enclosing `##` heading, lowercase it, collapse spaces and punctuation to single dashes, drop any leading number such as the `6.` in `## 6. Quiz questions`, and append a dash and a sequence number that restarts at 1 in each section. `## Quiz questions` gives `quiz-questions-1`, `quiz-questions-2`, and so on. Count every labelled chunk in the section in document order, `{r}` and `{webr}` alike. One variant: the "our answer" chunk that follows an exercise's question takes the question's label plus `-answer`, as in `introduction-3` and `introduction-3-answer`, so exercise numbers and label numbers stay in step.

## Hide the source of widget chunks

The **learnr** setup chunk you deleted almost always set `echo = FALSE` for the whole document. Without it, every `{r}` chunk shows its own source above its output, so the reader sees the R call that builds each question instead of just the question.

Add `#| echo: false` to each chunk that renders a widget: `student_info()`, `question()`, `quiz()`, `download_answers_button()` and `tutorial_options()`. Don't use a document-wide `execute: echo: false` in the YAML instead. `{webr}` cells are a pass-through engine that re-emits its own source as output by design, and a global override isn't verified safe for them.

## Exercise cells

A **learnr** exercise chunk:

````{verbatim}
```{r exercise-1, exercise = TRUE}
```
````

becomes a `{webr}` cell with three options:

````{verbatim}
```{webr}
#| label: functions-and-packages-2
#| exercise: ex_load_tidyverse
#| persist: true
______
glimpse(mtcars)
```
````

- `label` follows the convention above.
- `exercise` is any stable id. The bundled tutorial uses snake case, like `ex_total`, to keep it visibly distinct from the dashed label.
- `persist: true` saves the reader's typed code in their browser so it survives a reload and appears in the downloaded answers. Without it, their code is saved nowhere.

Keep whatever starter code the source had, whether empty, a leading comment, or **tutorial.helpers**' `____` blanks. Drop chunk options that mean nothing in the browser: `exercise.lines`, `exercise.timelimit`, `exercise.cap`, `exercise.df_print`, `exercise.blanks`, `message` and `warning`.

### Exercise setup

A **learnr** `ex1-setup` chunk has two translations. Either use a `{webr}` cell with `setup: true` and the same `exercise` id, which runs invisibly before the reader's code:

````{verbatim}
```{webr}
#| label: analysis-3
#| setup: true
#| exercise: ex_summarise
library(dplyr)
flights <- nycflights13::flights
```
````

or put the setup lines at the top of the exercise cell itself, which is simplest for a line or two and keeps the cell self-contained.

Whichever you choose, remember that `{webr}` cells do not share state. Each exercise runs in its own environment, separate from every other exercise and from any earlier demonstration cell, and a reload wipes all of it. Anything an exercise needs must come from its own setup cell or its own body.

### Hints and solutions

A hint chunk becomes a fenced div tied to the exercise id:

````{verbatim}
::: { .hint exercise="ex_load_tidyverse" }
Some hint text, or an inline code sample.
:::
````

Numbered hints (`ex1-hint-1`, `ex1-hint-2`, ...) become several consecutive `.hint` divs with the same `exercise`, which quarto-live reveals one at a time. A model answer becomes a `.solution` div, and its inner ` ```r ` block is required:

````{verbatim}
::: { .solution exercise="ex_load_tidyverse" }
```r
library(tidyverse)
glimpse(mtcars)
```
:::
````

**A graded exercise is the exception.** If the exercise also has a `check: true` cell, its solution must be a `{webr}` cell with `solution: true`, not a `.solution` div:

````{verbatim}
```{webr}
#| label: analysis-4
#| exercise: ex_load_tidyverse
#| solution: true
library(tidyverse)
glimpse(mtcars)
```
````

Only the cell form produces the hidden copy of the code that the grader compares against. With the div, submission fails with "No solution code was found." The cell still shows the reader a "Show solution" button.

### Grading

Translate a `check` chunk that uses **gradethis**, such as `gradethis::grade_this_code()`, into a `{webr}` cell with `check: true` and the exercise's id. Add the `_gradethis.qmd` include after `_knitr.qmd`, and give the exercise a `solution: true` cell as just described. If the source check is trivial or absent, leave it out. An exercise doesn't need grading to be useful.

## Questions

**learnr** questions become `learnr2::question()` calls in a `{r}` chunk, with `learnr2::answer()` for each answer and `learnr2::quiz()` to group them. The chunk runs once, when the tutorial is rendered, not once per reader. These arguments carry over unchanged: `allow_retry`, `random_answer_order` for choice questions, `correct` and `incorrect`.

- **A choice question**, built from `answer(..., correct = TRUE)` options, keeps the default `type = "auto"`. It becomes single or multiple choice by how many answers are correct. Use `type = "text"` for a typed exact-match answer.
- **`question_text()`**, the **tutorial.helpers** workhorse for both "explain in your own words" and CP/CR evidence questions, becomes `type = "reflection"`. Its correct answer's text, if it has one, is revealed as a model answer after the reader submits. It is never graded against their wording. Drop the options with no equivalent: `try_again_button`, `rows`, `options`, `trim` and `placeholder`.
- **`question_numeric()`** becomes `type = "reflection_editable"` with `validate = "integer"` and no `answer()`, as in the minutes question.
- **Custom `answer_fn` checkers**, regular expressions and partial credit have no equivalent. Reduce them to plain `answer()` matches, or to a `"reflection"` question if the point was open-ended.

Use `"reflection"`, not `"reflection_editable"`, for the old `allow_retry = TRUE, try_again_button = "Edit Answer"` idiom. **learnr2** locks every submitted answer by default, and [Tutorials in the Age of AI](ai.html) explains why: we show our answer right after the reader's, and an editable box next to it invites copying.

A `question()` always carries its prompt in `text`. That string is shown in the question box, saved with the answer, and printed beside it in the downloaded file. Where **tutorial.helpers** put the prompt in prose above a `question_text(NULL, ...)` chunk, move the question into `text` and keep any longer set-up prose above the chunk.

Choice and `"text"` questions, and `quiz()`, suit mechanics tutorials and feature tours. A normal data-science tutorial teaches concepts through exercises and knowledge drops rather than quizzes, so expect most translated questions to be `"reflection"`.

### Screenshot questions

`learnr2::question(type = "reflection", allow_image = TRUE)` lets the reader paste a screenshot as their answer. Use it in four cases, and convert the source's question whenever one applies:

1. **The student would copy text out of a web page.** **tutorial.helpers** tutorials often say "copy everything from X through Y on this page" and take a pasted mess of text, as the website tutorials in **vscode.tutorials** do for the student's own rendered pages. Ask instead for a screenshot of the relevant part of the page. The screenshot shows what the student actually saw, layout included, and is easier to check.
2. **The student produces an image,** usually a plot. Classic tutorials verify a plot exercise with `show_file()`, which proves the student has *code*, not that it drew the right picture. Ask for a screenshot of the plot instead, as in the plotting template in [Tutorials in the Age of AI](ai.html). This is very common in **misc.tutorials**. Keep `show_file()` for exercises whose result is text, such as a printed tibble or summary statistics.
3. **Nothing else can verify the step,** such as being signed in to an account or having a particular window open.
4. **The student carries out several small steps in a row.** Turning each one into its own question is sometimes more than the steps deserve. If every step is necessary, give the steps as plain instructions and end with a single screenshot of something that only looks right if all of them were done correctly. For example, after "create the repo, add a `.gitignore`, commit and push", ask for a screenshot of the repo's page on GitHub showing the `.gitignore` file. Pick the screen so a skipped or botched step shows: if the screenshot would look the same without one of the steps, that step needs its own check. When translating, use this for a run of steps the source left unchecked. Don't merge questions the source already asks separately, since that pacing was the author's choice.

When you convert a question, rewrite the prose around it to match. An instruction that says "copy" or "CP/CR" should now say what to screenshot. Any sentence explaining that pasted text may look messy goes, since it no longer applies. Keep the source's own answer, if it had one, as the answer shown after Continue: a screenshot of the expected page, or our answer chunk drawing the expected plot.

The first screenshot question a student meets should say how to take a screenshot and how to paste it: `Cmd + Shift + 4` on a Mac, `Win + Shift + S` on Windows, then click in the answer box and press `Ctrl + V` (`Cmd + V` on a Mac). Later ones can just ask for the screenshot.

Ask for one specific screenshot, never a hedged choice. "Paste a screenshot of your GitHub dashboard" works. "Paste a screenshot showing you're signed in, for example your profile page or the account menu" leaves the reader guessing which one counts. If it isn't obvious why that screen proves the step, say so.

If the prompt reads better as ordinary text on the page, write it there and pass `show_text = FALSE`. Still repeat the wording in `text`, which is kept in the saved data and read to screen-reader users.

## Images

Replace `knitr::include_graphics()` chunks with plain Markdown, `![](images/thing.png)`, optionally with `{width=90%}`. There is no chunk to label or hide.

## Sections and the Continue button

**learnr2** reveals a tutorial one section at a time. Every `##` and `###` heading becomes a stop behind a Continue button, and you add nothing to opt in. A Continue button always sits at the end of something to read, never directly under a heading: clicking it reveals the next heading together with the text that follows it, which may or may not include the next exercise. In particular, a `##` heading followed immediately by a bare `###`, the way **tutorial.helpers** opens every topic, shows the heading and that first block together. Some details for translations:

- **Keep every bare `###` divider.** A line holding only `###` becomes a Continue stop with no visible heading and a button that just says "Continue". **tutorial.helpers** puts two inside every exercise, one before the author's answer and one before the knowledge drop, and both pauses survive the translation.
- **Delete a titled heading that exists only as a pacing break,** with no content of its own, and fold its prose into the enclosing section.
- **`### Hints` and `### Solutions` subsections** that only wrap a `.hint` or `.solution` div are not separate stops. They appear with the exercise they belong to.
- **The table of contents can't be used to read ahead.** Entries for sections the reader hasn't reached are dimmed and do nothing when clicked. `learnr2::tutorial_options(allow_skip = TRUE)` restores **learnr**'s `allow_skip: yes`, where clicking an entry unlocks everything up to it.
- **Continue waits for answers.** A section's Continue button is disabled until every question and `student_info()` form above it has been submitted. `learnr2::tutorial_options(require_submission = FALSE)` turns that off, which suits reference material.
- **`toc: false` is supported.** Without a sidebar, the Start Over button moves to the top of the tutorial, under the title.
- **Links that leave the page open in a new tab,** so following one never makes the tutorial disappear. Write ordinary Markdown links. Nothing is needed to opt in.

Call `tutorial_options()` at most once, in its own chunk with `#| echo: false`.

## Packages, and code that runs at render time

The `webr:` block in the YAML header is the only place WebR learns what to install in the reader's browser. List every package that any `{webr}` cell uses, whether exercise, setup, check or demonstration. An exercise that calls `library(dplyr)` or `nycflights13::flights` needs both packages listed:

```yaml
webr:
  packages:
    - dplyr
    - nycflights13
```

A `{r}` chunk is different. It runs once, when the tutorial is rendered on your machine or in CI, never in the reader's browser. That gives it two legitimate jobs: rendering a **learnr2** widget, and showing *our answer*, an `#| echo: true` chunk that runs real code so the reader can compare it with their own. Every package such a chunk uses must be installed wherever the tutorial is rendered, which for a content package means listing it under Suggests. Such chunks must not reach for the network. Never use a `{r}` chunk to set up objects for a `{webr}` cell, since the browser never sees what a `{r}` chunk did.

## The page needs nothing installed

A **learnr2** tutorial page needs no local R, no RStudio, and no R package beyond those listed for WebR. A translation can get every mechanical rule right and still break this, by leaving prose that sends the reader to "the R Console" to run something like `tutorial.helpers::set_rstudio_settings()` or `rstudioapi::readRStudioPreference()`. A reader who opened the tutorial from a link, with nothing installed, hits a wall there.

When translating, look for and rewrite or remove anything that assumes:

- A separate, locally running R session the reader switches to, as opposed to a `{webr}` cell on the page.
- Any R package beyond base R and the `webr:` list being available on the page.
- RStudio itself, or its menus and settings.
- A manual procedure for restarting the tutorial from scratch. Every **learnr2** page has a Start Over button that clears everything, so point to it instead.

If the source tutorial's whole point was configuring a local R installation, it has no **learnr2** translation. Drop it rather than reproduce commands the reader can't run.

This is a rule about the *page*, not about the student. A normal AI-era tutorial has the student work in their own repository and Quarto document, run `show_file()` in their own R Terminal, and paste the result into a `"reflection"` question. That is the evidence model in [Tutorials in the Age of AI](ai.html), and the page itself still needs nothing installed. Note that `learnr2::show_file()` is for that kind of local session. It isn't available inside a `{webr}` cell, so changing `tutorial.helpers::show_file()` to `learnr2::show_file()` doesn't fix prose that tells a reader to run it on the page.

## Showing syntax without running it

To show an R chunk literally, as this vignette does, wrap it in a four-backtick block marked `{verbatim}`.

Inline code is harder. Writing <code>&#96;r x&#96;</code> in a sentence runs it, and wrapping it in an extra pair of backticks doesn't help, because Quarto still evaluates it. Spell the backticks as HTML entities inside a `<code>` tag instead:

```
<code>&#96;r x&#96;</code>
```

That renders as the literal syntax, because there is no backtick in the source for Quarto to match.

## Check it by rendering it

Syntax that looks right can still be wrong in ways only the rendered page shows, such as R source printed above a question. After translating, run the static checks and a real render:

```r
learnr2::check_tutorial("inst/tutorials/<name>")    # labels, echo, persist, boilerplate, packages
learnr2::render_tutorials("inst/tutorials/<name>")  # a real Quarto render
```

`check_tutorial()` catches a missing label, `echo: false` or `persist: true`, a graded exercise with a `.solution` div, a `{webr}` package missing from the `webr:` list, and missing boilerplate. Then open the rendered HTML in a browser and click through it.

`run_tutorial()` reads the *installed* copy of a package, not your working files. While editing a content package, load it with `devtools::load_all()` first, and `run_tutorial()` and `available_tutorials()` will read its tutorials from the source tree. Don't commit render artifacts such as `_extensions/`, `*.html`, `*_files/` and `.quarto/` alongside the `.qmd`, because `run_tutorial()` adds the extension and renders on demand.

A content package should test every tutorial from `tests/testthat/test-tutorials.R`:

```r
tutorials <- learnr2::available_tutorials(package = "<your package>", type = "quarto")
learnr2::check_tutorial(tutorials$path)
learnr2::render_tutorials(tutorials$path)
```

Guard that test with `testthat::skip_on_cran()` and `testthat::skip_if(is.null(quarto::quarto_path()))`, since it needs the Quarto command line tool.

## One learnr2 tutorial in a classic learnr package

A single **learnr2** `.qmd` can live in a package whose other tutorials are classic **learnr** `.Rmd` files. **learnr2**'s own functions scan every tutorial directory and prefer `.qmd`, so `learnr2::run_tutorial("<name>", package = "<host package>")` finds it, renders it and serves it. `R CMD check` on the host package treats the file as data and ignores it.

Classic tooling, though, only ever looks for `.Rmd`:

- `learnr::available_tutorials()` doesn't list a directory holding only a `.qmd`, so `learnr::run_tutorial()` can't launch it.
- `tutorial.helpers::return_tutorial_paths()` keeps only `.Rmd` files, so a host package whose tests render tutorials through it neither renders nor fails on the `.qmd`. It simply goes untested.

So, when you do this, also add **learnr2** to the host's Suggests (and to `Remotes` if it isn't on CRAN), add `inst/tutorials/*/_extensions/` and `.quarto` to `.Rbuildignore` and `.gitignore`, and give the `.qmd` its own test, as shown in the previous section.
