# DO NOT EDIT THIS FILE BY HAND! Instead edit the R Markdown source file `Rmd/pkgpurl.Rmd` and run `pkgpurl::purl_rmd()`.
# See `README.md#r-markdown-format` for more information on the literate programming approach used applying the R Markdown format.
# pkgpurl: Facilitate Authoring R Packages in the R Markdown File Format
# Copyright (C) 2025 Salim Brüggemann
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
utils::globalVariables(names = c(".",
"heading_lvl",
"i_subtitle",
"i_title",
"is_description_heading",
"is_heading_ignored",
"is_help_topic",
"subnode_ix"))
data_special_headings <-
tibble::tibble(type = character(),
heading_texts = list(),
meaning = character()) |>
tibble::add_row(type = "description",
heading_texts = list("DESCRIPTION"),
meaning = paste0("Paragraph(s) below this heading, which in turn is hierarchically below a `title` or `subtitle` heading, are used as the ",
"description for this very (sub)title (`desc` in the pkgdown reference index).")) |>
tibble::add_row(type = "ignore_title",
heading_texts = list("EXPORTED"),
meaning = paste0("Will never be used as `title` or `subtitle` in the pkgdown reference index (i.e. ignored). Simply serves as a (usually ",
"top-level) heading to indicate that the objects defined below it are ",
"[exported](https://r-pkgs.org/dependencies-in-practice.html#exports) by the package.")) |>
tibble::add_row(type = "ignore_content",
heading_texts = list(c("INTERNAL",
"NOTES",
"TEMPORARY",
"TMP")),
meaning = paste0("Everything that's hierarchically below one of these headings is completely ignored for pkgdown reference index ",
"generation."))
# `envir = parent.frame(5L)` doesn't assign in the right environment for some reason, so we explicitly provide the env
pkg_env <- rlang::current_env()
data_special_headings |>
dplyr::group_split(type) |>
purrr::walk(\(x) assign(x = paste0("heading_texts_", x$type),
value = purrr::list_c(x$heading_texts,
ptype = character()),
envir = pkg_env))
rm(pkg_env)
assemble_copyright_notice <- function(path) {
if (fs::is_dir(path)) {
path <- fs::path(path, "DESCRIPTION")
}
rlang::check_installed("desc",
reason = pal::reason_pkg_required())
notice <- character()
if (desc::desc_has_fields(keys = "Authors@R",
file = path)) {
pkg <- pal::desc_get_field_safe(key = "Package",
file = path)
desc <- pal::desc_get_field_safe(key = "Title",
default = "",
file = path)
authors <-
desc::desc_get_authors(file = path) |>
# reduce to copyright holders or otherwise authors
pal::when(any(purrr::map_lgl(., \(x) "cph" %in% x$role)) ~ purrr::keep(., \(x) "cph" %in% x$role),
any(purrr::map_lgl(., \(x) "aut" %in% x$role)) ~ purrr::keep(., \(x) "aut" %in% x$role),
~ .) |>
format(include = c("given", "family")) |>
pal::enum_str()
if (length(authors) > 0L) {
notice <- c(paste0(pkg, ": ", desc),
paste0("Copyright (C) ", format(Sys.Date(), "%Y"), " ", authors))
} else {
cli::cli_alert_warning(paste0("Neither copyright holders nor authors found in {.field Authors@R} field in the package's {.file DESCRIPTION} file, thus ",
"skipped adding copyright notice."))
}
} else {
cli::cli_alert_warning(paste0("No {.field Authors@R} field present in the package's {.file DESCRIPTION} file, thus skipped adding license notice.",
paste0(" Try converting the existing {.field Author} field to an {.field Authors@R} field using {.fun ",
"desc::desc_coerce_authors_at_r}.")[desc::desc_has_fields("Author")]))
}
notice
}
assemble_license_notice <- function(path) {
rlang::check_installed("desc",
reason = pal::reason_pkg_required())
notice <- character()
if (fs::is_dir(path)) path <- fs::path(path, "DESCRIPTION")
if (desc::desc_has_fields(keys = "License",
file = path)) {
license <- pal::desc_get_field_safe(key = "License",
file = path)
if (grepl(x = license,
pattern = "^\\s*(AGPL ?\\(>= ?3\\)|AGPL-3\\.0-or-later)\\s*$")) {
notice <- c(paste0("This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as ",
"published by the Free Software Foundation, either version 3 of the License, or any later version."),
paste0("This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of ",
"MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details."),
paste0("You should have received a copy of the GNU Affero General Public License along with this program. If not, see",
" <https://www.gnu.org/licenses/>."))
} else {
cli::cli_alert_warning(paste0("{.field License} field in the package's {.file DESCRIPTION} file is not set to {.val AGPL (>= 3)}, thus skipped adding ",
"license notice."))
}
} else {
cli::cli_alert_warning("No {.field License} field present in the package's {.file DESCRIPTION} file, thus skipped adding license notice.")
}
notice
}
document_pkg <- function(path,
...) {
cli::cli_progress_step(msg = "Building package documentation")
devtools::document(pkg = path,
...)
cli::cli_progress_done()
}
extract_md_heading_content <- function(x,
env) {
x |>
stringr::str_remove(pattern = "^#+") |>
stringr::str_trim() |>
purrr::map_chr(\(x) {
if (x == "") {
NA_character_
} else {
knitr::knit(text = x,
quiet = TRUE,
envir = env)
}
})
}
install_pkg <- function(path,
unload = FALSE,
quiet = FALSE,
...) {
cli::cli_progress_step(msg = "Building and installing package")
pkg_name <- pkgload::pkg_name(path)
# unregister pkg if it's attached
if (pkg_name %in% loadedNamespaces()) {
if (unload) {
pkgload::unload(package = pkg_name,
quiet = quiet)
} else {
pkgload::unregister(package = pkg_name)
}
}
devtools::install(pkg = path,
quiet = quiet,
...)
cli::cli_progress_done()
invisible(path)
}
install_pkg_rstudio_api <- function(quiet = FALSE) {
cli::cli_alert_info("Building and installing package via RStudio API (see the {.strong Build} pane)")
rstudioapi::executeCommand(commandId = "buildFull",
quiet = quiet)
}
#' Determine a package's main `.Rmd` source file
#'
#' Determines which R Markdown file under `Rmd/` in `path` is the package's main source file. If multiple `.Rmd` files are present, the one whose name matches
#' the package name is selected.
#'
#' @inheritParams purl_rmd
#'
#' @return A character vector, of length 1 if a main `.Rmd` is found, otherwise of length 0.
#' @keywords internal
#' @export
main_rmd <- function(path = ".") {
rmd_file_paths <- rmd_files(path = path)
n_rmd_file_paths <- length(rmd_file_paths)
if (!n_rmd_file_paths) {
cli::cli_abort("No {.file .Rmd} files found under {.arg path}.")
} else if (n_rmd_file_paths == 1L) {
result <- rmd_file_paths
} else {
result <-
rmd_file_paths |>
fs::path_file() |>
fs::path_ext_remove() |>
magrittr::is_in(pal::desc_get_field_safe(key = "Package",
file = path)) |>
which() %>%
magrittr::extract(rmd_file_paths, .) |>
pal::when(length(.) > 1L ~ # this is theoretically possible in case of subdirs under `Rmd/` or for case-sensitive filesystems (both `.Rmd` and `.rmd`)
cli::cli_abort(paste0("Multiple {.file .Rmd} files detected under {.path {fs::path(path, 'Rmd/')}} whose names match the package name: ",
"{.file {.}}")),
~ .)
}
result
}
normalize_md_lf <- function(x) {
stringr::str_replace_all(string = x,
pattern = "(?<!\\n)\\n(?!(\\n|$))",
replacement = " ")
}
process_rmd <- function(path_file,
path_pkg,
copyright_notice,
license_notice,
line_width = 160L) {
r_file_path <-
path_file |>
fs::path_file() |>
fs::path_ext_set(ext = ".gen.R") %>%
fs::path(path_pkg, "R", .)
knitr::purl(input = path_file,
output = r_file_path,
quiet = TRUE,
documentation = 0L)
# add reminder header lines to generated R file
result <-
path_file %>%
fs::path_rel(start = fs::path_dir(fs::path_dir(.))) %>%
paste0("DO NOT EDIT THIS FILE BY HAND! Instead edit the R Markdown source file `", ., "` and run `pkgpurl::purl_rmd()`.") |>
pal::as_comment_str("See `README.md#r-markdown-format` for more information on the literate programming approach used applying the R Markdown format.",
sep_paragraphs = FALSE) |>
# add copyright and license notices if indicated
paste0("\n",
if (length(copyright_notice) > 0L) {
pal::as_comment_str(copyright_notice, "",
sep_paragraphs = FALSE)
},
if (length(license_notice) > 0L) {
paste0(pal::as_comment_str(license_notice),
"\n")
},
brio::read_file(path = r_file_path))
brio::write_file(text = result,
path = r_file_path)
}
restart_r <- function() {
rlang::check_installed("rstudioapi",
reason = pal::reason_pkg_required())
if (rstudioapi::isAvailable()) {
rstudioapi::restartSession()
} else {
cli::cli_alert_warning("Unable to restart R session because it is running outside of RStudio.")
}
}
rmd_files <- function(path) {
checkmate::assert_directory_exists(path,
access = "r")
checkmate::assert_scalar(path)
fs::path(path, "Rmd") |>
fs::path_abs() |>
fs::dir_ls(recurse = TRUE,
type = "file",
regexp = "(?<!\\.nopurl)\\.[Rr]md$",
perl = TRUE)
}
#' Create new R Markdown package
#'
#' @description
#' Populates the directory specified via `path` with all the necessary files for a new R Markdown package.
#'
#' The `DESCRIPTION` file is created using
#' [usethis::use_description()] and all fields except `Package`, `URL` and `BugReports` are sourced from the [`usethis.description` \R
#' option](https://usethis.r-lib.org/reference/use_description.html) if defined.
#'
#' @param name Package name.
#' @param id_netlify Netlify site identifier.
#' @param path Path to the new package directory.
#' @param incl_roxygen2_meta Whether or not to create a `man/roxygen/meta.R` file that i.a. stores the metadata for [roxygen2's `@family`
#' tags](https://roxygen2.r-lib.org/articles/index-crossref.html#family).
#' @param incl_reexports Whether or not to create an `R/reexports.R` file prefilled with an opinionated set of magrittr and rland operator exports.
#' @param incl_sysdata_rmd Whether or not to create an `Rmd/sysdata.nopurl.Rmd` stub file.
#' @param incl_data_rmd Whether or not to create an `Rmd/data.nopurl.Rmd` stub file.
#' @param incl_asciicasts_rmd Whether or not to create an `Rmd/asciicasts.nopurl.Rmd` stub file.
#' @param incl_pkgdown_config Whether or not to create a minimal `pkgdown/_pkgdown.yml` configuration file.
#' @param incl_ripgrep_config Whether or not to create a ripgrep ignore file `.rgignore`.
#' @param incl_ack_config Whether or not to create an [`.ackrc` configuration file](https://beyondgrep.com/documentation/).
#' @param incl_air_config Whether or not to create an [`air.toml` configuration file](https://posit-dev.github.io/air/configuration.html). Note that air is not
#' of much use yet for R Markdown packages.
#'
#' @returns `path`, invisibly.
#' @export
create_pkg <- function(name,
id_netlify = NULL,
path = ".",
incl_roxygen2_meta = TRUE,
incl_reexports = FALSE,
incl_sysdata_rmd = FALSE,
incl_data_rmd = FALSE,
incl_asciicasts_rmd = FALSE,
incl_pkgdown_config = TRUE,
incl_ripgrep_config = TRUE,
incl_ack_config = FALSE,
incl_air_config = FALSE) {
checkmate::assert_string(name)
checkmate::assert_string(id_netlify,
null.ok = TRUE)
checkmate::assert_flag(incl_roxygen2_meta)
checkmate::assert_flag(incl_reexports)
checkmate::assert_flag(incl_sysdata_rmd)
checkmate::assert_flag(incl_data_rmd)
checkmate::assert_flag(incl_asciicasts_rmd)
checkmate::assert_flag(incl_pkgdown_config)
checkmate::assert_flag(incl_ripgrep_config)
checkmate::assert_flag(incl_ack_config)
checkmate::assert_flag(incl_air_config)
tpl_pkg <- utils::packageName()
# create dirs if necessary and set usethis proj
if (fs::dir_exists(path)) {
path <- fs::path(path, name)
}
fs::dir_create(path = path)
# switch to target dir
usethis::local_project(path = path,
force = TRUE)
fs::dir_create("R")
fs::dir_create("Rmd")
# create files without any content interpolation ----
usethis::use_template(template = "gitignore",
save_as = ".gitignore",
package = tpl_pkg)
usethis::use_template(template = "LICENSE.md",
package = tpl_pkg)
usethis::use_template(template = "PKG.Rproj",
save_as = fs::path(name,
ext = "Rproj"),
package = tpl_pkg)
usethis::use_template(template = "Rbuildignore",
save_as = ".Rbuildignore",
package = tpl_pkg)
usethis::use_template(template = "R/PKG-package.R",
save_as = fs::path("R", paste0(name, "-package"),
ext = "R"),
package = tpl_pkg)
usethis::use_template(template = "Rmd/PKG.Rmd",
save_as = fs::path("Rmd", name,
ext = "Rmd"),
package = tpl_pkg)
if (incl_sysdata_rmd) {
usethis::use_template(template = "Rmd/sysdata.nopurl.Rmd",
package = tpl_pkg)
}
if (incl_data_rmd) {
usethis::use_template(template = "Rmd/data.nopurl.Rmd",
package = tpl_pkg)
}
if (incl_asciicasts_rmd) {
usethis::use_template(template = "Rmd/asciicasts.nopurl.Rmd",
package = tpl_pkg)
}
if (incl_ack_config) {
usethis::use_template(template = "ackrc",
save_as = ".ackrc",
package = tpl_pkg)
}
if (incl_air_config) {
usethis::use_template(template = "air.toml",
package = tpl_pkg)
}
if (incl_roxygen2_meta) {
fs::dir_create("man/roxygen")
usethis::use_template(template = "man/roxygen/meta.R",
package = tpl_pkg)
}
if (incl_reexports) {
usethis::use_template(template = "R/reexports.R",
package = tpl_pkg)
}
if (incl_ripgrep_config) {
usethis::use_template(template = "rgignore",
save_as = ".rgignore",
package = tpl_pkg)
}
# create files customized to pkg ----
usethis::use_template(template = "README.Rmd",
data = list(pkg = name,
id_netlify = id_netlify),
package = tpl_pkg)
if (!is.null(id_netlify)) {
usethis::use_template(template = "netlify.toml",
data = list(pkg = name),
package = tpl_pkg)
}
if (incl_pkgdown_config) {
fs::dir_create("pkgdown")
usethis::use_template(template = "pkgdown/_pkgdown.yml",
data = list(pkg = name),
package = tpl_pkg)
}
usethis::use_description(fields = list(Package = name,
URL = paste0("https://gitlab.com/rpkg.dev/", name),
BugReports = paste0("https://gitlab.com/rpkg.dev/", name, "/-/issues")))
invisible(path)
}
#' Process R Markdown package from source to installation
#'
#' @description
#' Executes all steps to process an R package written in R Markdown format from source to installation in one go:
#'
#' 1. Purl all relevant `Rmd/*.Rmd` files to `R/*.gen.R` files using [purl_rmd()].
#' 2. Re-generate the [pkgdown reference index](https://pkgdown.r-lib.org/reference/build_reference.html#reference-index) based on the package's [main R
#' Markdown file][main_rmd()] using [gen_pkgdown_ref()] (if `gen_pkgdown_ref = TRUE`).
#' 3. Re-build the package documentation using [devtools::document()] (if `document = TRUE`).
#' 4. Build and install the package (if `build_and_install = TRUE`). This is done either using
#' [`rstudioapi::executeCommand(commandId = "buildFull")`][rstudioapi::executeCommand] (if `use_rstudio_api = TRUE`) or using [devtools::install()] (if
#' `use_rstudio_api = FALSE`).
#' 5. Restarts the R session using [rstudioapi::restartSession()] (if either `restart_r_session = TRUE` or `use_rstudio_api = TRUE`).
#'
#' @details
#' Note that the installation via [devtools::install()] (i.e. `use_rstudio_api = FALSE`) is known to fail in certain situations (lazy-load database corruption)
#' due to [unresolved deficiencies](https://bugs.r-project.org/show_bug.cgi?id=16644) in \R's namespace unloading. If you encounter an error, simply restart the
#' \R session and try again.
#'
#' `r pkgsnip::md_snip("rstudio_addin")`
#'
#' @inheritParams purl_rmd
#' @inheritParams devtools::document
#' @inheritParams devtools::install
#' @param document Whether or not to re-build the package documentation after purling `Rmd/*.Rmd` to `R/*.gen.R`.
#' @param build_and_install Whether or not to build and install the package after purling `Rmd/*.Rmd` to `R/*.gen.R`.
#' @param restart_r_session Whether or not to restart the R session. Highly recommended if `build_and_install = TRUE`, but only possible when R is run within
#' RStudio. Note that the R session is always restarted if `use_rstudio_api = TRUE`.
#' @param use_rstudio_api Whether or not to rely on the RStudio API to install the built package (which always triggers an R session restart regardless of
#' `restart_r_session`). If `NULL`, the RStudio API is automatically used if possible, i.e. RStudio is running. Note that installation without the RStudio API
#' has known issues, see section *Details* below for further information.
#' @param quiet `r pkgsnip::param_lbl("quiet")`
#'
#' @inherit purl_rmd return
#' @family high_lvl
#' @export
process_pkg <- function(path = ".",
add_copyright_notice = funky::config_val("add_copyright_notice"),
add_license_notice = funky::config_val("add_license_notice"),
gen_pkgdown_ref = funky::config_val("gen_pkgdown_ref"),
env = parent.frame(),
document = TRUE,
build_and_install = TRUE,
restart_r_session = build_and_install,
use_rstudio_api = NULL,
quiet = TRUE,
roclets = NULL,
args = getOption("devtools.install.args"),
dependencies = NA,
upgrade = "never",
keep_source = getOption("keep.source.pkgs")) {
checkmate::assert_flag(document)
checkmate::assert_flag(build_and_install)
checkmate::assert_flag(restart_r_session)
checkmate::assert_flag(use_rstudio_api,
null.ok = TRUE)
checkmate::assert_flag(quiet)
rlang::check_installed("devtools",
reason = pal::reason_pkg_required())
rlang::check_installed("pkgload",
reason = pal::reason_pkg_required())
if (is.null(use_rstudio_api) && nzchar(system.file(package = "rstudioapi")) && rstudioapi::isAvailable()) {
use_rstudio_api <- TRUE
} else {
use_rstudio_api <- isTRUE(use_rstudio_api)
}
# convert `Rmd/*.Rmd` to `R/*.gen.R`
purl_rmd(path = path,
add_copyright_notice = add_copyright_notice,
add_license_notice = add_license_notice,
gen_pkgdown_ref = gen_pkgdown_ref,
env = env)
# build roxygen2 documentation
if (document) {
document_pkg(path = path,
roclets = roclets,
quiet = quiet)
}
# build and install package
if (build_and_install) {
if (use_rstudio_api) {
install_pkg_rstudio_api(quiet = quiet)
} else {
install_pkg(path = path,
unload = FALSE,
quiet = quiet,
reload = TRUE,
args = args,
dependencies = dependencies,
upgrade = upgrade,
keep_source = keep_source)
if (restart_r_session) {
restart_r()
}
}
}
invisible(path)
}
#' Load R Markdown package
#'
#' @description
#' Executes the steps to load an R package written in R Markdown format in one go:
#'
#' 1. Purl all relevant `Rmd/*.Rmd` files to `R/*.gen.R` files using [purl_rmd()].
#' 2. Loads the package using [devtools::load_all()].
#'
#' @inheritParams purl_rmd
#' @inheritParams devtools::load_all
#'
#' @inherit purl_rmd return
#' @family high_lvl
#' @export
load_pkg <- function(path = ".",
add_copyright_notice = FALSE,
add_license_notice = FALSE,
gen_pkgdown_ref = FALSE,
reset = TRUE,
recompile = FALSE,
export_all = TRUE,
helpers = TRUE,
quiet = FALSE,
...) {
rlang::check_installed("devtools",
reason = pal::reason_pkg_required())
# convert `Rmd/*.Rmd` to `R/*.gen.R`
purl_rmd(path = path,
add_copyright_notice = add_copyright_notice,
add_license_notice = add_license_notice,
gen_pkgdown_ref = gen_pkgdown_ref)
# load pkg
devtools::load_all(path = path,
reset = reset,
recompile = recompile,
export_all = export_all,
helpers = helpers,
quiet = quiet,
...)
invisible(path)
}
#' Purl `Rmd/*.Rmd` to `R/*.gen.R`
#'
#' This function strives to provide a standardized way to convert all relevant `.Rmd` files in the `Rmd/` subdirectory to bare `.R` files in the `R/`
#' subdirectory using [knitr::purl()]. It is mainly intended for authoring R packages in the [R Markdown file format](https://rmarkdown.rstudio.com/).
#'
#' The generated `.R` files will be named the same as the `.Rmd` files plus the suffix `.gen` to indicate the file was auto-generated. So the file
#' `Rmd/foo.Rmd` for example will be converted to `R/foo.gen.R`.
#'
#' The R Markdown file format allows you to intermingle code with related prose in [Markdown syntax](https://bookdown.org/yihui/rmarkdown/markdown-syntax.html)
#' optimized for human readability. This facilitates (best) practices which are commonly referred to as
#' [_literate programming_](https://en.wikipedia.org/wiki/Literate_programming).
#'
#' In practice, the main advantage of writing R code in R Markdown is that you don't have to rely on
#' [`#` comments](https://cran.r-project.org/doc/manuals/r-release/R-lang.html#Comments) to explain, annotate or otherwise elaborate on your code. It also
#' allows you to easily compile your source code to beautifully looking HTML, PDF etc. files using [rmarkdown::render()].
#'
#' `r pkgsnip::md_snip("rstudio_addin")`
#'
#' # Files excluded from purling
#'
#' `purl_rmd()` does not generate an `.R` file for each and every R Markdown file in the `Rmd/` subdirectory. Two types of `.Rmd` files are excluded from
#' purling:
#'
#' 1. Files having the suffix `.nopurl` in their name, e.g. `Rmd/playground.nopurl.Rmd`.
#' 2. Hidden files [as per Unix convention](https://en.wikipedia.org/wiki/Hidden_file_and_hidden_directory#Unix_and_Unix-like_environments) whose names start
#' with a dot, e.g. `Rmd/.playground.Rmd`.
#'
#' The above convention allows for easy exclusion of specific `.Rmd` files from purling. A common case for this are scripts that generate [package-internal
#' data](https://r-pkgs.org/data.html#sec-data-sysdata) from raw sources. Such a script could be stored as `Rmd/data.nopurl.Rmd`, so that no corresponding file
#' under `R/*.R` is generated. For the sake of clarity, it's generally advised to prefer the `.nopurl` suffix over hiding files.
#'
#' @param path Path to the root of the package directory.
#' @param add_copyright_notice `r funky_config$description[funky_config$key == "add_copyright_notice"]` A logical scalar. Only applies if `path` [is actually an
#' R package directory][pal::is_pkg_dir].
#' @param add_license_notice `r funky_config$description[funky_config$key == "add_license_notice"]` A logical scalar. Only applies if `path` [is actually an R
#' package directory][pal::is_pkg_dir].
#' @param gen_pkgdown_ref `r funky_config$description[funky_config$key == "gen_pkgdown_ref"]` A logical scalar. Only applies if `path` [is actually an R package
#' directory][pal::is_pkg_dir], [pkgdown is set up][pal::is_pkgdown_dir] and a [main R Markdown file][main_rmd()] exists.
#' @param env Environment to evaluate R Markdown inline code expressions in when generating the pkgdown reference index. Only relevant if
#' `gen_pkgdown_ref = TRUE`.
#'
#' @return `path`, invisibly.
#' @family high_lvl
#' @export
purl_rmd <- function(path = ".",
add_copyright_notice = funky::config_val("add_copyright_notice"),
add_license_notice = funky::config_val("add_license_notice"),
gen_pkgdown_ref = funky::config_val("gen_pkgdown_ref"),
env = parent.frame()) {
checkmate::assert_flag(add_copyright_notice)
checkmate::assert_flag(add_license_notice)
checkmate::assert_flag(gen_pkgdown_ref)
rmd_files <- rmd_files(path = path)
if (length(rmd_files) > 0L) {
cli::cli_progress_step(msg = "Purling {.file Rmd/*.Rmd} to {.file R/*.gen.R}")
r_dir <- fs::path(path, "R/")
if (!fs::dir_exists(r_dir)) fs::dir_create(r_dir)
copyright_notice <- NULL
license_notice <- NULL
if (pal::is_pkg_dir(path)) {
if (add_copyright_notice) copyright_notice <- assemble_copyright_notice(path = path)
if (add_license_notice) license_notice <- assemble_license_notice(path = path)
if (gen_pkgdown_ref && pal::is_pkgdown_dir(path)) {
# determine pkgdown config file path
pkgdown_config_file <-
c("_pkgdown.yml",
"_pkgdown.yaml",
"pkgdown/_pkgdown.yml",
"pkgdown/_pkgdown.yaml",
"inst/_pkgdown.yml",
"inst/_pkgdown.yaml") %>%
fs::path(path, .) |>
fs::file_exists() %>%
magrittr::extract(which(.)[1L]) |>
names()
# determine main input file for pkgdown reference generation
main_rmd_file <- main_rmd(path = path)
gen_pkgdown_ref <- length(main_rmd_file) > 0L
} else {
gen_pkgdown_ref <- FALSE
}
} else {
impossible_opts <- c("add_copyright_notice"[isTRUE(add_copyright_notice)],
"add_license_notice"[isTRUE(add_license_notice)])
n_impossible_opts <- length(impossible_opts)
if (n_impossible_opts) {
cli::cli_abort(paste0("{.arg {impossible_opts[1L]}} ",
"and {.arg {impossible_opts[2L]}} "[n_impossible_opts > 1L],
"{qty(n_impossible_opts)} {?was/were both} set to {.val TRUE}, but {.file {path}} does not appear to be an R package ",
"directory \u2013 thus cannot extract the necessary information from the package's {.file DESCRIPTION} file."))
}
add_copyright_notice <- FALSE
add_license_notice <- FALSE
gen_pkgdown_ref <- FALSE
}
rmd_files %>% purrr::walk(process_rmd,
path_pkg = path,
copyright_notice = copyright_notice,
license_notice = license_notice)
if (gen_pkgdown_ref) {
ref <-
main_rmd_file |>
brio::read_file() |>
gen_pkgdown_ref(env = env)
if (length(ref$reference) > 0L) {
yaml::read_yaml(file = pkgdown_config_file,
# avoid default YAML sequence simplification, cf. https://github.com/vubiostat/r-yaml/issues/69
handlers = list(seq = \(x) x)) |>
purrr::discard_at(at = "reference") |>
c(ref) |>
yaml::write_yaml(file = pkgdown_config_file,
handlers = list(logical = yaml::verbatim_logical))
}
}
} else {
cli::cli_alert_warning("{.path {path}} does not appear to be an Rmd package directory. Nothing done.")
}
invisible(path)
}
#' Lint R Markdown package
#'
#' This is a convenience wrapper around [lintr::lint_dir()] which is tailored to a typical R Markdown package. To use this function, the
#' [lintr](https://github.com/jimhester/lintr/#readme) package is required.
#'
#' To avoid unnecessary noise, all the the generated `R/*.gen.R` files as well as R Markdown vignettes under `vignettes/*.Rmd` are excluded from linting.
#'
#' `r pkgsnip::md_snip("rstudio_addin")`
#'
#' @inheritParams purl_rmd
#' @inheritParams lintr::lint_dir
#' @inheritParams lintr::lint
#'
#' @inherit lintr::lint_dir return
#' @seealso [`default_linters`][default_linters] and [default_exclusions()]
#' @family high_lvl
#' @export
lint_rmd <- function(path = ".",
linters = default_linters,
cache = FALSE,
relative_path = TRUE,
exclusions = default_exclusions(excl_vignettes = TRUE),
pattern = "\\.[Rr]([Mm][Dd])?$",
parse_settings = TRUE,
show_progress = NULL) {
checkmate::assert_directory_exists(path,
access = "r")
rlang::check_installed("lintr",
reason = pal::reason_pkg_required())
lintr::lint_dir(path = path,
linters = linters,
cache = cache,
relative_path = relative_path,
exclusions = exclusions,
pattern = pattern,
parse_settings = parse_settings,
show_progress = show_progress)
}
#' Run `*.nopurl.Rmd` files
#'
#' @description
#' `r lifecycle::badge("experimental")`
#'
#' Executes `.Rmd` files which are supposed to contain code not included in the [source package](https://r-pkgs.org/structure.html#sec-source-package), i.e.
#' usually outsourced to separate `.Rmd` files with the [`.nopurl` suffix in their
#' filenames](https://pkgpurl.rpkg.dev/dev/reference/purl_rmd.html#-rmd-files-excluded-from-purling). Those files are typically used to generate package
#' data.
#'
#' If an error is encountered saying `internal error -3 in R_decompress1`, restart the R session and run again.
#'
#' @inheritParams purl_rmd
#' @inheritParams devtools::document
#' @inheritParams devtools::install
#' @param path_rmd Path(s) to the `.Rmd` files to be executed. A character vector.
#' @param env Environment to evaluate the `.Rmd` files in. If `NULL`, the [global environment][globalenv] is used.
#' @param document Whether or not to re-build the package documentation after the last `.Rmd` file is executed.
#' @param build_and_install Whether or not to build and install the package after each `.Rmd` file execution.
#' @param restart_r_session Whether or not to restart the R session after the last `.Rmd` file is executed. Highly recommended if `build_and_install = TRUE`,
#' but only possible when R is run within RStudio.
#'
#' @return `path_rmd`, invisibly.
#' @family high_lvl
#' @export
run_nopurl_rmd <- function(path = ".",
path_rmd = fs::dir_ls(path = fs::path(path, "Rmd"),
recurse = TRUE,
type = "file",
glob = "*.nopurl.Rmd"),
env = NULL,
document = TRUE,
build_and_install = TRUE,
restart_r_session = TRUE,
quiet = TRUE,
roclets = NULL,
args = getOption("devtools.install.args"),
dependencies = NA,
upgrade = "never",
keep_source = getOption("keep.source.pkgs")) {
checkmate::assert_directory_exists(path,
access = "r")
checkmate::assert_scalar(path)
checkmate::assert_file_exists(path_rmd,
access = "r",
extension = c("Rmd", "qmd"))
checkmate::assert_environment(env,
null.ok = TRUE)
checkmate::assert_flag(document)
checkmate::assert_flag(build_and_install)
checkmate::assert_flag(restart_r_session)
path_tmp_purled <- purrr::map_chr(path_rmd,
\(x) knitr::purl(input = x,
output = fs::file_temp(pattern = fs::path_ext_remove(fs::path_file(x)),
ext = "R"),
quiet = quiet))
for (i in seq_along(path_tmp_purled)) {
cli::cli_progress_step(msg = "{.strong Executing file {.file {path_tmp_purled[i]}}}")
# nolint start: undesirable_function_linter
source(file = path_tmp_purled[i],
local = ifelse(is.null(env),
FALSE,
env),
echo = FALSE,
encoding = "UTF-8")
# nolint end
cli::cli_progress_done()
is_last_path <- i == length(path_tmp_purled)
# build roxygen2 documentation
if (document && is_last_path) {
document_pkg(path = path,
roclets = roclets,
quiet = quiet)
}
if (build_and_install) {
install_pkg(path = path,
unload = TRUE,
quiet = quiet,
reload = TRUE,
quick = !document || !is_last_path,
build = TRUE,
args = args,
dependencies = dependencies,
upgrade = upgrade,
keep_source = keep_source)
}
}
fs::file_delete(path_tmp_purled)
if (restart_r_session) {
restart_r()
}
invisible(path_rmd)
}
#' Generate pkgdown reference index
#'
#' @description
#' Generates the [pkgdown reference index](https://pkgdown.r-lib.org/reference/build_reference.html#reference-index) based on the heading hierarchy structure of
#' a package's main `.Rmd` file.
#'
#' @details
#' Basically, all elements of the pkgdown reference index except `desc` keys are derived from `rmd`'s [Markdown
#' **headings**](https://pandoc.org/MANUAL.html#headings) and their **hierarchy**. The `desc` keys, however, are assembled from the paragraph(s) below some
#' specially named headings. See below for details.
#'
#' # Special headings
#'
#' Headings that exactly match certain strings (case-insensitive, but without any inline formatting or additional text) are treated specially. Here's an
#' overview:
#'
#' ```{r, results = "asis", echo = FALSE}
#' data_special_headings %>%
#' dplyr::select(-type) %>%
#' dplyr::rename(Headings = heading_texts,
#' Meaning = meaning) %>%
#' pal::pipe_table()
#' ```
#'
#' # Parsing rules
#'
#' To be able to unambiguously map an `.Rmd` file's heading hierarchy to the pkgdown reference index, some parsing rules are necessary. Attention must be paid
#' to the fact that, while (R) Markdown supports up to six heading levels (corresponding to HTML's `<h1>`–`<h6>` tags), the pkgdown reference index only
#' supports up to two (`title` and `subtitle`).
#'
#' The following rules define how the reference index is generated:
#'
#' 1. Headings below a heading named
#' `r data_special_headings |> dplyr::filter(type == "ignore_content") %$% heading_texts |> unlist() |> pal::enum_str(wrap = "*", sep2 = " or ")`
#' (case-insensitive, but without any inline formatting) are simply ignored when generating the reference index.
#' 2. Every heading that is a) inline-formatted as [verbatim](https://pandoc.org/MANUAL.html#verbatim) and b) doesn't contain any whitespace characters is
#' considered to be the name of a help topic (usually the name of a function or dataset) to be included in the reference index. This maps to the `contents`
#' key of the reference index' YAML.
#' 3. Non-help-topic headings above help topic headings are used as reference index (sub)titles as far as hierarchical nesting allows. More precisely,
#' non-help-topic headings *of the highest two levels* above the help topic heading are used as title and subtitle, the rest of the headings above the help
#' topic heading is ignored – and a heading named *EXPORTED* (case-insensitive, but without any inline formatting) is always ignored regardless of its
#' level. This maps to the `title` and `subtitle` keys of the reference index' YAML.
#' 4. Paragraph(s) below a heading named *DESCRIPTION* (case-insensitive, but without any inline formatting), that in turn is hierarchically below a title or
#' subtitle heading, are used as the description for the respective (sub)title. This maps to the `desc` key of the reference index' YAML.
#'
#' # Parsing example
#'
#' An example might better explain how the parsing rules work than a thousand words, so here's a simplified one.
#'
#' When fed to `gen_pkgdown_ref()`, the following R Markdown content...
#'
#' ```{r, results = "asis", echo = FALSE}
#' brio::read_lines("snippets/simplified_example.Rmd") %>%
#' c(paste(pal::as_str(rep("`", 4L)), "rmd"), ., pal::as_str(rep("`", 4L))) %>%
#' pal::cat_lines()
#' ```
#'
#' ...yields this pkgdown index (converted [to YAML][yaml::as.yaml()]):
#'
#' ```{r, comment = "", echo = FALSE}
#' brio::read_file("snippets/simplified_example.Rmd") |>
#' gen_pkgdown_ref() |>
#' yaml::as.yaml() |>
#' cat()
#' ```
#'
#' # Inline R code
#'
#' R Markdown [inline code](https://rmarkdown.rstudio.com/lesson-4.html) is fully supported in headings and descriptions, except for the above mentioned special
#' headings (otherwise, they're not recognized as special headings anymore).
#'
#' @param rmd The (R) Markdown file content as a character scalar.
#' @param env Environment to evaluate R Markdown inline code expressions in.
#'
#' @return A list.
#' @family low_lvl
#' @export
#'
#' @examples
#' if (pal::is_pkg_installed("tinkr")) {
#' yay::gh_text_file(path = "Rmd/pal.Rmd",
#' owner = "salim-b",
#' name = "pal") |>
#' pkgpurl::gen_pkgdown_ref() |>
#' yaml::as.yaml() |>
#' cat()
#' }
gen_pkgdown_ref <- function(rmd,
env = parent.frame()) {
checkmate::assert_string(rmd)
checkmate::assert_environment(env)
rlang::check_installed("xml2",
reason = pal::reason_pkg_required())
rmd_xml <-
rmd |>
pal::md_to_xml(strip_xml_ns = FALSE) |>
xml2::xml_contents()
# assemble necessary Markdown heading hierarchy information (one row for each node of `rmd_xml`)
hierarchy <-
tibble::tibble(subnode_ix = pal::md_xml_subnode_ix(xml = rmd_xml),
# is _relevant_ heading
is_heading = lengths(subnode_ix) > 0L) |>
tibble::rowid_to_column(var = "i") %>%
# add variables that are relevant for heading nodes only
dplyr::left_join(y = tibble::tibble(i = .$i[.$is_heading],
heading_lvl =
rmd_xml[i] |>
purrr::map_chr(xml2::xml_attr,
attr = "level") |>
as.integer(),
is_help_topic =
xml2::xml_length(rmd_xml[i]) == 1L
& rmd_xml[i] |>
xml2::xml_child(search = "d1:code") |>
xml2::xml_text() |>
stringr::str_detect(pattern = "^[^\\s`]+$"),
is_description_heading =
xml2::xml_length(rmd_xml[i]) == 1L
& rmd_xml[i] |>
xml2::xml_child(search = "d1:text") |>
xml2::xml_text(trim = TRUE) |>
stringr::str_detect(pattern = paste0("^(?i)", pal::fuse_regex(heading_texts_description), "$")),
is_ignored =
xml2::xml_length(rmd_xml[i]) == 1L
& rmd_xml[i] |>
xml2::xml_child(search = "d1:text") |>
xml2::xml_text(trim = TRUE) |>
stringr::str_detect(pattern = paste0("^(?i)", pal::fuse_regex(heading_texts_ignore_content), "$")),
is_heading_ignored =
xml2::xml_length(rmd_xml[i]) == 1L
& rmd_xml[i] |>
xml2::xml_child(search = "d1:text") |>
xml2::xml_text(trim = TRUE) |>
stringr::str_detect(pattern = paste0("^(?i)", pal::fuse_regex(heading_texts_ignore_title), "$"))),
by = "i") |>
tidyr::replace_na(replace = list(is_help_topic = FALSE,
is_description_heading = FALSE))
## complete `is_ignored`
for (i in hierarchy$i[hierarchy$is_ignored
& !is.na(hierarchy$is_ignored)
& lengths(hierarchy$subnode_ix) > 0L]) {
hierarchy$is_ignored[unlist(hierarchy$subnode_ix[i])] <- TRUE
}
hierarchy %<>% tidyr::replace_na(replace = list(is_ignored = FALSE))
## exclude ignored help topics
hierarchy$is_help_topic[hierarchy$is_ignored] <- FALSE
# warn if no valid help topic nodes are present
if (any(hierarchy$is_help_topic)) {
# initialize reference index data
data_ref_i <- tibble::tibble(i_title = integer(),
i_subtitle = integer(),
content = character())
# iterate over all help topic nodes, get their names as well as their (sub)title node indices
for (i in hierarchy$i[hierarchy$is_help_topic]) {
hierarchy_subset <-
hierarchy |>
dplyr::filter(!is_help_topic
& !is_heading_ignored
& purrr::map_lgl(hierarchy$subnode_ix,
\(x) !!i %in% x))
title_lvl <- pal::safe_min(hierarchy_subset$heading_lvl)
data_ref_i %<>% tibble::add_row(i_title =
hierarchy_subset |>
dplyr::filter(heading_lvl == title_lvl) %$%
ifelse(length(i) > 0L,
as.integer(i),
NA_integer_),
i_subtitle =
hierarchy_subset |>
dplyr::filter(heading_lvl > title_lvl) |>
dplyr::filter(heading_lvl == pal::safe_min(heading_lvl)) %$%
ifelse(length(i) > 0L,
as.integer(i),
NA_integer_),
content = xml2::xml_text(rmd_xml[i]))
}
# add actual (sub)titles and their descriptions
data_ref_i %<>%
dplyr::left_join(y = tibble::tibble(i_title =
.$i_title |>
unique() |>
setdiff(NA_integer_),
title =
rmd_xml[i_title] |>
purrr::map_chr(pal::xml_to_md) |>
extract_md_heading_content(env = env),
title_description =
hierarchy$subnode_ix[i_title] |>
purrr::map2_chr(.y = i_title,
.f = \(x, y) {
hierarchy[x, ] |>
dplyr::filter(is_description_heading
& heading_lvl == hierarchy$heading_lvl[y] + 1L) %$%
subnode_ix |>
purrr::list_c(ptype = integer()) %>%
magrittr::extract(rmd_xml, .) |>
purrr::map_chr(pal::xml_to_md) |>
knitr::knit(text = _,
quiet = TRUE,
envir = env) |>
stringr::str_trim() |>
pal::when(length(.) > 0L ~ paste(., collapse = "\n\n"),
~ NA_character_)
})),
by = "i_title") %>%
dplyr::left_join(y = tibble::tibble(i_subtitle =
.$i_subtitle |>
unique() |>
setdiff(NA_integer_),
subtitle =
rmd_xml[i_subtitle] |>
purrr::map_chr(pal::xml_to_md) |>
extract_md_heading_content(env = env),
subtitle_description =
hierarchy$subnode_ix[i_subtitle] |>
purrr::map2_chr(.y = i_subtitle,
.f = \(x, y) {
hierarchy[x, ] |>
dplyr::filter(is_description_heading
& heading_lvl == hierarchy$heading_lvl[y] + 1L) %$%
subnode_ix |>
purrr::list_c(ptype = integer()) %>%
magrittr::extract(rmd_xml, .) |>
purrr::map_chr(pal::xml_to_md) |>
knitr::knit(text = _,
quiet = TRUE,
envir = env) |>
stringr::str_trim() |>
pal::when(length(.) > 0L ~ paste(., collapse = "\n\n"),
~ NA_character_)
})),
by = "i_subtitle")
# assemble result list that can easily be converted to YAML using `yaml::as.yaml()`
result <-
data_ref_i |>
dplyr::group_by(i_title) |>
dplyr::group_map(\(data_title, key) {
title <- data_title$title[1L]
title_description <- data_title$title_description[1L]
item_title <- list()
if (!is.na(title)) {
item_title %<>% c(list(title = title))
}
if (!is.na(title_description)) {
item_title %<>% c(list(desc = title_description))
}
items_subtitle <-
data_title |>
dplyr::group_by(i_subtitle) |>
dplyr::group_map(\(data_subtitle, key) {
subtitle <- data_subtitle$subtitle[1L]
subtitle_description <- data_subtitle$subtitle_description[1L]
contents <- data_subtitle$content
item_subtitle <- list()
if (!is.na(subtitle)) {
item_subtitle %<>% c(list(subtitle = subtitle))
}
if (!is.na(subtitle_description)) {
item_subtitle %<>% c(list(desc = subtitle_description))
}
c(list(item_subtitle),
list(list(contents = contents)))
}) |>
purrr::list_flatten() |>
purrr::compact() |>
# move subtitle-less item to the front (the `NA` group is always processed last in `group_map()`)
pal::when(anyNA(data_title$subtitle) ~ .[c(length(.), seq_len(length(.) - 1L))],
~ .)
c(list(item_title),
items_subtitle)
}) |>
purrr::list_flatten() |>
purrr::compact() |>
# move title-less item to the front (the `NA` group is always processed last in `group_map()`)
pal::when(anyNA(data_ref_i$title) ~ .[c(length(.), seq_len(length(.) - 1L))],
~ .)
} else {
cli::cli_alert_warning("Unable to generate pkgdown reference index. No valid help topic headings found in main {.file .Rmd} file.")
result <- list()
}
list(reference = result)
}
#' pkgpurl's default lintr exclusions
#'
#' Opinionated set of files and folders to be excluded from linting, relative to the package path. To be used with [lint_rmd()], [lintr::lint_dir()] or
#' [lintr::lint_package()].
#'
#' @param excl_vignettes Whether or not to exclude all `.Rmd` files under `vignettes/`. A logical scalar.
#'
#' @return A named list of [lintr::linters].
#' @seealso [`default_linters`][default_linters] and [lint_rmd()]
#' @export
#'
#' @examples
#' pkgpurl::default_exclusions()
default_exclusions <- function(excl_vignettes = TRUE) {
checkmate::assert_flag(excl_vignettes)
c("docs",
"input",
"output",
"pkgdown",
"renv",
"packrat",
"tests",
list.files(path = "R",
recursive = TRUE,
full.names = TRUE,
pattern = "\\.gen\\.R$"),
list.files(path = "Rmd",
recursive = TRUE,
full.names = TRUE,
pattern = "\\.nopurl\\.Rmd$"),
if (excl_vignettes) list.files(path = "vignettes",
recursive = TRUE,
full.names = TRUE,
pattern = "\\.Rmd$"),
"README.Rmd")
}
#' pkgpurl's default linters
#'
#' Opinionated set of [linters][lintr::linters]. Built from [lintr::linters_with_defaults()] with lots of customizations. See the [relevant source
#' code](https://gitlab.com/rpkg.dev/pkgpurl/-/blob/master/Rmd/sysdata.nopurl.Rmd#default_linters) for details.
#'
#' @format A named list of [lintr::linters].
#' @seealso [default_exclusions()] and [lint_rmd()]
#' @export
#'
#' @examples
#' names(pkgpurl::default_linters)
"default_linters"
#' `r pkgsnip::title_lbl("funky_config", pkg = "pkgpurl")`
#'
#' `r pkgsnip::description_lbl("funky_config", pkg = "pkgpurl")`
#'
#' @format `r pkgsnip::return_lbl("tibble_cols", cols = colnames(funky_config))`
#' @export
#'
#' @examples
#' pkgpurl::funky_config
"funky_config"
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.