Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Imports:
janitor,
lubridate,
nanoarrow,
processx,
quarto,
readr,
renv,
rlang,
Expand Down
71 changes: 71 additions & 0 deletions R/cli.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#' @title Ask for Yes/No Confirmation
#'
#' @description Interactive yes/no confirmation with cli styling.
#' Returns TRUE for yes, FALSE for no. Follows the pattern from cli issue #488.
#'
#' @param text The question text (supports cli styling)
#' @param yes Character vector of affirmative options (default: c("Yes", "yeah"))
#' @param no Character vector of negative options (default: c("No", "nope"))
#' @param n_yes Number of yes options to show (default: 1)
#' @param n_no Number of no options to show (default: 1)
#' @param shuffle Whether to shuffle the options (default: TRUE)
#' @param ... Additional arguments passed to cli::cli_alert()
#' @param .envir Environment for glue interpolation
#'
#' @return Logical TRUE for yes, FALSE for no
#'
#' @keywords internal
#'
#' @examples
#' \dontrun{
#' # Simple confirmation
#' if (cli_yeah("Delete all files?")) {
#' # Proceed with deletion
#' }
#'
#' # Custom options
#' proceed <- cli_yeah(
#' "Continue with migration?",
#' yes = c("Yes, proceed", "Continue"),
#' no = c("Cancel", "Stop")
#' )
#' }
cli_yeah <- function(
text,
yes = c("Yes", "yeah"),
no = c("No", "nope"),
n_yes = 1,
n_no = 1,
shuffle = TRUE,
...,
.envir = parent.frame()
) {
if (!rlang::is_interactive()) {
cli::cli_abort(
c(
"User input required, but session is not interactive.",
i = "Query: {text}"
),
.envir = .envir
)
}

n_yes <- min(n_yes, length(yes))
n_no <- min(n_no, length(no))

# Sample options
qs <- c(sample(yes, n_yes), sample(no, n_no))

if (shuffle) {
qs <- sample(qs)
}

# Show the question
cli::cli_alert(text, ..., .envir = .envir)

# Present menu and get choice
choice <- utils::menu(qs, title = "Choose an option:")

# Return TRUE if yes option selected
choice != 0L && qs[[choice]] %in% yes
}
252 changes: 197 additions & 55 deletions R/template.R
Original file line number Diff line number Diff line change
@@ -1,74 +1,216 @@
#' @title Function to use the OJO quarto HTML template
#'
#' @description Wrapper for `quarto use template` command. Defaults to OJO html template.
#'
#' @param path The name of the directory where the template should be added. This should be a string representing an absolute path, or a path relative to the current working directory.
#' @param template The name of the Quarto template to use. This should be a string in the format "username/repository". Default is "openjusticeok/ojo-report-template".
#' @return This function does not return a value. If the user chooses not to proceed with the operation, the function exits silently.
validate_project_name <- function(name) {
stringr::str_detect(name, "^[a-z0-9]+(-[a-z0-9]+)*$")
}

prompt_project_name <- function() {
project_name <- readline(cli::format_inline("Project name (kebab-case): "))
while (!validate_project_name(project_name)) {
cli::cli_alert_danger("Invalid name. Use lowercase, numbers, hyphens only.")
project_name <- readline(cli::format_inline("Project name (kebab-case): "))
}
project_name
}

prompt_parent_dir <- function(default = fs::path_wd()) {
prompt_text <- cli::format_inline("Parent directory [{.path {default}}]: ")
parent_dir <- readline(prompt_text)
if (parent_dir == "") {
parent_dir <- default
}
parent_dir <- fs::path_abs(parent_dir)
if (!fs::dir_exists(parent_dir)) {
cli::cli_alert_info("Creating parent directory {.path {parent_dir}}...")
fs::dir_create(parent_dir, recurse = TRUE)
}
parent_dir
}

prompt_template <- function(default = "website") {
choices <- c(
"website" = "website (multi-page site)",
"report" = "report (single-page document)"
)
# Pre-select default by putting it first
if (default == "report") {
choices <- choices[c("report", "website")]
}
choice <- utils::menu(choices, title = cli::format_inline("Select template type:"))
if (choice == 0L) {
return(invisible())
}
names(choices)[choice]
}

