Skip to content

Commit 96f9e7d

Browse files
authored
Add a function to "purl" qmd to R script: qmd_to_r_script() (#266)
* Add extract_r_code feature * check for no cell and non supported cells * Add quiet to add_spin_preamble * Inform about mixed language input * Add internal helper to create preamble * error on existing R script file * document the new function and export * rename to qmd_to_r_script and mark as experimental * Add to pkgdown function list * Add NEWS and bump version * Add vignette to show the feature
1 parent 7d802e4 commit 96f9e7d

File tree

20 files changed

+796
-16
lines changed

20 files changed

+796
-16
lines changed

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: quarto
22
Title: R Interface to 'Quarto' Markdown Publishing System
3-
Version: 1.4.4.9023
3+
Version: 1.4.4.9024
44
Authors@R: c(
55
person("JJ", "Allaire", , "[email protected]", role = "aut",
66
comment = c(ORCID = "0000-0003-0174-9868")),

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export(add_spin_preamble)
44
export(check_newer_version)
55
export(is_using_quarto)
66
export(new_blog_post)
7+
export(qmd_to_r_script)
78
export(quarto_add_extension)
89
export(quarto_available)
910
export(quarto_binary_sitrep)

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- Added NA value detection in YAML processing to prevent silent failures when passing R's `NA` values to Quarto CLI. Functions `as_yaml()` and `write_yaml()` now validate for NA values and provide clear error messages with actionable suggestions. This addresses issues where R's `NA` values get converted to YAML strings (like `.na.real`) that Quarto doesn't recognize as missing values, because they are not supported in YAML 1.2 spec. This is to help users handle missing data appropriately before passing to Quarto (#168).
66

7+
- Added `qmd_to_r_script()` function to extract R code cells from Quarto documents and create R scripts. This experimental function preserves chunk options using `#|` syntax, adds YAML metadata as spin-style headers, and handles mixed-language documents by filtering only R cells. Complements the existing `add_spin_preamble()` function for working with R scripts in Quarto workflows (#208, quarto-dev/quarto-cli#9112).
8+
79
- Added `add_spin_preamble()` function to add YAML preambles to R scripts for use with Quarto Script rendering support. The function automatically detects existing preambles and provides flexible customization options through `title` and `preamble` parameters (#164).
810

911
- `quarto_create_project()` gains a `title` argument to set the project title independently from the directory name. This allows creating projects with custom titles, including when using `name = "."` to create a project in the current directory (thanks, @davidkane9, #148). This matches with `--title` addition for `quarto create project` in Quarto CLI v1.5.15.

R/spin.R

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#' in the `preamble` list. If NULL, uses `preamble$title` or filename as fallback.
2828
#' @param preamble Named list of YAML metadata to include in preamble.
2929
#' The `title` parameter takes precedence over `preamble$title` if both are provided.
30+
#' @param quiet If `TRUE`, suppresses messages and warnings.
3031
#' @return Invisibly returns the script path if modified, otherwise invisible NULL
3132
#'
3233
#' @examples
@@ -55,22 +56,31 @@
5556
#' )
5657
#' }
5758
#' @export
58-
add_spin_preamble <- function(script, title = NULL, preamble = NULL) {
59+
add_spin_preamble <- function(
60+
script,
61+
title = NULL,
62+
preamble = NULL,
63+
quiet = FALSE
64+
) {
5965
if (!fs::file_exists(script)) {
60-
cli::cli_abort(c(
61-
"File {.file {script}} does not exist.",
62-
"Please provide a valid file path."
63-
))
66+
cli::cli_abort(
67+
c(
68+
"File {.file {script}} does not exist.",
69+
"Please provide a valid file path."
70+
)
71+
)
6472
}
6573

6674
content <- xfun::read_utf8(script)
6775

6876
# if files starts with a spin preamble, do nothing
6977
if (grepl("^\\s*#'", content[1])) {
70-
cli::cli_inform(c(
71-
"File {.file {script}} already has a spin preamble.",
72-
"No changes made. Edit manually if needed."
73-
))
78+
if (isFALSE(quiet)) {
79+
cli::cli_inform(c(
80+
"File {.file {script}} already has a spin preamble.",
81+
"No changes made. Edit manually if needed."
82+
))
83+
}
7484
return(invisible())
7585
}
7686

@@ -93,14 +103,40 @@ add_spin_preamble <- function(script, title = NULL, preamble = NULL) {
93103
metadata$title <- fs::path_file(fs::path_ext_remove(script))
94104
}
95105

96-
preamble_text <- paste(
97-
"#'",
98-
xfun::split_lines(as_yaml_block(metadata))
99-
)
106+
preamble_text <- create_header_preamble(metadata)
100107

101108
new_content <- c(preamble_text, "", content)
102109
xfun::write_utf8(new_content, con = script)
103110

104-
cli::cli_inform("Added spin preamble to {.file {script}}")
111+
if (isFALSE(quiet)) {
112+
cli::cli_inform(c(
113+
"Added spin preamble to {.file {script}}."
114+
))
115+
}
105116
return(invisible(script))
106117
}
118+
119+
create_header_preamble <- function(metadata) {
120+
if (length(metadata) == 0) {
121+
return("")
122+
}
123+
build_preamble("#'", as_yaml_block(metadata))
124+
}
125+
126+
create_code_preamble <- function(metadata) {
127+
if (length(metadata) == 0) {
128+
return("")
129+
}
130+
# Remove trailing newline for this block as `as_yaml` adds one
131+
build_preamble("#|", sub("\n$", "", as_yaml(metadata)))
132+
}
133+
134+
build_preamble <- function(prepend, content) {
135+
if (!nzchar(content)) {
136+
return("")
137+
}
138+
paste(
139+
prepend,
140+
xfun::split_lines(content)
141+
)
142+
}

R/utils-extract.R

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#' Convert Quarto document to R script
2+
#'
3+
#' @description
4+
#' `r lifecycle::badge("experimental")`
5+
#'
6+
#' Extracts R code cells from a Quarto document and writes them to an R script
7+
#' file that can be rendered with the same options. The Markdown text is not
8+
#' preserved, but R chunk options are kept as comment headers using Quarto's
9+
#' `#|` syntax.
10+
#'
11+
#' This function is still experimental and may slightly change in
12+
#' future releases, depending on feedback.
13+
#'
14+
#' @param qmd Character. Path to the input Quarto document (.qmd file).
15+
#' @param script Character. Path to the output R script file. If `NULL`
16+
#' (default), the script file will have the same name as the input file
17+
#' but with `.R` extension.
18+
#'
19+
#' @details
20+
#' This function processes a Quarto document by:
21+
#' - Extracting only R code cells (markdown and cell in other languages are ignored)
22+
#' - Preserving chunk options as `#|` comment headers
23+
#' - Adding the document's YAML metadata as a spin-style header
24+
#' - Creating an R script that can be rendered with the same options
25+
#'
26+
#' ## File handling:
27+
#' - If the output R script already exists, the function will abort with an error
28+
#' - Non-R code cells (e.g., Python, Julia, Observable JS) are ignored
29+
#' - If no R code cells are found, the function does nothing and returns `NULL`
30+
#'
31+
#' ## Compatibility:
32+
#' The resulting R script is compatible with Quarto's script rendering via
33+
#' `knitr::spin()` and can be rendered directly with `quarto render script.R`.
34+
#' See <https://quarto.org/docs/computations/render-scripts.html#knitr> for
35+
#' more details on rendering R scripts with Quarto.
36+
#'
37+
#' The resulting R script uses Quarto's executable cell format with `#|`
38+
#' comments to preserve chunk options like `echo`, `eval`, `output`, etc.
39+
#'
40+
#' @return Invisibly returns the path to the created R script file, or
41+
#' `NULL` if no R code cells were found.
42+
#'
43+
#' @examples
44+
#' \dontrun{
45+
#' # Convert a Quarto document to R script
46+
#' qmd_to_r_script("my-document.qmd")
47+
#' # Creates "my-document.R"
48+
#'
49+
#' # Specify custom output file
50+
#' qmd_to_r_script("my-document.qmd", script = "extracted-code.R")
51+
#' }
52+
#'
53+
#' @export
54+
qmd_to_r_script <- function(qmd, script = NULL) {
55+
if (!file.exists(qmd)) {
56+
cli::cli_abort(
57+
c(
58+
"File {.file {qmd}} does not exist.",
59+
">" = "Please provide a valid Quarto document."
60+
),
61+
call = rlang::caller_env()
62+
)
63+
}
64+
65+
if (is.null(script)) {
66+
script <- fs::path_ext_set(qmd, "R")
67+
}
68+
69+
if (file.exists(script)) {
70+
cli::cli_abort(
71+
c(
72+
"File {.file {script}} already exists.",
73+
">" = "Please provide a new file name or remove the existing file."
74+
)
75+
)
76+
}
77+
78+
inspect <- quarto::quarto_inspect(qmd)
79+
fileInformation <- inspect$fileInformation[[1]]
80+
81+
codeCells <- fileInformation$codeCells
82+
if (length(codeCells) == 0) {
83+
cli::cli_inform(
84+
c(
85+
"No code cells found in {.file {qmd}}.",
86+
">" = "This function only extracts R code cells."
87+
)
88+
)
89+
return(invisible(NULL))
90+
}
91+
if (all(codeCells$language != "r")) {
92+
cli::cli_inform(
93+
c(
94+
"No R code cells found in {.file {qmd}}, only: {.emph {paste(unique(codeCells$language))}}.",
95+
">" = "This function only extracts R code cells."
96+
),
97+
call = rlang::caller_env()
98+
)
99+
return(invisible(NULL))
100+
}
101+
102+
if (any(codeCells$language != "r")) {
103+
cli::cli_inform(
104+
c(
105+
"Extracting only R code cells from {.file {qmd}}.",
106+
">" = "Other languages will be ignored (found {.emph {paste(setdiff(unique(codeCells$language), 'r'))}})."
107+
),
108+
call = rlang::caller_env()
109+
)
110+
}
111+
112+
r_codeCells <- codeCells[codeCells$language == "r", ]
113+
114+
content <- character(nrow(r_codeCells))
115+
for (i in seq_len(nrow(r_codeCells))) {
116+
row <- r_codeCells[i, ]
117+
metadata_list <- as.list(row$metadata)
118+
metadata_clean <- metadata_list[!is.na(metadata_list)]
119+
content[i] <- paste(
120+
c(create_code_preamble(metadata_clean), row$source),
121+
collapse = "\n"
122+
)
123+
}
124+
125+
xfun::write_utf8(content, script)
126+
add_spin_preamble(script, preamble = fileInformation$metadata, quiet = TRUE)
127+
}

_pkgdown.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,4 @@ reference:
6969
contents:
7070
- write_yaml_metadata_block
7171
- add_spin_preamble
72+
- qmd_to_r_script

man/add_spin_preamble.Rd

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/qmd_to_r_script.Rd

Lines changed: 68 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# qmd_to_r_script() errors on wrong qmd
2+
3+
Code
4+
qmd_to_r_script("nonexistent.qmd")
5+
Condition
6+
Error:
7+
! File 'nonexistent.qmd' does not exist.
8+
> Please provide a valid Quarto document.
9+
10+
# qmd_to_r_script() errors on existing script
11+
12+
Code
13+
qmd_to_r_script(resources_path("purl-r.qmd"), script = r_script)
14+
Condition
15+
Error in `qmd_to_r_script()`:
16+
! File <r script> already exists.
17+
> Please provide a new file name or remove the existing file.
18+
19+
# qmd_to_r_script() ignore other language code
20+
21+
Code
22+
qmd_to_r_script(resources_path("purl-r-ojs.qmd"), r_script)
23+
Message
24+
Extracting only R code cells from 'resources/purl-r-ojs.qmd'.
25+
> Other languages will be ignored (found ojs).
26+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#' ---
2+
#' title: Purl Test Document
3+
#' format: html
4+
#' ---
5+
#'
6+
7+
#| echo: false
8+
#| output: asis
9+
cat("Hello, world")
10+
11+
#| echo: true
12+
cat("more")
13+

0 commit comments

Comments
 (0)