diff --git a/.github/.gitignore b/.github/.gitignore new file mode 100644 index 0000000..2d19fc7 --- /dev/null +++ b/.github/.gitignore @@ -0,0 +1 @@ +*.html diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..f087a35 --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,33 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + +name: R-CMD-check.yaml + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml new file mode 100644 index 0000000..bfc9f4d --- /dev/null +++ b/.github/workflows/pkgdown.yaml @@ -0,0 +1,49 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + release: + types: [published] + workflow_dispatch: + +name: pkgdown.yaml + +permissions: read-all + +jobs: + pkgdown: + runs-on: ubuntu-latest + # Only restrict concurrency for non-PR jobs + concurrency: + group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::pkgdown, local::. + needs: website + + - name: Build site + run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) + shell: Rscript {0} + + - name: Deploy to GitHub pages πŸš€ + if: github.event_name != 'pull_request' + uses: JamesIves/github-pages-deploy-action@v4.5.0 + with: + clean: false + branch: gh-pages + folder: docs diff --git a/DESCRIPTION b/DESCRIPTION new file mode 100644 index 0000000..dc5a8ed --- /dev/null +++ b/DESCRIPTION @@ -0,0 +1,20 @@ +Package: peeky +Title: Download and Extract Shinylive Applications +Version: 0.1.0 +Authors@R: + person("James Joseph", "Balamuta", , "james.balamuta@gmail.com", role = c("aut", "cre")) +Description: Peeks into Quarto documents and standalone Shinylive applications + to download and extract their Shiny application source. Handles the + extraction of application files from app.json format into a directory structure. +URL: https://r-pkg.thecoatlessprofessor.com/peeky/, https://github.com/coatless-rpkg/peeky +BugReports: https://github.com/coatless-rpkg/peeky/issues +Imports: + jsonlite, + httr, + rvest, + fs, + cli +License: AGPL (>= 3) +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 diff --git a/NAMESPACE b/NAMESPACE new file mode 100644 index 0000000..8b1bcbe --- /dev/null +++ b/NAMESPACE @@ -0,0 +1,7 @@ +# Generated by roxygen2: do not edit by hand + +S3method(print,quarto_shinylive_apps) +S3method(print,standalone_shinylive_app) +export(peek_quarto_shinylive_app) +export(peek_shinylive_app) +export(peek_standalone_shinylive_app) diff --git a/R/find.R b/R/find.R new file mode 100644 index 0000000..555b00c --- /dev/null +++ b/R/find.R @@ -0,0 +1,144 @@ +#' Find and Validate Shinylive app.json +#' +#' Attempts to locate and validate a Shinylive app.json file from a given base URL. +#' The function tries multiple possible paths and validates both the HTTP response +#' and JSON structure. +#' +#' @param base_url Character string. The base URL to search for app.json. +#' +#' @return +#' A list with three components: +#' +#' - `valid` Logical indicating if a valid app.json was found +#' - `url` Character string of the successful URL, or NULL if not found +#' - `data` List containing the parsed JSON data if valid, NULL otherwise +#' +#' @details +#' The function performs the following steps: +#' +#' 1. Generates three possible paths to check: +#' - The base URL as provided +#' - base URL + `"/app.json"`` +#' - Parent directory + `"/app.json"` +#' 2. For each path: +#' - Attempts an HTTP GET request +#' - Verifies the content type is JSON +#' - Parses and validates the JSON structure +#' - Returns immediately if valid app.json is found +#' +#' @keywords internal +#' +#' @examples +#' \dontrun{ +#' # Direct app.json URL +#' result <- find_shinylive_app_json("https://example.com/app.json") +#' +#' # Directory containing app.json +#' result <- find_shinylive_app_json("https://example.com/myapp/") +#' +#' # Check if valid +#' if (result$valid) { +#' cat("Found app.json at:", result$url) +#' } +#' } +find_shinylive_app_json <- function(base_url) { + # List of possible paths to try + possible_paths <- c( + base_url, + file.path(base_url, "app.json"), + file.path(dirname(base_url), "app.json") + ) + + # Try each path + for (path in possible_paths) { + tryCatch({ + resp <- httr::GET(path) + + # If this is already JSON content, verify it's a valid app.json + if (grepl("application/json", httr::headers(resp)[["content-type"]], fixed = TRUE)) { + content <- httr::content(resp, "text") + # Try to parse as JSON and validate structure + json_data <- jsonlite::fromJSON(content, simplifyDataFrame = FALSE) + if (validate_app_json(json_data)) { + return(list( + valid = TRUE, + url = path, + data = json_data # Return parsed data instead of raw content + )) + } + } + }, error = function(e) NULL) + } + return(list(valid = FALSE, url = NULL, data = NULL)) +} + +#' Find Shinylive Code Blocks in Quarto HTML +#' +#' Parses HTML content to extract and process Shinylive code blocks for both R and Python +#' applications. This function identifies code blocks with class `'shinylive-r'` or +#' `'shinylive-python'` and processes their content into structured application data. +#' +#' @param html Character string containing HTML content. The HTML should contain +#' code blocks with class `'shinylive-r'` or `'shinylive-python'` to be processed. +#' +#' @return +#' A list of parsed Shinylive applications. Each list element contains: +#' +#' - `engine`: Character string indicating the application type (`"r"` or `"python"`) +#' - `options`: List of parsed YAML-style options from the code block +#' - `files`: List of file definitions, where each file contains: +#' - `name`: Character string of the file name +#' - `content`: Character string of the file content +#' - `type`: Character string indicating the file type +#' +#' @details +#' +#' The function performs the following steps: +#' +#' 1. Parses the HTML content using `rvest` +#' 2. Extracts code blocks with classes `'shinylive-r'` or `'shinylive-python'` +#' 3. For each code block: +#' - Determines the engine type from the 'data-engine' attribute +#' - Extracts the code text content +#' - Parses the code block structure using `parse_code_block()` +#' +#' Code blocks should follow the Shinylive format with optional YAML-style +#' options (prefixed with `'#|'`) and file markers (prefixed with `'## file:'`). +#' +#' @seealso parse_code_block +#' +#' @examples +#' \dontrun{ +#' html_content <- ' +#'
+#' #| viewerHeight: 500
+#' ## file: app.R
+#' library(shiny)
+#' ui <- fluidPage()
+#' server <- function(input, output) {}
+#' shinyApp(ui, server)
+#' 
+#' ' +#' apps <- find_shinylive_code(html_content) +#' } +#' +#' @keywords internal +find_shinylive_code <- function(html) { + # Parse HTML with rvest + doc <- rvest::read_html(html) + + # Find all shinylive code blocks (both R and Python) + code_blocks <- doc |> + rvest::html_elements("pre.shinylive-r, pre.shinylive-python") + + # Process each code block, using the data-engine attribute to determine the type + lapply(code_blocks, function(block) { + engine <- block |> rvest::html_attr("data-engine") + code_text <- block |> rvest::html_text() + + # Parse the code block with engine information + parse_code_block(code_text, engine) + }) +} + + diff --git a/R/peek.R b/R/peek.R new file mode 100644 index 0000000..1181f69 --- /dev/null +++ b/R/peek.R @@ -0,0 +1,328 @@ +#' Download and Extract Shinylive Applications from URLs +#' +#' Downloads and extracts Shinylive applications from various URL sources. The function +#' can handle both standalone Shinylive applications and Quarto documents containing +#' embedded Shinylive applications. It automatically detects the application type +#' and extracts the necessary files. +#' +#' @param url Character string. URL pointing to one of: +#' - A standalone Shinylive application (containing `app.json`) +#' - A directory containing `app.json` +#' - A Quarto document with embedded Shinylive applications +#' @param output_dir Character string. Directory where the application files should +#' be extracted. Defaults to `"converted_shiny_app"`. Will be created if it doesn't exist. +#' +#' @return +#' An object containing the extracted application information: +#' +#' - For standalone apps: Object of class `"standalone_shinylive_app"` +#' - For Quarto documents: Object of class `"quarto_shinylive_apps"` +#' +#' Both object types implement custom print methods that display: +#' +#' - Application type (R or Python) +#' - Commands to run the application +#' - List of extracted files +#' - Output directory location +#' +#' @details +#' The function follows these steps: +#' +#' 1. Downloads and analyzes the content at the provided URL +#' 2. Determines if the content is a Quarto document or standalone application +#' 3. For Quarto documents: +#' - Extracts all embedded Shinylive applications +#' - Creates separate directories for each application +#' 4. For standalone applications: +#' - Locates and validates the `app.json` file +#' - Extracts all application files to the specified directory +#' +#' @section URL Resolution: +#' +#' The function attempts several strategies to find app.json: +#' +#' - Direct use of the provided URL +#' - Appending `"app.json"` to the URL +#' - Checking the parent directory +#' +#' @section Error Handling: +#' +#' The function will error with informative messages if: +#' +#' - The URL cannot be accessed +#' - No valid Shinylive application is found +#' - The `app.json` structure is invalid +#' +#' @seealso +#' +#' - [peek_quarto_shinylive_app()] for handling Quarto documents specifically +#' - [peek_standalone_shinylive_app()] for handling standalone applications +#' +#' @export +#' @examplesIf interactive() +#' # Download a standalone Shinylive application +#' url <- "https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/" +#' +#' app <- peek_shinylive_app(url) +#' +#' # Extract to a specific directory +#' app <- peek_shinylive_app( +#' url, +#' output_dir = "my_extracted_app" +#' ) +#' +#' # Download from a Quarto document +#' apps <- peek_shinylive_app("https://quarto-ext.github.io/shinylive/") +peek_shinylive_app <- function(url, output_dir = "converted_shiny_app") { + # Download content + resp <- httr::GET(url) + if (httr::http_error(resp)) { + cli::cli_abort(c( + "Failed to download from URL", + "x" = "Could not access {.url {url}}", + "i" = "Check that the URL is accessible and you have internet access." + )) + } + + # Check content type + content_type <- httr::headers(resp)[["content-type"]] + + # If HTML, determine if it's a Quarto document + if (grepl("text/html", content_type, fixed = TRUE)) { + html_content <- httr::content(resp, "text") + doc <- rvest::read_html(html_content) + + # Check if it's a Quarto document (has main.content and quarto-specific elements) + is_quarto <- length(doc |> + rvest::html_elements("main.content#quarto-document-content")) > 0 + + if (is_quarto) { + return(peek_quarto_shinylive_app(url, output_format = "app-dir", output_path = output_dir)) + } + } + + # Find valid app.json URL and data + app_json_result <- find_shinylive_app_json(url) + if (!app_json_result$valid) { + cli::cli_abort(c( + "Could not find valid Shinylive application", + "x" = "No Shinylive app found at {.url {url}}", + "i" = "Check that the URL points to either a Shinylive app.json file or a directory containing app.json" + )) + } + + # Use the already parsed data + write_standalone_shinylive_app(app_json_result$data, app_json_result$url, output_dir) +} + +#' Extract Shinylive Applications from Quarto Documents +#' +#' Downloads a Quarto document and extracts all embedded Shinylive applications. +#' Applications can be extracted either as separate directories (for individual use) +#' or combined into a new Quarto document (for documentation). The function handles +#' both R and Python Shinylive applications. +#' +#' @param url Character string. URL of the Quarto document containing Shinylive +#' applications. The document should contain code blocks with class +#' `'shinylive-r'` or `'shinylive-python'`. +#' +#' @param output_format Character string. Determines how the applications should be +#' extracted. Must be one of: +#' - `"app-dir"`: Creates separate directories for each application +#' - `"quarto"`: Combines all applications into a single Quarto document +#' +#' @param output_path Character string or NULL. Where to write the extracted +#' applications. If NULL, uses default paths: +#' - For "app-dir": "./converted_shiny_apps/" +#' - For "quarto": "./converted_shiny_apps.qmd" +#' +#' @return +#' An object of class `"shinylive_commands"` that provides: +#' +#' - Pretty-printed instructions via cli +#' - Commands to run each extracted application +#' - Information about output locations +#' - Setup instructions for Quarto documents (if applicable) +#' +#' @section Output Formats: +#' The two output formats serve different purposes: +#' +#' - `"app-dir"`: +#' - Creates numbered directories (app_1, app_2, etc.) +#' - Each directory contains a complete, runnable application +#' - Includes metadata about the original application +#' - Best for running or modifying individual applications +#' +#' - `"quarto"`: +#' - Creates a single .qmd file containing all applications +#' - Preserves original YAML options and file structure +#' - Adds necessary Quarto configuration +#' - Best for documentation or sharing multiple applications +#' +#' @section Error Handling: +#' +#' The function will error with informative messages if: +#' +#' - The URL cannot be accessed +#' - No Shinylive applications are found in the document +#' - The document structure is invalid +#' +#' @seealso +#' +#' - [find_shinylive_code()] for the code block extraction +#' - [write_apps_to_dirs()] for directory output format +#' - [write_apps_to_quarto()] for Quarto document output format +#' +#' @export +#' @examplesIf interactive() +#' # Extract as separate applications +#' result <- peek_quarto_shinylive_app( +#' "https://quarto-ext.github.io/shinylive", +#' output_format = "app-dir" +#' ) +#' +#' # Combine into a new Quarto document +#' result <- peek_quarto_shinylive_app( +#' "https://quarto-ext.github.io/shinylive", +#' output_format = "quarto", +#' output_path = "my_apps.qmd" +#' ) +#' +#' # Print will show instructions for running the apps +#' print(result) +peek_quarto_shinylive_app <- function(url, + output_format = c("app-dir", "quarto"), + output_path = NULL) { + # Validate output format + output_format <- match.arg(output_format) + + # Set default output path if not provided + if (is.null(output_path)) { + output_path <- if (output_format == "app-dir") { + "converted_shiny_apps" + } else { + "converted_shiny_apps.qmd" + } + } + + # Download and parse HTML + resp <- httr::GET(url) + if (httr::http_error(resp)) { + cli::cli_abort(c( + "Failed to download Quarto document", + "x" = "Could not access {.url {url}}", + "i" = "Check that the URL is correct and you have internet access." + )) + } + + html_content <- httr::content(resp, "text") + + # Find and parse all shinylive code blocks + apps <- find_shinylive_code(html_content) + + # Check if any apps were found + if (length(apps) == 0) { + cli::cli_abort(c( + "No Shinylive applications found", + "x" = "The Quarto document at {.url {url}} contains no Shinylive applications.", + "i" = "Check that the document contains code blocks with class {.code shinylive-r} or {.code shinylive-python}." + )) + } + + # Handle output based on format + if (output_format == "app-dir") { + write_apps_to_dirs(apps, output_path) + } else { + write_apps_to_quarto(apps, output_path) + } + + # Return command object + create_quarto_shinylive_apps(apps, output_format, output_path) +} + +#' Download and Extract a Standalone Shinylive Application +#' +#' Downloads and extracts a standalone Shinylive application from a URL. The function +#' locates the application's `app.json` file, validates its structure, and extracts +#' all application files to a local directory. Works with both R and Python +#' Shinylive applications. +#' +#' @param url Character string. URL pointing to either: +#' - A Shinylive app.json file directly +#' - A directory containing app.json +#' +#' The function will automatically append `"app.json"` to directory URLs. +#' +#' @param output_dir Character string. Directory where the application files +#' should be extracted. Defaults to `"converted_shiny_app"`. Will be created +#' if it doesn't exist. If the directory already exists, files may be +#' overwritten. +#' +#' @return +#' An object of class `"standalone_shinylive_app"` containing: +#' +#' - List of extracted files and their contents +#' - Source URL of the application +#' - Output directory location +#' +#' The object has a custom print method that displays: +#' +#' - Application type (R or Python) +#' - Command to run the application +#' - List of extracted files by type +#' - File locations +#' +#' @section File Structure: +#' +#' A valid Shinylive application should have an app.json file containing: +#' +#' - At least one application file (e.g., `app.R` or `app.py`) +#' - Optional supporting files (e.g., data files, `requirements.txt`) +#' - File metadata including name, content, and type +#' +#' @section Error Handling: +#' +#' The function will error with informative messages if: +#' +#' - No `app.json` file is found at the URL +#' - The `app.json` file has invalid structure +#' - The `app.json` file cannot be downloaded +#' - Required application files are missing +#' +#' @seealso +#' - [find_shinylive_app_json()] for `app.json` validation +#' - [write_standalone_shinylive_app()] for file extraction +#' - [peek_shinylive_app()] for a more general-purpose download function +#' +#' @export +#' @examplesIf interactive() +#' +#' # Download from a direct app.json URL +#' app <- peek_standalone_shinylive_app( +#' "https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/app.json" +#' ) +#' +#' # Download from a directory URL (app.json will be appended) +#' app <- peek_standalone_shinylive_app( +#' "https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/", +#' output_dir = "my_local_app" +#' ) +#' +#' # Print shows how to run the application +#' print(app) +peek_standalone_shinylive_app <- function(url, output_dir = "converted_shiny_app") { + + # Find and validate URL for app.json + # we append app.json to the URL as one of the possible paths + app_json_result <- find_shinylive_app_json(url) + if (!app_json_result$valid) { + cli::cli_abort(c( + "Failed to find valid app.json", + "x" = "No app.json found at {.url {url}}", + "i" = "The URL should point to a directory containing a valid Shinylive app.json file" + )) + } + + # Parse the already validated data + write_standalone_shinylive_app(app_json_result$data, app_json_result$url, output_dir) +} diff --git a/R/peeky-package.R b/R/peeky-package.R new file mode 100644 index 0000000..a65cf64 --- /dev/null +++ b/R/peeky-package.R @@ -0,0 +1,6 @@ +#' @keywords internal +"_PACKAGE" + +## usethis namespace: start +## usethis namespace: end +NULL diff --git a/R/quarto-cell-parser.R b/R/quarto-cell-parser.R new file mode 100644 index 0000000..33b2889 --- /dev/null +++ b/R/quarto-cell-parser.R @@ -0,0 +1,225 @@ +#' Parse a Single Shinylive Code Block +#' +#' Parses the content of a Shinylive code block, extracting YAML-style options, +#' file definitions, and content. Handles both single-file and multi-file +#' applications for R and Python. +#' +#' @param code_text Character string. The raw text content of a Shinylive code block, +#' which may contain YAML-style options, file markers, and file content. +#' @param engine Character string. The type of Shinylive application, either `"r"` or +#' `"python"`. Determines default file extension when no explicit file is specified. +#' +#' @return +#' +#' A list with three components: +#' +#' - `engine`: Character string indicating the application type (`"r"` or `"python"`) +#' - `options`: List of parsed YAML-style options from block headers +#' - `files`: Named list of file definitions, where each file contains: +#' - `name`: Character string of the file name +#' - `content`: Character string of the file content +#' - `type`: Character string indicating the file type (defaults to `"text"``) +#' +#' @section Code Block Structure: +#' +#' The code block can contain several types of lines: +#' +#' - **YAML-style options:** Lines starting with `'#|'`` +#' - **File markers:** Lines starting with `'## file:'` +#' - **Type markers:** Lines starting with `'## type:'` +#' - **Content:** All other non-empty lines +#' +#' For single-file applications with no explicit file marker, the content is +#' automatically placed in: +#' +#' - `"app.R"` for R applications +#' - `"app.py"` for Python applications +#' +#' @examples +#' \dontrun{ +#' # Single-file R application +#' code <- ' +#' #| viewerHeight: 500 +#' library(shiny) +#' ui <- fluidPage() +#' server <- function(input, output) {} +#' shinyApp(ui, server) +#' ' +#' result1 <- parse_code_block(code, "r") +#' +#' # Multi-file Python application +#' code <- ' +#' #| fullWidth: true +#' ## file: app.py +#' from shiny import App, ui +#' app = App(app_ui) +#' ## file: requirements.txt +#' ## type: text +#' shiny>=0.5.0 +#' ' +#' result2 <- parse_code_block(code, "python") +#' } +#' +#' @seealso +#' +#' - [parse_yaml_options()] for YAML-style option parsing +#' - [find_shinylive_code()] for extracting code blocks from HTML +#' +#' @keywords internal +parse_code_block <- function(code_text, engine) { + # Split into lines for processing + lines <- strsplit(code_text, "\n")[[1]] + + # Extract YAML-style options (lines starting with #|) + yaml_lines <- lines[grep("^\\s*#\\|", lines)] + options <- parse_yaml_options(yaml_lines) + + # Initialize variables for file parsing + files <- list() + current_file <- NULL + current_content <- character() + current_type <- "text" + + # Process lines + for (i in seq_along(lines)) { + line <- lines[i] + + # Check for file marker + if (grepl("^##\\s*file:\\s*", line)) { + # Save previous file if it exists + if (!is.null(current_file)) { + files[[current_file]] <- list( + name = current_file, + content = paste(current_content, collapse = "\n"), + type = current_type + ) + } + + # Start new file + current_file <- sub("^##\\s*file:\\s*", "", line) + current_content <- character() + current_type <- "text" + next + } + + # Check for type marker + if (grepl("^##\\s*type:\\s*", line)) { + current_type <- sub("^##\\s*type:\\s*", "", line) + next + } + + # Skip YAML options + if (grepl("^\\s*#\\|", line)) { + next + } + + # If we're in a file, add content + if (!is.null(current_file)) { + current_content <- c(current_content, line) + } else if (!grepl("^\\s*#|^\\s*$", line)) { + # If no file specified yet and line isn't empty or comment, + # treat as single-file app with appropriate extension + current_file <- if (engine == "r") "app.R" else "app.py" + current_content <- c(current_content, line) + } + } + + # Save last file + if (!is.null(current_file)) { + files[[current_file]] <- list( + name = current_file, + content = paste(current_content, collapse = "\n"), + type = current_type + ) + } + + list( + engine = engine, + options = options, + files = files + ) +} + +#' Parse YAML-style Options from Shinylive Code Blocks +#' +#' Parses YAML-style configuration options from Shinylive code block headers. +#' These options appear as lines prefixed with `'#|'` and follow a simplified +#' YAML-like syntax for key-value pairs. +#' +#' @param yaml_lines Character vector. Each element should be a line containing +#' a YAML-style option in the format `'#| key: value'`. The `'#|'` prefix will +#' be stripped during processing. +#' +#' @return +#' A named list of parsed options where: +#' +#' - Array values (e.g., `'[1, 2, 3]'`) are converted to character vectors +#' - Boolean values ('true'/'false') are converted to logical values +#' - Numeric values are converted to numeric type +#' - Other values remain as character strings +#' +#' @details +#' The function handles several value types: +#' +#' - **Arrays:** Values in the format `'[item1, item2, ...]'` +#' - **Booleans:** Values 'true' or 'false' +#' - **Numbers:** Integer values +#' - **Strings:** All other values +#' +#' Lines that don't contain a colon (`':'`) are ignored. +#' +#' @examples +#' \dontrun{ +#' # Parse various types of options +#' yaml_lines <- c( +#' "#| viewerHeight: 500", +#' "#| components: [slider,button]", +#' "#| fullWidth: true", +#' "#| title: My App" +#' ) +#' options <- parse_yaml_options(yaml_lines) +#' # Results in: +#' # list( +#' # viewerHeight = 500, +#' # components = c("slider", "button"), +#' # fullWidth = TRUE, +#' # title = "My App" +#' # ) +#' } +#' +#' @seealso parse_code_block +#' +#' @keywords internal +parse_yaml_options <- function(yaml_lines) { + # Remove #| prefix and any leading/trailing whitespace + yaml_lines <- sub("^\\s*#\\|\\s*", "", yaml_lines) + + # Split each line into key-value pairs + options <- list() + for (line in yaml_lines) { + if (grepl(":", line)) { + parts <- strsplit(line, ":\\s*")[[1]] + key <- parts[1] + value <- parts[2] + + # Parse value (handle arrays, booleans, numbers) + if (grepl("^\\[.*\\]$", value)) { + # Array value + value <- strsplit(gsub("\\[|\\]|\\s", "", value), ",")[[1]] + } else if (value %in% c("true", "false")) { + # Boolean value + value <- value == "true" + } else if (grepl("^\\d+$", value)) { + # Numeric value + value <- as.numeric(value) + } + + options[[key]] <- value + } + } + + options +} + + + diff --git a/R/shinylive-quarto-apps-commands.R b/R/shinylive-quarto-apps-commands.R new file mode 100644 index 0000000..f9138a1 --- /dev/null +++ b/R/shinylive-quarto-apps-commands.R @@ -0,0 +1,108 @@ +#' Create a `quarto_shinylive_apps` class object +#' +#' @param apps List of parsed apps +#' @param output_format The format used (`"app-dir"` or `"quarto"`) +#' @param output_path Path where apps were written +#' +#' @return +#' Object of class `"quarto_shinylive_apps"` +#' +#' @keywords internal +create_quarto_shinylive_apps <- function(apps, output_format, output_path) { + structure( + list( + apps = apps, + output_format = output_format, + output_path = output_path + ), + class = "quarto_shinylive_apps" + ) +} + +#' Print method for `quarto_shinylive_apps` objects +#' +#' @param x Object of class `"quarto_shinylive_apps"` +#' @param ... Additional arguments passed to print +#' +#' @export +print.quarto_shinylive_apps <- function(x, ...) { + # Style definitions + path_style <- list(color = "cyan") + cmd_style <- list(color = "yellow") + + if (x$output_format == "app-dir") { + # Group apps by engine + r_apps <- which(sapply(x$apps, function(x) x$engine == "r")) + py_apps <- which(sapply(x$apps, function(x) x$engine == "python")) + + cli::cli_h1("Shinylive Applications") + + # Print R apps + if (length(r_apps) > 0) { + cli::cli_h2("R Applications") + cli::cli_text("Run in {.emph R}:") + for (app_num in r_apps) { + dir_path <- sprintf("%s/app_%d", x$output_path, app_num) + cli::cli_code(sprintf('shiny::runApp("%s")', dir_path)) + } + } + + # Print Python apps + if (length(py_apps) > 0) { + if (length(r_apps) > 0) cli::cli_text() # Add spacing + cli::cli_h2("Python Applications") + cli::cli_text("Run in {.emph Terminal}:") + for (app_num in py_apps) { + dir_path <- sprintf("%s/app_%d", x$output_path, app_num) + cli::cli_code(sprintf('shiny run --reload --launch-browser "%s"', dir_path)) + } + } + + } else { + # Quarto format + cli::cli_h1("Quarto Document with Shinylive Applications") + + # Get the directory path and check if it's different from working directory + doc_dir <- dirname(x$output_path) + doc_name <- basename(x$output_path) + needs_cd <- !identical(normalizePath(doc_dir), normalizePath(getwd())) + + cli::cli_h2("Setup and Preview Steps") + + # Calculate step numbers based on whether cd is needed + current_step <- 1 + + # Step: Change directory (only if needed) + if (needs_cd) { + cli::cli_text() + cli::cli_text("{.strong Step {current_step}:} Change to the document directory:") + cli::cli_code(sprintf('cd "%s"', doc_dir)) + current_step <- current_step + 1 + } + + # Step: Install extension + cli::cli_text() + cli::cli_text("{.strong Step {current_step}:} Install the Shinylive extension:") + cli::cli_code("quarto add quarto-ext/shinylive") + current_step <- current_step + 1 + + # Step: Preview document + cli::cli_text() + cli::cli_text("{.strong Step {current_step}:} Preview the document:") + cli::cli_code(sprintf('quarto preview "%s"', doc_name)) + + # Add summary of contained apps + cli::cli_text() + cli::cli_h2("Contents") + r_count <- sum(sapply(x$apps, function(x) x$engine == "r")) + py_count <- sum(sapply(x$apps, function(x) x$engine == "python")) + + cli::cli_bullets(c( + "*" = "R applications: {r_count}", + "*" = "Python applications: {py_count}" + )) + } + + # Add invisible return + invisible(x) +} diff --git a/R/shinylive-standalone-app-commands.R b/R/shinylive-standalone-app-commands.R new file mode 100644 index 0000000..26eba30 --- /dev/null +++ b/R/shinylive-standalone-app-commands.R @@ -0,0 +1,78 @@ +#' Create a `standalone_shinylive_app` class object +#' +#' @param app_data Data of the app +#' @param output_dir Path where app is written +#' @param url The location of where the app was downloaded from +#' +#' @return +#' Object of class `"standalone_shinylive_app"` +#' +#' @keywords internal +create_standalone_shinylive_app <- function(app_data, output_dir, url) { + structure( + list( + files = app_data, + output_dir = output_dir, + source_url = url + ), + class = "standalone_shinylive_app" + ) +} + +#' Print method for `standalone_shinylive_app` objects +#' +#' @param x Object of class `"standalone_shinylive_app"` +#' @param ... Additional arguments passed to print +#' +#' @export +print.standalone_shinylive_app <- function(x, ...) { + # First detect if it's an R or Python app by looking at file extensions + files_list <- if(is.list(x$files[[1]])) x$files else list(x$files) + file_names <- sapply(files_list, function(f) f$name) + + is_r_app <- any(grepl("\\.R$", file_names)) + is_python_app <- any(grepl("\\.py$", file_names)) + + cli::cli_h1("Standalone Shinylive Application") + + # Show app type and run command + if (is_r_app) { + cli::cli_text("Type: {.emph R Shiny}") + cli::cli_text("Run in {.emph R}:") + cli::cli_code(sprintf('shiny::runApp("%s")', x$output_dir)) + } else if (is_python_app) { + cli::cli_text("Type: {.emph Python Shiny}") + cli::cli_text("Run in {.emph Terminal}:") + cli::cli_code(sprintf('shiny run --reload --launch-browser "%s"', x$output_dir)) + } + + # Add contents section + cli::cli_text() + cli::cli_h2("Contents") + + # Group files by extension + extensions <- tools::file_ext(file_names) + extensions[extensions == ""] <- "no extension" + files_by_ext <- split(file_names, extensions) + + # List files by extension + for (ext in sort(names(files_by_ext))) { + files <- files_by_ext[[ext]] + if (ext == "no extension") { + cli::cli_text("{.strong Files without extension}:") + } else { + cli::cli_text("{.strong .{ext} files}:") + } + cli::cli_ul(files) + } + + # Add total file count + cli::cli_text() + cli::cli_text("Total files: {length(files_list)}") + + # Add location information + cli::cli_text() + cli::cli_text("Location: {.file {x$output_dir}}") + + invisible(x) +} diff --git a/R/utils.R b/R/utils.R new file mode 100644 index 0000000..cd72414 --- /dev/null +++ b/R/utils.R @@ -0,0 +1,135 @@ +#' Validate Shinylive `app.json` Structure +#' +#' Validates that a parsed `app.json` structure meets the requirements for a +#' Shinylive application. Checks for proper list structure, required fields, +#' and non-empty content. Provides detailed error messages when validation fails. +#' +#' @param json_data List. Parsed JSON data from an app.json file. Should be a list +#' where each element represents a file and contains: +#' - `name`: Character string of the file name +#' - `content`: Character string of the file content +#' - `type`: Character string indicating the file type +#' +#' @return Logical TRUE if validation passes. If validation fails, throws an error +#' with detailed information about the validation failure using cli_abort(). +#' +#' @section Validation Rules: +#' The function checks these requirements: +#' 1. `json_data` must be a list +#' 2. `json_data` must contain at least one element +#' 3. Each element must be a list (representing a file) +#' 4. Each file list must contain all required fields: +#' - `name` +#' - `content` +#' - `type` +#' +#' @section Error Messages: +#' +#' The function provides detailed error messages for these cases: +#' +#' - Not a list: "Expected a list or array of files" +#' - Empty list: "File list is empty" +#' - Invalid file entry: "Each entry must be a file object" +#' - Missing fields: Lists specific missing required fields +#' +#' +#' @section Expected JSON Structure: +#' The expected JSON structure is an array of objects, where each object represents +#' a file in the application. +#' +#' ```json +#' [ +#' { +#' "name": "app.R", +#' "content": "library(shiny)\n...", +#' "type": "text" +#' }, +#' { +#' "name": "data/example.csv", +#' "content": "x,y\n1,2\n...", +#' "type": "text" +#' } +#' ] +#' ``` +#' +#' @seealso +#' - [find_shinylive_app_json()] which uses this validation +#' +#' @keywords internal +#' +#' @examples +#' \dontrun{ +#' # Valid structure +#' valid_data <- list( +#' list( +#' name = "app.R", +#' content = "library(shiny)\n...", +#' type = "text" +#' ), +#' list( +#' name = "data.csv", +#' content = "x,y\n1,2", +#' type = "text" +#' ) +#' ) +#' validate_app_json(valid_data) # Returns TRUE +#' +#' # Invalid structures that will error: +#' validate_app_json(list()) # Empty list +#' validate_app_json(list( +#' list(name = "app.R") # Missing required fields +#' )) +#' } +validate_app_json <- function(json_data) { + if (!is.list(json_data)) { + cli::cli_abort(c( + "Invalid app.json structure", + "x" = "Expected a list or array of files", + "i" = "The app.json file should contain an array of objects, each representing a file" + )) + } + + if (length(json_data) == 0) { + cli::cli_abort(c( + "Invalid app.json structure", + "x" = "File list is empty", + "i" = "The app.json file must contain at least one file" + )) + } + + # Check if it's an array of file objects + required_fields <- c("name", "content", "type") + for (file in json_data) { + if (!is.list(file)) { + cli::cli_abort(c( + "Invalid app.json structure", + "x" = "Each entry must be a file object", + "i" = "Every item in app.json should be a list with {.field name}, {.field content}, and {.field type} fields" + )) + } + missing_fields <- setdiff(required_fields, names(file)) + if (length(missing_fields) > 0) { + cli::cli_abort(c( + "Invalid app.json structure", + "x" = "Missing required fields: {.field {missing_fields}}", + "i" = "Each file object must contain {.field name}, {.field content}, and {.field type}" + )) + } + } + + TRUE +} + +#' Calculate number of digits needed for padding +#' +#' @param n Number to determine digits for +#' +#' @return +#' Number of digits needed +#' +#' @keywords internal +padding_width <- function(n) { + if (n <= 0) return(1) + floor(log10(n)) + 1 +} + diff --git a/R/writers.R b/R/writers.R new file mode 100644 index 0000000..a7264be --- /dev/null +++ b/R/writers.R @@ -0,0 +1,392 @@ +#' Write Shinylive Applications to a Quarto Document +#' +#' Converts a list of parsed Shinylive applications into a single Quarto document. +#' Creates a properly formatted .qmd file with YAML frontmatter, organized sections +#' for each application, and correctly formatted code blocks with all necessary +#' markers and options. +#' +#' @param apps List of parsed Shinylive applications. Each application should +#' contain: +#' - `engine`: Character string identifying the app type (`"r"` or `"python"`) +#' - `options`: List of YAML-style options from the original code block +#' - `files`: Named list of file definitions, each containing: +#' - `name`: Character string of the file name +#' - `content`: Character string of the file content +#' - `type`: Character string indicating the file type +#' +#' @param qmd_path Character string. Path where the Quarto document should be +#' written. Should end with `.qmd` extension. Parent directory will be created +#' if it doesn't exist. +#' +#' @section Document Structure: +#' +#' Creates a Quarto document with this structure: +#' ````markdown +#' --- +#' title: Extracted Shinylive Applications +#' filters: +#' - shinylive +#' --- +#' +#' # Shinylive Applications +#' +#' ## Application 1 +#' +#' ```{shinylive-r} +#' #| viewerHeight: 500 +#' ## file: app.R +#' ## type: text +#' library(shiny) +#' ... +#' ``` +#' +#' ## Application 2 +#' ... +#' ```` +#' +#' @section Option Formatting: +#' Options are converted to YAML format based on their type: +#' - Logical: `#| option: true` or `#| option: false` +#' - Numeric: `#| option: 500` +#' - Character: +#' - Single: `#| option: "value"` +#' - Vector: `#| option: ["value1", "value2"]` +#' +#' @details +#' The function performs these steps: +#' +#' 1. Creates YAML frontmatter with required Quarto settings +#' 2. For each application: +#' - Adds a section header with application number +#' - Creates a code block with appropriate engine (`shinylive-r`/`shinylive-python`) +#' - Converts and adds all application options +#' - Adds file markers and content for each file +#' - Properly closes the code block +#' 3. Writes the complete document to the specified path +#' +#' @keywords internal +#' @examples +#' \dontrun{ +#' # Example apps list structure +#' apps <- list( +#' list( +#' engine = "r", +#' options = list( +#' viewerHeight = 500, +#' fullWidth = TRUE +#' ), +#' files = list( +#' "app.R" = list( +#' name = "app.R", +#' content = "library(shiny)\n...", +#' type = "text" +#' ) +#' ) +#' ) +#' ) +#' +#' write_apps_to_quarto(apps, "applications.qmd") +#' } +#' +#' @seealso +#' * [write_apps_to_dirs()] for alternative directory output format +write_apps_to_quarto <- function(apps, qmd_path) { + # Initialize Quarto content with YAML header + quarto_content <- c( + "---", + "title: Extracted Shinylive Applications", + "filters:", + " - shinylive", + "---", + "", + "# Shinylive Applications", + "" + ) + + # Add each app as a code block + for (i in seq_along(apps)) { + app <- apps[[i]] + + # Add section header for this app + quarto_content <- c( + quarto_content, + sprintf("## Application %d", i), + "" + ) + + # Start code block with engine and options + block_header <- sprintf("```{shinylive-%s}", app$engine) + if (length(app$options) > 0) { + # Convert options to YAML style + options_yaml <- sapply(names(app$options), function(key) { + value <- app$options[[key]] + if (is.logical(value)) { + sprintf("#| %s: %s", key, tolower(as.character(value))) + } else if (is.numeric(value)) { + sprintf("#| %s: %d", key, value) + } else if (is.character(value)) { + if (length(value) > 1) { + # Handle character vectors + sprintf("#| %s: [%s]", key, paste(sprintf('"%s"', value), collapse = ", ")) + } else { + sprintf('#| %s: "%s"', key, value) + } + } + }) + block_header <- c(block_header, options_yaml) + } + + quarto_content <- c(quarto_content, block_header) + + # Add file markers and content for each file + for (file_name in names(app$files)) { + file_data <- app$files[[file_name]] + quarto_content <- c( + quarto_content, + sprintf("## file: %s", file_name), + if (!is.null(file_data$type)) sprintf("## type: %s", file_data$type), + file_data$content + ) + } + + # Close code block and add spacing + quarto_content <- c(quarto_content, "```", "", "") + } + + # Write to file + writeLines(quarto_content, qmd_path) +} + + +#' Write Multiple Shinylive Applications to Separate Directories +#' +#' Takes a list of parsed Shinylive applications and writes each to its own +#' numbered subdirectory. Creates consistently numbered directories with proper +#' padding (e.g., `app_01`, `app_02`) and preserves all application files and +#' metadata. +#' +#' @param apps List of parsed Shinylive applications. Each application should +#' contain: +#' - `engine`: Character string identifying the app type (`"r"` or `"python"`) +#' - `options`: List of YAML-style options from the original code block +#' - `files`: Named list of file definitions, each containing: +#' - `name`: Character string of the file name +#' - `content`: Character string of the file content +#' - `type`: Character string indicating the file type +#' +#' @param base_dir Character string. Base directory where application +#' subdirectories should be created. Will be created if it doesn't exist. +#' +#' @details +#' The function performs these steps: +#' +#' 1. Creates the base directory if needed +#' 2. Calculates proper padding for subdirectory numbers +#' 3. For each application: +#' - Creates a padded, numbered subdirectory (e.g., `app_01`, `app_02`) +#' - Writes all application files, preserving directory structure +#' - Creates a metadata JSON file with engine and options info +#' +#' @section Directory Structure: +#' Creates a directory structure like: +#' +#' ```sh +#' base_dir/ +#' β”œβ”€β”€ app_01/ +#' β”‚ β”œβ”€β”€ app.R +#' β”‚ β”œβ”€β”€ data/ +#' β”‚ β”‚ └── example.csv +#' β”‚ └── shinylive_metadata.json +#' β”œβ”€β”€ app_02/ +#' β”‚ β”œβ”€β”€ app.py +#' β”‚ └── shinylive_metadata.json +#' └── ... +#' ``` +#' +#' @section Metadata File: +#' +#' Each directory includes a `shinylive_metadata.json` file containing: +#' +#' ```json +#' { +#' "engine": "r", +#' "options": { +#' "viewerHeight": 500, +#' "...": "..." +#' } +#' } +#' ``` +#' +#' @seealso +#' - [padding_width()] for directory number padding calculation +#' - [write_apps_to_quarto()] for alternative Quarto output format +#' +#' @keywords internal +#' @examples +#' \dontrun{ +#' # Example apps list structure +#' apps <- list( +#' list( +#' engine = "r", +#' options = list(viewerHeight = 500), +#' files = list( +#' "app.R" = list( +#' name = "app.R", +#' content = "library(shiny)\n...", +#' type = "text" +#' ) +#' ) +#' ), +#' list( +#' engine = "python", +#' options = list(), +#' files = list( +#' "app.py" = list( +#' name = "app.py", +#' content = "from shiny import App\n...", +#' type = "text" +#' ) +#' ) +#' ) +#' ) +#' +#' write_apps_to_dirs(apps, "extracted_apps") +#' } +write_apps_to_dirs <- function(apps, base_dir) { + fs::dir_create(base_dir) + + + # Calculate padding width based on number of apps + number_padding <- padding_width(length(apps)) + dir_format <- sprintf("app_%%0%dd", number_padding) + + # Process each app + for (i in seq_along(apps)) { + app <- apps[[i]] + + # Create numbered subdirectory using dynamic padding + app_dir <- file.path(base_dir, sprintf(dir_format, i)) + fs::dir_create(app_dir) + + # Write each file in the app + for (file_name in names(app$files)) { + file_data <- app$files[[file_name]] + file_path <- file.path(app_dir, file_name) + + # Ensure parent directory exists + fs::dir_create(dirname(file_path)) + + # Write file content + writeLines(file_data$content, file_path) + } + + # Write metadata + metadata <- list( + engine = app$engine, + options = app$options + ) + jsonlite::write_json( + metadata, + file.path(app_dir, "shinylive_metadata.json"), + auto_unbox = TRUE, + pretty = TRUE + ) + } +} + +#' Write Standalone Shinylive Application Files from JSON Data +#' +#' Extracts files from parsed Shinylive `app.json` data and writes them to a +#' specified directory. Creates a standalone application object containing +#' metadata and commands for running the application. +#' +#' @param json_data List. Parsed JSON data from a Shinylive `app.json` file. +#' Each element should be a list containing: +#' - `name`: Character string of the file name +#' - `content`: Character string of the file content +#' - `type`: Character string indicating the file type +#' +#' @param source_url Character string. The original URL from which the `app.json` +#' was downloaded. Used for reference and provenance tracking in the returned +#' object. +#' +#' @param output_dir Character string. Directory where application files should +#' be extracted. Defaults to `"converted_shiny_app"`. Will be created if it +#' doesn't exist. Existing files in this directory may be overwritten. +#' +#' @return +#' An object of class `"standalone_shinylive_app"` containing: +#' +#' - `files`: List of extracted files and their metadata +#' - `output_dir`: Path to the directory containing extracted files +#' - `source_url`: Original URL of the application +#' +#' @details +#' The function performs these steps: +#' +#' 1. Creates the output directory if it doesn't exist +#' 2. Iterates through each file in the JSON data +#' 3. Writes each file to the output directory, preserving names +#' 4. Creates a standalone application object with metadata +#' +#' File paths are created relative to the output directory. Parent +#' directories in file paths will be created as needed. +#' +#' @section File Structure: +#' Expected JSON data structure: +#' ``` +#' [ +#' { +#' "name": "app.R", +#' "content": "library(shiny)\n...", +#' "type": "text" +#' }, +#' { +#' "name": "data/example.csv", +#' "content": "x,y\n1,2\n...", +#' "type": "text" +#' } +#' ] +#' ``` +#' +#' +#' @seealso +#' - [create_standalone_shinylive_app()] for object creation +#' - [validate_app_json()] for JSON data validation +#' +#' @examples +#' \dontrun{ +#' # Example JSON data structure +#' json_data <- list( +#' list( +#' name = "app.R", +#' content = "library(shiny)\nui <- fluidPage()\n...", +#' type = "text" +#' ), +#' list( +#' name = "data.csv", +#' content = "col1,col2\n1,2\n3,4", +#' type = "text" +#' ) +#' ) +#' +#' app <- write_standalone_shinylive_app( +#' json_data, +#' "https://example.com/app.json", +#' "my_app" +#' ) +#' } +#' @keywords internal +write_standalone_shinylive_app <- function(json_data, source_url, output_dir = "converted_shiny_app") { + # Create output directory + fs::dir_create(output_dir) + + # Extract files + for (file in json_data) { + file_path <- file.path(output_dir, file$name) + writeLines(file$content, file_path) + } + + # Return standalone command object + create_standalone_shinylive_app(json_data, output_dir, source_url) +} diff --git a/README.Rmd b/README.Rmd new file mode 100644 index 0000000..37f0cd3 --- /dev/null +++ b/README.Rmd @@ -0,0 +1,149 @@ +--- +output: github_document +--- + + + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + fig.path = "man/figures/README-", + out.width = "100%" +) +``` + +# peeky + + +[![R-CMD-check](https://github.com/coatless-rpkg/peeky/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/coatless-rpkg/peeky/actions/workflows/R-CMD-check.yaml) + + +The `peeky` package is a tool for examining and extracting files from standalone +[Shinylive applications][slexplain] and [Quarto][quarto] documents that contain Shinylive components +through the [`quarto-shinylive`][qsl] extension. +This package works for both R and Python Shinylive applications. + +## There Are No Secrets in Shinylive + +The `peeky` package was developed to demonstrate a fundamental truth about +Shinylive applications stressed by its developers: **"There are no secrets."** + +Unlike traditional Shiny applications where server-side code remains private, +Shinylive apps run entirely in the web browser, making **all** associated +files accessible to users. This includes the source code, data, and any other +files used by the application. As a result, Shinylive applications are +transparent by design. + +This package was developed as part of ongoing discussions in STATS 290 about +Shiny application security, transparency, and deployment options. It serves as +a practical demonstration of the differences between traditional server-side +applications and modern browser-based alternatives. + +## Installation + +You can install the development version of peeky from [GitHub](https://github.com/) with: + +```r +# install.packages("remotes") +remotes::install_github("coatless-rpkg/peeky") +``` + +## Usage + +To use the package, load it into your R session: + +```r +library(peeky) +``` + +Inside the package, there are three main functions summarized in the table below: + +| Feature | `peek_shinylive_app()` | `peek_standalone_shinylive_app()` | `peek_quarto_shinylive_app()` | +|---------|:---------------------:|:--------------------------------:|:-----------------------------:| +| Handles standalone apps | βœ“ | βœ“ | βœ— | +| Handles Quarto docs | βœ“ | βœ— | βœ“ | +| Auto-detects type | βœ“ | βœ— | βœ— | +| Multiple apps per page | βœ“ | βœ— | βœ“ | +| Custom output path | βœ“ | βœ“ | βœ“ | +| Quarto format output | βœ“ | βœ— | βœ“ | + +### Extracting Shinylive Applications + +We suggest using the `peek_shinylive_app()` function as it can handle both +standalone Shinylive applications and Quarto documents with Shinylive components. +For instance, if we take the main Shinylive extension website, we get: + +```{r} +#| label: peek-shinylive-app +#| cache: true +peeky::peek_shinylive_app("https://quarto-ext.github.io/shinylive/") +``` + +This would be equivalent to if we ran the following: + +```r +peeky::peek_quarto_shinylive_app("https://quarto-ext.github.io/shinylive/") +``` + +By default, the extracted files will be placed in the current working directory +under the `converted_apps` directory. Each application will be placed in a +subdirectory named `app_1`, `app_2`, etc. If we want to specify a different +output directory, we can do so by providing the `output_path` argument. We +can also specify the output format as `quarto` to extract the files into a +single Quarto document. + +```{r} +#| label: peek-shinylive-app-output-dir +#| cache: true + +# Extract the Shinylive application into a different directory +peeky::peek_quarto_shinylive_app("https://quarto-ext.github.io/shinylive/", output_format = "quarto") +``` + +We can switch to the `peek_shinylive_standalone_app()` function if we know that +the URL is a standalone Shinylive application. For example, if we take the +example application used in the conversion tutorial from [an app.R to an R Shinylive app](https://github.com/coatless-tutorials/convert-shiny-app-r-shinylive) +on GitHub, we get: + +```{r} +#| label: peek-shinylive-standalone-app +#| cache: true +peeky::peek_standalone_shinylive_app("https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/") +``` + +## License + +AGPL (>= 3) + +## Notes + +### Evolution from Previous Approaches + +This package represents a more refined and comprehensive approach compared to +our [earlier tutorial][peeksl] +that focused solely on standalone R Shinylive applications. + +### Ethical Considerations + +This package is intended for educational purposes and to promote understanding +of web application visibility. Users should: + +- Respect intellectual property rights +- Use the tool responsibly +- Understand that the ability to view source code doesn't imply permission to reuse it without proper attribution or licensing +- Consider this knowledge when designing their own applications + +## Acknowledgements + +We greatly appreciate and are inspired by the work of the Shinylive team. +We also thank the [webR][webr] and [Pyodide][pyodide] teams for their +contributions to the broader ecosystem of browser-based data science that makes +tools like Shinylive possible. + +[quarto]: https://quarto.org +[slexplain]: https://shiny.posit.co/py/docs/shinylive.html +[qsl]: https://github.com/quarto-ext/shinylive +[webr]: https://docs.r-wasm.org/webr/latest/ +[pyodide]: https://pyodide.org/en/stable/ +[peeksl]: https://github.com/coatless-tutorials/peeking-at-an-r-shinylive-app-source-code diff --git a/README.md b/README.md index 265d893..8659dc7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,178 @@ + + + # peeky -All your shinylive apps are belong to us + + + +[![R-CMD-check](https://github.com/coatless-rpkg/peeky/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/coatless-rpkg/peeky/actions/workflows/R-CMD-check.yaml) + + +The `peeky` package is a tool for examining and extracting files from +standalone [Shinylive +applications](https://shiny.posit.co/py/docs/shinylive.html) and +[Quarto](https://quarto.org) documents that contain Shinylive components +through the +[`quarto-shinylive`](https://github.com/quarto-ext/shinylive) extension. +This package works for both R and Python Shinylive applications. + +## There Are No Secrets in Shinylive + +The `peeky` package was developed to demonstrate a fundamental truth +about Shinylive applications stressed by its developers: **β€œThere are no +secrets.”** + +Unlike traditional Shiny applications where server-side code remains +private, Shinylive apps run entirely in the web browser, making **all** +associated files accessible to users. This includes the source code, +data, and any other files used by the application. As a result, +Shinylive applications are transparent by design. + +This package was developed as part of ongoing discussions in STATS 290 +about Shiny application security, transparency, and deployment options. +It serves as a practical demonstration of the differences between +traditional server-side applications and modern browser-based +alternatives. + +## Installation + +You can install the development version of peeky from +[GitHub](https://github.com/) with: + +``` r +# install.packages("remotes") +remotes::install_github("coatless-rpkg/peeky") +``` + +## Usage + +To use the package, load it into your R session: + +``` r +library(peeky) +``` + +Inside the package, there are three main functions summarized in the +table below: + +| Feature | `peek_shinylive_app()` | `peek_standalone_shinylive_app()` | `peek_quarto_shinylive_app()` | +|----|:--:|:--:|:--:| +| Handles standalone apps | βœ“ | βœ“ | βœ— | +| Handles Quarto docs | βœ“ | βœ— | βœ“ | +| Auto-detects type | βœ“ | βœ— | βœ— | +| Multiple apps per page | βœ“ | βœ— | βœ“ | +| Custom output path | βœ“ | βœ“ | βœ“ | +| Quarto format output | βœ“ | βœ— | βœ“ | + +### Extracting Shinylive Applications + +We suggest using the `peek_shinylive_app()` function as it can handle +both standalone Shinylive applications and Quarto documents with +Shinylive components. For instance, if we take the main Shinylive +extension website, we get: + +``` r +peeky::peek_shinylive_app("https://quarto-ext.github.io/shinylive/") +#> +#> ── Shinylive Applications ────────────────────────────────────────────────────── +#> +#> ── Python Applications ── +#> +#> Run in Terminal: +#> shiny run --reload --launch-browser "converted_shiny_app/app_1" +#> shiny run --reload --launch-browser "converted_shiny_app/app_2" +#> shiny run --reload --launch-browser "converted_shiny_app/app_3" +#> shiny run --reload --launch-browser "converted_shiny_app/app_4" +``` + +This would be equivalent to if we ran the following: + +``` r +peeky::peek_quarto_shinylive_app("https://quarto-ext.github.io/shinylive/") +``` + +By default, the extracted files will be placed in the current working +directory under the `converted_apps` directory. Each application will be +placed in a subdirectory named `app_1`, `app_2`, etc. If we want to +specify a different output directory, we can do so by providing the +`output_path` argument. We can also specify the output format as +`quarto` to extract the files into a single Quarto document. + +``` r +# Extract the Shinylive application into a different directory +peeky::peek_quarto_shinylive_app("https://quarto-ext.github.io/shinylive/", output_format = "quarto") +#> +#> ── Quarto Document with Shinylive Applications ───────────────────────────────── +#> +#> ── Setup and Preview Steps ── +#> +#> Step 1: Install the Shinylive extension: +#> quarto add quarto-ext/shinylive +#> +#> Step 2: Preview the document: +#> quarto preview "converted_shiny_apps.qmd" +#> +#> ── Contents ── +#> +#> β€’ R applications: 0 +#> β€’ Python applications: 4 +``` + +We can switch to the `peek_shinylive_standalone_app()` function if we +know that the URL is a standalone Shinylive application. For example, if +we take the example application used in the conversion tutorial from [an +app.R to an R Shinylive +app](https://github.com/coatless-tutorials/convert-shiny-app-r-shinylive) +on GitHub, we get: + +``` r +peeky::peek_standalone_shinylive_app("https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/") +#> +#> ── Standalone Shinylive Application ──────────────────────────────────────────── +#> Type: R Shiny +#> Run in R: +#> shiny::runApp("converted_shiny_app") +#> +#> ── Contents ── +#> +#> .md files: +#> β€’ README.md +#> .R files: +#> β€’ app.R +#> +#> Total files: 2 +#> +#> Location: 'converted_shiny_app' +``` + +## License + +AGPL (\>= 3) + +## Notes + +### Evolution from Previous Approaches + +This package represents a more refined and comprehensive approach +compared to our [earlier +tutorial](https://github.com/coatless-tutorials/peeking-at-an-r-shinylive-app-source-code) +that focused solely on standalone R Shinylive applications. + +### Ethical Considerations + +This package is intended for educational purposes and to promote +understanding of web application visibility. Users should: + +- Respect intellectual property rights +- Use the tool responsibly +- Understand that the ability to view source code doesn’t imply + permission to reuse it without proper attribution or licensing +- Consider this knowledge when designing their own applications + +## Acknowledgements + +We greatly appreciate and are inspired by the work of the Shinylive +team. We also thank the [webR](https://docs.r-wasm.org/webr/latest/) and +[Pyodide](https://pyodide.org/en/stable/) teams for their contributions +to the broader ecosystem of browser-based data science that makes tools +like Shinylive possible. diff --git a/man/create_quarto_shinylive_apps.Rd b/man/create_quarto_shinylive_apps.Rd new file mode 100644 index 0000000..1ba4a7a --- /dev/null +++ b/man/create_quarto_shinylive_apps.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shinylive-quarto-apps-commands.R +\name{create_quarto_shinylive_apps} +\alias{create_quarto_shinylive_apps} +\title{Create a \code{quarto_shinylive_apps} class object} +\usage{ +create_quarto_shinylive_apps(apps, output_format, output_path) +} +\arguments{ +\item{apps}{List of parsed apps} + +\item{output_format}{The format used (\code{"app-dir"} or \code{"quarto"})} + +\item{output_path}{Path where apps were written} +} +\value{ +Object of class \code{"quarto_shinylive_apps"} +} +\description{ +Create a \code{quarto_shinylive_apps} class object +} +\keyword{internal} diff --git a/man/create_standalone_shinylive_app.Rd b/man/create_standalone_shinylive_app.Rd new file mode 100644 index 0000000..a1d565f --- /dev/null +++ b/man/create_standalone_shinylive_app.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shinylive-standalone-app-commands.R +\name{create_standalone_shinylive_app} +\alias{create_standalone_shinylive_app} +\title{Create a \code{standalone_shinylive_app} class object} +\usage{ +create_standalone_shinylive_app(app_data, output_dir, url) +} +\arguments{ +\item{app_data}{Data of the app} + +\item{output_dir}{Path where app is written} + +\item{url}{The location of where the app was downloaded from} +} +\value{ +Object of class \code{"standalone_shinylive_app"} +} +\description{ +Create a \code{standalone_shinylive_app} class object +} +\keyword{internal} diff --git a/man/find_shinylive_app_json.Rd b/man/find_shinylive_app_json.Rd new file mode 100644 index 0000000..aa22542 --- /dev/null +++ b/man/find_shinylive_app_json.Rd @@ -0,0 +1,57 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/find.R +\name{find_shinylive_app_json} +\alias{find_shinylive_app_json} +\title{Find and Validate Shinylive app.json} +\usage{ +find_shinylive_app_json(base_url) +} +\arguments{ +\item{base_url}{Character string. The base URL to search for app.json.} +} +\value{ +A list with three components: +\itemize{ +\item \code{valid} Logical indicating if a valid app.json was found +\item \code{url} Character string of the successful URL, or NULL if not found +\item \code{data} List containing the parsed JSON data if valid, NULL otherwise +} +} +\description{ +Attempts to locate and validate a Shinylive app.json file from a given base URL. +The function tries multiple possible paths and validates both the HTTP response +and JSON structure. +} +\details{ +The function performs the following steps: +\enumerate{ +\item Generates three possible paths to check: +\itemize{ +\item The base URL as provided +\item base URL + `"/app.json"`` +\item Parent directory + \code{"/app.json"} +} +\item For each path: +\itemize{ +\item Attempts an HTTP GET request +\item Verifies the content type is JSON +\item Parses and validates the JSON structure +\item Returns immediately if valid app.json is found +} +} +} +\examples{ +\dontrun{ +# Direct app.json URL +result <- find_shinylive_app_json("https://example.com/app.json") + +# Directory containing app.json +result <- find_shinylive_app_json("https://example.com/myapp/") + +# Check if valid +if (result$valid) { + cat("Found app.json at:", result$url) +} +} +} +\keyword{internal} diff --git a/man/find_shinylive_code.Rd b/man/find_shinylive_code.Rd new file mode 100644 index 0000000..e83741f --- /dev/null +++ b/man/find_shinylive_code.Rd @@ -0,0 +1,66 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/find.R +\name{find_shinylive_code} +\alias{find_shinylive_code} +\title{Find Shinylive Code Blocks in Quarto HTML} +\usage{ +find_shinylive_code(html) +} +\arguments{ +\item{html}{Character string containing HTML content. The HTML should contain +code blocks with class \code{'shinylive-r'} or \code{'shinylive-python'} to be processed.} +} +\value{ +A list of parsed Shinylive applications. Each list element contains: +\itemize{ +\item \code{engine}: Character string indicating the application type (\code{"r"} or \code{"python"}) +\item \code{options}: List of parsed YAML-style options from the code block +\item \code{files}: List of file definitions, where each file contains: +\itemize{ +\item \code{name}: Character string of the file name +\item \code{content}: Character string of the file content +\item \code{type}: Character string indicating the file type +} +} +} +\description{ +Parses HTML content to extract and process Shinylive code blocks for both R and Python +applications. This function identifies code blocks with class \code{'shinylive-r'} or +\code{'shinylive-python'} and processes their content into structured application data. +} +\details{ +The function performs the following steps: +\enumerate{ +\item Parses the HTML content using \code{rvest} +\item Extracts code blocks with classes \code{'shinylive-r'} or \code{'shinylive-python'} +\item For each code block: +\itemize{ +\item Determines the engine type from the 'data-engine' attribute +\item Extracts the code text content +\item Parses the code block structure using \code{parse_code_block()} +} +} + +Code blocks should follow the Shinylive format with optional YAML-style +options (prefixed with \code{'#|'}) and file markers (prefixed with \code{'## file:'}). +} +\examples{ +\dontrun{ +html_content <- ' +
+#| viewerHeight: 500
+## file: app.R
+library(shiny)
+ui <- fluidPage()
+server <- function(input, output) {}
+shinyApp(ui, server)
+
+' +apps <- find_shinylive_code(html_content) +} + +} +\seealso{ +parse_code_block +} +\keyword{internal} diff --git a/man/padding_width.Rd b/man/padding_width.Rd new file mode 100644 index 0000000..cc2a91d --- /dev/null +++ b/man/padding_width.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{padding_width} +\alias{padding_width} +\title{Calculate number of digits needed for padding} +\usage{ +padding_width(n) +} +\arguments{ +\item{n}{Number to determine digits for} +} +\value{ +Number of digits needed +} +\description{ +Calculate number of digits needed for padding +} +\keyword{internal} diff --git a/man/parse_code_block.Rd b/man/parse_code_block.Rd new file mode 100644 index 0000000..f829fa7 --- /dev/null +++ b/man/parse_code_block.Rd @@ -0,0 +1,85 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/quarto-cell-parser.R +\name{parse_code_block} +\alias{parse_code_block} +\title{Parse a Single Shinylive Code Block} +\usage{ +parse_code_block(code_text, engine) +} +\arguments{ +\item{code_text}{Character string. The raw text content of a Shinylive code block, +which may contain YAML-style options, file markers, and file content.} + +\item{engine}{Character string. The type of Shinylive application, either \code{"r"} or +\code{"python"}. Determines default file extension when no explicit file is specified.} +} +\value{ +A list with three components: +\itemize{ +\item \code{engine}: Character string indicating the application type (\code{"r"} or \code{"python"}) +\item \code{options}: List of parsed YAML-style options from block headers +\item \code{files}: Named list of file definitions, where each file contains: +\itemize{ +\item \code{name}: Character string of the file name +\item \code{content}: Character string of the file content +\item \code{type}: Character string indicating the file type (defaults to `"text"``) +} +} +} +\description{ +Parses the content of a Shinylive code block, extracting YAML-style options, +file definitions, and content. Handles both single-file and multi-file +applications for R and Python. +} +\section{Code Block Structure}{ + + +The code block can contain several types of lines: +\itemize{ +\item \strong{YAML-style options:} Lines starting with `'#|'`` +\item \strong{File markers:} Lines starting with \code{'## file:'} +\item \strong{Type markers:} Lines starting with \code{'## type:'} +\item \strong{Content:} All other non-empty lines +} + +For single-file applications with no explicit file marker, the content is +automatically placed in: +\itemize{ +\item \code{"app.R"} for R applications +\item \code{"app.py"} for Python applications +} +} + +\examples{ +\dontrun{ +# Single-file R application +code <- ' +#| viewerHeight: 500 +library(shiny) +ui <- fluidPage() +server <- function(input, output) {} +shinyApp(ui, server) +' +result1 <- parse_code_block(code, "r") + +# Multi-file Python application +code <- ' +#| fullWidth: true +## file: app.py +from shiny import App, ui +app = App(app_ui) +## file: requirements.txt +## type: text +shiny>=0.5.0 +' +result2 <- parse_code_block(code, "python") +} + +} +\seealso{ +\itemize{ +\item \code{\link[=parse_yaml_options]{parse_yaml_options()}} for YAML-style option parsing +\item \code{\link[=find_shinylive_code]{find_shinylive_code()}} for extracting code blocks from HTML +} +} +\keyword{internal} diff --git a/man/parse_yaml_options.Rd b/man/parse_yaml_options.Rd new file mode 100644 index 0000000..573b97f --- /dev/null +++ b/man/parse_yaml_options.Rd @@ -0,0 +1,62 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/quarto-cell-parser.R +\name{parse_yaml_options} +\alias{parse_yaml_options} +\title{Parse YAML-style Options from Shinylive Code Blocks} +\usage{ +parse_yaml_options(yaml_lines) +} +\arguments{ +\item{yaml_lines}{Character vector. Each element should be a line containing +a YAML-style option in the format \code{'#| key: value'}. The \code{'#|'} prefix will +be stripped during processing.} +} +\value{ +A named list of parsed options where: +\itemize{ +\item Array values (e.g., \code{'[1, 2, 3]'}) are converted to character vectors +\item Boolean values ('true'/'false') are converted to logical values +\item Numeric values are converted to numeric type +\item Other values remain as character strings +} +} +\description{ +Parses YAML-style configuration options from Shinylive code block headers. +These options appear as lines prefixed with \code{'#|'} and follow a simplified +YAML-like syntax for key-value pairs. +} +\details{ +The function handles several value types: +\itemize{ +\item \strong{Arrays:} Values in the format \code{'[item1, item2, ...]'} +\item \strong{Booleans:} Values 'true' or 'false' +\item \strong{Numbers:} Integer values +\item \strong{Strings:} All other values +} + +Lines that don't contain a colon (\code{':'}) are ignored. +} +\examples{ +\dontrun{ +# Parse various types of options +yaml_lines <- c( + "#| viewerHeight: 500", + "#| components: [slider,button]", + "#| fullWidth: true", + "#| title: My App" +) +options <- parse_yaml_options(yaml_lines) +# Results in: +# list( +# viewerHeight = 500, +# components = c("slider", "button"), +# fullWidth = TRUE, +# title = "My App" +# ) +} + +} +\seealso{ +parse_code_block +} +\keyword{internal} diff --git a/man/peek_quarto_shinylive_app.Rd b/man/peek_quarto_shinylive_app.Rd new file mode 100644 index 0000000..1a3f740 --- /dev/null +++ b/man/peek_quarto_shinylive_app.Rd @@ -0,0 +1,104 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/peek.R +\name{peek_quarto_shinylive_app} +\alias{peek_quarto_shinylive_app} +\title{Extract Shinylive Applications from Quarto Documents} +\usage{ +peek_quarto_shinylive_app( + url, + output_format = c("app-dir", "quarto"), + output_path = NULL +) +} +\arguments{ +\item{url}{Character string. URL of the Quarto document containing Shinylive +applications. The document should contain code blocks with class +\code{'shinylive-r'} or \code{'shinylive-python'}.} + +\item{output_format}{Character string. Determines how the applications should be +extracted. Must be one of: +\itemize{ +\item \code{"app-dir"}: Creates separate directories for each application +\item \code{"quarto"}: Combines all applications into a single Quarto document +}} + +\item{output_path}{Character string or NULL. Where to write the extracted +applications. If NULL, uses default paths: +\itemize{ +\item For "app-dir": "./converted_shiny_apps/" +\item For "quarto": "./converted_shiny_apps.qmd" +}} +} +\value{ +An object of class \code{"shinylive_commands"} that provides: +\itemize{ +\item Pretty-printed instructions via cli +\item Commands to run each extracted application +\item Information about output locations +\item Setup instructions for Quarto documents (if applicable) +} +} +\description{ +Downloads a Quarto document and extracts all embedded Shinylive applications. +Applications can be extracted either as separate directories (for individual use) +or combined into a new Quarto document (for documentation). The function handles +both R and Python Shinylive applications. +} +\section{Output Formats}{ + +The two output formats serve different purposes: +\itemize{ +\item \code{"app-dir"}: +\itemize{ +\item Creates numbered directories (app_1, app_2, etc.) +\item Each directory contains a complete, runnable application +\item Includes metadata about the original application +\item Best for running or modifying individual applications +} +\item \code{"quarto"}: +\itemize{ +\item Creates a single .qmd file containing all applications +\item Preserves original YAML options and file structure +\item Adds necessary Quarto configuration +\item Best for documentation or sharing multiple applications +} +} +} + +\section{Error Handling}{ + + +The function will error with informative messages if: +\itemize{ +\item The URL cannot be accessed +\item No Shinylive applications are found in the document +\item The document structure is invalid +} +} + +\examples{ +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +# Extract as separate applications +result <- peek_quarto_shinylive_app( + "https://quarto-ext.github.io/shinylive", + output_format = "app-dir" +) + +# Combine into a new Quarto document +result <- peek_quarto_shinylive_app( + "https://quarto-ext.github.io/shinylive", + output_format = "quarto", + output_path = "my_apps.qmd" +) + +# Print will show instructions for running the apps +print(result) +\dontshow{\}) # examplesIf} +} +\seealso{ +\itemize{ +\item \code{\link[=find_shinylive_code]{find_shinylive_code()}} for the code block extraction +\item \code{\link[=write_apps_to_dirs]{write_apps_to_dirs()}} for directory output format +\item \code{\link[=write_apps_to_quarto]{write_apps_to_quarto()}} for Quarto document output format +} +} diff --git a/man/peek_shinylive_app.Rd b/man/peek_shinylive_app.Rd new file mode 100644 index 0000000..a7094e9 --- /dev/null +++ b/man/peek_shinylive_app.Rd @@ -0,0 +1,102 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/peek.R +\name{peek_shinylive_app} +\alias{peek_shinylive_app} +\title{Download and Extract Shinylive Applications from URLs} +\usage{ +peek_shinylive_app(url, output_dir = "converted_shiny_app") +} +\arguments{ +\item{url}{Character string. URL pointing to one of: +\itemize{ +\item A standalone Shinylive application (containing \code{app.json}) +\item A directory containing \code{app.json} +\item A Quarto document with embedded Shinylive applications +}} + +\item{output_dir}{Character string. Directory where the application files should +be extracted. Defaults to \code{"converted_shiny_app"}. Will be created if it doesn't exist.} +} +\value{ +An object containing the extracted application information: +\itemize{ +\item For standalone apps: Object of class \code{"standalone_shinylive_app"} +\item For Quarto documents: Object of class \code{"quarto_shinylive_apps"} +} + +Both object types implement custom print methods that display: +\itemize{ +\item Application type (R or Python) +\item Commands to run the application +\item List of extracted files +\item Output directory location +} +} +\description{ +Downloads and extracts Shinylive applications from various URL sources. The function +can handle both standalone Shinylive applications and Quarto documents containing +embedded Shinylive applications. It automatically detects the application type +and extracts the necessary files. +} +\details{ +The function follows these steps: +\enumerate{ +\item Downloads and analyzes the content at the provided URL +\item Determines if the content is a Quarto document or standalone application +\item For Quarto documents: +\itemize{ +\item Extracts all embedded Shinylive applications +\item Creates separate directories for each application +} +\item For standalone applications: +\itemize{ +\item Locates and validates the \code{app.json} file +\item Extracts all application files to the specified directory +} +} +} +\section{URL Resolution}{ + + +The function attempts several strategies to find app.json: +\itemize{ +\item Direct use of the provided URL +\item Appending \code{"app.json"} to the URL +\item Checking the parent directory +} +} + +\section{Error Handling}{ + + +The function will error with informative messages if: +\itemize{ +\item The URL cannot be accessed +\item No valid Shinylive application is found +\item The \code{app.json} structure is invalid +} +} + +\examples{ +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +# Download a standalone Shinylive application +url <- "https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/" + +app <- peek_shinylive_app(url) + +# Extract to a specific directory +app <- peek_shinylive_app( + url, + output_dir = "my_extracted_app" +) + +# Download from a Quarto document +apps <- peek_shinylive_app("https://quarto-ext.github.io/shinylive/") +\dontshow{\}) # examplesIf} +} +\seealso{ +\itemize{ +\item \code{\link[=peek_quarto_shinylive_app]{peek_quarto_shinylive_app()}} for handling Quarto documents specifically +\item \code{\link[=peek_standalone_shinylive_app]{peek_standalone_shinylive_app()}} for handling standalone applications +} +} diff --git a/man/peek_standalone_shinylive_app.Rd b/man/peek_standalone_shinylive_app.Rd new file mode 100644 index 0000000..0d4ca50 --- /dev/null +++ b/man/peek_standalone_shinylive_app.Rd @@ -0,0 +1,92 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/peek.R +\name{peek_standalone_shinylive_app} +\alias{peek_standalone_shinylive_app} +\title{Download and Extract a Standalone Shinylive Application} +\usage{ +peek_standalone_shinylive_app(url, output_dir = "converted_shiny_app") +} +\arguments{ +\item{url}{Character string. URL pointing to either: +\itemize{ +\item A Shinylive app.json file directly +\item A directory containing app.json +} + +The function will automatically append \code{"app.json"} to directory URLs.} + +\item{output_dir}{Character string. Directory where the application files +should be extracted. Defaults to \code{"converted_shiny_app"}. Will be created +if it doesn't exist. If the directory already exists, files may be +overwritten.} +} +\value{ +An object of class \code{"standalone_shinylive_app"} containing: +\itemize{ +\item List of extracted files and their contents +\item Source URL of the application +\item Output directory location +} + +The object has a custom print method that displays: +\itemize{ +\item Application type (R or Python) +\item Command to run the application +\item List of extracted files by type +\item File locations +} +} +\description{ +Downloads and extracts a standalone Shinylive application from a URL. The function +locates the application's \code{app.json} file, validates its structure, and extracts +all application files to a local directory. Works with both R and Python +Shinylive applications. +} +\section{File Structure}{ + + +A valid Shinylive application should have an app.json file containing: +\itemize{ +\item At least one application file (e.g., \code{app.R} or \code{app.py}) +\item Optional supporting files (e.g., data files, \code{requirements.txt}) +\item File metadata including name, content, and type +} +} + +\section{Error Handling}{ + + +The function will error with informative messages if: +\itemize{ +\item No \code{app.json} file is found at the URL +\item The \code{app.json} file has invalid structure +\item The \code{app.json} file cannot be downloaded +\item Required application files are missing +} +} + +\examples{ +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} + +# Download from a direct app.json URL +app <- peek_standalone_shinylive_app( + "https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/app.json" +) + +# Download from a directory URL (app.json will be appended) +app <- peek_standalone_shinylive_app( + "https://tutorials.thecoatlessprofessor.com/convert-shiny-app-r-shinylive/", + output_dir = "my_local_app" +) + +# Print shows how to run the application +print(app) +\dontshow{\}) # examplesIf} +} +\seealso{ +\itemize{ +\item \code{\link[=find_shinylive_app_json]{find_shinylive_app_json()}} for \code{app.json} validation +\item \code{\link[=write_standalone_shinylive_app]{write_standalone_shinylive_app()}} for file extraction +\item \code{\link[=peek_shinylive_app]{peek_shinylive_app()}} for a more general-purpose download function +} +} diff --git a/man/peeky-package.Rd b/man/peeky-package.Rd new file mode 100644 index 0000000..3eeb859 --- /dev/null +++ b/man/peeky-package.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/peeky-package.R +\docType{package} +\name{peeky-package} +\alias{peeky} +\alias{peeky-package} +\title{peeky: Download and Extract Shinylive Applications} +\description{ +Peeks into Quarto documents and standalone Shinylive applications to download and extract their Shiny application source. Handles the extraction of application files from app.json format into a directory structure. +} +\seealso{ +Useful links: +\itemize{ + \item \url{https://r-pkg.thecoatlessprofessor.com/peeky/} + \item \url{https://github.com/coatless-rpkg/peeky} + \item Report bugs at \url{https://github.com/coatless-rpkg/peeky/issues} +} + +} +\author{ +\strong{Maintainer}: James Joseph Balamuta \email{james.balamuta@gmail.com} + +} +\keyword{internal} diff --git a/man/print.quarto_shinylive_apps.Rd b/man/print.quarto_shinylive_apps.Rd new file mode 100644 index 0000000..f1ed9ae --- /dev/null +++ b/man/print.quarto_shinylive_apps.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shinylive-quarto-apps-commands.R +\name{print.quarto_shinylive_apps} +\alias{print.quarto_shinylive_apps} +\title{Print method for \code{quarto_shinylive_apps} objects} +\usage{ +\method{print}{quarto_shinylive_apps}(x, ...) +} +\arguments{ +\item{x}{Object of class \code{"quarto_shinylive_apps"}} + +\item{...}{Additional arguments passed to print} +} +\description{ +Print method for \code{quarto_shinylive_apps} objects +} diff --git a/man/print.standalone_shinylive_app.Rd b/man/print.standalone_shinylive_app.Rd new file mode 100644 index 0000000..bfc21dd --- /dev/null +++ b/man/print.standalone_shinylive_app.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/shinylive-standalone-app-commands.R +\name{print.standalone_shinylive_app} +\alias{print.standalone_shinylive_app} +\title{Print method for \code{standalone_shinylive_app} objects} +\usage{ +\method{print}{standalone_shinylive_app}(x, ...) +} +\arguments{ +\item{x}{Object of class \code{"standalone_shinylive_app"}} + +\item{...}{Additional arguments passed to print} +} +\description{ +Print method for \code{standalone_shinylive_app} objects +} diff --git a/man/validate_app_json.Rd b/man/validate_app_json.Rd new file mode 100644 index 0000000..b768f8d --- /dev/null +++ b/man/validate_app_json.Rd @@ -0,0 +1,104 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{validate_app_json} +\alias{validate_app_json} +\title{Validate Shinylive \code{app.json} Structure} +\usage{ +validate_app_json(json_data) +} +\arguments{ +\item{json_data}{List. Parsed JSON data from an app.json file. Should be a list +where each element represents a file and contains: +\itemize{ +\item \code{name}: Character string of the file name +\item \code{content}: Character string of the file content +\item \code{type}: Character string indicating the file type +}} +} +\value{ +Logical TRUE if validation passes. If validation fails, throws an error +with detailed information about the validation failure using cli_abort(). +} +\description{ +Validates that a parsed \code{app.json} structure meets the requirements for a +Shinylive application. Checks for proper list structure, required fields, +and non-empty content. Provides detailed error messages when validation fails. +} +\section{Validation Rules}{ + +The function checks these requirements: +\enumerate{ +\item \code{json_data} must be a list +\item \code{json_data} must contain at least one element +\item Each element must be a list (representing a file) +\item Each file list must contain all required fields: +\itemize{ +\item \code{name} +\item \code{content} +\item \code{type} +} +} +} + +\section{Error Messages}{ + + +The function provides detailed error messages for these cases: +\itemize{ +\item Not a list: "Expected a list or array of files" +\item Empty list: "File list is empty" +\item Invalid file entry: "Each entry must be a file object" +\item Missing fields: Lists specific missing required fields +} +} + +\section{Expected JSON Structure}{ + +The expected JSON structure is an array of objects, where each object represents +a file in the application. + +\if{html}{\out{
}}\preformatted{[ + \{ + "name": "app.R", + "content": "library(shiny)\\n...", + "type": "text" + \}, + \{ + "name": "data/example.csv", + "content": "x,y\\n1,2\\n...", + "type": "text" + \} +] +}\if{html}{\out{
}} +} + +\examples{ +\dontrun{ +# Valid structure +valid_data <- list( + list( + name = "app.R", + content = "library(shiny)\n...", + type = "text" + ), + list( + name = "data.csv", + content = "x,y\n1,2", + type = "text" + ) +) +validate_app_json(valid_data) # Returns TRUE + +# Invalid structures that will error: +validate_app_json(list()) # Empty list +validate_app_json(list( + list(name = "app.R") # Missing required fields +)) +} +} +\seealso{ +\itemize{ +\item \code{\link[=find_shinylive_app_json]{find_shinylive_app_json()}} which uses this validation +} +} +\keyword{internal} diff --git a/man/write_apps_to_dirs.Rd b/man/write_apps_to_dirs.Rd new file mode 100644 index 0000000..73609ec --- /dev/null +++ b/man/write_apps_to_dirs.Rd @@ -0,0 +1,114 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/writers.R +\name{write_apps_to_dirs} +\alias{write_apps_to_dirs} +\title{Write Multiple Shinylive Applications to Separate Directories} +\usage{ +write_apps_to_dirs(apps, base_dir) +} +\arguments{ +\item{apps}{List of parsed Shinylive applications. Each application should +contain: +\itemize{ +\item \code{engine}: Character string identifying the app type (\code{"r"} or \code{"python"}) +\item \code{options}: List of YAML-style options from the original code block +\item \code{files}: Named list of file definitions, each containing: +\itemize{ +\item \code{name}: Character string of the file name +\item \code{content}: Character string of the file content +\item \code{type}: Character string indicating the file type +} +}} + +\item{base_dir}{Character string. Base directory where application +subdirectories should be created. Will be created if it doesn't exist.} +} +\description{ +Takes a list of parsed Shinylive applications and writes each to its own +numbered subdirectory. Creates consistently numbered directories with proper +padding (e.g., \code{app_01}, \code{app_02}) and preserves all application files and +metadata. +} +\details{ +The function performs these steps: +\enumerate{ +\item Creates the base directory if needed +\item Calculates proper padding for subdirectory numbers +\item For each application: +\itemize{ +\item Creates a padded, numbered subdirectory (e.g., \code{app_01}, \code{app_02}) +\item Writes all application files, preserving directory structure +\item Creates a metadata JSON file with engine and options info +} +} +} +\section{Directory Structure}{ + +Creates a directory structure like: + +\if{html}{\out{
}}\preformatted{base_dir/ + β”œβ”€β”€ app_01/ + β”‚ β”œβ”€β”€ app.R + β”‚ β”œβ”€β”€ data/ + β”‚ β”‚ └── example.csv + β”‚ └── shinylive_metadata.json + β”œβ”€β”€ app_02/ + β”‚ β”œβ”€β”€ app.py + β”‚ └── shinylive_metadata.json + └── ... +}\if{html}{\out{
}} +} + +\section{Metadata File}{ + + +Each directory includes a \code{shinylive_metadata.json} file containing: + +\if{html}{\out{
}}\preformatted{\{ + "engine": "r", + "options": \{ + "viewerHeight": 500, + "...": "..." + \} +\} +}\if{html}{\out{
}} +} + +\examples{ +\dontrun{ +# Example apps list structure +apps <- list( + list( + engine = "r", + options = list(viewerHeight = 500), + files = list( + "app.R" = list( + name = "app.R", + content = "library(shiny)\n...", + type = "text" + ) + ) + ), + list( + engine = "python", + options = list(), + files = list( + "app.py" = list( + name = "app.py", + content = "from shiny import App\n...", + type = "text" + ) + ) + ) +) + +write_apps_to_dirs(apps, "extracted_apps") +} +} +\seealso{ +\itemize{ +\item \code{\link[=padding_width]{padding_width()}} for directory number padding calculation +\item \code{\link[=write_apps_to_quarto]{write_apps_to_quarto()}} for alternative Quarto output format +} +} +\keyword{internal} diff --git a/man/write_apps_to_quarto.Rd b/man/write_apps_to_quarto.Rd new file mode 100644 index 0000000..e322635 --- /dev/null +++ b/man/write_apps_to_quarto.Rd @@ -0,0 +1,119 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/writers.R +\name{write_apps_to_quarto} +\alias{write_apps_to_quarto} +\title{Write Shinylive Applications to a Quarto Document} +\usage{ +write_apps_to_quarto(apps, qmd_path) +} +\arguments{ +\item{apps}{List of parsed Shinylive applications. Each application should +contain: +\itemize{ +\item \code{engine}: Character string identifying the app type (\code{"r"} or \code{"python"}) +\item \code{options}: List of YAML-style options from the original code block +\item \code{files}: Named list of file definitions, each containing: +\itemize{ +\item \code{name}: Character string of the file name +\item \code{content}: Character string of the file content +\item \code{type}: Character string indicating the file type +} +}} + +\item{qmd_path}{Character string. Path where the Quarto document should be +written. Should end with \code{.qmd} extension. Parent directory will be created +if it doesn't exist.} +} +\description{ +Converts a list of parsed Shinylive applications into a single Quarto document. +Creates a properly formatted .qmd file with YAML frontmatter, organized sections +for each application, and correctly formatted code blocks with all necessary +markers and options. +} +\details{ +The function performs these steps: +\enumerate{ +\item Creates YAML frontmatter with required Quarto settings +\item For each application: +\itemize{ +\item Adds a section header with application number +\item Creates a code block with appropriate engine (\code{shinylive-r}/\code{shinylive-python}) +\item Converts and adds all application options +\item Adds file markers and content for each file +\item Properly closes the code block +} +\item Writes the complete document to the specified path +} +} +\section{Document Structure}{ + + +Creates a Quarto document with this structure: + +\if{html}{\out{
}}\preformatted{--- +title: Extracted Shinylive Applications +filters: + - shinylive +--- + +# Shinylive Applications + +## Application 1 + +```\{shinylive-r\} +#| viewerHeight: 500 +## file: app.R +## type: text +library(shiny) +... +``` + +## Application 2 +... +}\if{html}{\out{
}} +} + +\section{Option Formatting}{ + +Options are converted to YAML format based on their type: +\itemize{ +\item Logical: \verb{#| option: true} or \verb{#| option: false} +\item Numeric: \verb{#| option: 500} +\item Character: +\itemize{ +\item Single: \verb{#| option: "value"} +\item Vector: \verb{#| option: ["value1", "value2"]} +} +} +} + +\examples{ +\dontrun{ +# Example apps list structure +apps <- list( + list( + engine = "r", + options = list( + viewerHeight = 500, + fullWidth = TRUE + ), + files = list( + "app.R" = list( + name = "app.R", + content = "library(shiny)\n...", + type = "text" + ) + ) + ) +) + +write_apps_to_quarto(apps, "applications.qmd") +} + +} +\seealso{ +\itemize{ +\item \code{\link[=write_apps_to_dirs]{write_apps_to_dirs()}} for alternative directory output format +} +} +\keyword{internal} diff --git a/man/write_standalone_shinylive_app.Rd b/man/write_standalone_shinylive_app.Rd new file mode 100644 index 0000000..1bbc8ce --- /dev/null +++ b/man/write_standalone_shinylive_app.Rd @@ -0,0 +1,103 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/writers.R +\name{write_standalone_shinylive_app} +\alias{write_standalone_shinylive_app} +\title{Write Standalone Shinylive Application Files from JSON Data} +\usage{ +write_standalone_shinylive_app( + json_data, + source_url, + output_dir = "converted_shiny_app" +) +} +\arguments{ +\item{json_data}{List. Parsed JSON data from a Shinylive \code{app.json} file. +Each element should be a list containing: +\itemize{ +\item \code{name}: Character string of the file name +\item \code{content}: Character string of the file content +\item \code{type}: Character string indicating the file type +}} + +\item{source_url}{Character string. The original URL from which the \code{app.json} +was downloaded. Used for reference and provenance tracking in the returned +object.} + +\item{output_dir}{Character string. Directory where application files should +be extracted. Defaults to \code{"converted_shiny_app"}. Will be created if it +doesn't exist. Existing files in this directory may be overwritten.} +} +\value{ +An object of class \code{"standalone_shinylive_app"} containing: +\itemize{ +\item \code{files}: List of extracted files and their metadata +\item \code{output_dir}: Path to the directory containing extracted files +\item \code{source_url}: Original URL of the application +} +} +\description{ +Extracts files from parsed Shinylive \code{app.json} data and writes them to a +specified directory. Creates a standalone application object containing +metadata and commands for running the application. +} +\details{ +The function performs these steps: +\enumerate{ +\item Creates the output directory if it doesn't exist +\item Iterates through each file in the JSON data +\item Writes each file to the output directory, preserving names +\item Creates a standalone application object with metadata +} + +File paths are created relative to the output directory. Parent +directories in file paths will be created as needed. +} +\section{File Structure}{ + +Expected JSON data structure: + +\if{html}{\out{
}}\preformatted{[ + \{ + "name": "app.R", + "content": "library(shiny)\\n...", + "type": "text" + \}, + \{ + "name": "data/example.csv", + "content": "x,y\\n1,2\\n...", + "type": "text" + \} +] +}\if{html}{\out{
}} +} + +\examples{ +\dontrun{ +# Example JSON data structure +json_data <- list( + list( + name = "app.R", + content = "library(shiny)\nui <- fluidPage()\n...", + type = "text" + ), + list( + name = "data.csv", + content = "col1,col2\n1,2\n3,4", + type = "text" + ) +) + +app <- write_standalone_shinylive_app( + json_data, + "https://example.com/app.json", + "my_app" +) +} +} +\seealso{ +\itemize{ +\item \code{\link[=create_standalone_shinylive_app]{create_standalone_shinylive_app()}} for object creation +\item \code{\link[=validate_app_json]{validate_app_json()}} for JSON data validation +} +} +\keyword{internal}