#' @title Function to use the OKPolicy quarto website template
#' @description Wrapper for `quarto use template` command. Creates a new project directory
#' and installs a Quarto template. The project name is derived from the last component
#' of the path and must be in kebab-case format.
#' @param path Character. The path to the project directory. Can be absolute or relative
#' to the current working directory. The last component of the path will be used as the
#' project name and must be in kebab-case (lowercase letters, numbers, and hyphens only).
#' If `NULL` (default) and in interactive mode, prompts for project name and location.
#' @param template Character. The type of project template to use. Must be one of:
#' * `"website"` (default) - Multi-page website template
#' * `"report"` - Single-page report template
#' @param .interactive Logical. Whether to prompt for user confirmation in interactive mode.
#' Defaults to `rlang::is_interactive()`.
#'
#' @export
#' @return Invisibly returns the result of the quarto command execution.
#'
#' @examples
#' \dontrun{
#' ojo_use_template(template = "openjusticeok/ojo-report-template", path = "my_directory")
#' # Interactive mode (prompts for project name and location)
#' ojo_use_template()
#'
#' # Create a new website project (default)
#' ojo_use_template("my-new-website")
#'
#' # Create a report project
#' ojo_use_template("my-new-report", template = "report")
#'
#' # Create a project in a specific directory
#' ojo_use_template("~/Documents/Reports/my-new-website")
#'
#' # Create a project with interactive turned off
#' ojo_use_template("my-new-website", .interactive = FALSE)
#' }
#' @export
ojo_use_template <- function(
path,
template = "openjusticeok/ojo-report-template"
path = NULL,
template = c("website", "report"),
.interactive = rlang::is_interactive()
) {
command <- paste("quarto use template", template, "--no-prompt")
# If path is NULL and interactive, enter full interactive mode
if (is.null(path)) {
if (!.interactive) {
cli::cli_abort("{.arg path} is required in non-interactive mode.")
}

full_dir <- dplyr::if_else(
fs::is_absolute_path(path),
path,
fs::path_wd(path)
)
project_name <- prompt_project_name()
parent_dir <- prompt_parent_dir()
selected_template <- prompt_template()

# Check if the directory exists
if (!dir.exists(full_dir)) {
# If not, ask if we should create it
ans <- utils::menu(
choices = c("Yes", "No"),
title = paste0(
"The path `",
full_dir,
"` does not exist.",
"\nDo you want to create it?"
)
)
# If they say no, just stop
if (ans == 2) {
# Handle cancellation from template prompt
if (is.null(selected_template)) {
return(invisible())
}
# Otherwise, go ahead
fs::dir_create(full_dir)

template <- selected_template
path <- fs::path(parent_dir, project_name)
}

# Check that the directory is empty
if (!dir_empty(full_dir)) {
cli::cli_abort(
paste0(
"Provided directory `", full_dir, "` is not empty!\n",
"Please specify an empty directory (or enter the name of one to create) for the report to live in!"
)
)
# Validate template argument
template <- rlang::arg_match(template)

# Map friendly names to GitHub template specs
template_spec <- switch(template,
website = "openjusticeok/okpolicy-quarto-templates/okpolicy-website-template",
report = "openjusticeok/okpolicy-quarto-templates/okpolicy-report-template"
)

# Path Resolution
project_dir <- fs::path_abs(path)
project_name <- fs::path_file(path)

# Validate project name
if (!validate_project_name(project_name)) {
cli::cli_abort(c(
"x" = "{.arg path} must end with a valid kebab-case project name.",
"i" = "Allowed: lowercase letters, numbers, hyphens",
"i" = "Examples: {.code my-project}, {.code report-2024}, {.code data-analysis}"
))
}

# Confirm that directory is correct
if (utils::menu(choices = c("Yes", "No"), title = paste0(
"The template `",
template,
"` will be added to the directory `",
full_dir,
"`.\nDo you want to proceed?"
)) == 2) {
return(invisible())
quarto_args <- c("use", "template", template_spec, "--no-prompt")

# Find the executable
quarto_bin <- quarto::quarto_path(normalize = TRUE)

# Graceful fail if Quarto is not installed
if (is.null(quarto_bin) || !nzchar(quarto_bin)) {
cli::cli_abort("Quarto is not installed or could not be found.")
}
# Directory handling logic
success <- FALSE

if (!fs::dir_exists(project_dir)) {
# Directory doesn't exist, create automatically
cli::cli_alert_info("Creating directory {.path {project_dir}}...")
fs::dir_create(project_dir)

# Set up cleanup on failure
on.exit(if (!success) try(fs::dir_delete(project_dir), silent = TRUE), add = TRUE)
} else {
# Directory exists, check if empty
if (!dir_empty(project_dir)) {
# Directory not empty, abort
cli::cli_abort(c(
"x" = "Directory {.path {project_dir}} exists and is not empty.",
"i" = "Remove the directory first or choose a different path."
))
}
# Directory exists but empty, proceed without asking
}

# Confirm user wants to proceed (only for new directories in interactive mode)
if (.interactive && !fs::dir_exists(project_dir)) {
if (!cli_yeah("Install the template to {.path {project_dir}}?")) {
cli::cli_alert_info("Aborted by user.")
fs::dir_delete(project_dir)
return(invisible())
}
}

# Run command
withr::with_dir(new = full_dir, code = system(command))
cli::cli_alert_info("Installing {.field {template}} template to {.path {project_dir}}...")

# Error/Status/StdErr/StdOut capturing
result <- tryCatch(
withr::with_dir(
new = project_dir,
code = {
processx::run(
command = quarto_bin,
args = quarto_args,
echo = .interactive, # print the stdout of the command
echo_cmd = .interactive, # print the command that will be run
error_on_status = TRUE, # if the command fails, throw an R error, which is then caught by the tryCatch block below
spinner = .interactive # show a spinner while the command is running
)
}
),
# c() is concatenating the following CLI styling together for cli::abort message
# x = red
# i = blue
# " " = invisible indent for clean continuation lines
# v = green
# ! = yellow warning
system_command_status_error = function(error) {
cli::cli_abort(
c(
"x" = "Quarto encountered an error. Template installation failed.",
# if quarto wrote a message to stderr write it to CLI, if not write "(no stderr)"
" " = if (nzchar(error$stderr)) error$stderr else "(no stderr)",
"i" = "Exit status: {error$status}"
),
parent = error
)
},
# captures other errors that happen before or during quarto launch
error = function(error) {
cli::cli_abort(
c(
"!" = "Something went wrong while preparing the Quarto command.",
"i" = conditionMessage(error)
),
parent = error
)
}
)

success <- TRUE
cli::cli_alert_success(
"{.field {template}} template successfully added to directory {.path {project_dir}}!"
)

cli::cli_alert_success(paste0(
"Template `", template, "` successfully added to directory `", full_dir, "`!"
))
invisible(result)
}
Loading
Loading