Skip to content

feat: add project_path() function for Quarto-aware path construction #265

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

Merged
merged 24 commits into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: quarto
Title: R Interface to 'Quarto' Markdown Publishing System
Version: 1.4.4.9024
Version: 1.4.4.9025
Authors@R: c(
person("JJ", "Allaire", , "[email protected]", role = "aut",
comment = c(ORCID = "0000-0003-0174-9868")),
Expand All @@ -23,6 +23,7 @@ Imports:
htmltools,
jsonlite,
later,
lifecycle,
processx,
rlang,
rmarkdown,
Expand Down
5 changes: 5 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

export(add_spin_preamble)
export(check_newer_version)
export(find_project_root)
export(get_running_project_root)
export(is_using_quarto)
export(new_blog_post)
export(project_path)
export(qmd_to_r_script)
export(quarto_add_extension)
export(quarto_available)
Expand Down Expand Up @@ -47,6 +50,7 @@ importFrom(htmltools,div)
importFrom(htmltools,span)
importFrom(jsonlite,fromJSON)
importFrom(later,later)
importFrom(lifecycle,deprecated)
importFrom(processx,process)
importFrom(processx,run)
importFrom(rlang,caller_env)
Expand All @@ -58,5 +62,6 @@ importFrom(tools,vignetteEngine)
importFrom(utils,browseURL)
importFrom(xfun,base64_encode)
importFrom(xfun,env_option)
importFrom(xfun,normalize_path)
importFrom(yaml,as.yaml)
importFrom(yaml,write_yaml)
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# quarto (development version)

- Added `project_path()`, `get_running_project_root()`, and `find_project_root()` functions for Quarto-aware project path construction. These functions provide a consistent way to reference files relative to the project root, working both during Quarto rendering (using `QUARTO_PROJECT_ROOT` environment variables) and in interactive sessions (using intelligent project detection). The `project_path()` function is particularly useful in Quarto document cells where you need to reference data files or scripts from the project root regardless of the document's location in subdirectories (#180).

- `quarto_preview()` now explicitly returns the preview server URL (invisibly) and documents this behavior. This enables programmatic workflows such as taking screenshots with **webshot2** or passing the URL to other automation tools (thanks, @cwickham, #233).

- 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).
Expand Down
2 changes: 2 additions & 0 deletions R/quarto-package.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
#' @importFrom cli cli_inform
#' @importFrom htmltools div
#' @importFrom htmltools span
#' @importFrom lifecycle deprecated
#' @importFrom rlang caller_env
#' @importFrom tools vignetteEngine
#' @importFrom xfun base64_encode
#' @importFrom xfun env_option
#' @importFrom xfun normalize_path
## usethis namespace: end
NULL
10 changes: 5 additions & 5 deletions R/quarto.R
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ quarto_path <- function(normalize = TRUE) {
if (!normalize) {
return(quarto_path)
}
normalizePath(quarto_path, winslash = "/", mustWork = FALSE)
xfun::normalize_path(quarto_path)
}

get_quarto_path_env <- function() {
Expand Down Expand Up @@ -257,7 +257,7 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) {
return(FALSE)
}

quarto_found <- normalizePath(quarto_found, mustWork = FALSE)
quarto_found <- xfun::normalize_path(quarto_found)

same_config <- TRUE
if (debug) {
Expand All @@ -271,8 +271,8 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) {
))
}

