Skip to content

Prototype single files for web and slides - #7

Draft
willgearty wants to merge 15 commits into
mainfrom
single-file-prototype
Draft

Prototype single files for web and slides#7
willgearty wants to merge 15 commits into
mainfrom
single-file-prototype

Conversation

@willgearty

@willgearty willgearty commented Jul 14, 2026

Copy link
Copy Markdown
Member

This introduces some infrastructure to make a single longform module quarto file render well for both a webpage (html) and for slides (revealjs). At the moment it uses no external extensions.

There are two components:

  1. web_and_slides.R: this script can be used to convert a standard longform quarto file to one that supports webpage/slides rendering
    • wraps each paragraph in ::: {.narration}, which is treated as notes for revealjs and as normal prose for html
    • adds the web_and_slides.lua filter to the yaml front matter if it isn't there (see # 2 below)
    • adds execute: output-location: fragment and execute: echo: true to the yaml front matter; this ensures that code chunk output is included in the slides and is added as fragments (separate slides) by default
    • adds revealjs.smaller = true to the yaml front matter: this ensures that figures shrink to fit on slides
    • throws a report of any headings that will end up as awkward in the slides (e.g., a # header with text after it)
    • the output can be the same as the input or they can be different (in case you want to keep the original, like in this PR as an example)
    • lives at the root of the repo
  2. web_and_slides.lua: contains various filters and processing steps that alter the way the quarto file is rendered to slides
    • handles the treatment of .narration (notes for slides, prose for webpage)
    • headings below the slide level (default is ##) are promoted so each becomes its own slide
    • back-to-back code chunks are brought in one-by-one on separate slides (like the fragment functionality)
    • back-to-back plots are split to separate slides (the prose/notes before each plot are kept with the plot)
    • callouts are converted to separate slides
    • ideally lives within each module folder (could also theoretically live at the root, but would need to be copied for each archived folder for reproducibility)

So the general workflow would look something like this:

  1. User makes original longform quarto document (as if they were making a teaching webpage)
  2. User runs the quarto document through web_and_slides.R
  3. User locally previews the rendering for both webpage and slides
  4. User removes .narration wherever text should be shown on slides verbatim
  5. User adds additional speaker notes as needed (these are never shown on the webpage format)
  6. User further adjusts the content with conditional content as needed
  7. User makes small finishing touches for either format (e.g., https://emilhvitfeldt.github.io/quarto-revealjs-editable/)

Rendered webpage:
https://palaeoverse.org/modules/pr-preview/pr-7/training/modules/ggplot/index.html

Rendered slides:
https://palaeoverse.org/modules/pr-preview/pr-7/training/modules/ggplot/index-slides.html

TODO:

  • demonstrate removing some .narration usage
  • demonstrate extra speaker notes
  • demonstrate conditional content
  • demonstrate slide formatting
  • fix reported headers

Note that the script and .lua filter were generated iteratively over the course of a substantial conversation with Claude. The original module file was made entirely by me, and I've reviewed all of the code along the way.

@github-actions

Copy link
Copy Markdown

@etiennebacher

etiennebacher commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary: I adapted the Lua filter and tried it on the draft module on "browser() and reprex()", and it seems to work fine. My main concern is that it makes index.qmd quite harder to read (I expand on this and provide an example below). Also, I think there are things to improve in the workflow to make this both easier to use and more flexible.

I'm curious to see how this will work in practice in the long run, but it's true there's a risk of drift between the written content and the slides, so it is nice to have everything in a single file. I thought theming and options would be an issue but it's easy to have format-specific options in the YAML.

What I'd do:

  • add {.slides} to have text that should be on slides only
  • rename {.narration} to {.long-format} so that the distinction with {.slides} is clearer

Workflow complexity

The workflow seems pretty complicated to me. In particular, I don't understand the purpose of the R script: is this here only to convert qmd files that weren't made with this workflow and therefore need the {.narration} divs (i.e. currently your ggplot2 module and my couple of draft PRs), or is it supposed to be run regularly? Maybe my misunderstanding comes from the fact that I tried this workflow by writing the long-form content and the slides in parallel, instead of doing the long-form content first and then adapt it to the slides format.

It just seems to me that steps 2-7 in the workflow you detailed could be replaced by:

Wrap the content you're writing in either ::: {.narration} or ::: {.slides} depending on whether it should end up in the written module or in slides. If the content should appear in both, do not wrap it in :::

This would also require a single index.qmd (I was a bit confused on whether I needed an index.orig.qmd and an index.qmd).

Either way, this increases the complexity for people who are not familiar with Quarto, which I think is unavoidable but we should ensure that we have screenshots / screen captures to clearly show how this is supposed to be used.

Readability

I usually include custom text in slides (bullet points most of the time). I (locally) adapted the Lua filter to have a {.slides} tag for text that should appear in slides only. I adapted the first couple of sections of the module on "browser() and reprex()" to use this syntax and the file is harder to read but still okay to me, what do you think?

Example of revamped `index.qmd` in module on browser() and reprex()
---
title: "Debugging with reprex() and browser()"
description: "How to efficiently solve bugs."
author: "Etienne Bacher"
date: "2026-06-25"
categories: [r]
difficulty: Intermediate
image: R_logo.png
format:
  html: 
    toc: true
  revealjs:
    smaller: true
    output-file: index-slides.html
    theme: [serif, custom.scss]
    pdf-separate-fragments: true
    strip-comments: true
    auto-animate-duration: 0.8
    code-copy: true
    slide-number: true
    toc: false
    incremental: true
execute:
  output-location: fragment
  echo: true
  warning: false
  message: false
  freeze: auto
editor: 
  markdown: 
    wrap: 72
filters:
  - at: pre-ast
    path: ../web_and_slides.lua
---
[ TODO: why is it useful to know how to debug in the age of AI? ]


::: {.slides}
---
:::


::: {.narration}
The following code is taken from the ["Latitudinal trends in Phanerozoic reefs" vignette](https://palaeoverse.palaeoverse.org/articles/phanerozoic-reefs.html), but let's pretend we have written this code from scratch:
:::

::: {.slides}
<br>
Code from ["Latitudinal trends in Phanerozoic reefs" vignette](https://palaeoverse.palaeoverse.org/articles/phanerozoic-reefs.html):

<br>
:::


```{r, error = TRUE}
library(palaeoverse)

data(reefs)
reef_counts <- group_apply(occdf = reefs, group = "interval", fun = nrow)
# Assign a common time scale based on an interval key
reefs <- look_up(occdf = reefs,
                 early_interval = "interval",
                 late_interval = "interval",
                 int_key = interval_key)
reefs <- subset(reefs, interval_max_ma <= 541)
# Extract Phanerozoic stage-level stages for time bins
bins <- time_bins(interval = "Phanerozoic", rank = "stage")

bin_time(occdf = reefs, bins = bins, method = "mid")
```

::: {.narration}
We now have an error, but its cause is not immediately clear.
Therefore, we'll have to do some debugging.
:::

::: {.slides}
. . .

<br>
Hmm, not a very helpful error message...
:::

# Reducing the problem

::: {.narration}
The longer the code you try to debug, the more potential causes you have to explore. The first thing to do when you can't find the bug in your code right away is to build a minimal **repr**oducible **ex**ample, aka a *reprex* (this is also known as a MRE, for "Minimal Reproducible Example").

In our example above, we can reduce the problem in two ways: we can reduce the data first, and then reduce the code.
:::

::: {.slides}
## Reducing the problem

<br><br><br>

- **Longer code means more potential causes to explore.** 

<br>

- First thing to do: build a minimal **repr**oducible **ex**ample (aka a *reprex*, aka a MRE, for "Minimal Reproducible Example")

<br>

- We can reduce the problem in two ways: 
  
  - reduce the data first
  - reduce the code
:::


## Reduce the size of the data

::: {.narration}

In the debugging process, we will likely run the code at least a handful of times. 
Therefore, we want to make sure that it runs as fast as possible so that we don't lose our focus.
The easiest way to do this is to reduce the size of our input data.

In addition to making the error appear faster, it is also easier to compare the actual and expected outputs when the data is as small as possible.

In this example, the dataset we are using has thousands of rows and 14 columns:
:::

::: {.slides}
<br><br>

::: {.nonincremental}

* When we debug, we run the code at least a few times.

&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{{< fa arrow-right >}} &nbsp; we want to make sure the code runs as fast as possible

<br>

* Easiest way to do that: reduce the size of our input data.

<br>

* Here, our data is pretty large:

<br>
:::
:::

```{r}
dim(reefs)
```

::: {.narration}
Let's try to reduce the size of our data:
:::

::: {.slides}
## Reduce the size of the data

<br><br>

Let's try to reduce the size of our data:
:::
```{r, error = TRUE}
reefs2 <- head(reefs)

reef_counts <- group_apply(occdf = reefs2, group = "interval", fun = nrow)
# Assign a common time scale based on an interval key
reefs2 <- look_up(occdf = reefs2,
                 early_interval = "interval",
                 late_interval = "interval",
                 int_key = interval_key)
reefs2 <- subset(reefs2, interval_max_ma <= 541)
# Extract Phanerozoic stage-level stages for time bins
bins <- time_bins(interval = "Phanerozoic", rank = "stage")

bin_time(occdf = reefs2, bins = bins, method = "mid")
```

::: {.narration}
We still see the same error but now we only have 6 rows left, much better!
Let's try to reduce that to one row and to remove some columns.
:::

::: {.slides}
<br>
{{< fa arrow-right >}} &nbsp; Same error but with 6 rows only!
:::


::: {.slides}
## Reduce the size of the data

<br>
Let's try to reduce that to one row and fewer columns:
:::

```{r, error = TRUE}
reefs2 <- head(reefs, n = 1)
reefs2 <- reefs2[, 1:5]

reef_counts <- group_apply(occdf = reefs2, group = "interval", fun = nrow)
```

::: {.narration}
Hmm, we don't have the same error anymore, so we removed too many columns.
Let's try to keep 6 instead of 5:
:::

::: {.slides}
<br>
{{< fa arrow-right >}} &nbsp; Hmm, we removed too many columns... let's try to keep 6 instead of 5.

## Reduce the size of the data
:::

```{r, error = TRUE}
reefs2 <- head(reefs, n = 1)
reefs2 <- reefs2[, 1:6]

reef_counts <- group_apply(occdf = reefs2, group = "interval", fun = nrow)
# Assign a common time scale based on an interval key
reefs2 <- look_up(occdf = reefs2,
                 early_interval = "interval",
                 late_interval = "interval",
                 int_key = interval_key)
reefs2 <- subset(reefs2, interval_max_ma <= 541)
# Extract Phanerozoic stage-level stages for time bins
bins <- time_bins(interval = "Phanerozoic", rank = "stage")

bin_time(occdf = reefs2, bins = bins, method = "mid")
```

::: {.narration}
Bingo! We have the same error as before, but now we only have one row and six columns.

Let's now try to reduce the size of the code.
:::


::: {.slides}
{{< fa arrow-right >}} &nbsp; Nice! Same error as before, but with only one row and six columns.

Let's now try to reduce the size of the code.
:::

Small papercut to fix in the current implementation IMO: contiguous {.narration} blocks should be concatenated. Currently, if we have two paragraphs in markdown then they both get wrapped in a separate {.narration} block, which increases clutter. For instance:

hello there
   
hello again

becomes

::: {.narration}
hello there
:::
   
::: {.narration}
hello again
:::

It would be nice to have this instead:

::: {.narration}
hello there
   
hello again
:::

Other

Not an actionable comment, but I just wanted to point out that Quarto v2 will be welcome here to give good error messages for syntax errors, e.g. when some fenced divs are not closed (currently it warns only).

Re:

web_and_slides.lua ideally lives within each module folder (could also theoretically live at the root, but would need to be copied for each archived folder for reproducibility)

We could have this:

filters:
  - at: pre-ast
    path: web_and_slides_autogenerated.lua

and have a script that copies the Lua script from ../web_and_slides.lua (root) to <module_name>/web_and_slides_autogenerated.lua at the very start of quarto render. This way the module is self-contained but we have only one Lua script to maintain?

@willgearty

willgearty commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Just some clarifying responses:

Workflow complexity

The workflow seems pretty complicated to me. In particular, I don't understand the purpose of the R script: is this here only to convert qmd files that weren't made with this workflow and therefore need the {.narration} divs (i.e. currently your ggplot2 module and my couple of draft PRs), or is it supposed to be run regularly?

Just once per module (perhaps this is made confusing by the existence of the original file, which is only here for demonstration purposes). Once it's run on someone's original file, the script should ideally never be used again and the user should just edit the new (or now edited) file.

Maybe my misunderstanding comes from the fact that I tried this workflow by writing the long-form content and the slides in parallel, instead of doing the long-form content first and then adapt it to the slides format.

It just seems to me that steps 2-7 in the workflow you detailed could be replaced by:

Wrap the content you're writing in either ::: {.narration} or ::: {.slides} depending on whether it should end up in the written module or in slides. If the content should appear in both, do not wrap it in :::

Ah, yes, based on Lewis's prompting, I designed the script for people who made ONLY the long-form content first. If you make both in parallel, doing it as you describe is likely easier.

This would also require a single index.qmd (I was a bit confused on whether I needed an index.orig.qmd and an index.qmd).

See above

Either way, this increases the complexity for people who are not familiar with Quarto, which I think is unavoidable but we should ensure that we have screenshots / screen captures to clearly show how this is supposed to be used.

Agreed. My thought is that we could have a module that walks people through this process (or just a document).

@willgearty

willgearty commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Changes:

  1. Wrap consecutive paragraphs with a single {.narration} div
  2. Now handle {.slides-only} and {.html-only} divs
  3. .lua file in root, copied to module during pre-render (I've also updated this in the PR preview workflow, but it won't work until after this is merged)
  4. Added details to the README
    • Describes the two new files and the three new tags (with a small example)
    • Describes the two avenues a module author could take (the one described by me in the OP and the one described by @etiennebacher above)

Other notes:

  • I left {.narration} instead of renaming it believe it accurately describes the treatment of the text within (prose in html, speaker notes in revealjs)
  • I agree that readability goes down a little bit, but I think that only really impacts authors. I don't expect users to be reading the source code very often (I imagine they will usually be copy-and-pasting from the rendered website). I think the decreased risk of drift between the two content types makes up for this.
  • Before this is actually merged, index.qmd.orig should probably be deleted (unless there is value in an example?)

@etiennebacher etiennebacher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you and sorry for the delay to review this, I have some comments to clarify the two workflows.

Importantly, I think we also wanted to give the user the possibility to have one qmd for the long format and one qmd for the slides, but this is not mentioned here.

Comment thread README.md
Comment on lines +120 to +121
Rscript web_and_slides.r <input.qmd> [output.qmd]
[--filter=<path/to/filter.lua>] [--no-inject] [--no-settings]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where we need to provide the filter and other arguments? Could this be simplified to:

Rscript web_and_slides.r <input.qmd> <output.qmd>

Also, this needs to run in the terminal and everyone is not familiar with that, is there a way to run it with source()?

@willgearty willgearty Aug 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was developed as a CLI tool, source() is not currently an option. Don't the WIP contribution docs introduce people to using air via the terminal? Within RStudio/Positron, using the terminal is pretty easy.

Comment thread README.md
Comment on lines +130 to +137
2. Registers the filter in the front matter at the `pre-ast` stage, replacing any
earlier registration. `--no-inject` skips this; `--filter=` names a different
file.
3. Sets slide-friendly YAML (skip with `--no-settings`):
- adds `execute.echo: true` (renders source code on slides)
- adds `execute.output-location: fragment` (renders code results as separate chunk)
- adds `format.revealjs.smaller: true` (text shrinks to fit on slides)
- removes `format.revealjs.scrollable` (disables scrolling through slides)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--no-inject and --no-settings make the entire process more complex for little benefits IMO. What if the R script didn't modify the YAML at all and instead we'd just recommend that people copy-paste the YAML from ggplot/index.qmd for instance? That way they could tweak the YAML if needed and we'd get rid of these two options.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to make the whole procedure as painless as possible for users (by doing the YAML changes for them). I'm fine with removing the --no-settings option and making this always happen, but I think asking people to manually copy YAML and "tweak" it themselves is less user friendly and is a recipe for more headaches.

Comment thread README.md
Comment on lines +141 to +142
The front matter is checked for valid YAML before anything is written. Needs the
`readr`, `stringr`, `yaml`, and `fs` packages.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's only in CI, right? Does this need to be mentioned here?

@willgearty willgearty Aug 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks are also done with the web_and_slides.r script:

modules/web_and_slides.r

Lines 216 to 231 in d10e77a

# --- validate the edited front matter parses as YAML before writing ----------
validate_front_matter <- function(yaml) {
if (length(yaml) < 2) return(invisible())
if (!requireNamespace("yaml", quietly = TRUE)) {
warning("Package 'yaml' not installed; skipping front-matter validation")
return(invisible())
}
fm <- paste(yaml[-c(1, length(yaml))], collapse = "\n") # drop the --- fences
tryCatch(
yaml::yaml.load(fm),
error = function(e) stop("Edited front matter is not valid YAML (nothing written): ",
conditionMessage(e))
)
invisible()
}
validate_front_matter(yaml)

Comment thread README.md

With no `output.qmd` specified it rewrites the input in place.

# Two ways to build a module

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should come before explaining the lua + R script workflow above since this workflow is only relevant for the "long-form first then convert" section.

Comment thread README.md
Comment on lines +5 to +8
One folder per module: `<module>/index.qmd`, plus whatever images and data it
needs. Every module is rendered twice from that single source: 1) as a page on the
[Palaeoverse website](https://palaeoverse.org/training/modules) and 2) as a
reveal.js slide deck (`index-slides.html`) for teaching the same material live.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we had chatted on Zulip about allowing both having a single qmd that creates the long format and the slides, and two qmds with one per format? I think @LewisAJones would prefer two qmds.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would prefer we stick to a single document to reduce maintenance overhead (since I have a hunch that the individual authors will not be maintaining their own modules longterm), but I suppose as long as everything is reproducible (and easy to maintain), it wouldn't be the end of the world if someone makes two separate documents (but I would prefer that we avoid using powerpoints, etc).

@LewisAJones

Copy link
Copy Markdown
Member

👋🏻 @willgearty @etiennebacher thanks for all your work on this... this was a pretty interesting technical issue to read through! I've not reviewed the code properly at this stage as there are things perhaps worth discussing.

I think you've done a great job with the README.md and describing the two different pathways to do this. I find the workflow a little complicated with quite a few steps, but your description of it is clear and I'm not sure there is a way to remove those steps with this approach.

With your current "Two ways to build a module", if I was forced to choose, I would almost certainly use option 1 (long-form first, then convert) to just give me an initial scaffold and then edit accordingly. However, I personally would not want my long format and slide content in the same document. I would find this very cumbersome for editing, resolving issues/bugs, reviewing, and updating. I do see @willgearty's point that this should make it easier to ensure that relevant content remains in sync across the long format and slides. That said, there are other ways we can ensure this. For example, the workflow @etiennebacher setup to make sure that our DESCRIPTION and citation file remain in sync. I also don't think it's too much to expect maintainers to update relevant sections in both files (they will be living next to each other in a folder anyway?).

If I think about how I would actually want to work (in terms of efficiency and focusing on content development), I'd most likely just duplicate my long-format .qmd file and edit it down to my slide content, adding images etc where appropriate. This way, I wouldn't necessarily need to worry about dealing with the three fenced-div (:::) classes control to the same extent and would lead to a cleaner document. What I (and maybe others?) would find most useful is just having two separate .qmd templates with the front matter pre-defined/with placeholders. This could also include some examples of what you can do with Quarto (e.g. call outs) for those less familiar with them? I believe this would be the easiest for individuals to get started on developing content rather than mental juggling different presentation forms in the same place.

I do see the value in what you've put together here though, and I imagine it could be useful to those more familiar with Quarto, or want to make use of this workflow. However, we should probably just accept that individuals might want to work in different ways for this, and as long as everything is prepared in Quarto and can be rendered appropriately, we allow them to use their own approach? I view what you've put together here as a tool to help module developers rather than a required workflow. If you agree, should we then have 3(?) "recommended approaches", and simply define what they must provide at a minimum (e.g. mymodule/index.qmd).

I realise I've rambled a lot here and appreciate you've been working on this for some time so I've likely missed context... do let me know if you want to jump on a call to discuss anything.

A couple of small comments:

  • The automated slide rendering imposes several rules on the user, I think this could be frustrating at times (e.g. one plot per slide)
  • While I agree that most individuals will want to see the rendered version of index.qmd, some may also want to interact with the source file, and as you mentioned @willgearty, the original author may not always be the maintainer of a module. I think this is some justification for trying to keep the index.qmd quite simple to navigate and understand what's going on.

@willgearty

Copy link
Copy Markdown
Member Author

I think that's where @etiennebacher and I were converging here as well. I'll update the README accordingly.
In terms of examples, would each of you prefer (feel free to prefer one or more):

  • An example module that shows off each of the two (or three different strategies)
  • Assume that eventually there will be such examples and just have the README for now
  • A folder with templates

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants