diff --git a/DESCRIPTION b/DESCRIPTION
index 5f1fff73e..7009713fe 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 dc2c477e8..b55d43222 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 000000000..93ad2fa33
--- /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 485d0d950..b08d8ecca 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 183609b52..09c3ef6a5 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 000000000..0745d5f1c
--- /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 62dc0e6f1..27b862751 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 000000000..730f0eb26
--- /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"))
+})