Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert to quarto #149

Closed
wants to merge 19 commits into from
Closed

Conversation

stephan-koenig
Copy link

Hi,

Summary

Conversion of {bookdown} to Quarto book and slides (hopefully can be used as a template for other book clubs).
Depends on workflows provided in PR r4ds/r4dsactions#5.

Details

gitbook allows split_by: section to split each chapter into separate pages using sections (defined by level 2 headings), in essence creating almost the equivalent of presentation slides. Unfortunately, the Quarto book does not have a similar option (see this discussion).

After some experimentation, I decided it would be best to render the Quarto project once as a book and once as slides using Quarto project profiles and embed the slides as profile content only in the book using shortcodes using the embedio extension.

In general, I tried to use bash commands on macOS and a modified version of knitr::convert_chunk_header() (both mainly generated with GPT-4o) to automate some of the necessary text replacements. For details on the clean-up tasks, see the different commits.

Caveats

Currently, the "Render project" button in the build pane of RStudio will only render the book. To render everything, render first the book and then the slides (rendering the book second would overwrite the slides output):

quarto render --profile book
quarto render --profile slides

Render time is also pretty long because of a code chunk in 21-databases.qmd and because everything will be computed twice. I decided against keeping computations using the Quarto project option freeze because its behaviour can be unpredictable for people not having experience with it.

Commits

function (
    input,
    output = NULL,
    type = c("multiline", "wrap",  "yaml"),
    width = 0.9 * getOption("width")) {
  text = xfun::read_utf8(input)
  ext = xfun::file_ext(input)
  if (missing(type) && ext == "qmd")
    type = "yaml"
  type = match.arg(type)
  pattern = knitr:::detect_pattern(text, ext)
  if (pattern == "brew")
    return()
  markdown_mode = pattern == "md"
  chunk_begin = knitr:::all_patterns[[pattern]]$chunk.begin
  nb_added = 0L
  new_text = text
  for (i in grep(chunk_begin, text)) {
    indent = knitr:::get_chunk_indent(text[i])
    header = knitr:::extract_params_src(chunk_begin, text[i])
    engine = if (markdown_mode)
      knitr:::get_chunk_engine(header)
    else "r"
    params = if (markdown_mode)
      knitr:::get_chunk_params(header)
    else header
    if (params == "")
      next
    params2 = knitr:::clean_empty_params(params)
    params2 = trimws(knitr:::clean_empty_params(params2))
    opt_chars = xfun:::get_option_comment(engine)
    prefix = paste0(indent, opt_chars$start)
    new_text[i + nb_added] = gsub(params, "", text[i], fixed = TRUE)
    if (type == "wrap") {
      params3 = strwrap(params2, width, prefix = prefix)
    }
    else if (type == "multiline") {
      res = xfun::csv_options(params2)
      params3 = sprintf("%s = %s,", names(res), deparsed_string(res))
      last = length(params3)
      params3[last] = gsub(",$", "", params3[last])
      params3 = if (width <= 0)
        paste0(prefix, params3)
      else {
        strwrap(params3, width, prefix = prefix)
      }
    }
    else {
      params3 = xfun::csv_options(params2)
      params3 = lapply(params3, function(x) {
        if (is.symbol(x) || is.language(x)) {
          x = deparse(x, 500L)
          attr(x, "tag") = "!expr"
        }
        x
      })
      params3 = knitr:::dash_names(params3)
      params3 = strsplit(yaml::as.yaml(params3, handlers = list(logical = function(x) {
        x = tolower(x)
        class(x) = "verbatim"
        x
      }, numeric = function(x) {
        if (length(x) != 1) return(x)
        x2 = as.integer(x)
        if (x2 == x) x2 else x
      }), line.sep = "\n"), "\n")[[1]]
      params3 = paste0(prefix, params3)
    }
    if (nzchar(opt_chars$end))
      params3 = paste0(params3, opt_chars$end)
    new_text = append(new_text, c(params3, ""), after = i + nb_added)
    nb_added = nb_added + length(params3) + 1
  }
  if (is.null(output))
    return(new_text)
  if (is.function(output))
    output = output(input)
  xfun::write_utf8(new_text, output)
  invisible(output)
}
find . -type f -name "*.qmd" | while read file
do
  if grep -q '^#|' "$file"; then
    sed -i '' '/^#| label: /s/_/-/g' "$file"
  fi
done
  • Replace spaces with dashes