quarto_r_env <- normalizePath(get_quarto_path_env(), mustWork = FALSE)
quarto_system <- normalizePath(unname(Sys.which("quarto")), mustWork = FALSE)
quarto_r_env <- xfun::normalize_path(get_quarto_path_env())
quarto_system <- xfun::normalize_path(unname(Sys.which("quarto")))
# quarto R package will use QUARTO_PATH env var with higher priority than latest version on path $PATH
# and RStudio IDE does not use this environment variable
if (!is.na(quarto_r_env) && identical(quarto_r_env, quarto_found)) {
Expand All @@ -296,7 +296,7 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) {
# RStudio IDE > Render button will use RSTUDIO_QUARTO env var with higher priority than latest version on path $PATH
rstudio_env <- Sys.getenv("RSTUDIO_QUARTO", unset = NA)
if (!is.na(rstudio_env)) {
rstudio_env <- normalizePath(rstudio_env, mustWork = FALSE)
rstudio_env <- xfun::normalize_path(rstudio_env)
if (!identical(rstudio_env, quarto_found)) {
same_config <- FALSE
if (verbose) {
Expand Down
4 changes: 4 additions & 0 deletions R/use.R
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
) {
rlang::check_required(template)

if (!fs::dir_exists(dir)) {
fs::dir_create(dir)

Check warning on line 40 in R/use.R

View check run for this annotation

Codecov / codecov/patch

R/use.R#L40

Added line #L40 was not covered by tests
}

if (!is_empty_dir(dir) && quarto_available("1.5.15")) {
cli::cli_abort(c(
"{.arg dir} must be an empty directory.",
Expand Down
245 changes: 245 additions & 0 deletions R/utils-projects.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#' Get path relative to project root (Quarto-aware)
#'
#' @description
#' `r lifecycle::badge("experimental")`
#'
#' This function constructs file paths relative to the project root when
#' running in a Quarto context (using `QUARTO_PROJECT_ROOT` or `QUARTO_PROJECT_DIR`
#' environment variables), or falls back to intelligent project root detection
#' when not in a Quarto context.
#'
#' It is experimental and subject to change in future releases. The automatic
#' project root detection may not work reliably in all contexts, especially when
#' projects have complex directory structures or when running in non-standard
#' environments. For a more explicit and potentially more robust approach,
#' consider using [here::i_am()] to declare your project structure,
#' followed by [here::here()] for path construction. See examples for comparison.
#'
#' @details
#' The function uses the following fallback hierarchy to determine the project root:
#'
#' - Quarto environment variables set during Quarto commands (e.g., `quarto render`):
#' - `QUARTO_PROJECT_ROOT` environment variable (set by Quarto commands)
#' - `QUARTO_PROJECT_DIR` environment variable (alternative Quarto variable)
#'
#' - Fallback to intelligent project root detection using [xfun::proj_root()] for interactive sessions:
#' - `_quarto.yml` or `_quarto.yaml` (Quarto project files)
#' - `DESCRIPTION` file with `Package:` field (R package or Project)
#' - `.Rproj` files with `Version:` field (RStudio projects)
#'
#' Last fallback is the current working directory if no project root can be determined.
#' A warning is issued to alert users that behavior may differ between interactive use and Quarto rendering,
#' as in this case the computed path may be wrong.
#'
#' @section Use in Quarto document cells:
#'
#' This function is particularly useful in Quarto document cells where you want to
#' use a path relative to the project root dynamically during rendering.
#'
#' ````markdown
#' ```{r}`r ''`
#' # Get a csv path from data directory in the Quarto project root
#' data <- project_path("data", "my_data.csv")
#' ```
#' ````
#'
#' @param ... Character vectors of path components to be joined
#' @param root Project root directory. If `NULL` (default), automatic detection
#' is used following the hierarchy described above
#' @return A character vector of the normalized file path relative to the project root.
#'
#' @examples
#' \dontrun{
#' # Create a dummy Quarto project structure for example
#' tmpdir <- tempfile("quarto_project")
#' dir.create(tmpdir)
#' quarto::quarto_create_project(
#' 'test project', type = 'blog',
#' dir = tmpdir, no_prompt = TRUE, quiet = TRUE
#' )
#' project_dir <- file.path(tmpdir, "test project")
#'
#' # Simulate working within a blog post
#' xfun::in_dir(
#' dir = file.path(project_dir, "posts", "welcome"), {
#'
#' # Reference a data file from project root
#' # ../../data/my_data.csv
#' quarto::project_path("data", "my_data.csv")
#'
#' # Reference a script from project root
#' # ../../R/analysis.R
#' quarto::project_path("R", "analysis.R")
#'
#' # Explicitly specify root (overrides automatic detection)
#' # ../../data/file.csv
#' quarto::project_path("data", "file.csv", root = "../..")
#'
#' # Alternative approach using here::i_am() (potentially more robust)
#' # This approach requires you to declare where you are in the project:
#' if (requireNamespace("here", quietly = TRUE)) {
#' # Declare that this document is in the project root or subdirectory
#' here::i_am("posts/welcome/index.qmd")
#'
#' # Now here::here() will work reliably from the project root
#' here::here("data", "my_data.csv")
#' here::here("R", "analysis.R")
#' }
#' })
#'
#' }
#'
#' @seealso
#' * [here::here()] and [here::i_am()] for a similar function that works with R projects
#' * [find_project_root()] to search for Quarto Project configuration in parents directories
#' * [get_running_project_root()] for detecting the project root in Quarto commands
#' * [xfun::from_root()] for the underlying path construction
#' * [xfun::proj_root()] for project root detection logic
#'
#' @export
project_path <- function(..., root = NULL) {
if (is.null(root)) {
# Try Quarto project environment variables first
quarto_root <- get_running_project_root()

root <- if (!is.null(quarto_root) && nzchar(quarto_root)) {
quarto_root
} else {
# Try to find project root using xfun::proj_root() with extended rules
tryCatch(
{
# Create extended rules that include Quarto and VS Code project files
extended_rules <- rbind(
# this should be the same as Quarto environment variables
# which are only set when running Quarto commands
c("_quarto.yml", ""), # Quarto project config
c("_quarto.yaml", ""), # Alternative Quarto config
xfun::root_rules # Default rules (DESCRIPTION, .Rproj)
)

proj_root <- xfun::proj_root(rules = extended_rules)
if (!is.null(proj_root)) {
proj_root
} else {
cli::cli_warn(c(
"Failed to determine project root using {.fun xfun::proj_root}. Using current working directory.",
">" = "This may lead to different behavior interactively vs running Quarto commands."
))
getwd()
}
},
error = function(e) {
# Fall back to working directory if proj_root() fails
cli::cli_warn(c(
"Failed to determine project root: {e$message}. Using current working directory as a fallback.",
">" = "This may lead to different behavior interactively vs running Quarto commands."
))
getwd() # Return the working directory
}
)
}
}

# Normalize the root path
root <- xfun::normalize_path(root)
# Use xfun::from_root for better path handling
path <- rlang::try_fetch(
xfun::from_root(..., root = root, error = TRUE),
error = function(e) {
rlang::abort(
c(
"Failed to construct project path",
">" = "Ensure you are using valid path components."
),
parent = e,
call = rlang::caller_env()
)

Check warning on line 156 in R/utils-projects.R

View check run for this annotation

Codecov / codecov/patch

R/utils-projects.R#L149-L156

Added lines #L149 - L156 were not covered by tests
}
)
path
}

#' Get the root of the currently running Quarto project
#'
#' @description
#' This function is to be used inside cells and will return the project root
#' when doing [quarto_render()] by detecting Quarto project environment variables.
#'
#' @details
#' Quarto sets `QUARTO_PROJECT_ROOT` and `QUARTO_PROJECT_DIR` environment
#' variables when executing commands within a Quarto project context (e.g.,
#' `quarto render`, `quarto preview`). This function detects their presence.
#'
#' Note that this function will return `NULL` when running code interactively
#' in an IDE (even within a Quarto project directory), as these specific
#' environment variables are only set during Quarto command execution.
#'
#' @section Use in Quarto document cells:
#'
#' This function is particularly useful in Quarto document cells where you want to
#' get the project root path dynamically during rendering. Cell example:
#'
#' ````markdown
#' ```{r}`r ''`
#' # Get the project root path
#' project_root <- get_running_project_root()
#' ```
#' ````
#'
#' @return Character Quarto project root path from set environment variables.
#'
#' @seealso
#' * [find_project_root()] for finding the Quarto project root directory
#' * [project_path()] for constructing paths relative to the project root
#' @examples
#' \dontrun{
#' get_running_project_root()
#' }
#' @export
get_running_project_root <- function() {
root <- Sys.getenv("QUARTO_PROJECT_ROOT", Sys.getenv("QUARTO_PROJECT_DIR"))
if (!nzchar(root)) {
return()
}
root
}

#' Find the root of a Quarto project
#'
#' @description
#' This function checks if the current working directory is within a Quarto
#' project by looking for Quarto project files (`_quarto.yml` or `_quarto.yaml`).
#' Unlike [get_running_project_root()], this works both during rendering and
#' interactive sessions.
#'
#' @param path Character. Path to check for Quarto project files. Defaults to
#' current working directory.
#'
#' @return Character Path of the project root directory if found, or `NULL`
#'
#' @examplesIf quarto_available()
#' tmpdir <- tempfile()
#' dir.create(tmpdir)
#' find_project_root(tmpdir)
#' quarto_create_project("test-proj", type = "blog", dir = tmpdir, no_prompt = TRUE, quiet = TRUE)
#' blog_post_dir <- file.path(tmpdir, "test-proj", "posts", "welcome")
#' find_project_root(blog_post_dir)
#'
#' xfun::in_dir(blog_post_dir, {
#' # Check if current directory is a Quarto project or in one
#' !is.null(find_project_root())
#' })
#'
#' # clean up
#' unlink(tmpdir, recursive = TRUE)
#'
#'
#' @seealso [get_running_project_root()] for detecting active Quarto rendering
#' @export
find_project_root <- function(path = ".") {
quarto_rules <- rbind(
c("_quarto.yml", ""),
c("_quarto.yaml", "")
)
xfun::proj_root(path = path, rules = quarto_rules)
}
5 changes: 4 additions & 1 deletion R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@

is_empty_dir <- function(dir) {
if (!dir.exists(dir)) {
return(FALSE)
rlang::warn(
"Directory {.path {dir}} does not exist. Assuming it is empty."
)
return(TRUE)

Check warning on line 112 in R/utils.R

View check run for this annotation

Codecov / codecov/patch

R/utils.R#L109-L112

Added lines #L109 - L112 were not covered by tests
}
files <- list.files(dir, all.files = TRUE, no.. = TRUE)
length(files) == 0
Expand Down
3 changes: 3 additions & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ reference:
contents:
- quarto_create_project
- new_blog_post
- project_path
- find_project_root
- get_running_project_root

- title: "Configuration"
desc: >
Expand Down
Loading