Skip to content

Commit ed6e6e4

Browse files
authored
feat: add project_path() function for Quarto-aware path construction (#265)
* feat: add project_path() function for Quarto-aware path construction * Add tests * use xfun::normalize_path everywhere * Bump version
1 parent 96f9e7d commit ed6e6e4

19 files changed

+812
-9
lines changed

DESCRIPTION

Lines changed: 2 additions & 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.9024
3+
Version: 1.4.4.9025
44
Authors@R: c(
55
person("JJ", "Allaire", , "jj@posit.co", role = "aut",
66
comment = c(ORCID = "0000-0003-0174-9868")),
@@ -23,6 +23,7 @@ Imports:
2323
htmltools,
2424
jsonlite,
2525
later,
26+
lifecycle,
2627
processx,
2728
rlang,
2829
rmarkdown,

NAMESPACE

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
export(add_spin_preamble)
44
export(check_newer_version)
5+
export(find_project_root)
6+
export(get_running_project_root)
57
export(is_using_quarto)
68
export(new_blog_post)
9+
export(project_path)
710
export(qmd_to_r_script)
811
export(quarto_add_extension)
912
export(quarto_available)
@@ -47,6 +50,7 @@ importFrom(htmltools,div)
4750
importFrom(htmltools,span)
4851
importFrom(jsonlite,fromJSON)
4952
importFrom(later,later)
53+
importFrom(lifecycle,deprecated)
5054
importFrom(processx,process)
5155
importFrom(processx,run)
5256
importFrom(rlang,caller_env)
@@ -58,5 +62,6 @@ importFrom(tools,vignetteEngine)
5862
importFrom(utils,browseURL)
5963
importFrom(xfun,base64_encode)
6064
importFrom(xfun,env_option)
65+
importFrom(xfun,normalize_path)
6166
importFrom(yaml,as.yaml)
6267
importFrom(yaml,write_yaml)

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# quarto (development version)
22

3+
- 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).
4+
35
- `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).
46

57
- 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).

R/quarto-package.R

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
#' @importFrom cli cli_inform
77
#' @importFrom htmltools div
88
#' @importFrom htmltools span
9+
#' @importFrom lifecycle deprecated
910
#' @importFrom rlang caller_env
1011
#' @importFrom tools vignetteEngine
1112
#' @importFrom xfun base64_encode
1213
#' @importFrom xfun env_option
14+
#' @importFrom xfun normalize_path
1315
## usethis namespace: end
1416
NULL

R/quarto.R

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ quarto_path <- function(normalize = TRUE) {
2121
if (!normalize) {
2222
return(quarto_path)
2323
}
24-
normalizePath(quarto_path, winslash = "/", mustWork = FALSE)
24+
xfun::normalize_path(quarto_path)
2525
}
2626

2727
get_quarto_path_env <- function() {
@@ -257,7 +257,7 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) {
257257
return(FALSE)
258258
}
259259

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

262262
same_config <- TRUE
263263
if (debug) {
@@ -271,8 +271,8 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) {
271271
))
272272
}
273273

274-
quarto_r_env <- normalizePath(get_quarto_path_env(), mustWork = FALSE)
275-
quarto_system <- normalizePath(unname(Sys.which("quarto")), mustWork = FALSE)
274+
quarto_r_env <- xfun::normalize_path(get_quarto_path_env())
275+
quarto_system <- xfun::normalize_path(unname(Sys.which("quarto")))
276276
# quarto R package will use QUARTO_PATH env var with higher priority than latest version on path $PATH
277277
# and RStudio IDE does not use this environment variable
278278
if (!is.na(quarto_r_env) && identical(quarto_r_env, quarto_found)) {
@@ -296,7 +296,7 @@ quarto_binary_sitrep <- function(verbose = TRUE, debug = FALSE) {
296296
# RStudio IDE > Render button will use RSTUDIO_QUARTO env var with higher priority than latest version on path $PATH
297297
rstudio_env <- Sys.getenv("RSTUDIO_QUARTO", unset = NA)
298298
if (!is.na(rstudio_env)) {
299-
rstudio_env <- normalizePath(rstudio_env, mustWork = FALSE)
299+
rstudio_env <- xfun::normalize_path(rstudio_env)
300300
if (!identical(rstudio_env, quarto_found)) {
301301
same_config <- FALSE
302302
if (verbose) {

R/use.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ quarto_use_template <- function(
3636
) {
3737
rlang::check_required(template)
3838

39+
if (!fs::dir_exists(dir)) {
40+
fs::dir_create(dir)
41+
}
42+
3943
if (!is_empty_dir(dir) && quarto_available("1.5.15")) {
4044
cli::cli_abort(c(
4145
"{.arg dir} must be an empty directory.",

R/utils-projects.R

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#' Get path relative to project root (Quarto-aware)
2+
#'
3+
#' @description
4+
#' `r lifecycle::badge("experimental")`
5+
#'
6+
#' This function constructs file paths relative to the project root when
7+
#' running in a Quarto context (using `QUARTO_PROJECT_ROOT` or `QUARTO_PROJECT_DIR`
8+
#' environment variables), or falls back to intelligent project root detection
9+
#' when not in a Quarto context.
10+
#'
11+
#' It is experimental and subject to change in future releases. The automatic
12+
#' project root detection may not work reliably in all contexts, especially when
13+
#' projects have complex directory structures or when running in non-standard
14+
#' environments. For a more explicit and potentially more robust approach,
15+
#' consider using [here::i_am()] to declare your project structure,
16+
#' followed by [here::here()] for path construction. See examples for comparison.
17+
#'
18+
#' @details
19+
#' The function uses the following fallback hierarchy to determine the project root:
20+
#'
21+
#' - Quarto environment variables set during Quarto commands (e.g., `quarto render`):
22+
#' - `QUARTO_PROJECT_ROOT` environment variable (set by Quarto commands)
23+
#' - `QUARTO_PROJECT_DIR` environment variable (alternative Quarto variable)
24+
#'
25+
#' - Fallback to intelligent project root detection using [xfun::proj_root()] for interactive sessions:
26+
#' - `_quarto.yml` or `_quarto.yaml` (Quarto project files)
27+
#' - `DESCRIPTION` file with `Package:` field (R package or Project)
28+
#' - `.Rproj` files with `Version:` field (RStudio projects)
29+
#'
30+
#' Last fallback is the current working directory if no project root can be determined.
31+
#' A warning is issued to alert users that behavior may differ between interactive use and Quarto rendering,
32+
#' as in this case the computed path may be wrong.
33+
#'
34+
#' @section Use in Quarto document cells:
35+
#'
36+
#' This function is particularly useful in Quarto document cells where you want to
37+
#' use a path relative to the project root dynamically during rendering.
38+
#'
39+
#' ````markdown
40+
#' ```{r}`r ''`
41+
#' # Get a csv path from data directory in the Quarto project root
42+
#' data <- project_path("data", "my_data.csv")
43+
#' ```
44+
#' ````
45+
#'
46+
#' @param ... Character vectors of path components to be joined
47+
#' @param root Project root directory. If `NULL` (default), automatic detection
48+
#' is used following the hierarchy described above
49+
#' @return A character vector of the normalized file path relative to the project root.
50+
#'
51+
#' @examples
52+
#' \dontrun{
53+
#' # Create a dummy Quarto project structure for example
54+
#' tmpdir <- tempfile("quarto_project")
55+
#' dir.create(tmpdir)
56+
#' quarto::quarto_create_project(
57+
#' 'test project', type = 'blog',
58+
#' dir = tmpdir, no_prompt = TRUE, quiet = TRUE
59+
#' )
60+
#' project_dir <- file.path(tmpdir, "test project")
61+
#'
62+
#' # Simulate working within a blog post
63+
#' xfun::in_dir(
64+
#' dir = file.path(project_dir, "posts", "welcome"), {
65+
#'
66+
#' # Reference a data file from project root
67+
#' # ../../data/my_data.csv
68+
#' quarto::project_path("data", "my_data.csv")
69+
#'
70+
#' # Reference a script from project root
71+
#' # ../../R/analysis.R
72+
#' quarto::project_path("R", "analysis.R")
73+
#'
74+
#' # Explicitly specify root (overrides automatic detection)
75+
#' # ../../data/file.csv
76+
#' quarto::project_path("data", "file.csv", root = "../..")
77+
#'
78+
#' # Alternative approach using here::i_am() (potentially more robust)
79+
#' # This approach requires you to declare where you are in the project:
80+
#' if (requireNamespace("here", quietly = TRUE)) {
81+
#' # Declare that this document is in the project root or subdirectory
82+
#' here::i_am("posts/welcome/index.qmd")
83+
#'
84+
#' # Now here::here() will work reliably from the project root
85+
#' here::here("data", "my_data.csv")
86+
#' here::here("R", "analysis.R")
87+
#' }
88+
#' })
89+
#'
90+
#' }
91+
#'
92+
#' @seealso
93+
#' * [here::here()] and [here::i_am()] for a similar function that works with R projects
94+
#' * [find_project_root()] to search for Quarto Project configuration in parents directories
95+
#' * [get_running_project_root()] for detecting the project root in Quarto commands
96+
#' * [xfun::from_root()] for the underlying path construction
97+
#' * [xfun::proj_root()] for project root detection logic
98+
#'
99+
#' @export
100+
project_path <- function(..., root = NULL) {
101+
if (is.null(root)) {
102+
# Try Quarto project environment variables first
103+
quarto_root <- get_running_project_root()
104+
105+
root <- if (!is.null(quarto_root) && nzchar(quarto_root)) {
106+
quarto_root
107+
} else {
108+
# Try to find project root using xfun::proj_root() with extended rules
109+
tryCatch(
110+
{
111+
# Create extended rules that include Quarto and VS Code project files
112+
extended_rules <- rbind(
113+
# this should be the same as Quarto environment variables
114+
# which are only set when running Quarto commands
115+
c("_quarto.yml", ""), # Quarto project config
116+
c("_quarto.yaml", ""), # Alternative Quarto config
117+
xfun::root_rules # Default rules (DESCRIPTION, .Rproj)
118+
)
119+
120+
proj_root <- xfun::proj_root(rules = extended_rules)
121+
if (!is.null(proj_root)) {
122+
proj_root
123+
} else {
124+
cli::cli_warn(c(
125+
"Failed to determine project root using {.fun xfun::proj_root}. Using current working directory.",
126+
">" = "This may lead to different behavior interactively vs running Quarto commands."
127+
))
128+
getwd()
129+
}
130+
},
131+
error = function(e) {
132+
# Fall back to working directory if proj_root() fails
133+
cli::cli_warn(c(
134+
"Failed to determine project root: {e$message}. Using current working directory as a fallback.",
135+
">" = "This may lead to different behavior interactively vs running Quarto commands."
136+
))
137+
getwd() # Return the working directory
138+
}
139+
)
140+
}
141+
}
142+
143+
# Normalize the root path
144+
root <- xfun::normalize_path(root)
145+
# Use xfun::from_root for better path handling
146+
path <- rlang::try_fetch(
147+
xfun::from_root(..., root = root, error = TRUE),
148+
error = function(e) {
149+
rlang::abort(
150+
c(
151+
"Failed to construct project path",
152+
">" = "Ensure you are using valid path components."
153+
),
154+
parent = e,
155+
call = rlang::caller_env()
156+
)
157+
}
158+
)
159+
path
160+
}
161+
162+
#' Get the root of the currently running Quarto project
163+
#'
164+
#' @description
165+
#' This function is to be used inside cells and will return the project root
166+
#' when doing [quarto_render()] by detecting Quarto project environment variables.
167+
#'
168+
#' @details
169+
#' Quarto sets `QUARTO_PROJECT_ROOT` and `QUARTO_PROJECT_DIR` environment
170+
#' variables when executing commands within a Quarto project context (e.g.,
171+
#' `quarto render`, `quarto preview`). This function detects their presence.
172+
#'
173+
#' Note that this function will return `NULL` when running code interactively
174+
#' in an IDE (even within a Quarto project directory), as these specific
175+
#' environment variables are only set during Quarto command execution.
176+
#'
177+
#' @section Use in Quarto document cells:
178+
#'
179+
#' This function is particularly useful in Quarto document cells where you want to
180+
#' get the project root path dynamically during rendering. Cell example:
181+
#'
182+
#' ````markdown
183+
#' ```{r}`r ''`
184+
#' # Get the project root path
185+
#' project_root <- get_running_project_root()
186+
#' ```
187+
#' ````
188+
#'
189+
#' @return Character Quarto project root path from set environment variables.
190+
#'
191+
#' @seealso
192+
#' * [find_project_root()] for finding the Quarto project root directory
193+
#' * [project_path()] for constructing paths relative to the project root
194+
#' @examples
195+
#' \dontrun{
196+
#' get_running_project_root()
197+
#' }
198+
#' @export
199+
get_running_project_root <- function() {
200+
root <- Sys.getenv("QUARTO_PROJECT_ROOT", Sys.getenv("QUARTO_PROJECT_DIR"))
201+
if (!nzchar(root)) {
202+
return()
203+
}
204+
root
205+
}
206+
207+
#' Find the root of a Quarto project
208+
#'
209+
#' @description
210+
#' This function checks if the current working directory is within a Quarto
211+
#' project by looking for Quarto project files (`_quarto.yml` or `_quarto.yaml`).
212+
#' Unlike [get_running_project_root()], this works both during rendering and
213+
#' interactive sessions.
214+
#'
215+
#' @param path Character. Path to check for Quarto project files. Defaults to
216+
#' current working directory.
217+
#'
218+
#' @return Character Path of the project root directory if found, or `NULL`
219+
#'
220+
#' @examplesIf quarto_available()
221+
#' tmpdir <- tempfile()
222+
#' dir.create(tmpdir)
223+
#' find_project_root(tmpdir)
224+
#' quarto_create_project("test-proj", type = "blog", dir = tmpdir, no_prompt = TRUE, quiet = TRUE)
225+
#' blog_post_dir <- file.path(tmpdir, "test-proj", "posts", "welcome")
226+
#' find_project_root(blog_post_dir)
227+
#'
228+
#' xfun::in_dir(blog_post_dir, {
229+
#' # Check if current directory is a Quarto project or in one
230+
#' !is.null(find_project_root())
231+
#' })
232+
#'
233+
#' # clean up
234+
#' unlink(tmpdir, recursive = TRUE)
235+
#'
236+
#'
237+
#' @seealso [get_running_project_root()] for detecting active Quarto rendering
238+
#' @export
239+
find_project_root <- function(path = ".") {
240+
quarto_rules <- rbind(
241+
c("_quarto.yml", ""),
242+
c("_quarto.yaml", "")
243+
)
244+
xfun::proj_root(path = path, rules = quarto_rules)
245+
}

R/utils.R

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ has_internet <- function(host = "https://www.google.com") {
106106

107107
is_empty_dir <- function(dir) {
108108
if (!dir.exists(dir)) {
109-
return(FALSE)
109+
rlang::warn(
110+
"Directory {.path {dir}} does not exist. Assuming it is empty."
111+
)
112+
return(TRUE)
110113
}
111114
files <- list.files(dir, all.files = TRUE, no.. = TRUE)
112115
length(files) == 0

_pkgdown.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ reference:
3535
contents:
3636
- quarto_create_project
3737
- new_blog_post
38+
- project_path
39+
- find_project_root
40+
- get_running_project_root
3841

3942
- title: "Configuration"
4043
desc: >

0 commit comments

Comments
 (0)