find . -type f -name "*.qmd" | while read file
do
  if grep -q '^#| label: .* ' "$file"; then
    sed -i '' '/^#| label: /s/ /-/g' "$file"
    sed -i '' 's/^#|-label:-/#| label: /' "$file"
  fi
done
  • Convert to lower case
for file in *.qmd; do
  awk '{if ($0 ~ /^#\| label: /) {print tolower($0)} else {print $0}}' "$file" > temp.txt && mv temp.txt "$file"
done
sed -i '' 's/^```{yaml}/```yaml/' 29-quarto_formats.qmd
find . -type f -name "*.qmd" | while read file
do
  sed -i '' -E 's/`r knitr::include_url\("([^"]+)"\)`/{{< video \1 >}}/g' "$file"
done
find . -type f -name "*.qmd" | while read file
do
  sed -i '' -E 's/<details>/::: {.callout-tip collapse="true"}/; s/[[:space:]]*<summary>[[:space:]]*Meeting chat log[[:space:]]*<\/summary>/## Meeting chat log/g; s/<\/details>/:::/g' "$file"
done
for file in *.qmd; do
    filename="${file%.qmd}"
    awk -v filename="$filename" '
    BEGIN { title_found = 0 }
    {
        if (title_found == 0 && /^# /) {
            title_found = 1
            print $0
            print ""
            print "::: {.content-visible when-profile=\"slides\"}"
            print "{{< revealjs \"slides/" filename ".html\" >}}"
            print ":::"
        } else {
            print $0
        }
    }' "$file" > "${file}.tmp"
    mv "${file}.tmp" "$file"
done
  • Convert level 1 headings to title in YAML
    Level 1 headings cause vertical navigation in Quarto revealjs slides.
    Using YAML works both for book and slides format. When using YAML, it
    is not possible to keep a book chapter unnumbered like with
    # <title> {.unnumbered}, so the level 1 headings of unnumbered
    chapters index.qmd and 00-introduction.qmd must stay the same.
    The following code was run locally on macOS and changes to index.qmd
    and 00-introduction.qmd were manually discarded:
find . -type f -name "*.qmd" | while read -r file; do
  heading=$(awk 'NR==1{print; exit}' "$file" | sed 's/^# //')
  title_block="---\ntitle: \"${heading}\"\n---"
  sed -i "1s|^#.*|$title_block|" "$file"
done

Used `Find in Files...` and replace in RStudio
To include an empty line after the YAML block, the following modified
version of `knitr::convert_chunk_header()` was used:

```r
function (
    input,
    output = NULL,
    type = c("multiline", "wrap",  "yaml"),
    width = 0.9 * getOption("width")) {
  text = xfun::read_utf8(input)
  ext = xfun::file_ext(input)
  if (missing(type) && ext == "qmd")
    type = "yaml"
  type = match.arg(type)
  pattern = knitr:::detect_pattern(text, ext)
  if (pattern == "brew")
    return()
  markdown_mode = pattern == "md"
  chunk_begin = knitr:::all_patterns[[pattern]]$chunk.begin
  nb_added = 0L
  new_text = text
  for (i in grep(chunk_begin, text)) {
    indent = knitr:::get_chunk_indent(text[i])
    header = knitr:::extract_params_src(chunk_begin, text[i])
    engine = if (markdown_mode)
      knitr:::get_chunk_engine(header)
    else "r"
    params = if (markdown_mode)
      knitr:::get_chunk_params(header)
    else header
    if (params == "")
      next
    params2 = knitr:::clean_empty_params(params)
    params2 = trimws(knitr:::clean_empty_params(params2))
    opt_chars = xfun:::get_option_comment(engine)
    prefix = paste0(indent, opt_chars$start)
    new_text[i + nb_added] = gsub(params, "", text[i], fixed = TRUE)
    if (type == "wrap") {
      params3 = strwrap(params2, width, prefix = prefix)
    }
    else if (type == "multiline") {
      res = xfun::csv_options(params2)
      params3 = sprintf("%s = %s,", names(res), deparsed_string(res))
      last = length(params3)
      params3[last] = gsub(",$", "", params3[last])
      params3 = if (width <= 0)
        paste0(prefix, params3)
      else {
        strwrap(params3, width, prefix = prefix)
      }
    }
    else {
      params3 = xfun::csv_options(params2)
      params3 = lapply(params3, function(x) {
        if (is.symbol(x) || is.language(x)) {
          x = deparse(x, 500L)
          attr(x, "tag") = "!expr"
        }
        x
      })
      params3 = knitr:::dash_names(params3)
      params3 = strsplit(yaml::as.yaml(params3, handlers = list(logical = function(x) {
        x = tolower(x)
        class(x) = "verbatim"
        x
      }, numeric = function(x) {
        if (length(x) != 1) return(x)
        x2 = as.integer(x)
        if (x2 == x) x2 else x
      }), line.sep = "\n"), "\n")[[1]]
      params3 = paste0(prefix, params3)
    }
    if (nzchar(opt_chars$end))
      params3 = paste0(params3, opt_chars$end)
    new_text = append(new_text, c(params3, ""), after = i + nb_added)
    nb_added = nb_added + length(params3) + 1
  }
  if (is.null(output))
    return(new_text)
  if (is.function(output))
    output = output(input)
  xfun::write_utf8(new_text, output)
  invisible(output)
}
```
The following code was run locally on macOS:

