Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d082c16
feat: adds tm_rmarkdown module
averissimo Nov 5, 2025
6d064e1
docs: update roxygen
averissimo Nov 5, 2025
2c6b90e
docs: update roxygen
averissimo Nov 5, 2025
b326567
chore: fix some comments
averissimo Nov 5, 2025
2e690ff
fix: minor typo
averissimo Nov 5, 2025
2b17cde
chore: enforce srv protection against allow_download = FALSE
averissimo Nov 5, 2025
157c194
feat: add extra inputs
averissimo Nov 6, 2025
a279ec0
fix: minor fix in names
averissimo Nov 6, 2025
611acf5
[skip style] [skip vbump] Restyle files
github-actions[bot] Nov 6, 2025
1fa0d8b
chore: remove extra parameter
averissimo Nov 6, 2025
309c5ac
docs: update documentation
averissimo Nov 6, 2025
3640c41
pr: copy rmd and setup tempdir only once
averissimo Nov 6, 2025
3b0c5d4
chore: use the output of render (file path) instead of explicit name
averissimo Nov 6, 2025
14cdf42
pr: rename rmd_data to params for consistency
averissimo Nov 6, 2025
c563c71
pr: address main points of review from @gogonzo
averissimo Nov 7, 2025
8c1acaa
[skip roxygen] [skip vbump] Roxygen Man Pages Auto Update
github-actions[bot] Nov 7, 2025
d05971a
fix: adds baseenc for now in description
averissimo Nov 7, 2025
59736a7
pr: apply feedback from meeting with @gogonzo
averissimo Nov 12, 2025
5dc5747
[skip roxygen] [skip vbump] Roxygen Man Pages Auto Update
github-actions[bot] Nov 12, 2025
6da1e04
docs: improve documentation
averissimo Nov 14, 2025
198a462
[skip style] [skip vbump] Restyle files
github-actions[bot] Nov 14, 2025
4b6d7df
[skip roxygen] [skip vbump] Roxygen Man Pages Auto Update
github-actions[bot] Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Imports:
lattice (>= 0.18-4),
lifecycle (>= 0.2.0),
MASS (>= 7.3-60),
rmarkdown (>= 2.23),
rtables (>= 0.6.11),
scales (>= 1.3.0),
shinyjs (>= 2.1.0),
Expand All @@ -73,7 +74,6 @@ Suggests:
nestcolor (>= 0.1.0),
pkgload,
rlang (>= 1.0.0),
rmarkdown (>= 2.23),
roxy.shinylive,
rvest,
shinytest2,
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export(tm_g_scatterplot)
export(tm_g_scatterplotmatrix)
export(tm_missing_data)
export(tm_outliers)
export(tm_rmarkdown)
export(tm_t_crosstable)
export(tm_variable_browser)
import(ggplot2)
Expand Down
267 changes: 267 additions & 0 deletions R/tm_rmarkdown.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
#' `teal` module: Rmarkdown render
#'
#' Module to render R Markdown files using the data provided in the `teal_data` object.
#'
#' The R Markdown file should be designed to accept parameters corresponding to the datasets.
#' See using `params` in R Markdown documentation:
#' [bookdown.org/yihui/rmarkdown/params-use.html](https://bookdown.org/yihui/rmarkdown/params-use.html)
#'
#' For example, if the `teal_data` object contains datasets named "mtcars" and "iris",
#' the R Markdown file can define parameters as follows:
#' ```yaml
#' ---
#' title: "R Markdown Report"
#' output: html_document
#' params:
#' mtcars: NULL
#' iris: NULL
#' ---
#' ````
#'
#' The libraries used in the R Markdown file must be available in
#' the Shiny app environment.
#'
#' @inheritParams teal::module
#' @inheritParams shared_params
#'
#' @param rmd_file (`character`) Path to the R Markdown file to be rendered.
#' The file must be accessible from the Shiny app environment.
#' @param allow_download (`logical`) whether to allow downloading of the R Markdown file.
#' Defaults to `TRUE`.
#' @param extra_transform (`list`) of [teal::teal_transform_module()] that will be added in the module's UI.
#' This can be used to create interactive inputs that modify the parameters in R Markdown rendering.
#'
#' @inherit shared_params return
#'
#' @inheritSection teal::example_module Reporting
#'
#' @examplesShinylive
#' library(teal.modules.general)
#' interactive <- function() TRUE
#' {{ next_example }}
#' @examples
#'
#' # general data example
#' data <- teal_data()
#' data <- within(data, {
#' CO2 <- CO2
#' CO2[["primary_key"]] <- seq_len(nrow(CO2))
#' })
#' join_keys(data) <- join_keys(join_key("CO2", "CO2", "primary_key"))
#'
#'
#' app <- init(
#' data = data,
#' modules = modules(
#' tm_rmarkdown(
#' label = "RMarkdown Module",
#' rmd_file = system.file(file.path("sample_files", "test.Rmd"), package = "teal.modules.general")
#' )
#' )
#' )
#' if (interactive()) {
#' shinyApp(app$ui, app$server)
#' }
#'
#' @examples
#' nrow_transform <- teal_transform_module(
#' label = "N Rows selector",
#' ui = function(id) {
#' ns <- NS(id)
#' tags$div(
#' numericInput(ns("n_rows"), "Show n rows", value = 5, min = 0, max = 200, step = 5)
#' )
#' },
#' server = function(id, data) {
#' moduleServer(id, function(input, output, session) {
#' reactive({
#' req(data())
#' within(data(),
#' {
#' rmd_data$n_rows <- n_rows_value
#' },
#' n_rows_value = input$n_rows
#' )
#' })
#' })
#' }
#' )
#'
#' app <- init(
#' data = data,
#' modules = modules(
#' tm_rmarkdown(
#' label = "RMarkdown Module",
#' rmd_file = system.file(file.path("sample_files", "test.Rmd"), package = "teal.modules.general"),
#' allow_download = FALSE,
#' extra_transform = list(nrow_transform)
#' )
#' )
#' ) |> shiny::runApp()
#' @export
#'
tm_rmarkdown <- function(label = "RMarkdown Module",
rmd_file,
datanames = "all",
allow_download = TRUE,
pre_output = NULL,
post_output = NULL,
transformators = list(),
extra_transform = list()) {
message("Initializing tm_rmarkdown")

# Start of assertions

checkmate::assert_string(label)
checkmate::assert_file(rmd_file, access = "r")
checkmate::assert_flag(allow_download)

checkmate::assert_multi_class(pre_output, c("shiny.tag", "shiny.tag.list", "html"), null.ok = TRUE)
checkmate::assert_multi_class(post_output, c("shiny.tag", "shiny.tag.list", "html"), null.ok = TRUE)

# End of assertions

# Make UI args
args <- as.list(environment())

ans <- module(
label = label,
server = srv_rmarkdown,
server_args = list(rmd_file = rmd_file, allow_download = allow_download, extra_transform = extra_transform),
ui = ui_rmarkdown,
ui_args = args,
transformators = transformators,
datanames = datanames
)
# attr(ans, "teal_bookmarkable") <- TRUE

Check warning on line 136 in R/tm_rmarkdown.R

View workflow job for this annotation

GitHub Actions / SuperLinter 🦸‍♀️ / Lint R code 🧶

file=R/tm_rmarkdown.R,line=136,col=5,[commented_code_linter] Remove commented code.
ans
}

