Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
218 changes: 218 additions & 0 deletions R/tm_rmarkdown.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#' `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`.
#'
#' @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 = "test.Rmd"
#' )
#' )
#' )
#' if (interactive()) {
#' shinyApp(app$ui, app$server)
#' }
#'
#' @export
#'
tm_rmarkdown <- function(label = "RMarkdown Module",
rmd_file,
datanames = "all",
allow_download = TRUE,
pre_output = NULL,
post_output = NULL,
transformators = 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),
ui = ui_rmarkdown,
ui_args = args,
transformators = transformators,
datanames = datanames
)
# attr(ans, "teal_bookmarkable") <- TRUE
ans
}

# UI function for the rmarkdown module
ui_rmarkdown <- function(id, rmd_file, allow_download, ...) {
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"
)
}

),
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) {
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"
)
}

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)))
)
)
})

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()),
Copy link
Contributor

Choose a reason for hiding this comment

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

Passing data through params breaks reproducibility. In Rmd one has to use params$<dataset name>. I think data should be passed via envir.

Skärmavbild 2025-11-06 kl  13 46 01
Suggested change
params = datasets[["rmd_data"]],
envir = new.env(parent = globalenv()),
envir = q_r(),

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 fine with this, it's a bit more custom that using params, but it would be cleaner.

The only reason to use params would be to use existing features of R Markdown, instead of custom workflow

That is, if data is passed via envir, what would be the ideal workflow for the statistician to develop the original R Markdown file?

1. Using envir = data() in rmarkdown::render()

  1. Statistician will have a code chunk that generates whatever data they require
  2. Removes chunk:
    • Manually before sending it or use chunk opts to hide it {r eval = FALSE, include = FALSE}
    • We provide a flag in envir?
      • {r eval = !(exists(".teal_takes_care_data") && isTRUE(.teal_takes_care_data)), include = !(exists(".teal_takes_care_data") && isTRUE(.teal_takes_care_data))}
  3. Send to app developer
  4. App developer uses it module

2. Using params = as.list(data()) in rmarkdown::render()

As it is right now as it has an override mechanism

---
# ...
params:
  ADSL: !r teal.data::rADSL
  # ...
---
  1. Statistician develops Rmd with params set to whatever data they use
  2. Send to app developer
  3. App developer uses it on module

3. Other?

?

Copy link
Contributor

Choose a reason for hiding this comment

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

That is, if data is passed via envir, what would be the ideal workflow for the statistician to develop the original R Markdown file?

Maybe sort of a debug-mode where data() is dumped into environment and app-developer can just execute code-chunks one by one.

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 <- 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

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
})
})
}
106 changes: 106 additions & 0 deletions man/tm_rmarkdown.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.