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 285a537cb..cfbc53f00 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,8 @@ # pkgdown (development version) * `build_sitemap()` no longer includes redirected pages (#2582). -* `build_reference_index()` now displays lifecycle badges next to the function name (#2123). You can now also use `has_lifecycle()` to select functions by their lifecycle status. +* 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). * The search dropdown has been tweaked to look more like the other navbar menu items (#2338). diff --git a/R/build-article.R b/R/build-article.R index 246052727..2e6a98d70 100644 --- a/R/build-article.R +++ b/R/build-article.R @@ -52,7 +52,7 @@ build_article <- function(name, default_data <- list( pagetitle = escape_html(front$title), - toc = toc <- front$toc %||% TRUE, + toc = front$toc %||% TRUE, opengraph = list(description = front$description %||% pkg$package), source = repo_source(pkg, input), filename = path_file(input), diff --git a/R/external-deps.R b/R/external-deps.R new file mode 100644 index 000000000..1348970b5 --- /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)) { + utils::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/package.R b/R/package.R index baa61970e..d8832adea 100644 --- a/R/package.R +++ b/R/package.R @@ -288,7 +288,9 @@ extract_source <- function(x) { } extract_lifecycle <- function(x) { - fig <- extract_figure(x) + desc <- purrr::keep(x, inherits, "tag_description") + fig <- extract_figure(desc) + if (!is.null(fig) && length(fig) > 0 && length(fig[[1]]) > 0) { path <- as.character(fig[[1]][[1]]) if (grepl("lifecycle", path)) { diff --git a/R/render.R b/R/render.R index 21f59a1ce..cc177ad90 100644 --- a/R/render.R +++ b/R/render.R @@ -128,6 +128,7 @@ data_template <- function(pkg = ".", depth = 0L) { out$navbar <- data_navbar(pkg, depth = depth) out$footer <- data_footer(pkg) + out$lightswitch <- uses_lightswitch(pkg) print_yaml(out) } 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/assets/lightswitch.js b/inst/BS5/assets/lightswitch.js new file mode 100644 index 000000000..9467125ae --- /dev/null +++ b/inst/BS5/assets/lightswitch.js @@ -0,0 +1,85 @@ + +/*! + * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under the Creative Commons Attribution 3.0 Unported License. + * Updates for {pkgdown} by the {bslib} authors, also licensed under CC-BY-3.0. + */ + +const getStoredTheme = () => localStorage.getItem('theme') +const setStoredTheme = theme => localStorage.setItem('theme', theme) + +const getPreferredTheme = () => { + const storedTheme = getStoredTheme() + if (storedTheme) { + return storedTheme + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +const setTheme = theme => { + if (theme === 'auto') { + document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) + } else { + document.documentElement.setAttribute('data-bs-theme', theme) + } +} + +function bsSetupThemeToggle () { + 'use strict' + + const showActiveTheme = (theme, focus = false) => { + var activeLabel, activeIcon; + + document.querySelectorAll('[data-bs-theme-value]').forEach(element => { + const buttonTheme = element.getAttribute('data-bs-theme-value') + const isActive = buttonTheme == theme + + element.classList.toggle('active', isActive) + element.setAttribute('aria-pressed', isActive) + + if (isActive) { + activeLabel = element.textContent; + activeIcon = element.querySelector('span').classList.value; + } + }) + + const themeSwitcher = document.querySelector('#dropdown-lightswitch') + if (!themeSwitcher) { + return + } + + themeSwitcher.setAttribute('aria-label', activeLabel) + themeSwitcher.querySelector('span').classList.value = activeIcon; + + if (focus) { + themeSwitcher.focus() + } + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const storedTheme = getStoredTheme() + if (storedTheme !== 'light' && storedTheme !== 'dark') { + setTheme(getPreferredTheme()) + } + }) + + window.addEventListener('DOMContentLoaded', () => { + showActiveTheme(getPreferredTheme()) + + document + .querySelectorAll('[data-bs-theme-value]') + .forEach(toggle => { + toggle.addEventListener('click', () => { + const theme = toggle.getAttribute('data-bs-theme-value') + setTheme(theme) + setStoredTheme(theme) + showActiveTheme(theme, true) + }) + }) + }) +} + +setTheme(getPreferredTheme()); +bsSetupThemeToggle(); diff --git a/inst/BS5/assets/pkgdown.js b/inst/BS5/assets/pkgdown.js index bdb4c706a..9757bf9ef 100644 --- a/inst/BS5/assets/pkgdown.js +++ b/inst/BS5/assets/pkgdown.js @@ -152,88 +152,3 @@ async function searchFuse(query, callback) { }); }); })(window.jQuery || window.$) - -/*! - * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) - * Copyright 2011-2023 The Bootstrap Authors - * Licensed under the Creative Commons Attribution 3.0 Unported License. - * Updates for {pkgdown} by the {bslib} authors, also licensed under CC-BY-3.0. - */ - -const getStoredTheme = () => localStorage.getItem('theme') -const setStoredTheme = theme => localStorage.setItem('theme', theme) - -const getPreferredTheme = () => { - const storedTheme = getStoredTheme() - if (storedTheme) { - return storedTheme - } - - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' -} - -const setTheme = theme => { - if (theme === 'auto') { - document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) - } else { - document.documentElement.setAttribute('data-bs-theme', theme) - } -} - -function bsSetupThemeToggle () { - 'use strict' - - const showActiveTheme = (theme, focus = false) => { - var activeLabel, activeIcon; - - document.querySelectorAll('[data-bs-theme-value]').forEach(element => { - const buttonTheme = element.getAttribute('data-bs-theme-value') - const isActive = buttonTheme == theme - - element.classList.toggle('active', isActive) - element.setAttribute('aria-pressed', isActive) - - if (isActive) { - activeLabel = element.textContent; - activeIcon = element.querySelector('span').classList.value; - } - }) - - const themeSwitcher = document.querySelector('#dropdown-lightswitch') - if (!themeSwitcher) { - return - } - - themeSwitcher.setAttribute('aria-label', activeLabel) - themeSwitcher.querySelector('span').classList.value = activeIcon; - - if (focus) { - themeSwitcher.focus() - } - } - - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - const storedTheme = getStoredTheme() - if (storedTheme !== 'light' && storedTheme !== 'dark') { - setTheme(getPreferredTheme()) - } - }) - - window.addEventListener('DOMContentLoaded', () => { - showActiveTheme(getPreferredTheme()) - - document - .querySelectorAll('[data-bs-theme-value]') - .forEach(toggle => { - toggle.addEventListener('click', () => { - const theme = toggle.getAttribute('data-bs-theme-value') - setTheme(theme) - setStoredTheme(theme) - showActiveTheme(theme, true) - }) - }) - }) -} - -setTheme(getPreferredTheme()); -bsSetupThemeToggle(); diff --git a/inst/BS5/assets/pkgdown.scss b/inst/BS5/assets/pkgdown.scss index 5e0a32531..911382fe7 100644 --- a/inst/BS5/assets/pkgdown.scss +++ b/inst/BS5/assets/pkgdown.scss @@ -127,8 +127,8 @@ aside { .row > aside { margin: 0.5rem; width: calc(100vw - 1rem); - background-color: $gray-100; - border-color: $border-color; + background-color: RGBA(var(--bs-body-color-rgb), 0.1); + border-color: var(--bs-border-color); @include border-radius($border-radius); h2:first-child { diff --git a/inst/BS5/templates/head.html b/inst/BS5/templates/head.html index 183609b52..94b79040b 100644 --- a/inst/BS5/templates/head.html +++ b/inst/BS5/templates/head.html @@ -18,25 +18,11 @@ {{/has_favicons}} -{{{headdeps}}} - - - - - - - - - - +{{#lightswitch}} + +{{/lightswitch}} - - - - - - - +{{{headdeps}}} @@ -80,10 +66,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..c71a7dff9 100644 --- a/tests/testthat/_snaps/init.md +++ b/tests/testthat/_snaps/init.md @@ -19,15 +19,37 @@ init_site(pkg) Message -- Initialising site ----------------------------------------------------------- + Copying /BS5/assets/lightswitch.js to lightswitch.js 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/_snaps/render.md b/tests/testthat/_snaps/render.md index 514d8979d..420a49623 100644 --- a/tests/testthat/_snaps/render.md +++ b/tests/testthat/_snaps/render.md @@ -61,6 +61,7 @@ footer: left:

Developed by Hadley Wickham, RStudio.

right:

Site built with pkgdown {version}.

+ lightswitch: no # check_opengraph validates inputs 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")) +}) diff --git a/tests/testthat/test-package.R b/tests/testthat/test-package.R index 5a262a6d1..ef068eb64 100644 --- a/tests/testthat/test-package.R +++ b/tests/testthat/test-package.R @@ -98,11 +98,18 @@ test_that("read_meta() errors gracefully if _pkgdown.yml failed to parse", { # lifecycle --------------------------------------------------------------- -test_that("can extract lifecycle badges", { - expect_equal( - extract_lifecycle(rd_text(lifecycle::badge("deprecated"))), - "deprecated" +test_that("can extract lifecycle badges from description", { + rd_desc <- rd_text( + paste0("\\description{", lifecycle::badge("deprecated"), "}"), + fragment = FALSE + ) + rd_param <- rd_text( + paste0("\\arguments{\\item{pkg}{", lifecycle::badge("deprecated"), "}}"), + fragment = FALSE ) + + expect_equal(extract_lifecycle(rd_desc), "deprecated") + expect_equal(extract_lifecycle(rd_param), NULL) }) test_that("malformed figures fail gracefully", {