diff --git a/DESCRIPTION b/DESCRIPTION index 99b20fa5..63745a35 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: quarto Title: R Interface to 'Quarto' Markdown Publishing System -Version: 1.4.4.9021 +Version: 1.4.4.9022 Authors@R: c( person("JJ", "Allaire", , "jj@posit.co", role = "aut", comment = c(ORCID = "0000-0003-0174-9868")), diff --git a/NAMESPACE b/NAMESPACE index eb15c20c..a6750f2e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(add_spin_preamble) export(check_newer_version) export(is_using_quarto) export(new_blog_post) diff --git a/NEWS.md b/NEWS.md index 84ef6bcf..7d53c37f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # quarto (development version) +- 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). + - `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. - `quarto_use_template()` now supports using templates in another empty directory via the `dir` argument. However, the function will fail with a clear error message when used in non-empty directories, as interactive prompting is required and handled by Quarto CLI directly (requires Quarto 1.5.15+). Follow for [quarto-dev/quarto-cli#11127](https://github.com/quarto-dev/quarto-cli/issues/11127) for change with `--no-prompt` behavior in future Quarto versions. diff --git a/R/blog.R b/R/blog.R index 94e245e3..8f347da2 100644 --- a/R/blog.R +++ b/R/blog.R @@ -89,8 +89,7 @@ make_post_yaml <- function(title, ...) { if (length(yml_values$categories) == 0) { yml_values <- yml_values[names(yml_values) != "categories"] } - yml_values <- as_yaml(yml_values) - yml_values <- paste0("---\n", yml_values, "---\n") + yml_values <- as_yaml_block(yml_values) yml_values } diff --git a/R/metadata.R b/R/metadata.R index d7bc6c3e..b0002851 100644 --- a/R/metadata.R +++ b/R/metadata.R @@ -89,7 +89,6 @@ write_yaml_metadata_block <- function(..., .list = NULL) { if (length(meta) == 0) { return() } - res <- as_yaml(meta) - yaml_block <- paste0("---\n", res, "---\n") + yaml_block <- as_yaml_block(meta) knitr::asis_output(yaml_block) } diff --git a/R/spin.R b/R/spin.R new file mode 100644 index 00000000..c78f964e --- /dev/null +++ b/R/spin.R @@ -0,0 +1,106 @@ +#' Add spin preamble to R script +#' +#' Adds a minimal spin preamble to an R script file if one doesn't already exist. +#' The preamble includes a title derived from the filename and is formatted as +#' a YAML block suitable preprended with `#'` for [knitr::spin()]. +#' +#' This is useful to prepare R scripts for use with +#' Quarto Script rendering support. +#' See +#' +#' @section Preamble format: +#' For a script named `analysis.R`, the function adds this preamble by default: +#' ``` +#' #' --- +#' #' title: analysis +#' #' --- +#' #' +#' +#' # Original script content starts here +#' ``` +#' +#' This is the minimal preamble required for Quarto Script rendering, so that +#' [Engine Bindings](https://quarto.org/docs/computations/execution-options.html#engine-binding) works. +#' +#' @param script Path to the R script file +#' @param title Custom title for the preamble. If provided, overrides any title +#' in the `preamble` list. If NULL, uses `preamble$title` or filename as fallback. +#' @param preamble Named list of YAML metadata to include in preamble. +#' The `title` parameter takes precedence over `preamble$title` if both are provided. +#' @return Invisibly returns the script path if modified, otherwise invisible NULL +#' +#' @examples +#' \dontrun{ +#' # Basic usage with default title +#' add_spin_preamble("analysis.R") +#' +#' # Custom title +#' add_spin_preamble("analysis.R", title = "My Analysis") +#' +#' # Custom preamble with multiple fields +#' add_spin_preamble("analysis.R", preamble = list( +#' title = "Advanced Analysis", +#' author = "John Doe", +#' date = Sys.Date(), +#' format = "html" +#' )) +#' +#' # Title parameter overrides preamble title +#' add_spin_preamble("analysis.R", +#' title = "Final Title", # This takes precedence +#' preamble = list( +#' title = "Ignored Title", +#' author = "John Doe" +#' ) +#' ) +#' } +#' @export +add_spin_preamble <- function(script, title = NULL, preamble = NULL) { + if (!fs::file_exists(script)) { + cli::cli_abort(c( + "File {.file {script}} does not exist.", + "Please provide a valid file path." + )) + } + + content <- xfun::read_utf8(script) + + # if files starts with a spin preamble, do nothing + if (grepl("^\\s*#'", content[1])) { + cli::cli_inform(c( + "File {.file {script}} already has a spin preamble.", + "No changes made. Edit manually if needed." + )) + return(invisible()) + } + + # Build preamble metadata + metadata <- list() + + # Start with preamble list if provided + if (!is.null(preamble)) { + if (!is.list(preamble)) { + cli::cli_abort("`preamble` must be a named list.") + } + metadata <- preamble + } + + # Add or override title + if (!is.null(title)) { + metadata$title <- title + } else if (is.null(metadata$title)) { + # Use filename as default title if none provided + metadata$title <- fs::path_file(fs::path_ext_remove(script)) + } + + preamble_text <- paste( + "#'", + xfun::split_lines(as_yaml_block(metadata)) + ) + + new_content <- c(preamble_text, "", content) + xfun::write_utf8(new_content, con = script) + + cli::cli_inform("Added spin preamble to {.file {script}}") + return(invisible(script)) +} diff --git a/R/utils.R b/R/utils.R index fc965140..c9d9caa2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -24,6 +24,12 @@ write_yaml <- function(x, file) { yaml::write_yaml(x, file, handlers = yaml_handlers) } +as_yaml_block <- function(x) { + # Convert to YAML and wrap in a block + yaml_content <- as_yaml(x) + paste0("---\n", yaml_content, "---\n") +} + # inline knitr:::merge_list() merge_list <- function(x, y) { diff --git a/_pkgdown.yml b/_pkgdown.yml index c2ada90b..58b7c61d 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -68,3 +68,4 @@ reference: These functions are used to help with Quarto documents and projects: contents: - write_yaml_metadata_block + - add_spin_preamble diff --git a/man/add_spin_preamble.Rd b/man/add_spin_preamble.Rd new file mode 100644 index 00000000..3fadbdeb --- /dev/null +++ b/man/add_spin_preamble.Rd @@ -0,0 +1,72 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/spin.R +\name{add_spin_preamble} +\alias{add_spin_preamble} +\title{Add spin preamble to R script} +\usage{ +add_spin_preamble(script, title = NULL, preamble = NULL) +} +\arguments{ +\item{script}{Path to the R script file} + +\item{title}{Custom title for the preamble. If provided, overrides any title +in the \code{preamble} list. If NULL, uses \code{preamble$title} or filename as fallback.} + +\item{preamble}{Named list of YAML metadata to include in preamble. +The \code{title} parameter takes precedence over \code{preamble$title} if both are provided.} +} +\value{ +Invisibly returns the script path if modified, otherwise invisible NULL +} +\description{ +Adds a minimal spin preamble to an R script file if one doesn't already exist. +The preamble includes a title derived from the filename and is formatted as +a YAML block suitable preprended with \verb{#'} for \code{\link[knitr:spin]{knitr::spin()}}. +} +\details{ +This is useful to prepare R scripts for use with +Quarto Script rendering support. +See \url{https://quarto.org/docs/computations/render-scripts.html#knitr} +} +\section{Preamble format}{ + +For a script named \code{analysis.R}, the function adds this preamble by default: + +\if{html}{\out{
}}\preformatted{#' --- +#' title: analysis +#' --- +#' + +# Original script content starts here +}\if{html}{\out{
}} + +This is the minimal preamble required for Quarto Script rendering, so that +\href{https://quarto.org/docs/computations/execution-options.html#engine-binding}{Engine Bindings} works. +} + +\examples{ +\dontrun{ +# Basic usage with default title +add_spin_preamble("analysis.R") + +# Custom title +add_spin_preamble("analysis.R", title = "My Analysis") + +# Custom preamble with multiple fields +add_spin_preamble("analysis.R", preamble = list( + title = "Advanced Analysis", + author = "John Doe", + date = Sys.Date(), + format = "html" +)) + +# Title parameter overrides preamble title +add_spin_preamble("analysis.R", + title = "Final Title", # This takes precedence + preamble = list( + title = "Ignored Title", + author = "John Doe" + ) +) +} +} diff --git a/tests/testthat/_snaps/spin.md b/tests/testthat/_snaps/spin.md new file mode 100644 index 00000000..4825b61e --- /dev/null +++ b/tests/testthat/_snaps/spin.md @@ -0,0 +1,17 @@ +# add_spin_preamble checks for file existence + + Code + add_spin_preamble("non_existent_file.R") + Condition + Error in `add_spin_preamble()`: + ! File 'non_existent_file.R' does not exist. + Please provide a valid file path. + +# add_spin_preamble validates preamble argument + + Code + add_spin_preamble(tmp_file, preamble = "not a list") + Condition + Error in `add_spin_preamble()`: + ! `preamble` must be a named list. + diff --git a/tests/testthat/_snaps/spin/spin_preamble-custom-preamble.R b/tests/testthat/_snaps/spin/spin_preamble-custom-preamble.R new file mode 100644 index 00000000..57b52142 --- /dev/null +++ b/tests/testthat/_snaps/spin/spin_preamble-custom-preamble.R @@ -0,0 +1,9 @@ +#' --- +#' title: My Report +#' author: John Doe +#' format: html +#' --- +#' + +library(ggplot2) +plot(1:10) diff --git a/tests/testthat/_snaps/spin/spin_preamble-custom-title.R b/tests/testthat/_snaps/spin/spin_preamble-custom-title.R new file mode 100644 index 00000000..26d6fcaf --- /dev/null +++ b/tests/testthat/_snaps/spin/spin_preamble-custom-title.R @@ -0,0 +1,7 @@ +#' --- +#' title: Custom Analysis +#' --- +#' + +x <- 1 +y <- 2 diff --git a/tests/testthat/_snaps/spin/spin_preamble-empty.R b/tests/testthat/_snaps/spin/spin_preamble-empty.R new file mode 100644 index 00000000..240b926b --- /dev/null +++ b/tests/testthat/_snaps/spin/spin_preamble-empty.R @@ -0,0 +1,6 @@ +#' --- +#' title: report +#' --- +#' + + diff --git a/tests/testthat/_snaps/spin/spin_preamble-preamble-title.R b/tests/testthat/_snaps/spin/spin_preamble-preamble-title.R new file mode 100644 index 00000000..e3054fae --- /dev/null +++ b/tests/testthat/_snaps/spin/spin_preamble-preamble-title.R @@ -0,0 +1,7 @@ +#' --- +#' title: Preamble Title +#' author: Jane Doe +#' --- +#' + +x <- 1 diff --git a/tests/testthat/_snaps/spin/spin_preamble-title-override.R b/tests/testthat/_snaps/spin/spin_preamble-title-override.R new file mode 100644 index 00000000..81757d40 --- /dev/null +++ b/tests/testthat/_snaps/spin/spin_preamble-title-override.R @@ -0,0 +1,7 @@ +#' --- +#' title: Override Title +#' author: John Doe +#' --- +#' + +x <- 1 diff --git a/tests/testthat/_snaps/spin/spin_preamble.R b/tests/testthat/_snaps/spin/spin_preamble.R new file mode 100644 index 00000000..c2c8b203 --- /dev/null +++ b/tests/testthat/_snaps/spin/spin_preamble.R @@ -0,0 +1,7 @@ +#' --- +#' title: report +#' --- +#' + +x <- 1 +y <- 2 diff --git a/tests/testthat/test-spin.R b/tests/testthat/test-spin.R new file mode 100644 index 00000000..83aea6f1 --- /dev/null +++ b/tests/testthat/test-spin.R @@ -0,0 +1,169 @@ +# file specific helpers ---- +create_test_script <- function( + name, + content = c("x <- 1", "y <- 2"), + envir = rlang::caller_env() +) { + tmp_dir <- withr::local_tempdir(.local_envir = envir) + withr::local_dir(tmp_dir, .local_envir = envir) + script <- name + xfun::write_utf8(content, script) + script +} + +expect_preamble_added <- function(script, snapshot_name) { + expect_message( + result <- add_spin_preamble(script), + "Added spin preamble" + ) + expect_equal(result, script) + + announce_snapshot_file(name = snapshot_name) + expect_snapshot_file(script, snapshot_name) +} + +# Tests ---- + +test_that("add_spin_preamble checks for file existence", { + expect_snapshot( + error = TRUE, + add_spin_preamble("non_existent_file.R") + ) +}) + +test_that("add_spin_preamble adds preamble to file without one", { + script <- create_test_script("report.R") + expect_preamble_added(script, "spin_preamble.R") + + skip_on_cran() + skip_if_no_quarto("1.4.511") + expect_no_error(quarto_render(script, quiet = TRUE)) + expect_true(file.exists(xfun::with_ext(script, "html"))) +}) + +test_that("add_spin_preamble doesn't modify file with existing preamble", { + # Create file with existing preamble + tmp_file <- withr::local_tempfile(fileext = ".R") + original_content <- c( + "#' ---", + "#' title: \"Existing Title\"", + "#' ---", + "#' ", + "x <- 1" + ) + xfun::write_utf8(original_content, tmp_file) + + # Try to add preamble + expect_message( + result <- add_spin_preamble(tmp_file), + "already has a spin preamble" + ) + + # Check return value is invisible + expect_invisible(expect_null(result)) + + # Check content unchanged + new_content <- xfun::read_utf8(tmp_file) + expect_equal(new_content, original_content) +}) + +test_that("add_spin_preamble detects preamble with leading whitespace", { + # Create file with preamble that has leading whitespace + tmp_file <- withr::local_tempfile(fileext = ".R") + original_content <- c( + " #' This is a comment", + "x <- 1" + ) + xfun::write_utf8(original_content, tmp_file) + + expect_message( + add_spin_preamble(tmp_file), + "already has a spin preamble" + ) + + # Content should be unchanged + new_content <- xfun::read_utf8(tmp_file) + expect_equal(new_content, original_content) +}) + +test_that("add_spin_preamble works with empty file", { + script <- create_test_script("report.R", "") + expect_preamble_added(script, "spin_preamble-empty.R") +}) + +test_that("add_spin_preamble works with custom title", { + script <- create_test_script("analysis.R") + + expect_message( + result <- add_spin_preamble(script, title = "Custom Analysis"), + "Added spin preamble" + ) + expect_equal(result, script) + + announce_snapshot_file(name = "spin_preamble-custom-title.R") + expect_snapshot_file(script, "spin_preamble-custom-title.R") +}) + +test_that("add_spin_preamble works with custom preamble", { + script <- create_test_script("report.R", c("library(ggplot2)", "plot(1:10)")) + + expect_message( + result <- add_spin_preamble( + script, + preamble = list( + title = "My Report", + author = "John Doe", + format = "html" + ) + ), + "Added spin preamble" + ) + expect_equal(result, script) + + announce_snapshot_file(name = "spin_preamble-custom-preamble.R") + expect_snapshot_file(script, "spin_preamble-custom-preamble.R") +}) + +test_that("title parameter overrides preamble title", { + script <- create_test_script("override.R", "x <- 1") + + expect_message( + result <- add_spin_preamble( + script, + title = "Override Title", + preamble = list(title = "Original Title", author = "John Doe") + ), + "Added spin preamble" + ) + expect_equal(result, script) + + announce_snapshot_file(name = "spin_preamble-title-override.R") + expect_snapshot_file(script, "spin_preamble-title-override.R") +}) + +test_that("preamble title is used when title parameter is NULL", { + script <- create_test_script("preamble_title.R", "x <- 1") + + expect_message( + result <- add_spin_preamble( + script, + title = NULL, + preamble = list(title = "Preamble Title", author = "Jane Doe") + ), + "Added spin preamble" + ) + expect_equal(result, script) + + announce_snapshot_file(name = "spin_preamble-preamble-title.R") + expect_snapshot_file(script, "spin_preamble-preamble-title.R") +}) + +test_that("add_spin_preamble validates preamble argument", { + tmp_file <- withr::local_tempfile(fileext = ".R") + writeLines("x <- 1", tmp_file) + + expect_snapshot( + error = TRUE, + add_spin_preamble(tmp_file, preamble = "not a list") + ) +})