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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ jobs:
data.table
dplyr
haven
htmltools
R6
ragg
readr
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
data.table
dplyr
haven
htmltools
R6
ragg
readr
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
data.table
dplyr
haven
htmltools
R6
ragg
readr
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ The following R packages are required for tests:
- rstudioapi
- tibble
- haven
- htmltools
- R6
- readr

Expand Down
230 changes: 217 additions & 13 deletions crates/ark/src/modules/positron/html_widgets.R
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
#
# html_widgets.R
#
# Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
# Copyright (C) 2023-2026 Posit Software, PBC. All rights reserved.
#
#

#' @export
.ps.is_notebook <- function() {
.ps.Call("ps_is_notebook")
}

#' @export
.ps.view_html_widget <- function(x, ...) {
# Render the widget to a tag list.
rendered <- htmltools::as.tags(x, standalone = TRUE)
if (isTRUE(.ps.is_notebook())) {
view_html_widget_inline(x)
return(invisible(x))
}

view_html_widget_viewer(x)
invisible(x)
}

# Notebook / background path: render the widget to a single self-contained
# HTML document, with each JS/CSS dependency inlined as a `data:` URI, then
# emit it as a `display_data` IOPub message. The notebook saves the payload
# verbatim, so it must not reference temp files on disk.
view_html_widget_inline <- function(x) {
rendered <- htmltools::renderTags(x)
html <- embed_tags(rendered)
label <- widget_label(x)
.ps.Call("ps_html_widget_emit", html, label)
}

# Render the tag list to a temporary file using html_print. Don't view the
# file yet; we'll do that in a bit.
# Console path: write the widget to a temp HTML file via htmltools, then hand
# the path to Positron's UI comm so it can be served by the Viewer pane.
# The temp directory survives for the life of the R session, so relative
# references from the HTML to its sibling `*_files/` directory resolve.
view_html_widget_viewer <- function(x) {
rendered <- htmltools::as.tags(x, standalone = TRUE)
tmp_file <- htmltools::html_print(rendered, viewer = NULL)

# Guess whether this is a plot-like widget based on its sizing policy.
Expand All @@ -19,20 +46,197 @@
# Derive the height of the viewer pane from the sizing policy of the widget.
height <- .ps.validate.viewer.height(x$sizingPolicy$viewer$paneHeight)

# Attempt to derive a label for the widget from its class. If the class is
# empty, use a default label.
label <- class(x)[1]
if (nzchar(label)) {
label <- paste(label, "HTML widget")
} else {
label <- "R HTML widget"
}
label <- widget_label(x)

# Pass the widget to the viewer. Positron will assemble the final HTML
# document from these components.
.ps.Call("ps_html_viewer", tmp_file, label, height, destination)
}

# Build a self-contained `<!DOCTYPE html>` document from a `renderTags()`
# result. Each dependency is inlined as base64 `data:` URIs so that the
# returned string can be saved into a Jupyter notebook and reopened without
# any external file references.
embed_tags <- function(rendered) {
deps <- filter_seen_deps(rendered$dependencies)
dep_html <- vapply(deps, render_dep_inline, character(1))

head_parts <- c(
'<meta charset="utf-8"/>',
dep_html,
if (nzchar(rendered$head %||% "")) rendered$head else NULL
)

paste0(
"<!DOCTYPE html>\n",
"<html>\n",
"<head>\n",
paste(head_parts, collapse = "\n"),
"\n</head>\n",
"<body>\n",
rendered$html,
"\n</body>\n",
"</html>\n"
)
}

