From 7e70874d8bddad07212fe1bf5c2bbf7c398b0ca3 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 14:08:38 +0000 Subject: [PATCH 1/9] feat: parallel article building via `n_cores` Add `n_cores` argument to `build_articles()`, `build_site()`, and `build_site_github_pages()` so articles can be rendered in parallel via `purrr::in_parallel()` + mirai. Default `n_cores = 1L` preserves the existing serial code path and requires no new packages; `Inf` autodetects via `parallel::detectCores()`. Co-Authored-By: Claude Opus 4.7 (1M context) --- DESCRIPTION | 3 +- NEWS.md | 2 + R/build-articles.R | 73 ++++++++++++++++++++++--- R/build-github.R | 4 +- R/build-quarto-articles.R | 70 +++++++++++++++++------- R/build.R | 7 +++ man/build_articles.Rd | 8 +++ man/build_site.Rd | 8 +++ man/build_site_github_pages.Rd | 10 +++- tests/testthat/_snaps/build-articles.md | 28 ++++++++++ tests/testthat/test-build-articles.R | 15 +++++ 11 files changed, 197 insertions(+), 31 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index c0848ca0b..970da77b2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -36,7 +36,7 @@ Imports: lifecycle, openssl, nanonext (>= 1.8.0), - purrr (>= 1.0.0), + purrr (>= 1.1.0), ragg (>= 1.4.0), rlang (>= 1.1.4), rmarkdown (>= 2.27), @@ -56,6 +56,7 @@ Suggests: knitr (>= 1.50), magick, methods, + mirai, pkgload (>= 1.0.2), quarto, rsconnect, diff --git a/NEWS.md b/NEWS.md index 9a6d2e03c..b62d04cd9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # pkgdown (development version) +* `build_articles()`, `build_site()`, and `build_site_github_pages()` gain an `n_cores` argument to build articles in parallel via `purrr::in_parallel()`. The default (`n_cores = 1L`) preserves the traditional serial build; values greater than 1 require the mirai package, and `Inf` autodetects via `parallel::detectCores()`. + * When previewing a site, it is now served via a local http server. This enables dynamic features such as search to work correctly (@shikokuchuo, #2975). * do not autolink code that is in a link (href) in Rd files (#2972) diff --git a/R/build-articles.R b/R/build-articles.R index 7d9945c3f..dfeb02bbf 100644 --- a/R/build-articles.R +++ b/R/build-articles.R @@ -191,6 +191,12 @@ #' make article output reproducible. An integer scalar or `NULL` for no seed. #' @param preview If `TRUE`, or `is.na(preview) && interactive()`, will preview #' freshly generated section in browser. +#' @param n_cores Number of workers to use when building articles in +#' parallel. A positive integer (fractional values are rounded up), or +#' `Inf` to use `parallel::detectCores()`. Defaults to `1L`, which keeps +#' the traditional serial build and does not require the \pkg{mirai} +#' package. Values greater than 1 require \pkg{mirai} and use +#' [purrr::in_parallel()]. #' @export #' @order 1 build_articles <- function( @@ -198,6 +204,7 @@ build_articles <- function( quiet = TRUE, lazy = TRUE, seed = 1014L, + n_cores = 1L, override = list(), preview = FALSE ) { @@ -205,6 +212,7 @@ build_articles <- function( check_bool(quiet) check_bool(lazy) check_number_whole(seed, allow_null = TRUE) + n_cores <- check_n_cores(n_cores) if (nrow(pkg$vignettes) == 0L) { return(invisible()) @@ -213,19 +221,66 @@ build_articles <- function( cli::cli_rule("Building articles") build_articles_index(pkg) - unwrap_purrr_error(purrr::walk( - pkg$vignettes$name[pkg$vignettes$type == "rmd"], - build_article, - pkg = pkg, - lazy = lazy, - seed = seed, - quiet = quiet - )) - build_quarto_articles(pkg, quiet = quiet) + rmd_names <- pkg$vignettes$name[pkg$vignettes$type == "rmd"] + if (n_cores == 1L) { + unwrap_purrr_error(purrr::walk( + rmd_names, + build_article, + pkg = pkg, + lazy = lazy, + seed = seed, + quiet = quiet + )) + } else { + rlang::check_installed("mirai") + mirai::daemons(n_cores) + withr::defer(mirai::daemons(0)) + unwrap_purrr_error(purrr::walk( + rmd_names, + purrr::in_parallel( + function(name, pkg, lazy, seed, quiet) { + pkgdown::build_article( + name, + pkg = pkg, + lazy = lazy, + seed = seed, + quiet = quiet + ) + }, + pkg = pkg, + lazy = lazy, + seed = seed, + quiet = quiet + ) + )) + } + build_quarto_articles(pkg, quiet = quiet, n_cores = n_cores) preview_site(pkg, "articles", preview = preview) } +check_n_cores <- function( + n_cores, + arg = rlang::caller_arg(n_cores), + call = rlang::caller_env() +) { + if ( + !is.numeric(n_cores) || + length(n_cores) != 1L || + is.na(n_cores) || + n_cores < 1 + ) { + cli::cli_abort( + "{.arg {arg}} must be a positive integer or {.code Inf}.", + call = call + ) + } + if (is.infinite(n_cores)) { + return(as.integer(parallel::detectCores())) + } + as.integer(ceiling(n_cores)) +} + # Articles index ---------------------------------------------------------- #' @export diff --git a/R/build-github.R b/R/build-github.R index a40ac5972..6e7bb627e 100644 --- a/R/build-github.R +++ b/R/build-github.R @@ -22,7 +22,8 @@ build_site_github_pages <- function( dest_dir = "docs", clean = TRUE, install = FALSE, - new_process = FALSE + new_process = FALSE, + n_cores = 1L ) { pkg <- as_pkgdown(pkg, override = list(destination = dest_dir)) @@ -36,6 +37,7 @@ build_site_github_pages <- function( preview = FALSE, install = install, new_process = new_process, + n_cores = n_cores, ... ) build_github_pages(pkg) diff --git a/R/build-quarto-articles.R b/R/build-quarto-articles.R index 33f01dbbb..db095193b 100644 --- a/R/build-quarto-articles.R +++ b/R/build-quarto-articles.R @@ -1,5 +1,11 @@ -build_quarto_articles <- function(pkg = ".", article = NULL, quiet = TRUE) { +build_quarto_articles <- function( + pkg = ".", + article = NULL, + quiet = TRUE, + n_cores = 1L +) { pkg <- as_pkgdown(pkg) + n_cores <- check_n_cores(n_cores) qmds <- pkg$vignettes[pkg$vignettes$type == "qmd", ] if (!is.null(article)) { @@ -57,24 +63,35 @@ build_quarto_articles <- function(pkg = ".", article = NULL, quiet = TRUE) { } # Read generated data from quarto template and render into pkgdown template - unwrap_purrr_error(purrr::walk2( - qmds$file_in, - qmds$file_out, - function(input_file, output_file) { - built_path <- path(output_dir, path_rel(output_file, "articles")) - if (!file_exists(built_path)) { - cli::cli_abort("No built file found for {.file {input_file}}") - } - if (path_ext(output_file) == "html") { - data <- data_quarto_article(pkg, built_path, input_file) - render_page(pkg, "quarto", data, output_file, quiet = TRUE) - - update_html(path(pkg$dst_path, output_file), tweak_quarto_html) - } else { - file_copy(built_path, path(pkg$dst_path, output_file), overwrite = TRUE) - } - } - )) + if (n_cores == 1L) { + unwrap_purrr_error(purrr::walk2( + qmds$file_in, + qmds$file_out, + quarto_article_postprocess, + pkg = pkg, + output_dir = output_dir + )) + } else { + rlang::check_installed("mirai") + mirai::daemons(n_cores) + withr::defer(mirai::daemons(0)) + unwrap_purrr_error(purrr::walk2( + qmds$file_in, + qmds$file_out, + purrr::in_parallel( + function(input_file, output_file, pkg, output_dir) { + pkgdown:::quarto_article_postprocess( + input_file, + output_file, + pkg = pkg, + output_dir = output_dir + ) + }, + pkg = pkg, + output_dir = output_dir + ) + )) + } # Report on which files have changed new_digest <- purrr::map_chr(path(pkg$dst_path, qmds$file_out), file_digest) @@ -99,6 +116,21 @@ build_quarto_articles <- function(pkg = ".", article = NULL, quiet = TRUE) { invisible() } +quarto_article_postprocess <- function(input_file, output_file, pkg, output_dir) { + built_path <- path(output_dir, path_rel(output_file, "articles")) + if (!file_exists(built_path)) { + cli::cli_abort("No built file found for {.file {input_file}}") + } + if (path_ext(output_file) == "html") { + data <- data_quarto_article(pkg, built_path, input_file) + render_page(pkg, "quarto", data, output_file, quiet = TRUE) + + update_html(path(pkg$dst_path, output_file), tweak_quarto_html) + } else { + file_copy(built_path, path(pkg$dst_path, output_file), overwrite = TRUE) + } +} + quarto_render <- function(pkg, path, quiet = TRUE, frame = caller_env()) { # Override default quarto format metadata_path <- withr::local_tempfile( diff --git a/R/build.R b/R/build.R index f3d31c6ea..707137119 100644 --- a/R/build.R +++ b/R/build.R @@ -317,6 +317,7 @@ build_site <- function( run_dont_run = FALSE, seed = 1014L, lazy = FALSE, + n_cores = 1L, override = list(), preview = NA, devel = FALSE, @@ -354,6 +355,7 @@ build_site <- function( run_dont_run = run_dont_run, seed = seed, lazy = lazy, + n_cores = n_cores, override = override, preview = preview, devel = devel, @@ -366,6 +368,7 @@ build_site <- function( run_dont_run = run_dont_run, seed = seed, lazy = lazy, + n_cores = n_cores, override = override, preview = preview, devel = devel, @@ -380,6 +383,7 @@ build_site_external <- function( run_dont_run = FALSE, seed = 1014L, lazy = FALSE, + n_cores = 1L, override = list(), preview = NA, devel = TRUE, @@ -392,6 +396,7 @@ build_site_external <- function( run_dont_run = run_dont_run, seed = seed, lazy = lazy, + n_cores = n_cores, override = override, install = FALSE, preview = FALSE, @@ -428,6 +433,7 @@ build_site_local <- function( run_dont_run = FALSE, seed = 1014L, lazy = FALSE, + n_cores = 1L, override = list(), preview = NA, devel = TRUE, @@ -461,6 +467,7 @@ build_site_local <- function( build_articles( pkg, lazy = lazy, + n_cores = n_cores, override = override, quiet = quiet, preview = FALSE diff --git a/man/build_articles.Rd b/man/build_articles.Rd index 108e0ae25..7fb3e8bc4 100644 --- a/man/build_articles.Rd +++ b/man/build_articles.Rd @@ -11,6 +11,7 @@ build_articles( quiet = TRUE, lazy = TRUE, seed = 1014L, + n_cores = 1L, override = list(), preview = FALSE ) @@ -40,6 +41,13 @@ modified more recently than the output file.} \item{seed}{Seed used to initialize random number generation in order to make article output reproducible. An integer scalar or \code{NULL} for no seed.} +\item{n_cores}{Number of workers to use when building articles in +parallel. A positive integer (fractional values are rounded up), or +\code{Inf} to use \code{parallel::detectCores()}. Defaults to \code{1L}, which keeps +the traditional serial build and does not require the \pkg{mirai} +package. Values greater than 1 require \pkg{mirai} and use +\code{\link[purrr:in_parallel]{purrr::in_parallel()}}.} + \item{override}{An optional named list used to temporarily override values in \verb{_pkgdown.yml}} diff --git a/man/build_site.Rd b/man/build_site.Rd index c63f98cb3..0a585c6ea 100644 --- a/man/build_site.Rd +++ b/man/build_site.Rd @@ -10,6 +10,7 @@ build_site( run_dont_run = FALSE, seed = 1014L, lazy = FALSE, + n_cores = 1L, override = list(), preview = NA, devel = FALSE, @@ -31,6 +32,13 @@ make article output reproducible. An integer scalar or \code{NULL} for no seed.} \item{lazy}{If \code{TRUE}, will only rebuild articles and reference pages if the source is newer than the destination.} +\item{n_cores}{Number of workers to use when building articles in +parallel. A positive integer (fractional values are rounded up), or +\code{Inf} to use \code{parallel::detectCores()}. Defaults to \code{1L}, which keeps +the traditional serial build and does not require the \pkg{mirai} +package. Values greater than 1 require \pkg{mirai} and use +\code{\link[purrr:in_parallel]{purrr::in_parallel()}}.} + \item{override}{An optional named list used to temporarily override values in \verb{_pkgdown.yml}} diff --git a/man/build_site_github_pages.Rd b/man/build_site_github_pages.Rd index c4cce889f..a47ef5c13 100644 --- a/man/build_site_github_pages.Rd +++ b/man/build_site_github_pages.Rd @@ -10,7 +10,8 @@ build_site_github_pages( dest_dir = "docs", clean = TRUE, install = FALSE, - new_process = FALSE + new_process = FALSE, + n_cores = 1L ) } \arguments{ @@ -28,6 +29,13 @@ so it is available for vignettes.} \item{new_process}{If \code{TRUE}, will run \code{build_site()} in a separate process. This enhances reproducibility by ensuring nothing that you have loaded in the current process affects the build process.} + +\item{n_cores}{Number of workers to use when building articles in +parallel. A positive integer (fractional values are rounded up), or +\code{Inf} to use \code{parallel::detectCores()}. Defaults to \code{1L}, which keeps +the traditional serial build and does not require the \pkg{mirai} +package. Values greater than 1 require \pkg{mirai} and use +\code{\link[purrr:in_parallel]{purrr::in_parallel()}}.} } \description{ Designed to be run as part of automated workflows for deploying diff --git a/tests/testthat/_snaps/build-articles.md b/tests/testthat/_snaps/build-articles.md index 8eefa9d67..0cf5a676f 100644 --- a/tests/testthat/_snaps/build-articles.md +++ b/tests/testthat/_snaps/build-articles.md @@ -88,3 +88,31 @@ Error: ! In _pkgdown.yml, 1 vignette missing from index: "c". +# check_n_cores validates and resolves n_cores + + Code + check_n_cores(0) + Condition + Error: + ! `0` must be a positive integer or `Inf`. + Code + check_n_cores(-1) + Condition + Error: + ! `-1` must be a positive integer or `Inf`. + Code + check_n_cores("two") + Condition + Error: + ! `"two"` must be a positive integer or `Inf`. + Code + check_n_cores(NA) + Condition + Error: + ! `NA` must be a positive integer or `Inf`. + Code + check_n_cores(c(1, 2)) + Condition + Error: + ! `c(1, 2)` must be a positive integer or `Inf`. + diff --git a/tests/testthat/test-build-articles.R b/tests/testthat/test-build-articles.R index 3a6c287a0..b929c74ca 100644 --- a/tests/testthat/test-build-articles.R +++ b/tests/testthat/test-build-articles.R @@ -139,3 +139,18 @@ test_that("check doesn't include getting started vignette", { expect_no_error(data_articles_index(pkg)) }) + +test_that("check_n_cores validates and resolves n_cores", { + expect_snapshot(error = TRUE, { + check_n_cores(0) + check_n_cores(-1) + check_n_cores("two") + check_n_cores(NA) + check_n_cores(c(1, 2)) + }) + + expect_identical(check_n_cores(1), 1L) + expect_identical(check_n_cores(2L), 2L) + expect_identical(check_n_cores(1.4), 2L) + expect_identical(check_n_cores(Inf), as.integer(parallel::detectCores())) +}) From 27fe482e406503d70770eb3fcdd3d5fe69de217f Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 14:12:53 +0000 Subject: [PATCH 2/9] chore: add parallel to Suggests `parallel::detectCores()` is used by `check_n_cores()` when `n_cores = Inf`. Co-Authored-By: Claude Opus 4.7 (1M context) --- DESCRIPTION | 1 + 1 file changed, 1 insertion(+) diff --git a/DESCRIPTION b/DESCRIPTION index 970da77b2..43339d41a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -57,6 +57,7 @@ Suggests: magick, methods, mirai, + parallel, pkgload (>= 1.0.2), quarto, rsconnect, From a0dc9dbc522a0f7570f781effe30bb60844794fb Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 14:39:50 +0000 Subject: [PATCH 3/9] fix: avoid `:::` to own namespace in parallel quarto worker Replace `pkgdown:::quarto_article_postprocess(...)` with `utils::getFromNamespace("quarto_article_postprocess", "pkgdown")` inside the `purrr::in_parallel()` worker closure. This clears the R CMD check NOTE about calls to own-namespace `:::`. Co-Authored-By: Claude Opus 4.7 (1M context) --- R/build-quarto-articles.R | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/R/build-quarto-articles.R b/R/build-quarto-articles.R index db095193b..6405ae086 100644 --- a/R/build-quarto-articles.R +++ b/R/build-quarto-articles.R @@ -80,7 +80,11 @@ build_quarto_articles <- function( qmds$file_out, purrr::in_parallel( function(input_file, output_file, pkg, output_dir) { - pkgdown:::quarto_article_postprocess( + postprocess <- utils::getFromNamespace( + "quarto_article_postprocess", + "pkgdown" + ) + postprocess( input_file, output_file, pkg = pkg, From ee37d03593a9febf20e959511c076dcb7581fdab Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 16:43:00 +0000 Subject: [PATCH 4/9] test: accept upstream snapshot drift * build-quarto-articles: pandoc added a default-styles comment header to its CSS output. * build-reference: evaluate/knitr/rmarkdown now consumes two extra RNG draws before the example runs, shifting the output by two values. Both are environmental drift, not behavioral changes from this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/testthat/_snaps/build-quarto-articles.md | 3 +++ tests/testthat/_snaps/build-reference.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/testthat/_snaps/build-quarto-articles.md b/tests/testthat/_snaps/build-quarto-articles.md index bf7b98aee..97a6df730 100644 --- a/tests/testthat/_snaps/build-quarto-articles.md +++ b/tests/testthat/_snaps/build-quarto-articles.md @@ -4,6 +4,9 @@ cat(data$includes$style) Output + /* Default styles provided by pandoc. + ** See https://pandoc.org/MANUAL.html#variables-for-html for config info. + */ code{white-space: pre-wrap;} span.smallcaps{font-variant: small-caps;} div.columns{display: flex; gap: min(4vw, 1.5em);} diff --git a/tests/testthat/_snaps/build-reference.md b/tests/testthat/_snaps/build-reference.md index 59e9de3ff..e2719c4d3 100644 --- a/tests/testthat/_snaps/build-reference.md +++ b/tests/testthat/_snaps/build-reference.md @@ -18,5 +18,5 @@ Code cat(examples) Output - #> [1] 0.600760886 0.157208442 0.007399441 0.466393497 0.497777389 + #> [1] 0.080750138 0.834333037 0.600760886 0.157208442 0.007399441 From 27f58f860bcc6ca09fa928c4ca61bde80ff1718a Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 20:38:38 +0000 Subject: [PATCH 5/9] fix: also require carrier for parallel builds `purrr::in_parallel()` uses `carrier::crate()` to serialize worker closures and raises an undeclared-dependency error partway through if carrier isn't installed. Add carrier to Suggests and to the `rlang::check_installed()` pre-flight so the requirement is surfaced up-front with pkgdown's own error path. Co-Authored-By: Claude Opus 4.7 (1M context) --- DESCRIPTION | 1 + R/build-articles.R | 2 +- R/build-quarto-articles.R | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 43339d41a..f796b4137 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -46,6 +46,7 @@ Imports: xml2 (>= 1.3.1), yaml (>= 2.3.9) Suggests: + carrier, covr, diffviewer, evaluate (>= 0.24.0), diff --git a/R/build-articles.R b/R/build-articles.R index dfeb02bbf..5a8a48938 100644 --- a/R/build-articles.R +++ b/R/build-articles.R @@ -232,7 +232,7 @@ build_articles <- function( quiet = quiet )) } else { - rlang::check_installed("mirai") + rlang::check_installed(c("mirai", "carrier")) mirai::daemons(n_cores) withr::defer(mirai::daemons(0)) unwrap_purrr_error(purrr::walk( diff --git a/R/build-quarto-articles.R b/R/build-quarto-articles.R index 6405ae086..6353e29ae 100644 --- a/R/build-quarto-articles.R +++ b/R/build-quarto-articles.R @@ -72,7 +72,7 @@ build_quarto_articles <- function( output_dir = output_dir )) } else { - rlang::check_installed("mirai") + rlang::check_installed(c("mirai", "carrier")) mirai::daemons(n_cores) withr::defer(mirai::daemons(0)) unwrap_purrr_error(purrr::walk2( From b976862ce19953614869524f059d91957f21c143 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 20:41:41 +0000 Subject: [PATCH 6/9] fix: correct `purrr::in_parallel()` closure signature `in_parallel(.f, ...)` passes the iteration element(s) to `.f` and captures the extra `...` bindings into `.f`'s environment via `carrier::crate()`, not into its formals. The previous closures listed `pkg`, `lazy`, `seed`, `quiet` as arguments, so when purrr invoked them with only the iteration variable the additional formals were unbound and raised "argument pkg is missing". Drop the extras from the formals and reference them as free variables in the body; the carrier crate supplies them. Co-Authored-By: Claude Opus 4.7 (1M context) --- R/build-articles.R | 2 +- R/build-quarto-articles.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/build-articles.R b/R/build-articles.R index 5a8a48938..dbfe7076a 100644 --- a/R/build-articles.R +++ b/R/build-articles.R @@ -238,7 +238,7 @@ build_articles <- function( unwrap_purrr_error(purrr::walk( rmd_names, purrr::in_parallel( - function(name, pkg, lazy, seed, quiet) { + function(name) { pkgdown::build_article( name, pkg = pkg, diff --git a/R/build-quarto-articles.R b/R/build-quarto-articles.R index 6353e29ae..319fad3c1 100644 --- a/R/build-quarto-articles.R +++ b/R/build-quarto-articles.R @@ -79,7 +79,7 @@ build_quarto_articles <- function( qmds$file_in, qmds$file_out, purrr::in_parallel( - function(input_file, output_file, pkg, output_dir) { + function(input_file, output_file) { postprocess <- utils::getFromNamespace( "quarto_article_postprocess", "pkgdown" From 001e6f59cd7ab37d692bf71f95160b51a40a581c Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 22 Apr 2026 20:44:38 +0000 Subject: [PATCH 7/9] fix: propagate `.libPaths()` to parallel workers When `install = TRUE`, pkgdown installs the package into a `withr::local_temp_libpaths()` directory visible only to the main R session. mirai workers are fresh subprocesses that default to the system `.libPaths()` and cannot see the temp install, so any article that depends on dev-only exports of the host package fails on the worker. Capture `.libPaths()` at dispatch time and re-set it inside each worker call via the `carrier` crate. This costs one cheap call per article and keeps the parallel path honest for `install = TRUE`. Co-Authored-By: Claude Opus 4.7 (1M context) --- R/build-articles.R | 4 +++- R/build-quarto-articles.R | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/R/build-articles.R b/R/build-articles.R index dbfe7076a..98d1fa3ac 100644 --- a/R/build-articles.R +++ b/R/build-articles.R @@ -239,6 +239,7 @@ build_articles <- function( rmd_names, purrr::in_parallel( function(name) { + .libPaths(libs) pkgdown::build_article( name, pkg = pkg, @@ -250,7 +251,8 @@ build_articles <- function( pkg = pkg, lazy = lazy, seed = seed, - quiet = quiet + quiet = quiet, + libs = .libPaths() ) )) } diff --git a/R/build-quarto-articles.R b/R/build-quarto-articles.R index 319fad3c1..5dcd6b21d 100644 --- a/R/build-quarto-articles.R +++ b/R/build-quarto-articles.R @@ -80,6 +80,7 @@ build_quarto_articles <- function( qmds$file_out, purrr::in_parallel( function(input_file, output_file) { + .libPaths(libs) postprocess <- utils::getFromNamespace( "quarto_article_postprocess", "pkgdown" @@ -92,7 +93,8 @@ build_quarto_articles <- function( ) }, pkg = pkg, - output_dir = output_dir + output_dir = output_dir, + libs = .libPaths() ) )) } From 695688151c373b0024255ca4f6b0231f84bc4f20 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 21 May 2026 16:33:11 +0000 Subject: [PATCH 8/9] fix: unique-per-call probe file in copy_article_images() When two or more workers render articles from the same source directory in parallel, the fixed filename /--find-assets.html written by copy_article_images() collides between workers, surfacing as [EEXIST] Failed to copy '.html' to '/--find-assets.html': file already exists [ENOENT] Failed to remove '/--find-assets.html': no such file or directory depending on whether the failing worker raced on the file_copy or on its sibling's deferred file_delete. The race is observable with as few as two workers and bites reliably at >= 6 workers on a 400- vignette package (see nlmixr2/nlmixr2lib PR #427, run 26225121646). The probe file has to live next to the input Rmd so the relative paths in the built HTML resolve when rmarkdown::find_external_resources reads them back; tempdir() would break that. Generate a unique filename per call via tempfile(pattern = "--find-assets-", tmpdir = path_dir(input_path), fileext = ".html") so concurrent workers in the same source directory each get their own probe. Co-Authored-By: Claude Opus 4.7 (1M context) --- NEWS.md | 2 ++ R/build-article.R | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index b62d04cd9..6948502ae 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ * `build_articles()`, `build_site()`, and `build_site_github_pages()` gain an `n_cores` argument to build articles in parallel via `purrr::in_parallel()`. The default (`n_cores = 1L`) preserves the traditional serial build; values greater than 1 require the mirai package, and `Inf` autodetects via `parallel::detectCores()`. +* The internal `copy_article_images()` helper no longer races on a fixed `--find-assets.html` probe file when articles in the same source directory are built in parallel; the probe filename is now made unique per call via `tempfile()`. + * When previewing a site, it is now served via a local http server. This enables dynamic features such as search to work correctly (@shikokuchuo, #2975). * do not autolink code that is in a link (href) in Rd files (#2972) diff --git a/R/build-article.R b/R/build-article.R index bcd8e0352..7b02f9941 100644 --- a/R/build-article.R +++ b/R/build-article.R @@ -260,11 +260,18 @@ copy_article_images <- function(built_path, input_path, output_path) { ext_src <- rmarkdown::find_external_resources(input_path) # temporarily copy the rendered html into the input path directory and scan - # again for additional external resources that may be been included by R code - tempfile <- path(path_dir(input_path), "--find-assets.html") - withr::defer(try(file_delete(tempfile))) - file_copy(built_path, tempfile) - ext_post <- rmarkdown::find_external_resources(tempfile) + # again for additional external resources that may be been included by R code. + # The probe file has to live next to the input Rmd so relative paths in the + # HTML resolve correctly; use tempfile() so concurrent workers in the same + # source directory don't race on a fixed filename. + probe_file <- tempfile( + pattern = "--find-assets-", + tmpdir = path_dir(input_path), + fileext = ".html" + ) + withr::defer(try(file_delete(probe_file))) + file_copy(built_path, probe_file) + ext_post <- rmarkdown::find_external_resources(probe_file) ext <- rbind(ext_src, ext_post) ext <- ext[!duplicated(ext$path), ] From fb389dcb6d9cba77795a0d8b95186fe32a07ec9e Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 21 May 2026 16:56:35 +0000 Subject: [PATCH 9/9] test: accept upstream bootstrap-5.3.8 snapshot drift The init.md snapshot still pinned bootstrap-5.3.1 paths but the bundled bs_theme dependency has bumped to 5.3.8. All R-CMD-check platforms (ubuntu, macos, windows; release/devel/oldrel) fail test-init.R:3:3 ("informative print method") with a 6-line diff between deps/bootstrap-5.3.1/ and deps/bootstrap-5.3.8/. Pure version-bump snapshot update; no functional change. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/testthat/_snaps/init.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testthat/_snaps/init.md b/tests/testthat/_snaps/init.md index 56b905cd8..1f8eb2336 100644 --- a/tests/testthat/_snaps/init.md +++ b/tests/testthat/_snaps/init.md @@ -8,9 +8,9 @@ Copying /BS5/assets/lightswitch.js to lightswitch.js Copying /BS5/assets/link.svg to link.svg Copying /BS5/assets/pkgdown.js to pkgdown.js - Updating deps/bootstrap-5.3.1/bootstrap.bundle.min.js - Updating deps/bootstrap-5.3.1/bootstrap.bundle.min.js.map - Updating deps/bootstrap-5.3.1/bootstrap.min.css + Updating deps/bootstrap-5.3.8/bootstrap.bundle.min.js + Updating deps/bootstrap-5.3.8/bootstrap.bundle.min.js.map + Updating deps/bootstrap-5.3.8/bootstrap.min.css Updating deps/bootstrap-toc-1.0.1/bootstrap-toc.min.js Updating deps/clipboard.js-2.0.11/clipboard.min.js Updating deps/font-awesome-6.5.2/css/all.css