Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ jobs:
with:
use-public-rspm: true

- name: Sync the Lua filter into each module
# Mirrors _quarto.yml's pre-render hook that runs locally.
working-directory: modules
run: Rscript sync_filter.r

- name: Copy PR module(s) into the website
shell: Rscript {0}
env:
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ _site
_freeze
.DS_Store

*_files/
*.html

**/*.quarto_ipynb

/.luarc.json
web_and_slides_autogenerated.lua
191 changes: 189 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,190 @@
List of modules built by the Palaeoverse team.
# Palaeoverse Modules

One folder per module.
List of teaching modules built by the Palaeoverse team.

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.
Comment on lines +5 to +8

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).


The two files at the root of this repo exist to make one source serve both
outputs:

| File | Role |
| --- | --- |
| `web_and_slides.lua` | Pandoc/Quarto filter, applied at render time. Decides what appears on the website, what appears on the slides, and how the slides are broken up. |
| `web_and_slides.r` | One-shot authoring helper. Converts a finished long-form document into the tagged form the filter expects. |

# Authoring a module

## Front matter

Start from an existing module (`ggplot/index.qmd` is a good template). These
fields matter:

```yaml
---
title: "Plotting in R with ggplot2"
description: "Visualizing your data with the grammar of graphics"
author: "Will Gearty"
date: "2026-06-12"
categories: [r, tidyverse, dataviz] # various tags
difficulty: Beginner # Beginner/Intermediate/Advanced
image: images/2d_density.png # thumbnail for module
format:
html: default
revealjs:
smaller: true
output-file: index-slides.html # the deck, alongside the page
execute:
output-location: fragment # slide output reveals on click
echo: true
freeze: auto # must be auto, not true
filters:
- at: pre-ast
path: web_and_slides_autogenerated.lua
---
```

`difficulty`, `categories` and `freeze: auto` are enforced by CI (see
[Technical summary](#technical-summary)). The `filters` entry is what activates
everything below; `web_and_slides.r` adds it for you, or you can copy it.

## Writing for two outputs

Three fenced-div (`:::`) classes control where content lands:

| Class | Website Tutorial | Slides |
| --- | --- | --- |
| `.narration` | normal prose | speaker notes |
| `.slides-only` | dropped | shown on the slide |
| `.html-only` | shown | dropped |

Anything not wrapped in one of these appears in both outputs. So the usual shape
of a module is: headings and code chunks shared by both outputs, the connecting
prose in `.narration` (a paragraph on the page, a note you talk from on the
slide), and the occasional `.slides-only` bullet summary or `.html-only` aside.

````markdown
## Making a scatter plot

::: {.narration}
On the website this is a paragraph of explanation. On the slides it is what you
say out loud while the plot is up.

Consecutive paragraphs can share one block.
:::

::: {.slides-only}
- x is body mass
- y is flipper length
:::

```{r}
ggplot(penguins) +
aes(x = body_mass, y = flipper_len) +
geom_point()
```
````

## Automated slide rendering via `web_and_slides.lua`

The following changes are applied to the reveal.js slides via our custom Lua filter:

- **Headings become slides:** `##` starts a slide as usual; `###` and deeper are
promoted so each also gets its own slide, instead of piling onto the parent.
A `#` heading becomes a centered divider slide, so don't put content under one.
- **One plot per slide:** The slide closes after each figure. Prose that follows
a figure moves to the next slide (introducing it), unless nothing but prose
remains before the next heading — then it stays put rather than making a blank
slide. Split slides repeat the current heading, so they keep a title.
- **Callouts get their own slide:** A callout is un-boxed onto a slide of its
own, titled by its own heading. Give a callout a `## Heading` as its first line
(rather than `title="..."`) if you want that title on the slide; an untitled
callout keeps the section title.
- **Multi-chunk slides build up:** A slide holding two or more code chunks is
expanded into an auto-animate sequence: one step per chunk, earlier chunks
staying on screen, and the notes for each chunk advancing with it.

Preview both outputs with:

```sh
quarto render ggplot/index.qmd # -> index.html and index-slides.html
```

## `web_and_slides.r` Helper

The `web_and_slides.r` helper file takes a long-form document and mechanically prepares it:

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

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.

```

It does the following:

1. Wraps each set of consecutive prose paragraphs in a single
`::: {.narration}` block. Headings, code chunks, lists, blockquotes,
tables, standalone images, and existing fenced divs (including callouts and
everything inside them) are left untouched.
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)
Comment on lines +130 to +137

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.

4. Reports headings that will render awkwardly (e.g., `#` become centered title slides).
You should fix these by hand.

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

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)


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.


## 1. Long-form first, then convert

_Best when the module is primarily a written tutorial, or already exists as prose._

Write the module as an ordinary prose-and-code document, ignoring slides
entirely. When it reads well, run `web_and_slides.r` over it once, then clean up
the result by hand: act on any headings it flagged and add `:::{.slides-only}` /
`:::{.html-only}` blocks where the two outputs should diverge.

```sh
# write the preliminary tutorial in mymodule/index.qmd.orig
Rscript web_and_slides.r mymodule/index.qmd.orig mymodule/index.qmd
quarto render mymodule/index.qmd
```

The script is intended to be run as a **single pass once the long-form version is
finished**. However, in principle it can be run on the same file multiple times.
Prose already inside a `.narration` block is left alone, so a second run over its
own output changes nothing. If you start hand-editing the converted file, keep
editing that file. If you would rather keep iterating on the long-form text,
keep it alongside as `index.qmd.orig` and regenerate `index.qmd` (but any hand
cleanup you did to `index.qmd` will need redoing).

## 2. Tag as you go

_Best when you are thinking about the slides and the prose tutorial at the same
time, and when you want fine control over which output gets what._

Skip `web_and_slides.r` completely. Write `index.qmd` with the intent to render
both output types from the beginning. Copy the `filters` entry and the
slide-friendly `execute` / `revealjs` settings from another module's front matter.
Then, as you write use an `.html-only` block if you only want the text to
appear in the prose tutorial, a `.slides-only` block if a section should only
appear on the slides (e.g., bullet points), or a `.narration` block have
content appear in the prose tutorial and be shown as speaker notes for the slides.

# Additional references:

- Quarto tutorial ([with Positron](https://quarto.org/docs/get-started/hello/positron.html) | [with RStudio](https://quarto.org/docs/get-started/computations/rstudio.html))
- [Markdown basics in Quarto](https://quarto.org/docs/authoring/markdown-basics.html)
- [HTML basics in Quarto](https://quarto.org/docs/output-formats/html-basics.html)
- [Revealjs in Quarto](https://quarto.org/docs/presentations/revealjs/)
- [Quarto Listings](https://quarto.org/docs/websites/website-listings.html)
7 changes: 7 additions & 0 deletions _quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
project:
type: default
# modules only, so README.md / LICENSE.md aren't rendered
render:
- "*/*.qmd"
# refresh each module's copy of the Lua filter before rendering
pre-render: sync_filter.r
Loading
Loading