# UI function for the rmarkdown module
ui_rmarkdown <- function(id, rmd_file, allow_download, extra_transform, ...) {
args <- list(...)
ns <- NS(id)

teal.widgets::standard_layout(
output = teal.widgets::white_small_well(
tags$div(
tags$h4(
"Rendered report from: ",
tags$code(basename(rmd_file))
),
if (allow_download) {
downloadButton(
ns("download_rmd"),
sprintf("Download '%s'", basename(rmd_file)),
class = "btn-primary btn-sm"
)
},
ui_transform_teal_data(ns("extra_transform"), transformators = extra_transform)
),
tags$hr(),
uiOutput(ns("rmd_output"))
),
encoding = NULL,
pre_output = args$pre_output,
post_output = args$post_output
)
}

# Server function for the rmarkdown module
srv_rmarkdown <- function(id, data, rmd_file, allow_download, extra_transform) {
checkmate::assert_class(data, "reactive")
checkmate::assert_class(isolate(data()), "teal_data")
moduleServer(id, function(input, output, session) {
if (allow_download) {
output$download_rmd <- downloadHandler(
filename = function() basename(rmd_file),
Copy link
Contributor

@gogonzo gogonzo Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't downloadable rmarkdown contain also "reproducible" qenv code? IMO tm_rmarkdown(rmd_file) is just a template and returning a template doesn't make sense if it lacks data to actually execute templete-file.

Edit:

I think app user should be able to download rendered document?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't downloadable rmarkdown contain also "reproducible" qenv code? IMO tm_rmarkdown(rmd_file) is just a template and returning a template doesn't make sense if it lacks data to actually execute templete-file.

Good point. I didn't want to do that before the rendering as it mean that the pre-processing would be run again.

But this can be added in post-rendering to the Download document and in the reporter markdown version.

This does imply we need to parse the Rmd and resulting markdown:

  1. Read the md
  2. Find line after yml header -- fallback to start of document if no header present
  3. Add new R code chunk with contents of qenv

(we could even inject this in the module UI as well to keep consistency)

I think app user should be able to download rendered document?

They can, when downloading the reporter. I don't think we should overlap functionality here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See how to convert .Rmd to .R and back using knitr (thats' in teal.reporter dependency)

Copy link
Contributor Author

@averissimo averissimo Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@llrs-roche parsing and adding the section in .R or in .Rmd would have similar challenges?

From my initial findings knitr::purl() is promising, but it does have some minor limitations with chunk options (it doesn't play well when evaluating params: {r, eval = !is.null(params$CO2)})

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I missed your comment @averissimo.
I'm not sure I fully understand the question, but adding a new section to the .Rmd could be simply an append of new text (if it is after the last one). But here parsing arguments and code is delegates to knitr.
I haven't tested it but from reading it the chunk param would be respected and included on .R file (I'm not sure about params and yaml headers).

content = function(file) file.copy(rmd_file, file),
contentType = "text/plain"
)
}

pre_decorated_q_r <- reactive({
data_q <- req(data())
teal.reporter::teal_card(data_q) <- c(
teal.reporter::teal_card(data_q),
teal.reporter::teal_card("## Module's output(s)")
)
eval_code(
data_q,
sprintf(
"rmd_data <- list(%s)",
toString(sprintf("%1$s = %1$s", sapply(names(data_q), as.name)))
)
)
})

q_r <- data_with_output_decorated <- teal::srv_transform_teal_data(
"extra_transform",
data = pre_decorated_q_r,
transformators = extra_transform
)

rendered_path_r <- reactive({
datasets <- req(q_r()) # Ensure data is available
temp_dir <- tempdir()
temp_rmd <- tempfile(tmpdir = temp_dir, fileext = ".Rmd")
temp_html <- tempfile(tmpdir = temp_dir, fileext = ".md")
file.copy(rmd_file, temp_rmd) # Use a copy of the Rmd file to avoid modifying the original

tryCatch(
{
rmarkdown::render(
temp_rmd,
output_format = rmarkdown::md_document(
variant = "gfm",
toc = TRUE,
preserve_yaml = TRUE
),
output_file = temp_html,
params = datasets[["rmd_data"]],
envir = new.env(parent = globalenv()),
quiet = TRUE,
runtime = "static"
)
temp_html
},
error = function(e) {
warning("Error rendering RMD file: ", e$message) # verbose error in logs
e
}
)
})

rendered_html_r <- reactive({
output_path <- req(rendered_path_r())
validate(
need(inherits(output_path, "character"), "Error rendering RMD file. Please contact the app developer.")
)
htmltools::includeMarkdown(output_path)
})

output$rmd_output <- renderUI(rendered_html_r())

reactive({
out_data <- q_r()

if (allow_download) {
out_data <- eval_code(
q_r(),
paste(
sep = "\n",
sprintf("## R Markdown contents are generated from file, please download it from the module UI."),
sprintf("# rmarkdown::render(%s, params = rmd_data)", shQuote(basename(rmd_file), type = "cmd"))
)
)
out_data@verified <- FALSE # manual change verified status as code is being injected
}

teal.reporter::teal_card(out_data) <- c(
Copy link
Contributor Author

@averissimo averissimo Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue with keeping it in markdown format: Images are rendered to file that needs to be "kept" until report rendering

Possible workarounds:

  • Modify generated md file to convert image from paths to base64
  • Keep the images until the report generation
  • Re-render during report generation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been solved in c563c71 by creating a custom reporter object for this module.

It keeps the rendered HTML and uses it as caching (only renders once)

It also keeps the contents of images in memory to be dumped and used later on.

Why memory?

  • In case files on disk change (multiple reports being added)
  • Prevent having to track and keep all images being generated

Copy link
Contributor Author

@averissimo averissimo Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still room for improvement here, but we have a working solution.

Improvement:

  • rmarkdown fixes issue raised above (and we manipulate image tag to use base64)
  • Avoid encoding to base64 keep it in raw format for example.

teal.reporter::teal_card(out_data),
rendered_html_r()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

markdown fits here better. We can also split it into list() suitable with teal_card()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what would be better as I think of this as a monolith element. At least until we can actually break down the executable code.

We could keep it as a single markdown object (i.e. character vector) and only render as needed. note: the current approach was meant to reduce duplicate rendering (1. Module UI; 2. Reporter previewer; 3. Reporter download).

Splitting might be difficult to achieve as there are multiline grammar and context in markdown (for instance code chunks, or just newlines without a new paragraph in text)

toHTML(c("# ada", "", "bla.", "ca.", "```r", "1+1", "3*3", "```", "some text")) # works
# vs.
lapply(c("# ada", "", "bla.", "ca.", "```r", "1+1", "3*3", "```", "some text"), toHTML) # doesn't work

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could keep it as a single markdown object

I think its good to have a single markdown string because anyway at the end (before download) we will concatenate everything to the single file. Only editor will show one combined text-field for this element (i think it is not a big issue so far)

the current approach was meant to reduce duplicate rendering

This is a fair reason. I feel (not a strong opinion) that if you render markdown using rmarkdown::render and includeMarkdown the speed should be similar to rmarkdown::render to html. rmarkdown renders in two stages to html, first by converting Rmd -> md and then pandoc renders from md -> html.

The biggest issue with including html is that html can't be converted to pdf, otf etc.

Copy link
Contributor Author

@averissimo averissimo Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gogonzo see new commits.

  • Uses qenv environment instead of params
  • Downloaded Rmd from main module adds data processing in module
  • Markdown is rendered once
    • HTML is rendered only once on top of markdown
  • Uses custom to_rmd and toHTML to achieve goals

Let me know if you have comments :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

)
out_data
})
})
}
33 changes: 33 additions & 0 deletions inst/sample_files/test.Rmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: "Test R Markdown"
output: html_document
params:
CO2: NULL
n_rows: NULL
---

This is an example of an R markdown file with an inline r execution that gives the current date: `r Sys.Date()`

Code chunk that performs a simple calculation (`1+1`)

```{r}
1 + 1
```

Code chunk that shows the structure of the params object

```{r}
lapply(params, class)
```

Code chunk that shows the summary of the first `n_rows` of the `CO2` dataset if it is provided

```{r}
summary(head(params$CO2, n = params$n_rows))
```

Code chunk that plots the first `n_rows` of the `CO2` dataset if it is provided

```{r, eval = !is.null(params$CO2)}
plot(head(params$CO2, n = params$n_rows))
```
Loading
Loading