- Replace underscores with dashes

```shell
find . -type f -name "*.qmd" | while read file
do
  if grep -q '^#|' "$file"; then
    sed -i '' '/^#| label: /s/_/-/g' "$file"
  fi
done
```

- Replace spaces with dashes

```shell
find . -type f -name "*.qmd" | while read file
do
  if grep -q '^#| label: .* ' "$file"; then
    sed -i '' '/^#| label: /s/ /-/g' "$file"
    sed -i '' 's/^#|-label:-/#| label: /' "$file"
  fi
done
```

- Convert to lower case

```shell
for file in *.qmd; do
  awk '{if ($0 ~ /^#\| label: /) {print tolower($0)} else {print $0}}' "$file" > temp.txt && mv temp.txt "$file"
done
```
The following code was run locally on macOS:

```shell
sed -i '' 's/^```{yaml}/```yaml/' 29-quarto_formats.qmd
```
The following code was run locally on macOS:

```shell
find . -type f -name "*.qmd" | while read file
do
  sed -i '' -E 's/`r knitr::include_url\("([^"]+)"\)`/{{< video \1 >}}/g' "$file"
done
```
The following code was run locally on macOS:

```shell
find . -type f -name "*.qmd" | while read file
do
  sed -i '' -E 's/<details>/::: {.callout-tip collapse="true"}/; s/[[:space:]]*<summary>[[:space:]]*Meeting chat log[[:space:]]*<\/summary>/## Meeting chat log/g; s/<\/details>/:::/g' "$file"
done
```
The following code was run locally on macOS to add shortcodes:

```shell
for file in *.qmd; do
    filename="${file%.qmd}"
    awk -v filename="$filename" '
    BEGIN { title_found = 0 }
    {
        if (title_found == 0 && /^# /) {
            title_found = 1
            print $0
            print ""
            print "::: {.content-visible when-profile=\"slides\"}"
            print "{{< revealjs \"slides/" filename ".html\" >}}"
            print ":::"
        } else {
            print $0
        }
    }' "$file" > "${file}.tmp"
    mv "${file}.tmp" "$file"
done
```
Level 1 headings cause vertical navigation in Quarto revealjs slides.
Using YAML works both for book and slides format. When using YAML, it
is not possible to keep a book chapter unnumbered like with
`# <title> {.unnumbered}`, so the level 1 headings of unnumbered
chapters `index.qmd` and `00-introduction.qmd` must stay the same.
The following code was run locally on macOS and changes to `index.qmd`
and `00-introduction.qmd` were manually discarded:

```shell
find . -type f -name "*.qmd" | while read -r file; do
  heading=$(awk 'NR==1{print; exit}' "$file" | sed 's/^# //')
  title_block="---\ntitle: \"${heading}\"\n---"
  sed -i "1s|^#.*|$title_block|" "$file"
done
```
Only level 1 headings can be unnumbered in books but these cause the
same file to be rendered as revealjs slides with vertical navigation.
The `.content-visible` class is used to generate poject-specific
headings, i.e., unnumbered chapters for book or a level 2 heading for
slides.
@jonthegeek
Copy link
Member

Thank you for this effort! Unfortunately, I already have an in-progress PR to implement a much simpler system, like I used in https://dslc.io/wapir. I have to manually add chapters to the _quarto.yml file, but that generally happens once at the beginning of a project, so it shouldn't be an issue.

In general, I highly recommend opening an issue in a repo before taking on something this large! That gives the maintainer a chance to let you know if your project is likely to be helpful.

Thanks again for putting in this work! I'll check out your process to see if there's anything to borrow for the approach I'm taking!

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.

2 participants