From ba79068da1e00de7823602275fc0b121fef90612 Mon Sep 17 00:00:00 2001 From: Salim B Date: Tue, 28 May 2024 19:59:17 +0200 Subject: [PATCH] Avoid exernal assets (#2249) Now turn them into HTML dependencies objects, caching them in the user cache directory. Fixes #2099. --------- Co-authored-by: Hadley Wickham --- DESCRIPTION | 3 +- NEWS.md | 1 + R/external-deps.R | 125 +++++++++++++++++++++++++ R/theme.R | 5 +- inst/BS5/templates/head.html | 22 ----- tests/testthat/_snaps/external-deps.md | 17 ++++ tests/testthat/_snaps/init.md | 21 +++++ tests/testthat/test-external-deps.R | 15 +++ 8 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 R/external-deps.R create mode 100644 tests/testthat/_snaps/external-deps.md create mode 100644 tests/testthat/test-external-deps.R diff --git a/DESCRIPTION b/DESCRIPTION index 5f1fff73ed..7009713fe6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -26,11 +26,13 @@ Imports: desc (>= 1.4.0), digest, downlit (>= 0.4.0), + fontawesome, fs (>= 1.4.0), httr (>= 1.4.2), jsonlite, magrittr, memoise, + openssl, purrr (>= 1.0.0), ragg, rlang (>= 1.1.0), @@ -52,7 +54,6 @@ Suggests: lifecycle, magick, methods, - openssl, pkgload (>= 1.0.2), rsconnect, rstudioapi, diff --git a/NEWS.md b/NEWS.md index dc2c477e81..b55d432223 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # pkgdown (development version) +* All external assets (JS, CSS, fonts) are now directly included in the site instead of fetched from external CDN (@salim-b, #2249) * `build_reference_index()` now displays function lifecycle badges next to the function name (#2123). The badges are extracted only from the function description. You can now also use `has_lifecycle()` to select functions by their lifecycle status. * `build_articles()` now recognises a new `external-articles` top-level field that allows you to define articles that live in other packages (#2028). * New light switch makes it easy for users to switch between light and dark themes for the website (based on work in bslib by @gadenbuie). For now this behaviour is opt-in with `template.light-switch: true` but in the future we may turn it on automatically. See the customization vignette for details (#1696). diff --git a/R/external-deps.R b/R/external-deps.R new file mode 100644 index 0000000000..93ad2fa33c --- /dev/null +++ b/R/external-deps.R @@ -0,0 +1,125 @@ +external_dependencies <- function() { + list( + fontawesome::fa_html_dependency(), + cached_dependency( + name = "headroom", + version = "0.11.0", + files = list( + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/headroom/0.11.0/headroom.min.js", + integrity = "sha256-AsUX4SJE1+yuDu5+mAVzJbuYNPHj/WroHuZ8Ir/CkE0=" + ), + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/headroom/0.11.0/jQuery.headroom.min.js", + integrity = "sha256-ZX/yNShbjqsohH1k95liqY9Gd8uOiE1S4vZc+9KQ1K4=" + ) + ) + ), + cached_dependency( + name = "bootstrap-toc", + version = "1.0.1", + files = list( + list( + url = "https://cdn.jsdelivr.net/gh/afeld/bootstrap-toc@v1.0.1/dist/bootstrap-toc.min.js", + integrity = "sha256-4veVQbu7//Lk5TSmc7YV48MxtMy98e26cf5MrgZYnwo=" + ) + ) + ), + cached_dependency( + name = "clipboard.js", + version = "2.0.11", + files = list( + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.11/clipboard.min.js", + integrity = "sha512-7O5pXpc0oCRrxk8RUfDYFgn0nO1t+jLuIOQdOMRp4APB7uZ4vSjspzp5y6YDtDs4VzUSTbWzBFZ/LKJhnyFOKw==" + ) + ) + ), + cached_dependency( + name = "search", + version = "1.0.0", + files = list( + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/fuse.js/6.4.6/fuse.min.js", + integrity = "sha512-KnvCNMwWBGCfxdOtUpEtYgoM59HHgjHnsVGSxxgz7QH1DYeURk+am9p3J+gsOevfE29DV0V+/Dd52ykTKxN5fA==" + ), + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/autocomplete.js/0.38.0/autocomplete.jquery.min.js", + integrity = "sha512-GU9ayf+66Xx2TmpxqJpliWbT5PiGYxpaG8rfnBEk1LL8l1KGkRShhngwdXK1UgqhAzWpZHSiYPc09/NwDQIGyg==" + ), + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js", + integrity = "sha512-5CYOlHXGh6QpOFA/TeTylKLWfB3ftPsde7AnmhuitiTX4K5SqCLBeKro6sPS8ilsz1Q4NRx3v8Ko2IBiszzdww==" + ) + ) + ), + cached_dependency( + name = "MathJax", + version = "2.7.5", + files = list( + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js", + integrity = "sha256-nvJJv9wWKEm88qvoQl9ekL2J+k/RWIsaSScxxlsrv8k=" + ), + list( + url = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/config/TeX-AMS-MML_HTMLorMML.js", + integrity = "sha256-84DKXVJXs0/F8OTMzX4UR909+jtl4G7SPypPavF+GfA=" + ) + ) + ) + ) +} + +cached_dependency <- function(name, version, files) { + cache_dir <- path(tools::R_user_dir("pkgdown", "cache"), name, version) + dir_create(cache_dir) + + for (file in files) { + cache_path <- path(cache_dir, path_file(file$url)) + if (!file_exists(cache_path)) { + download.file(file$url, cache_path, quiet = TRUE, mode = "wb") + check_integrity(cache_path, file$integrity) + } + } + dep_files <- path_rel(dir_ls(cache_dir), cache_dir) + + htmltools::htmlDependency( + name = name, + version = version, + src = cache_dir, + script = dep_files[path_ext(dep_files) == "js"], + stylesheet = dep_files[path_ext(dep_files) == "css"] + ) +} + +check_integrity <- function(path, integrity) { + parsed <- parse_integrity(integrity) + if (!parsed$size %in% c(256L, 384L, 512L)) { + cli::cli_abort( + "{.field integrity} must use SHA-256, SHA-384, or SHA-512", + .internal = TRUE + ) + } + + hash <- compute_hash(path, parsed$size) + if (hash != parsed$hash) { + cli::cli_abort( + "Downloaded asset does not match known integrity", + .internal = TRUE + ) + } + + invisible() +} + +compute_hash <- function(path, size) { + con <- file(path, encoding = "UTF-8") + openssl::base64_encode(openssl::sha2(con, size)) +} + +parse_integrity <- function(x) { + size <- as.integer(regmatches(x, regexpr("(?<=^sha)\\d{3}", x, perl = TRUE))) + hash <- regmatches(x, regexpr("(?<=^sha\\d{3}-).+", x, perl = TRUE)) + + list(size = size, hash = hash) +} diff --git a/R/theme.R b/R/theme.R index 485d0d9505..b08d8ecca3 100644 --- a/R/theme.R +++ b/R/theme.R @@ -5,7 +5,7 @@ build_bslib <- function(pkg = ".", call = caller_env()) { cur_deps <- find_deps(pkg) cur_digest <- purrr::map_chr(cur_deps, file_digest) - deps <- bslib::bs_theme_dependencies(bs_theme) + deps <- c(bslib::bs_theme_dependencies(bs_theme), external_dependencies()) deps <- lapply(deps, htmltools::copyDependencyToDir, path(pkg$dst_path, "deps")) deps <- lapply(deps, htmltools::makeDependencyRelative, pkg$dst_path) @@ -17,7 +17,8 @@ build_bslib <- function(pkg = ".", call = caller_env()) { changed <- all_deps[!diff | is.na(diff)] if (length(changed) > 0) { - purrr::walk(changed, function(dst) { + withr::local_locale(LC_COLLATE = "C") + purrr::walk(sort(changed), function(dst) { cli::cli_inform("Updating {dst_path(path_rel(dst, pkg$dst_path))}") }) } diff --git a/inst/BS5/templates/head.html b/inst/BS5/templates/head.html index 183609b52c..09c3ef6a56 100644 --- a/inst/BS5/templates/head.html +++ b/inst/BS5/templates/head.html @@ -19,24 +19,6 @@ {{/has_favicons}} {{{headdeps}}} - - - - - - - - - - - - - - - - - - @@ -80,10 +62,6 @@ {{/google_site_verification}}{{/yaml}} - - - - {{#yaml}}{{#ganalytics}} diff --git a/tests/testthat/_snaps/external-deps.md b/tests/testthat/_snaps/external-deps.md new file mode 100644 index 0000000000..0745d5f1c6 --- /dev/null +++ b/tests/testthat/_snaps/external-deps.md @@ -0,0 +1,17 @@ +# check integrity validates integrity + + Code + check_integrity(temp, "sha123-abc") + Condition + Error in `check_integrity()`: + ! integrity must use SHA-256, SHA-384, or SHA-512 + i This is an internal error that was detected in the pkgdown package. + Please report it at with a reprex () and the full backtrace. + Code + check_integrity(temp, "sha256-abc") + Condition + Error in `check_integrity()`: + ! Downloaded asset does not match known integrity + i This is an internal error that was detected in the pkgdown package. + Please report it at with a reprex () and the full backtrace. + diff --git a/tests/testthat/_snaps/init.md b/tests/testthat/_snaps/init.md index 62dc0e6f11..27b8627510 100644 --- a/tests/testthat/_snaps/init.md +++ b/tests/testthat/_snaps/init.md @@ -22,12 +22,33 @@ Copying /BS5/assets/link.svg to link.svg Copying /BS5/assets/pkgdown.js to pkgdown.js Copying pkgdown/extra.css to extra.css + Updating deps/MathJax-2.7.5/MathJax.js + Updating deps/MathJax-2.7.5/TeX-AMS-MML_HTMLorMML.js Updating deps/bootstrap-5.3.1/bootstrap.bundle.min.js Updating deps/bootstrap-5.3.1/bootstrap.bundle.min.js.map Updating deps/bootstrap-5.3.1/bootstrap.min.css + Updating deps/bootstrap-toc-1.0.1/bootstrap-toc.min.js + Updating deps/clipboard.js-2.0.11/clipboard.min.js + Updating deps/font-awesome-6.4.2/css/all.css + Updating deps/font-awesome-6.4.2/css/all.min.css + Updating deps/font-awesome-6.4.2/css/v4-shims.css + Updating deps/font-awesome-6.4.2/css/v4-shims.min.css + Updating deps/font-awesome-6.4.2/webfonts/fa-brands-400.ttf + Updating deps/font-awesome-6.4.2/webfonts/fa-brands-400.woff2 + Updating deps/font-awesome-6.4.2/webfonts/fa-regular-400.ttf + Updating deps/font-awesome-6.4.2/webfonts/fa-regular-400.woff2 + Updating deps/font-awesome-6.4.2/webfonts/fa-solid-900.ttf + Updating deps/font-awesome-6.4.2/webfonts/fa-solid-900.woff2 + Updating deps/font-awesome-6.4.2/webfonts/fa-v4compatibility.ttf + Updating deps/font-awesome-6.4.2/webfonts/fa-v4compatibility.woff2 + Updating deps/headroom-0.11.0/headroom.min.js + Updating deps/headroom-0.11.0/jQuery.headroom.min.js Updating deps/jquery-3.6.0/jquery-3.6.0.js Updating deps/jquery-3.6.0/jquery-3.6.0.min.js Updating deps/jquery-3.6.0/jquery-3.6.0.min.map + Updating deps/search-1.0.0/autocomplete.jquery.min.js + Updating deps/search-1.0.0/fuse.min.js + Updating deps/search-1.0.0/mark.min.js # site meta doesn't break unexpectedly diff --git a/tests/testthat/test-external-deps.R b/tests/testthat/test-external-deps.R new file mode 100644 index 0000000000..730f0eb26b --- /dev/null +++ b/tests/testthat/test-external-deps.R @@ -0,0 +1,15 @@ +test_that("check integrity validates integrity", { + temp <- withr::local_tempfile(lines = letters) + + expect_snapshot(error = TRUE, { + check_integrity(temp, "sha123-abc") + check_integrity(temp, "sha256-abc") + }) + + integrity <- paste0("sha256-", compute_hash(temp, 256L)) + expect_no_error(check_integrity(temp, integrity)) +}) + +test_that("can parse integrity", { + expect_equal(parse_integrity("sha256-abc"), list(size = 256L, hash = "abc")) +})