# Render one `htmlDependency` as the `<link>`/`<script>` block to embed in
# `<head>`. Local files (`src$file`) are base64-inlined; CDN-only deps
# (`src$href`) fall back to a remote reference, which is best-effort
# self-containment but at least keeps the widget functional online.
render_dep_inline <- function(dep) {
file_base <- dep$src[["file"]]
href_base <- dep$src[["href"]]

parts <- character()

# Stylesheets
for (css in as_named_resource(dep$stylesheet)) {
if (!is.null(file_base)) {
parts <- c(
parts,
sprintf(
'<link rel="stylesheet" href="%s"/>',
file_to_data_uri(file.path(file_base, css), "text/css")
)
)
} else if (!is.null(href_base)) {
parts <- c(
parts,
sprintf(
'<link rel="stylesheet" href="%s/%s"/>',
href_base,
css
)
)
}
}

# Scripts
for (js in as_named_resource(dep$script)) {
if (!is.null(file_base)) {
parts <- c(
parts,
sprintf(
'<script src="%s"></script>',
file_to_data_uri(
file.path(file_base, js),
"application/javascript"
)
)
)
} else if (!is.null(href_base)) {
parts <- c(
parts,
sprintf(
'<script src="%s/%s"></script>',
href_base,
js
)
)
}
}

# Inline <script>/<style> blocks the dep wants in <head>.
if (length(dep$head) && nzchar(dep$head)) {
parts <- c(parts, dep$head)
}

paste(parts, collapse = "\n")
}

# `htmlDependency()` allows `script`/`stylesheet` to be either a character
# vector or a list of named lists (with `src=` and other attributes for
# subresource integrity etc.). Normalize to a character vector of source
# paths; richer attributes are dropped on the floor for now.
as_named_resource <- function(x) {
if (is.null(x)) {
return(character())
}
if (is.character(x)) {
return(x)
}
vapply(
x,
function(item) {
if (is.character(item)) item else item[["src"]] %||% NA_character_
},
character(1)
)
}

# Read a file and return a `data:<mime>;base64,...` URI. Used for inlining
# JS/CSS dependencies. The mime types we pass in are static; charset for CSS
# is set explicitly so non-ASCII glyphs in fonts.css etc. render correctly.
# (base64enc is a hard dependency of htmltools, so it's guaranteed to be
# available wherever this code path runs.)
file_to_data_uri <- function(path, mime) {
bytes <- readBin(path, what = "raw", n = file.info(path)$size)
encoded <- base64enc::base64encode(bytes)
mime_with_charset <- if (identical(mime, "text/css")) {
"text/css;charset=utf-8"
} else {
mime
}
paste0("data:", mime_with_charset, ";base64,", encoded)
}

# Per-session dedup: when enabled, each `htmlDependency` keyed by
# `name@version` is inlined once. Subsequent widgets in the same session that
# share a dep (e.g. two plotly figures both pulling in plotly.js) emit just
# their body markup and rely on the earlier cell's `<script>` having
# registered the library globally.
#
# This only works on frontends that render `text/html` outputs into a shared
# DOM (classic Jupyter, JupyterLab). Positron's notebook view isolates each
# cell's output, so a deduped second widget would find an empty global scope
# and render blank. Default off; opt in with
# `options(ark.html_widget.deduplicate = TRUE)` if you know your frontend
# shares scope across cells.
filter_seen_deps <- function(deps) {
if (!isTRUE(getOption("ark.html_widget.deduplicate", FALSE))) {
return(deps)
}

cache <- html_dep_cache()
keep <- logical(length(deps))
for (i in seq_along(deps)) {
dep <- deps[[i]]
key <- paste0(dep$name, "@", dep$version)
if (is.null(cache[[key]])) {
cache[[key]] <- TRUE
keep[i] <- TRUE
}
}
deps[keep]
}

html_dep_cache <- function() {
if (is.null(the$html_dep_cache)) {
the$html_dep_cache <- new.env(parent = emptyenv())
}
the$html_dep_cache
}

# Test hook: clear the per-session dedup cache so tests can assert dedup
# behavior independent of each other.
#' @export
.ps.html_widget_reset_deps <- function() {
the$html_dep_cache <- NULL
invisible(NULL)
}

# Derive a human-readable label for the `text/plain` fallback from the
# widget's class. Falls back to a generic label if the class is empty.
widget_label <- function(x) {
label <- class(x)[1]
if (length(label) && nzchar(label)) {
paste(label, "HTML widget")
} else {
"R HTML widget"
}
}

#' @export
.ps.viewer.addOverrides <- function() {
add_s3_override("print.htmlwidget", .ps.view_html_widget)
Expand Down
Loading
Loading