R CMD check
notes about undefined global objects used in magrittr pipescf. https://github.com/tidyverse/magrittr/issues/29#issuecomment-74313262
utils::globalVariables(names = c(".", "heading_lvl", "i_subtitle", "i_title", "is_description_heading", "is_heading_ignored", "is_help_topic", "subnode_ix"))
data_special_headings
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."))
Pre-assign each heading type to a character vector variable for optimal performance.
# `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
Assemble a package's copyright notice
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
Assemble a package's license 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
Builds a package's roxygen2 documentation.
document_pkg <- function(path, ...) { cli::cli_progress_step(msg = "Building package documentation") devtools::document(pkg = path, ...) cli::cli_progress_done() }
extract_md_heading_content
Removes leading #
s and trailing newlines.
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
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
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) }
main_rmd
#' 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
Replaces single line feeds ("\n"
) with whitespaces (" "
), except for a trailing line feed.
normalize_md_lf <- function(x) { stringr::str_replace_all(string = x, pattern = "(?<!\\n)\\n(?!(\\n|$))", replacement = " ") }
process_rmd
Helper function purling a single file Rmd/*.Rmd
to R/*.gen.R
.
NOTES:
R/
are not allowed, so we flatten output path hierarchy, c.f. https://r-pkgs.org/code.html#fn1 (footnote 1).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
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
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_pkg
#' 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_pkg
TODO:
process_pkg()
's output to it instead of the console.NOTES:
process_pkg(use_rstudio_api = FALSE)
to fail with an error
"lazy-load database ... is corrupt".#' 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_pkg
#' 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
TODO:
Support more OSS licenses in param add_license_notice
/ fn assemble_license_notice()
. Default license headers would have to be systematically fetched
from somewhere for this!
Notable stuff:
There's an Open Source License API by the OSI; maybe create an API wrapper pkg for R? (unfortunately, default license headers don't seem to be included in the data returned by the API (yet))
There's the Software Package Data Exchange (SPDX) project by the Linux Foundation systematically offering license
information in a standardized format and defining unique license identifiers (SPDX IDs); an individual license page can be accessed under the URL
https://spdx.org/licenses/[SPDX-ID].html
; it seems to always include a section Standard License Header 🎉 (which just says "There is no standard
license header for the license" if there's none).
Maybe we should add the SPDX-License-Identifier
and SPDX-FileCopyrightText
tags to the generated R files
besides the license header and instead of the current copyright notice?
Viable solution:
Determine a package license's SPDX identifier. If the OSI API would offer any (fuzzy) search endpoint, we could feed the license info in the pkg DESCRIPTION to the OSI API and query the SPDX identifier. But the OSI API doesn't seem to ... so at least we need to implement a mapping from the license identifiers officially supported by R (like the "standard" short specifications) to the SPDX identifiers!
Based on the SPDX identifier, scrape the SPDX page for the Standard License Header section.
NOTES:
#' 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_rmd
NOTES:
linters
. To list all of lintr's default linters, use: names(lintr::default_linters)
.#' 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
#' 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) }
gen_pkgdown_ref
NOTES:
The pkgdown reference supports a max. of 2 hierarchy levels.
Use desc: |
YAML syntax to preserve linebreaks and thus allow multi-paragraph descriptions.
For a sophisticated pkgdown reference example, see the one of ggplot2 (and compare with its source).
#' 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 #' 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 #' 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 #' 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) }
default_exclusions
#' 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") }
default_linters
#' 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"
funky_config
#' `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.