diff --git a/DESCRIPTION b/DESCRIPTION index 36ce49b8..1a8c7c62 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -32,8 +32,9 @@ Imports: utils, xfun, yaml (>= 2.3.10) -Suggests: - bslib, +Suggests: + brand.yml, + bslib (>= 0.9.0), callr, curl, dplyr, diff --git a/R/list.R b/R/list.R index 597ef8ef..9b796748 100644 --- a/R/list.R +++ b/R/list.R @@ -19,6 +19,10 @@ quarto_list_extensions <- function() { x <- quarto_list(args, quarto_bin = quarto_bin, echo = FALSE) # Clean the stderr output to remove extra spaces and ensure consistent formatting stderr_cleaned <- gsub("\\s+$", "", x$stderr) + # Quarto CLI prepends a "Quarto version: X.Y.Z" line to stderr when log level + # is DEBUG (auto-enabled in GHA debug mode). Strip it so read.table() can use + # the real header row. See https://github.com/quarto-dev/quarto-cli/issues/14532. + stderr_cleaned <- sub("^Quarto version:[^\n]*\n", "", stderr_cleaned) if (grepl("No extensions are installed", stderr_cleaned)) { invisible() } else { diff --git a/R/quarto-args.R b/R/quarto-args.R index b3f79011..940da43c 100644 --- a/R/quarto-args.R +++ b/R/quarto-args.R @@ -26,3 +26,31 @@ append_cli_args <- function(new, append_to = NULL, after = length(append_to)) { } new } + +cli_arg_metadata <- function(metadata = NULL, metadata_file = NULL) { + if (is.null(metadata) && is.null(metadata_file)) { + return(list(args = character(), tmp_file = NULL)) + } + if (is.null(metadata)) { + return(list( + args = c("--metadata-file", metadata_file), + tmp_file = NULL + )) + } + if (!is.null(metadata_file)) { + file_content <- yaml::read_yaml(metadata_file, eval.expr = FALSE) + metadata <- merge_list(file_content, metadata) + } + tmp <- tempfile(pattern = "quarto-meta", fileext = ".yml") + # Remove tmp if write_yaml() errors before we hand the path to the + # caller; the caller (e.g. quarto_render()) registers its own + # on.exit cleanup for the success path. + success <- FALSE + on.exit(if (!success) unlink(tmp), add = TRUE) + write_yaml(metadata, tmp) + success <- TRUE + list( + args = c("--metadata-file", tmp), + tmp_file = tmp + ) +} diff --git a/R/remove.R b/R/remove.R index 7dd68a0a..928fe344 100644 --- a/R/remove.R +++ b/R/remove.R @@ -28,9 +28,23 @@ quarto_remove_extension <- function( rlang::check_required(extension) installed_extensions <- quarto_list_extensions() - if (is.null(installed_extensions)) { + # TODO: adapt based on https://github.com/quarto-dev/quarto-r/issues/301 + if (quarto_available(max = "1.9")) { + if (is.null(installed_extensions)) { + if (!quiet) { + cli::cli_alert_warning("No extensions installed.") + } + return(invisible(FALSE)) + } + } + + # Check extension is installed + is_installed <- extension %in% installed_extensions$Id + if (!is_installed) { if (!quiet) { - cli::cli_alert_warning("No extensions installed.") + cli::cli_alert_warning( + "{.str {extension}} is not among installed extensions." + ) } return(invisible(FALSE)) } diff --git a/R/render.R b/R/render.R index 8c17e6bd..15f845cb 100644 --- a/R/render.R +++ b/R/render.R @@ -197,18 +197,10 @@ quarto_render <- function( args <- c(args, "--cache-refresh") } # metadata to pass to quarto render - if (!is.null(metadata)) { - # We merge meta if there is metadata_file passed - if (!missing(metadata_file)) { - file_content <- yaml::read_yaml(metadata_file, eval.expr = FALSE) - metadata <- merge_list(file_content, metadata) - } - meta_file <- tempfile(pattern = "quarto-meta", fileext = ".yml") - on.exit(unlink(meta_file), add = TRUE) - write_yaml(metadata, meta_file) - args <- c(args, "--metadata-file", meta_file) - } else if (!missing(metadata_file)) { - args <- c(args, "--metadata-file", metadata_file) + meta_result <- cli_arg_metadata(metadata, metadata_file) + args <- c(args, meta_result$args) + if (!is.null(meta_result$tmp_file)) { + on.exit(unlink(meta_result$tmp_file), add = TRUE) } if (isTRUE(debug)) { args <- c(args, "--debug") diff --git a/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out b/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out new file mode 100644 index 00000000..0983e9d0 --- /dev/null +++ b/tests/testthat/_snaps/metadata-render/metadata-toplevel-override.test.out @@ -0,0 +1,10 @@ +Pandoc + Meta + { unMeta = + fromList + [ ( "custom-key" , MetaInlines [ Str "overridden" ] ) + , ( "title" , MetaInlines [ Str "Doc" ] ) + , ( "yml-only-key" , MetaInlines [ Str "kept" ] ) + ] + } + [] diff --git a/tests/testthat/_snaps/quarto.md b/tests/testthat/_snaps/quarto.md index b5391a47..4b884212 100644 --- a/tests/testthat/_snaps/quarto.md +++ b/tests/testthat/_snaps/quarto.md @@ -8,7 +8,7 @@ Caused by error: x Error returned by quarto CLI. i Rerun with `quiet = FALSE` to see the full error message. - Caused by error: + Caused by error in `processx::run()`: ! System command 'quarto' failed # quarto_run report full quarto cli error message @@ -26,7 +26,7 @@ Stack trace: - Caused by error: + Caused by error in `processx::run()`: ! System command 'quarto' failed # is_using_quarto correctly check directory diff --git a/tests/testthat/_snaps/remove.md b/tests/testthat/_snaps/remove.md index 52b77684..0d18b167 100644 --- a/tests/testthat/_snaps/remove.md +++ b/tests/testthat/_snaps/remove.md @@ -1,14 +1,16 @@ # Removing an extension Code - expect_false(quarto_remove_extension("quarto-ext/fontawesome", no_prompt = TRUE)) + withr::with_dir(wd, expect_false(quarto_remove_extension( + "quarto-ext/fontawesome", no_prompt = TRUE))) Message - ! No extensions installed. + ! "quarto-ext/fontawesome" is not among installed extensions. --- Code - expect_true(quarto_remove_extension("quarto-ext/fontawesome", no_prompt = TRUE)) + withr::with_dir(wd, expect_true(quarto_remove_extension( + "quarto-ext/fontawesome", no_prompt = TRUE))) Message v Extension `quarto-ext/fontawesome` successfully removed. diff --git a/tests/testthat/_snaps/render/metadata-file.test.out b/tests/testthat/_snaps/render/metadata-file.test.out deleted file mode 100644 index 2490cc43..00000000 --- a/tests/testthat/_snaps/render/metadata-file.test.out +++ /dev/null @@ -1,6 +0,0 @@ -Pandoc - Meta - { unMeta = - fromList [ ( "title" , MetaInlines [ Str "test" ] ) ] - } - [ Para [ Str "content" ] ] diff --git a/tests/testthat/_snaps/render/metadata-merged.test.out b/tests/testthat/_snaps/render/metadata-merged.test.out deleted file mode 100644 index 4cb8ddd8..00000000 --- a/tests/testthat/_snaps/render/metadata-merged.test.out +++ /dev/null @@ -1,10 +0,0 @@ -Pandoc - Meta - { unMeta = - fromList - [ ( "any" , MetaInlines [ Str "one" ] ) - , ( "other" , MetaInlines [ Str "thing" ] ) - , ( "title" , MetaInlines [ Str "test2" ] ) - ] - } - [ Para [ Str "content" ] ] diff --git a/tests/testthat/_snaps/render/metadata.test.out b/tests/testthat/_snaps/render/metadata.test.out deleted file mode 100644 index 2490cc43..00000000 --- a/tests/testthat/_snaps/render/metadata.test.out +++ /dev/null @@ -1,6 +0,0 @@ -Pandoc - Meta - { unMeta = - fromList [ ( "title" , MetaInlines [ Str "test" ] ) ] - } - [ Para [ Str "content" ] ] diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index 7e50a907..eda8b077 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -185,6 +185,11 @@ transform_quarto_cli_in_output <- function( return( function(lines) { + # Quarto CLI prepends "Quarto version: X.Y.Z" on stderr under DEBUG log + # level (auto-on in GHA debug mode). Strip so snapshots stay stable. + # See https://github.com/quarto-dev/quarto-cli/issues/14532. + lines <- lines[!grepl("^\\s*Quarto version:\\s+[0-9]", lines)] + if (hide_stack) { # Hide possible stack first stack_trace_index <- which(grepl("\\s*Stack trace\\:", lines)) diff --git a/tests/testthat/test-list.R b/tests/testthat/test-list.R index 12a8d827..c5e62892 100644 --- a/tests/testthat/test-list.R +++ b/tests/testthat/test-list.R @@ -5,17 +5,19 @@ test_that("Listing extensions", { skip_if_offline("github.com") qmd <- local_qmd_file(c("content")) withr::local_dir(dirname(qmd)) - expect_null(quarto_list_extensions()) + # TODO: Adapt depending on https://github.com/quarto-dev/quarto-r/issues/301 + # expect_null(quarto_list_extensions()) + default <- quarto_list_extensions()$Id quarto_add_extension("quarto-ext/fontawesome", no_prompt = TRUE, quiet = TRUE) expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) expect_identical( - quarto_list_extensions()$Id, + setdiff(quarto_list_extensions()$Id, default), c("quarto-ext/fontawesome") ) quarto_add_extension("quarto-ext/lightbox", no_prompt = TRUE, quiet = TRUE) expect_true(dir.exists("_extensions/quarto-ext/lightbox")) expect_identical( - quarto_list_extensions()$Id, + setdiff(quarto_list_extensions()$Id, default), c("quarto-ext/fontawesome", "quarto-ext/lightbox") ) }) diff --git a/tests/testthat/test-metadata-args.R b/tests/testthat/test-metadata-args.R new file mode 100644 index 00000000..02fad936 --- /dev/null +++ b/tests/testthat/test-metadata-args.R @@ -0,0 +1,110 @@ +test_that("cli_arg_metadata returns empty args when both inputs NULL", { + result <- cli_arg_metadata(metadata = NULL, metadata_file = NULL) + expect_identical(result$args, character()) + expect_null(result$tmp_file) +}) + +test_that("cli_arg_metadata with metadata only writes temp YAML and uses --metadata-file", { + skip_if_not_installed("withr") + skip_if_not_installed("yaml") + result <- cli_arg_metadata( + metadata = list(title = "test", lang = "fr"), + metadata_file = NULL + ) + withr::defer(unlink(result$tmp_file)) + + expect_length(result$args, 2L) + expect_identical(result$args[1], "--metadata-file") + expect_true(file.exists(result$args[2])) + expect_identical(result$args[2], result$tmp_file) + + written <- yaml::read_yaml(result$tmp_file) + expect_identical(written, list(title = "test", lang = "fr")) +}) + +test_that("cli_arg_metadata with metadata_file only passes path verbatim", { + skip_if_not_installed("withr") + yml <- withr::local_tempfile(fileext = ".yml") + yaml::write_yaml(list(title = "file-title"), yml) + + result <- cli_arg_metadata(metadata = NULL, metadata_file = yml) + expect_identical(result$args, c("--metadata-file", yml)) + expect_null(result$tmp_file) +}) + +test_that("cli_arg_metadata merges metadata over metadata_file with metadata winning", { + skip_if_not_installed("withr") + skip_if_not_installed("yaml") + yml <- withr::local_tempfile(fileext = ".yml") + yaml::write_yaml(list(title = "from-file", other = "kept"), yml) + + result <- cli_arg_metadata( + metadata = list(title = "from-list", new = "added"), + metadata_file = yml + ) + withr::defer(unlink(result$tmp_file)) + + expect_identical(result$args[1], "--metadata-file") + expect_identical(result$args[2], result$tmp_file) + + written <- yaml::read_yaml(result$tmp_file) + expect_identical( + written, + list(title = "from-list", other = "kept", new = "added") + ) +}) + +test_that("cli_arg_metadata preserves nested list structure in temp YAML", { + skip_if_not_installed("withr") + skip_if_not_installed("yaml") + nested <- list(format = list(html = list(`toc-title` = "Custom"))) + result <- cli_arg_metadata(metadata = nested, metadata_file = NULL) + withr::defer(unlink(result$tmp_file)) + + written <- yaml::read_yaml(result$tmp_file) + expect_identical(written, nested) +}) + +test_that("cli_arg_metadata removes temp file if write_yaml errors", { + skip_if_not_installed("withr") + captured_tmp <- NULL + local_mocked_bindings( + write_yaml = function(x, file) { + captured_tmp <<- file + file.create(file) + stop("simulated write failure") + } + ) + + expect_error( + cli_arg_metadata(metadata = list(title = "x"), metadata_file = NULL), + "simulated write failure" + ) + expect_false(is.null(captured_tmp)) + expect_false(file.exists(captured_tmp)) +}) + +test_that("cli_arg_metadata merge is shallow (top-level keys replaced wholesale)", { + # Documents current behavior - metadata replaces top-level keys entirely. + # If we ever switch to deep merge, this test should be updated, not silently broken. + skip_if_not_installed("withr") + skip_if_not_installed("yaml") + yml <- withr::local_tempfile(fileext = ".yml") + yaml::write_yaml( + list(format = list(html = list(toc = TRUE, `toc-title` = "Original"))), + yml + ) + + result <- cli_arg_metadata( + metadata = list(format = list(html = list(`toc-title` = "Overridden"))), + metadata_file = yml + ) + withr::defer(unlink(result$tmp_file)) + + written <- yaml::read_yaml(result$tmp_file) + # toc=TRUE from file is LOST because metadata's `format` replaces wholesale + expect_identical( + written, + list(format = list(html = list(`toc-title` = "Overridden"))) + ) +}) diff --git a/tests/testthat/test-metadata-render.R b/tests/testthat/test-metadata-render.R new file mode 100644 index 00000000..cccdb758 --- /dev/null +++ b/tests/testthat/test-metadata-render.R @@ -0,0 +1,80 @@ +# Integration test for the actual motivation behind switching to +# --metadata-file in PR #52: top-level (non-format-nested) keys from +# _quarto.yml can be overridden, which --metadata key:value cannot do +# (--metadata only reaches keys under the chosen output format). +# Format-nested keys (e.g. format.html.toc-title) are consumed by +# Quarto before reaching Pandoc, so they do not appear in native AST; +# a top-level key is the right surface to demonstrate the override. +# A Lua filter scrubs all metadata except an allowlist so the +# native-AST snapshot stays small and stable across Quarto versions. + +test_that("metadata overrides keys from _quarto.yml", { + skip_if_no_quarto() + skip_if_not_installed("withr") + skip_if_not_installed("xfun") + + proj <- withr::local_tempdir("quarto-metadata-nested-") + + xfun::write_utf8( + c( + "project:", + " type: default", + "format:", + " html:", + " toc-title: Original", + "custom-key: from-quarto-yml", + # control key only set in _quarto.yml; proves project metadata + # was actually read (not just our --metadata-file override) + "yml-only-key: kept" + ), + file.path(proj, "_quarto.yml") + ) + + xfun::write_utf8( + c( + "---", + "title: Doc", + "---", + "", + "body" + ), + file.path(proj, "index.qmd") + ) + + # Lua filter: keep only allowlisted meta keys, drop document body. + # Result is a tiny, deterministic native AST regardless of what else + # Quarto injects (engines, extension paths, etc.). + xfun::write_utf8( + c( + 'function Pandoc(doc)', + ' local wanted = {', + ' ["title"] = true,', + ' ["custom-key"] = true,', + ' ["yml-only-key"] = true,', + ' }', + ' local kept = pandoc.Meta({})', + ' for k, v in pairs(doc.meta) do', + ' if wanted[k] then kept[k] = v end', + ' end', + ' return pandoc.Pandoc({}, kept)', + 'end' + ), + file.path(proj, "extract-meta.lua") + ) + + withr::local_dir(proj) + + quarto_render( + "index.qmd", + output_format = "native", + metadata = list(`custom-key` = "overridden"), + quarto_args = c("--lua-filter", "extract-meta.lua"), + quiet = TRUE + ) + + announce_snapshot_file(name = "metadata-toplevel-override.test.out") + expect_snapshot_file( + file.path(proj, "index.native"), + "metadata-toplevel-override.test.out" + ) +}) diff --git a/tests/testthat/test-remove.R b/tests/testthat/test-remove.R index 5ccb8bc8..ee3e1e6c 100644 --- a/tests/testthat/test-remove.R +++ b/tests/testthat/test-remove.R @@ -2,16 +2,30 @@ test_that("Removing an extension", { skip_if_no_quarto() skip_if_offline("github.com") qmd <- local_qmd_file(c("content")) - withr::local_dir(dirname(qmd)) - expect_snapshot(expect_false(quarto_remove_extension( - "quarto-ext/fontawesome", - no_prompt = TRUE - ))) - quarto_add_extension("quarto-ext/fontawesome", no_prompt = TRUE, quiet = TRUE) - expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) - expect_snapshot(expect_true(quarto_remove_extension( - "quarto-ext/fontawesome", - no_prompt = TRUE - ))) - expect_false(dir.exists("_extensions")) + # with expect_snapshot, can't use withr::local_dir() + wd <- dirname(qmd) + local_edition(3) + expect_snapshot(withr::with_dir( + wd, + expect_false(quarto_remove_extension( + "quarto-ext/fontawesome", + no_prompt = TRUE + )) + )) + withr::with_dir(wd, { + quarto_add_extension( + "quarto-ext/fontawesome", + no_prompt = TRUE, + quiet = TRUE + ) + expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) + }) + expect_snapshot(withr::with_dir( + wd, + expect_true(quarto_remove_extension( + "quarto-ext/fontawesome", + no_prompt = TRUE + )) + )) + withr::with_dir(wd, expect_false(dir.exists("_extensions"))) }) diff --git a/tests/testthat/test-render.R b/tests/testthat/test-render.R index d0c6ca38..7bb92958 100644 --- a/tests/testthat/test-render.R +++ b/tests/testthat/test-render.R @@ -11,46 +11,6 @@ test_that("R Markdown documents can be rendered", { unlink("test.html") }) -test_that("metadata argument works in quarto_render", { - skip_if_no_quarto() - qmd <- local_qmd_file(c("content")) - # metadata - expect_snapshot_qmd_output( - name = "metadata", - input = qmd, - output_format = "native", - metadata = list(title = "test") - ) -}) - -test_that("metadata-file argument works in quarto_render", { - skip_if_no_quarto() - skip_if_not_installed("withr") - qmd <- local_qmd_file(c("content")) - yaml <- withr::local_tempfile(fileext = ".yml") - write_yaml(list(title = "test"), yaml) - expect_snapshot_qmd_output( - name = "metadata-file", - input = qmd, - output_format = "native", - metadata_file = yaml - ) -}) - -test_that("metadata-file and metadata are merged in quarto_render", { - skip_if_no_quarto() - skip_if_not_installed("withr") - qmd <- local_qmd_file(c("content")) - yaml <- withr::local_tempfile(fileext = ".yml") - write_yaml(list(title = "test", other = "thing"), yaml) - expect_snapshot_qmd_output( - name = "metadata-merged", - input = qmd, - output_format = "native", - metadata_file = yaml, - metadata = list(title = "test2", any = "one") - ) -}) test_that("quarto_args in quarto_render", { skip_if_no_quarto() diff --git a/tests/testthat/test-theme.R b/tests/testthat/test-theme.R index 2e1ba8a0..587376b5 100644 --- a/tests/testthat/test-theme.R +++ b/tests/testthat/test-theme.R @@ -2,6 +2,8 @@ skip_on_cran() skip_if_no_quarto() skip_if_not_installed("withr") +skip_if_not_installed("brand.yml") +skip_if_not_installed("bslib", "0.9.0") # We need to install the package in a temporary library when we are in dev mode install_dev_package() diff --git a/tests/testthat/test-update.R b/tests/testthat/test-update.R index 25263ec2..565757fd 100644 --- a/tests/testthat/test-update.R +++ b/tests/testthat/test-update.R @@ -8,16 +8,18 @@ test_that("Updating an extension", { no_prompt = TRUE, quiet = TRUE ) - expect_equal(quarto_list_extensions()$Version, "0.0.1") + installed <- quarto_list_extensions() + fontawesome_row <- installed[installed$Id == "quarto-ext/fontawesome", ] + expect_equal(fontawesome_row$Version, "0.0.1") quarto_update_extension( "quarto-ext/fontawesome", no_prompt = TRUE, quiet = TRUE ) expect_true(dir.exists("_extensions/quarto-ext/fontawesome")) - expect_true( - as.numeric_version(current_version <- quarto_list_extensions()$Version) > - "0.0.1" - ) + installed <- quarto_list_extensions() + fontawesome_row <- installed[installed$Id == "quarto-ext/fontawesome", ] + current_version <- fontawesome_row$Version + expect_true(as.numeric_version(current_version) > "0.0.1") expect_false(identical(current_version, "0.0.1")) }) diff --git a/tests/testthat/test-utils-extract.R b/tests/testthat/test-utils-extract.R index 4ffd3f43..02529557 100644 --- a/tests/testthat/test-utils-extract.R +++ b/tests/testthat/test-utils-extract.R @@ -19,6 +19,9 @@ test_that("qmd_to_r_script() errors on existing script", { test_that("qmd_to_r_script() writes R file that renders", { skip_if_no_quarto() + # TODO: Problem with quarto 1.9 - reactivate when fix. + # https://github.com/quarto-dev/quarto-cli/issues/14529 + skip_if_quarto("1.9") r_script <- withr::local_tempfile(pattern = "purl", fileext = ".R") announce_snapshot_file(name = "purl.R")