diff --git a/DESCRIPTION b/DESCRIPTION index 8525c74a..ba9a1b47 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: quarto Title: R Interface to 'Quarto' Markdown Publishing System -Version: 1.4.4.9027 +Version: 1.4.4.9028 Authors@R: c( person("JJ", "Allaire", , "jj@posit.co", role = "aut", comment = c(ORCID = "0000-0003-0174-9868")), @@ -31,7 +31,7 @@ Imports: tools, utils, xfun, - yaml + yaml (>= 2.3.10) Suggests: bslib, callr, diff --git a/NAMESPACE b/NAMESPACE index ee02003b..1ef027a9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -44,6 +44,7 @@ export(theme_colors_gt) export(theme_colors_plotly) export(theme_colors_thematic) export(write_yaml_metadata_block) +export(yaml_quote_string) import(rlang) importFrom(cli,cli_abort) importFrom(cli,cli_inform) diff --git a/NEWS.md b/NEWS.md index f4ce3e1f..35a99198 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,10 @@ # quarto (development version) +- Improved YAML 1.2 compatibility features to ensure proper parsing by Quarto's js-yaml parser. + - The `yaml_quote_string()` function allows explicit control over string quoting in YAML output. + - `write_yaml_metadata_block()` automatically handles data corruption prevention from leading zero strings like `"029"` that would be misinterpreted as octal numbers (becoming `29`) (thanks, @Mosk915, quarto-dev/quarto-cli#12736, #242). Boolean values are also correctly formatted as lowercase (`true`/`false`) instead of YAML 1.1 variants like `yes`/`no`. + - This change also benefits other functions writing YAML like `quarto_render()` when using `metadata=` or `execute_params=` arguments. + - Package is now licenced MIT like Quarto CLI. - Added `detect_bookdown_crossrefs()` function to help users migrate from bookdown to Quarto by identifying cross-references that need manual conversion. The function scans R Markdown or Quarto files to detect bookdown-specific cross-reference syntax (like `\@ref(fig:label)` and `(\#eq:label)`) and provides detailed guidance on converting them to Quarto syntax (like `@fig-label` and `{#eq-label}`). It offers both compact and verbose reporting modes, with context-aware warnings that only show syntax patterns actually found in your files. diff --git a/R/metadata.R b/R/metadata.R index b0002851..22c82de9 100644 --- a/R/metadata.R +++ b/R/metadata.R @@ -27,13 +27,44 @@ #' If no metadata is provided (empty `...` and `NULL` or empty `.list`), #' the function returns `NULL` without generating any output. #' -#' **Important**: When using this function in Quarto documents, you must set -#' the chunk option `output: asis` (or `#| output: asis`) for the metadata -#' block to be properly processed by Quarto. -#' #' This addresses the limitation where Quarto metadata must be static and #' cannot be set dynamically from R code during document rendering. #' +#' ## YAML 1.2 Compatibility: +#' To ensure compatibility with Quarto's YAML 1.2 parser (js-yaml), the function +#' automatically handles two key differences between R's yaml package (YAML 1.1) +#' and YAML 1.2: +#' +#' ### Boolean values: +#' R logical values (`TRUE`/`FALSE`) are converted to lowercase +#' YAML 1.2 format (`true`/`false`) using [yaml::verbatim_logical()]. This prevents +#' YAML 1.1 boolean representations like `yes`/`no` from being used. +#' +#' ### String quoting: +#' Strings with leading zeros that contain digits 8 or 9 (like `"029"`, `"089"`) +#' are automatically quoted to prevent them from being parsed as octal numbers, +#' which would result in data corruption (e.g., `"029"` becoming `29`). +#' Valid octal numbers containing only digits 0-7 (like `"0123"`) are handled +#' by the underlying \pkg{yaml} package. +#' +#' For manual control over string quoting behavior, use [yaml_quote_string()]. +#' +#' ## Quarto Usage: +#' To use this function in a Quarto document, create an R code chunk with +#' the `output: asis` option: +#' +#' ``` +#' ```{r} +#' #| output: asis +#' write_yaml_metadata_block(admin = TRUE, version = "1.0") +#' ``` +#' ``` +#' +#' Without the `output: asis` option, the YAML metadata block will be +#' displayed as text rather than processed as metadata by Quarto. +#' +#' @inherit yaml_character_handler seealso +#' #' @examples #' \dontrun{ #' # In a Quarto document R chunk with `#| output: asis`: @@ -47,6 +78,12 @@ #' timestamp = Sys.Date() #' ) #' +#' # Strings with leading zeros are automatically quoted for YAML 1.2 compatibility +#' write_yaml_metadata_block( +#' zip_code = "029", # Automatically quoted as "029" +#' build_id = "0123" # Quoted by yaml package (valid octal) +#' ) +#' #' # Use with .list parameter #' metadata_list <- list(version = "1.0", debug = FALSE) #' write_yaml_metadata_block(.list = metadata_list) @@ -65,20 +102,8 @@ #' # ::: #' } #' -#' @section Quarto Usage: -#' To use this function in a Quarto document, create an R code chunk with -#' the `output: asis` option: -#' -#' ``` -#' ```{r} -#' #| output: asis -#' write_yaml_metadata_block(admin = TRUE, version = "1.0") -#' ``` -#' ``` -#' -#' Without the `output: asis` option, the YAML metadata block will be -#' displayed as text rather than processed as metadata by Quarto. -#' +#' @seealso [yaml_quote_string()] for explicitly controlling which strings are quoted +#' in YAML output when you encounter edge cases that need manual handling. #' #' @export write_yaml_metadata_block <- function(..., .list = NULL) { diff --git a/R/utils.R b/R/utils.R index 5f8abe85..81226fe2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -3,15 +3,103 @@ relative_to_wd <- function(path) { rmarkdown::relative_to(getwd(), path) } +#' Add quoted attribute to strings for YAML output +#' +#' This function allows users to explicitly mark strings that should be quoted +#' in YAML output, giving full control over quoting behavior. +#' +#' This is particularly useful for special values that might be misinterpreted +#' as \pkg{yaml} uses YAML 1.1 and Quarto expects YAML 1.2. +#' +#' The `quoted` attribute is a convention used by [yaml::as.yaml()] +#' +#' @param x A character vector or single string +#' @return The input with quoted attributes applied +#' @examples +#' yaml::as.yaml(list(id = yaml_quote_string("1.0"))) +#' yaml::as.yaml(list(id = "1.0")) +#' +#' @export +yaml_quote_string <- function(x) { + if (!is.character(x)) { + cli::cli_abort("yaml_quote_string() only works with character vectors") + } + + result <- vector("list", length(x)) + for (i in seq_along(x)) { + val <- x[i] + attr(val, "quoted") <- TRUE + result[[i]] <- val + } + + if (length(result) == 1) { + return(result[[1]]) + } + + result +} + +is_valid_yaml11_octal <- function(val) { + # Check if the value is a valid YAML 1.1 octal number + # Valid octals are 0o[0-7]+, but we only quote those with leading zeros + # that contain digits 8 or 9, which are invalid in octal, as they + # would not be quoted already by the R yaml package. + # YAML 1.1 spec for int: https://yaml.org/type/int.html + invalid <- !is.na(val) && + val != "" && + val != "0" && + grepl("^0[0-9]+$", val) && + grepl("[89]", val) + !invalid +} + +#' YAML character handler for YAML 1.1 to 1.2 compatibility +#' +#' This handler bridges the gap between R's yaml package (YAML 1.1) and +#' js-yaml (YAML 1.2) by quoting strings with leading zeros that would be +#' misinterpreted as octal numbers. +#' +#' According to YAML 1.1 spec, octal integers are `0o[0-7]+`. The R yaml +#' package only quotes valid octals (containing only digits 0-7), but js-yaml +#' attempts to parse ANY leading zero string as octal, causing data corruption +#' for invalid octals like "029" → 29. +#' +#' @seealso [YAML 1.1 int spec](https://yaml.org/type/int.html) +#' +#' @param x A character vector +#' @return The input with quoted attributes applied where needed +#' @keywords internal +yaml_character_handler <- function(x) { + apply_quote <- function(x) { + # Skip if already has quoted attribute (user control via yaml_quote_string()) + if (!is.null(attr(x, "quoted")) && attr(x, "quoted")) { + return(x) + } + # Quote leading zero strings that are NOT valid octals (YAML 1.1 vs 1.2 gap) + # Valid octals contain only digits 0-7, invalid ones contain 8 or 9 + if (!(is_valid_yaml11_octal(x))) { + attr(x, "quoted") <- TRUE + } + return(x) + } + # For single elements, process directly + if (length(x) == 1) { + return(apply_quote(x)) + } else { + # For vectors, process each element and return as list to preserve attributes + result <- vector("list", length(x)) + for (i in seq_along(x)) { + result[[i]] <- apply_quote(x[i]) + } + return(result) + } +} + # Specific YAML handlers # as quarto expects YAML 1.2 and yaml R package supports 1.1 yaml_handlers <- list( - # Handle yes/no from 1.1 to 1.2 - # https://github.com/vubiostat/r-yaml/issues/131 - logical = function(x) { - value <- ifelse(x, "true", "false") - structure(value, class = "verbatim") - } + logical = yaml::verbatim_logical, + character = yaml_character_handler ) #' @importFrom yaml as.yaml diff --git a/_pkgdown.yml b/_pkgdown.yml index 0eea97f6..be601ba3 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -66,11 +66,17 @@ reference: contents: - starts_with("tbl_qmd_") +- title: "YAML Helpers" + desc: > + These functions are used to help with YAML metadata in Quarto documents: + contents: + - write_yaml_metadata_block + - yaml_quote_string + - title: "Miscellaneous" desc: > These functions are used to help with Quarto documents and projects: contents: - - write_yaml_metadata_block - add_spin_preamble - qmd_to_r_script - detect_bookdown_crossrefs diff --git a/man/write_yaml_metadata_block.Rd b/man/write_yaml_metadata_block.Rd index d7ec2517..e8c7bca9 100644 --- a/man/write_yaml_metadata_block.Rd +++ b/man/write_yaml_metadata_block.Rd @@ -37,14 +37,34 @@ takes precedence and will override the value from \code{.list}. If no metadata is provided (empty \code{...} and \code{NULL} or empty \code{.list}), the function returns \code{NULL} without generating any output. -\strong{Important}: When using this function in Quarto documents, you must set -the chunk option \code{output: asis} (or \verb{#| output: asis}) for the metadata -block to be properly processed by Quarto. - This addresses the limitation where Quarto metadata must be static and cannot be set dynamically from R code during document rendering. +\subsection{YAML 1.2 Compatibility:}{ + +To ensure compatibility with Quarto's YAML 1.2 parser (js-yaml), the function +automatically handles two key differences between R's yaml package (YAML 1.1) +and YAML 1.2: +\subsection{Boolean values:}{ + +R logical values (\code{TRUE}/\code{FALSE}) are converted to lowercase +YAML 1.2 format (\code{true}/\code{false}) using [yaml::verbatim_logical()]. This prevents +YAML 1.1 boolean representations like \code{yes}/\code{no} from being used. +} + +\subsection{String quoting:}{ + +Strings with leading zeros that contain digits 8 or 9 (like \code{"029"}, \code{"089"}) +are automatically quoted to prevent them from being parsed as octal numbers, +which would result in data corruption (e.g., \code{"029"} becoming \code{29}). +Valid octal numbers containing only digits 0-7 (like \code{"0123"}) are handled +by the underlying \pkg{yaml} package. + +For manual control over string quoting behavior, use [yaml_quote_string()]. +} + } -\section{Quarto Usage}{ + +\subsection{Quarto Usage:}{ To use this function in a Quarto document, create an R code chunk with the \code{output: asis} option: @@ -58,9 +78,12 @@ write_yaml_metadata_block(admin = TRUE, version = "1.0") Without the `output: asis` option, the YAML metadata block will be displayed as text rather than processed as metadata by Quarto. + +[yaml::verbatim_logical()]: R:yaml::verbatim_logical() +[yaml_quote_string()]: R:yaml_quote_string() }\if{html}{\out{}} } - +} \examples{ \dontrun{ # In a Quarto document R chunk with `#| output: asis`: @@ -74,6 +97,12 @@ write_yaml_metadata_block( timestamp = Sys.Date() ) +# Strings with leading zeros are automatically quoted for YAML 1.2 compatibility +write_yaml_metadata_block( + zip_code = "029", # Automatically quoted as "029" + build_id = "0123" # Quoted by yaml package (valid octal) +) + # Use with .list parameter metadata_list <- list(version = "1.0", debug = FALSE) write_yaml_metadata_block(.list = metadata_list) @@ -93,3 +122,7 @@ write_yaml_metadata_block( } } +\seealso{ +\code{\link[=yaml_quote_string]{yaml_quote_string()}} for explicitly controlling which strings are quoted +in YAML output when you encounter edge cases that need manual handling. +} diff --git a/man/yaml_character_handler.Rd b/man/yaml_character_handler.Rd new file mode 100644 index 00000000..3f26d6cd --- /dev/null +++ b/man/yaml_character_handler.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{yaml_character_handler} +\alias{yaml_character_handler} +\title{YAML character handler for YAML 1.1 to 1.2 compatibility} +\usage{ +yaml_character_handler(x) +} +\arguments{ +\item{x}{A character vector} +} +\value{ +The input with quoted attributes applied where needed +} +\description{ +This handler bridges the gap between R's yaml package (YAML 1.1) and +js-yaml (YAML 1.2) by quoting strings with leading zeros that would be +misinterpreted as octal numbers. +} +\details{ +According to YAML 1.1 spec, octal integers are \verb{0o[0-7]+}. The R yaml +package only quotes valid octals (containing only digits 0-7), but js-yaml +attempts to parse ANY leading zero string as octal, causing data corruption +for invalid octals like "029" → 29. +} +\seealso{ +\href{https://yaml.org/type/int.html}{YAML 1.1 int spec} +} +\keyword{internal} diff --git a/man/yaml_quote_string.Rd b/man/yaml_quote_string.Rd new file mode 100644 index 00000000..c3a7c87e --- /dev/null +++ b/man/yaml_quote_string.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{yaml_quote_string} +\alias{yaml_quote_string} +\title{Add quoted attribute to strings for YAML output} +\usage{ +yaml_quote_string(x) +} +\arguments{ +\item{x}{A character vector or single string} +} +\value{ +The input with quoted attributes applied +} +\description{ +This function allows users to explicitly mark strings that should be quoted +in YAML output, giving full control over quoting behavior. +} +\details{ +This is particularly useful for special values that might be misinterpreted +as \pkg{yaml} uses YAML 1.1 and Quarto expects YAML 1.2. + +The \code{quoted} attribute is a convention used by \code{\link[yaml:as.yaml]{yaml::as.yaml()}} +} +\examples{ +yaml::as.yaml(list(id = yaml_quote_string("1.0"))) +yaml::as.yaml(list(id = "1.0")) + +} diff --git a/tests/testthat/_snaps/convert-bookdown.md b/tests/testthat/_snaps/convert-bookdown.md index 353284c7..947983aa 100644 --- a/tests/testthat/_snaps/convert-bookdown.md +++ b/tests/testthat/_snaps/convert-bookdown.md @@ -486,8 +486,8 @@ -- File: '' -- -- Theorem Block Unlabeled references: - * Line 2 (':2'): `` ```{theorem name="Pythagorean theorem"} - `` -> `Manual conversion required: Use ::: {#thm-} syntax. See + * Line 2 (':2'): `` ```{theorem name="Pythagorean theorem"} `` -> `Manual + conversion required: Use ::: {#thm-} syntax. See https://quarto.org/docs/authoring/cross-references.html#theorems-and-proofs` i Summary of conversion requirements: @@ -495,8 +495,7 @@ ! Theorem environments require manual restructuring Bookdown old syntax WITHOUT label: ```{theorem chunk_name} Quarto syntax: :::{#thm-label} - See: - + See: # detects theorem div syntax correctly diff --git a/tests/testthat/_snaps/utils.md b/tests/testthat/_snaps/utils.md index 489f5216..21b48af5 100644 --- a/tests/testthat/_snaps/utils.md +++ b/tests/testthat/_snaps/utils.md @@ -103,3 +103,17 @@ * Use `NULL` instead of `NA` for missing optional parameters * Handle missing values within your document code using conditional logic +# write_yaml_metadata_block produces YAML 1.2 compatible output + + Code + cat(write_yaml_metadata_block(title = "Test Document", zip_code = "029", build = "0123", + version = yaml_quote_string("1.0"), debug = TRUE)) + Output + --- + title: Test Document + zip_code: "029" + build: '0123' + version: "1.0" + debug: true + --- + diff --git a/tests/testthat/test-convert-bookdown.R b/tests/testthat/test-convert-bookdown.R index 8d088caa..df490d0a 100644 --- a/tests/testthat/test-convert-bookdown.R +++ b/tests/testthat/test-convert-bookdown.R @@ -211,6 +211,7 @@ test_that("detects theorem block with label correctly", { }) test_that("detects theorem block without label correctly", { + local_reproducible_output(width = 100) test_file <- local_rmd_file( "# Test Document", "```{theorem name=\"Pythagorean theorem\"}", diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 827b3982..b2a3612f 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -110,3 +110,130 @@ test_that("quarto_render uses write_yaml validation", { error = TRUE ) }) + + +test_that("yaml_quote_string adds quoted attribute to strings", { + # Single string + result <- yaml_quote_string("1.0") + expect_true(attr(result, "quoted")) + expect_equal(as.character(result), "1.0") + + # Multiple strings + multi_result <- yaml_quote_string(c("1.0", "2.0")) + expect_length(multi_result, 2) + expect_true(attr(multi_result[[1]], "quoted")) + expect_true(attr(multi_result[[2]], "quoted")) +}) + +test_that("yaml_quote_string only works with character vectors", { + expect_error( + yaml_quote_string(123), + "yaml_quote_string() only works with character vectors", + fixed = TRUE + ) + + expect_error( + yaml_quote_string(c(1, 2, 3)), + "yaml_quote_string() only works with character vectors", + fixed = TRUE + ) +}) + +test_that("yaml_character_handler quotes invalid octal strings only", { + invalid_octals <- c("029", "089", "099", "0189") + for (case in invalid_octals) { + expect_true( + attr(yaml_character_handler(!!case), "quoted"), + ) + } +}) + +test_that("yaml_character_handler doesn't quote valid octals or regular strings", { + not_quoted_cases <- c("0123", "007", "0567", "0", "123", "hello", "abc123") + + for (case in not_quoted_cases) { + expect_null( + attr(yaml_character_handler(!!case), "quoted"), + ) + } +}) + +test_that("yaml_character_handler preserves user-set quoted attribute", { + x <- "hello" + attr(x, "quoted") <- TRUE + + result <- yaml_character_handler(x) + expect_true(attr(result, "quoted")) + expect_equal(as.character(result), "hello") +}) + +test_that("yaml_character_handler handles character vectors", { + result <- yaml_character_handler(c("029", "0123", "089", "hello")) + + expect_length(result, 4) + expect_true(attr(result[[1]], "quoted")) + expect_null(attr(result[[2]], "quoted")) + expect_true(attr(result[[3]], "quoted")) + expect_null(attr(result[[4]], "quoted")) +}) + +test_that("yaml_character_handler handles edge cases", { + edge_cases <- c(NA_character_, "", "0") + result <- yaml_character_handler(edge_cases) + expect_null(attr(result[[1]], "quoted")) + expect_null(attr(result[[2]], "quoted")) + expect_null(attr(result[[3]], "quoted")) +}) + +test_that("as_yaml quotes invalid octals but lets yaml package handle valid ones", { + expect_identical(as_yaml(list(x = "029")), "x: \"029\"\n") + expect_identical(as_yaml(list(x = "089")), "x: \"089\"\n") + expect_identical(as_yaml(list(x = "0123")), "x: '0123'\n") + expect_identical(as_yaml(list(x = "007")), "x: '007'\n") + expect_identical(as_yaml(list(x = "hello")), "x: hello\n") +}) + +test_that("as_yaml preserves user control with yaml_quote_string", { + result <- as_yaml(list( + version = yaml_quote_string("1.0"), + auto_quoted = "029", + normal = "hello" + )) + expect_match(result, "version: \"1\\.0\"") + expect_match(result, "auto_quoted: \"029\"") + expect_match(result, "normal: hello") +}) + +test_that("as_yaml handles complex nested structures", { + result <- as_yaml(list( + metadata = list( + invalid_octals = c("029", "089"), + versions = c(yaml_quote_string("1.0"), yaml_quote_string("2.0")), + names = c("alice", "bob") + ), + config = list( + zip = "12345", + code = "029" + ) + )) + expect_match(result, "- \"029\"") + expect_match(result, "- \"089\"") + expect_match(result, "- '1\\.0'") + expect_match(result, "- '2\\.0'") + expect_match(result, "- alice") + expect_match(result, "- bob") + expect_match(result, "zip: '12345'") + expect_match(result, "code: \"029\"") +}) + +test_that("write_yaml_metadata_block produces YAML 1.2 compatible output", { + expect_snapshot(cat( + write_yaml_metadata_block( + title = "Test Document", + zip_code = "029", + build = "0123", + version = yaml_quote_string("1.0"), + debug = TRUE + ) + )) +})