Skip to content

Instantly share code, notes, and snippets.

@jcheng5
Created May 29, 2024 20:27
Show Gist options
  • Save jcheng5/0db73f27692c8d09dd8ecf72a4b322de to your computer and use it in GitHub Desktop.
Save jcheng5/0db73f27692c8d09dd8ecf72a4b322de to your computer and use it in GitHub Desktop.

Experimental function to concatenate lots of HTML dependencies' JavaScript files into one single one.

A number of caveats apply:

  1. Strict mode: Many JavaScript files contain "use strict"; at the top of the file. This causes slightly different behavior in the JS engine. If the concatenated JS files have different expectations regarding strict mode, there could be problems.
  2. Hosted scripts: This script does not attempt to concatenate JS files that are hosted on CDNs. Instead, they're left in their original form. It's possible that files we do concatenate, either assume that a CDN-hosted script is loaded before it itself is, or, the opposite. combine_js has a parameter that lets you decide whether to put the combined script first or last, there's no way for us to decide automatically (assuming there's even a right answer).
  3. ES6 modules: We leave type="module" alone, because for ES6 modules the file boundaries are significant.
  4. defer attribute: This script ignores defer and just concatenates them... probably should leave them alone instead.

Note that it doesn't attempt to concatenate CSS, as they can contain relative URL paths that are resolved against the URL of the CSS file. This wouldn't be impossible to deal with but I haven't done anything about it.

library(htmltools)
# == Top-level function ========================
#' @param x An htmltools object that contains HTML dependencies that should be
#' combined.
#' @param combined_first If `TRUE`, the combined JS bundle will come before any
#' JS dependencies that were not able to be bundled (either because they were
#' <script type="module"> or because they are hosted on a CDN). If `FALSE`,
#' then the combined JS bundle will be put at the end.
combine_js <- function(x, combined_first = TRUE) {
rendered <- renderTags(x)
rendered$dependencies <- merge_deps(rendered$dependencies, combined_first)
tagList(
rendered$dependencies,
tags$head(rendered$head),
# TODO: what to do about singletons?
HTML(rendered$html)
) |> browsable(is.browsable(x))
}
# == Supporting functions ========================
merge_deps <- function(deps, combined_first = TRUE) {
scripts_and_deps <- lapply(deps, function(dep) {
if (is.null(dep$script)) {
# It has no scripts, do nothing
return(list(scripts = NULL, dep = dep))
}
if (is.null(dep$src$file)) {
# It's an href-based dependency, do nothing
return(list(scripts = NULL, dep = dep))
}
split <- split_scripts(dep$script)
dep$script <- split$separate
list(
scripts = split$combine,
dep = dep
)
})
scripts_to_combine <- lapply(scripts_and_deps, function(sd) {
dep <- sd$dep
scripts <- sd$scripts
script_paths <- file.path(dep$src$file, scripts)
if (!is.null(sd$package)) {
script_paths <- htmltools:::system_file(script_paths, package = sd$package)
}
# TODO: Assert that the script files all exist
return(script_paths)
}) |> unlist(recursive = FALSE)
modified_dependencies <- lapply(scripts_and_deps, function(sd) sd$dep)
destdir <- tempfile(pattern = "combined_js_dep")
dir.create(destdir)
destfile <- file.path(destdir, "combined.js")
f <- file(destfile, "wt")
on.exit(close(f))
for (script_path in scripts_to_combine) {
writeLines(paste("/***", script_path, "***/\n"), f)
writeLines(readLines(script_path, encoding = "utf-8", warn = FALSE), f)
writeLines("\n\n", f)
}
combined_dep <- htmlDependency(
"htmltools-combined-js",
"1.0.0",
src = destdir,
script = "combined.js",
all_files = FALSE
)
if (isTRUE(combined_first)) {
c(list(combined_dep), modified_dependencies)
} else {
c(modified_dependencies, list(combined_dep))
}
}
split_scripts <- function(scripts) {
if (is.null(scripts)) {
return(list(separate=NULL, combine=NULL))
} else if (is.character(scripts)) {
return(list(separate=NULL, combine=scripts))
} else if (is.list(scripts)) {
can_combine <- vapply(scripts, function(script) {
if (is.character(script)) {
TRUE
} else if (is.list(script)){
!identical(script$type, "module")
} else {
stop("unexpected script shape")
}
}, logical(1))
return(list(
separate = scripts[!can_combine],
combine = normalize_scripts(scripts[can_combine])
))
} else {
stop("unexpected scripts shape")
}
}
normalize_scripts <- function(scripts) {
lapply(scripts, function(script) {
if (is.character(script)) {
script
} else if (is.list(script)) {
script$src
} else {
stop("unexpected script shape")
}
})
}
# == Example ========================
library(ggplot2)
library(plotly)
library(leaflet)
library(bslib)
x <- page_fillable(
ggplotly(ggplot(cars, aes(speed, dist)) + geom_point()),
leaflet(quakes) |> addTiles() |> addCircleMarkers()
)
print(combine_js(x), browse=TRUE)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment