diff --git a/DESCRIPTION b/DESCRIPTION index 6889163..d1f2c09 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -35,8 +35,10 @@ Imports: tools, usethis (>= 2.2.3), utils (>= 4.3.3), - stringr -Suggests: + stringr, + yaml +Suggests: + brand.yml, knitr, remotes, rmarkdown, diff --git a/NAMESPACE b/NAMESPACE index d679928..98dc78e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -17,4 +17,5 @@ export(update_citation) export(update_description) export(update_gsheet_metadata) export(update_metadata) +export(use_brand) importFrom(utils,head) diff --git a/NEWS.md b/NEWS.md index 3c25ecf..272355e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,21 @@ # washr (development version) +- New `use_brand()` installs the openwashdata brand (`_brand.yml` and the + logo files it references) from the central openwashdata/brand repository + into the active package, refreshes an existing copy idempotently, and + wires an existing `_pkgdown.yml` to the brand through bslib so the + package site renders with the brand fonts and colors (#109). + +- `setup_readme()` no longer writes a dead license link. The README template + carried the package name placeholder in URL encoded form, so whisker never + substituted it and every generated README linked to + `.../%7B%7B%7Bpackagename%7D%7D%7D/blob/main/LICENSE.md` (#101). + +- `update_citation()` adds `CITATION.cff` to `.Rbuildignore`, so `R CMD check` + no longer reports a non-standard file at the top level of the data package. + cffr only adds the entry itself when handed a file path, and washr hands it + a cff object (#102). + # washr 1.0.2 Patch release: bug fixes only, no new API. New maintainer: Lars Schöbitz. diff --git a/R/update_citation.R b/R/update_citation.R index bdaa7b9..22686d2 100644 --- a/R/update_citation.R +++ b/R/update_citation.R @@ -35,6 +35,10 @@ update_citation <- function(doi = NULL){ # Writes the CFF file cffr::cff_write(mod_cff) + # cffr adds CITATION.cff to .Rbuildignore only when cff_write() is given a + # path; for a cff object it returns early, so do it here (idempotent). + usethis::use_build_ignore("CITATION.cff") + # Now write a CITATION file from the CITATION.cff file # Use inst/CITATION instead (the default if not provided) path_cit <- file.path("inst/CITATION") diff --git a/R/use_brand.R b/R/use_brand.R new file mode 100644 index 0000000..75c12be --- /dev/null +++ b/R/use_brand.R @@ -0,0 +1,164 @@ +#' Install or refresh the openwashdata brand in the active package +#' +#' @description +#' `use_brand()` copies the openwashdata brand definition (`_brand.yml`) +#' and the logo files it references from the central +#' [openwashdata/brand](https://github.com/openwashdata/brand) repository +#' into the package root. Re-running the function refreshes an existing +#' copy and reports which files changed, so consuming packages stay in +#' sync with the central definition. +#' +#' Brand values are never edited locally: change them in +#' openwashdata/brand first, then refresh consumers with `use_brand()`. +#' +#' @details +#' With `pkgdown = TRUE` (the default), an existing `_pkgdown.yml` is +#' pointed at the brand through bslib (`template.bslib.brand`), so the +#' next [pkgdown::build_site()] renders the site with the brand fonts +#' and colors. The wiring rewrites `_pkgdown.yml` through the yaml +#' package, which does not preserve comments in that file. When no +#' `_pkgdown.yml` exists, the wiring is skipped with a hint to run +#' [setup_website()] first. Building the wired site requires the +#' brand.yml package (bslib asks for it at build time); it is listed in +#' Suggests and installed on demand. +#' +#' @param ref Character. Git reference (branch or tag) of +#' openwashdata/brand to copy from. Defaults to `"main"`. +#' @param pkgdown Logical. Should `_pkgdown.yml` be wired to use the +#' brand via bslib? Defaults to `TRUE`. +#' @param source Character. Advanced: an alternative source for the +#' brand files, either a local directory or a URL prefix. When `NULL` +#' (the default), the raw GitHub content of openwashdata/brand at +#' `ref` is used. Mainly useful for tests and offline work. +#' +#' @returns Invisibly, a character vector of the files written or +#' updated (empty when everything was already current). +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' # Install the brand and wire the pkgdown site +#' use_brand() +#' +#' # Refresh later, without touching _pkgdown.yml +#' use_brand(pkgdown = FALSE) +#' } +use_brand <- function(ref = "main", pkgdown = TRUE, source = NULL) { + if (is.null(source)) { + source <- paste0( + "https://raw.githubusercontent.com/openwashdata/brand/", ref + ) + } + + changed <- character(0) + + # The brand definition itself. + brand_tmp <- fetch_brand_file(source, "_brand.yml") + changed <- c(changed, place_brand_file(brand_tmp, "_brand.yml")) + + # The logo files the brand definition references. + brand <- yaml::read_yaml("_brand.yml") + for (path in brand_logo_paths(brand)) { + fetched <- fetch_brand_file(source, path) + changed <- c(changed, place_brand_file(fetched, path)) + } + + if (isTRUE(pkgdown)) { + changed <- c(changed, wire_pkgdown_brand()) + } + + if (length(changed) == 0) { + usethis::ui_done("Brand is up to date; nothing to change.") + } + invisible(changed) +} + +# Download or copy one brand file into a tempfile. +fetch_brand_file <- function(base, path) { + tmp <- tempfile() + if (dir.exists(base)) { + src <- file.path(base, path) + if (!file.exists(src)) { + usethis::ui_stop("Brand source file not found: {src}") + } + file.copy(src, tmp) + } else { + url <- paste(base, path, sep = "/") + ok <- tryCatch( + { + utils::download.file(url, tmp, quiet = TRUE, mode = "wb") + TRUE + }, + error = function(e) FALSE, + warning = function(w) FALSE + ) + if (!ok) { + usethis::ui_stop( + "Could not download {url}. Check the network connection and that openwashdata/brand carries the file on this ref." + ) + } + } + tmp +} + +# Write a fetched file to its destination when new or changed; report and +# return the destination path, or an empty vector when unchanged. +place_brand_file <- function(tmp, dest) { + destdir <- dirname(dest) + if (destdir != "." && !dir.exists(destdir)) { + dir.create(destdir, recursive = TRUE) + } + status <- if (!file.exists(dest)) { + "written" + } else if (identical( + unname(tools::md5sum(tmp)), unname(tools::md5sum(dest)) + )) { + "unchanged" + } else { + "updated" + } + if (status == "unchanged") { + return(character(0)) + } + file.copy(tmp, dest, overwrite = TRUE) + usethis::ui_done("{usethis::ui_path(dest)} {status}.") + dest +} + +# The logo paths a brand definition references: the named images plus any +# size entries that are direct paths rather than image names. +brand_logo_paths <- function(brand) { + logo <- brand$logo + if (is.null(logo)) { + return(character(0)) + } + images <- unlist(logo$images, use.names = FALSE) + sizes <- unlist(logo[setdiff(names(logo), "images")], use.names = FALSE) + direct <- setdiff(sizes, names(logo$images)) + unique(c(images, direct)) +} + +# Point an existing _pkgdown.yml at the brand through bslib. Returns the +# config path when it changed, or an empty vector. +wire_pkgdown_brand <- function() { + configpath <- "_pkgdown.yml" + if (!file.exists(configpath)) { + usethis::ui_info( + "No _pkgdown.yml found; skipping the pkgdown wiring. Run washr::setup_website() first, then use_brand() again." + ) + return(character(0)) + } + config <- yaml::read_yaml(configpath) + if (identical(config$template$bslib$brand, "_brand.yml")) { + return(character(0)) + } + config$template$bslib$brand <- "_brand.yml" + if (is.null(config$template$bootstrap)) { + config$template$bootstrap <- 5 + } + yaml::write_yaml(config, configpath) + usethis::ui_done("{usethis::ui_path(configpath)} wired to the brand via bslib.") + usethis::ui_info("Rebuild the site with pkgdown::build_site() to apply the brand.") + configpath +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 9bc7660..3828995 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -37,3 +37,4 @@ reference: contents: - setup_readme - setup_website + - use_brand diff --git a/dev/metadata-2026-08/decision-canonical-sources.md b/dev/metadata-2026-08/decision-canonical-sources.md new file mode 100644 index 0000000..cb5627b --- /dev/null +++ b/dev/metadata-2026-08/decision-canonical-sources.md @@ -0,0 +1,60 @@ +# Metadata design decision: canonical sources and field mappings + +Owner: Lars Schöbitz. Started 2026-08-19, agreed 2026-08-20, ahead of the 2026-09-16 due date (#67). Resolves #47. Governs #68, #69, #70, #71, #87. Binding. + +## Decision + +Three canonical sources hold every metadata fact. Everything else is generated from them, never hand-edited. + +1. **DESCRIPTION**: package name, title, description, license, version, date, authors (Authors@R), repository URL, keywords (see below). +2. **data-raw/dictionary.csv**: datasets and variables (`directory, file_name, variable_name, variable_type, description`). +3. **CITATION.cff**: citation string and DOI. Itself generated from DESCRIPTION by `update_citation()`; the DOI is the only fact entered there and only via the `doi` argument. + +`update_metadata()` auto-populates every derivable field in the four dataspice files plus the JSON-LD, and reports the fields that remain blank. The hand-typed creator registry goes away: creators derive from Authors@R. + +The staging layer keeps dataspice's file format and drops the dataspice package. `update_metadata()` scaffolds and writes the four CSVs itself, and dataspice leaves Imports. Retiring the CSVs entirely and generating the JSON-LD straight from the canonical sources stays open as a v1.2.0 question, to revisit once the consolidated `update_metadata()` has seen use. + +## Field mappings + +**biblio.csv** (dataspice schema): + +| Field | Source | +|---|---| +| title, description, license | DESCRIPTION (current behavior, kept) | +| datePublished | DESCRIPTION Date | +| citation | DOI from CITATION.cff when present | +| keywords | DESCRIPTION `X-schema.org-keywords` (see below) | +| funder | org default, becomes a config value under #81 | +| geographicDescription, bounding coords, wktString, startDate, endDate | manual, reported blank | + +**access.csv**: one row per dataset per distribution (csv, xlsx). `fileName`/`name` from dictionary `file_name`. `contentUrl` built from the repository URL in DESCRIPTION; the current code assumes the repo is named after the dataset file, which is wrong for any package whose dataset name differs from the repo name. `encodingFormat` becomes a MIME type (`text/csv`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`). + +**attributes.csv**: a projection of the dictionary, not a verbatim copy: `fileName <- file_name`, `variableName <- variable_name`, `description <- description`, `unitText` manual, reported blank. `directory` and `variable_type` stay dictionary-only; they have no dataspice home. The current copy of all five dictionary columns does not conform and breaks dataspice tooling. + +**creators.csv**: derived from Authors@R, roles aut and cre. `name` from given and family, `email` from person, `id` from the ORCID comment when present, `affiliation` from an affiliation comment when present, else the org default. `add_creator()` is removed. + +## Keywords: one canonical home + +Keywords live in DESCRIPTION as `X-schema.org-keywords` (comma-separated). From there they flow to CITATION.cff (verified 2026-08-20: cffr 1.4.1 reads the field into the CFF keywords array; satisfies the advisory keywords in openwashdata/pkgreview#35), to biblio.csv, and to the JSON-LD keywords array. No other file accepts hand-entered keywords. Preservation mechanics across regeneration are #73's scope. + +## Artifacts and what each is for + +- **data/metadata/*.csv**: machine-readable staging, input to the JSON-LD. Repo-only (Rbuildignored), never shipped. +- **CITATION.cff and inst/CITATION**: citation for humans, GitHub, Zenodo, and R's `citation()`. +- **JSON-LD (schema.org/Dataset)**: search-engine discoverability. It has value only when embedded in a crawlable page, so it belongs to the pkgdown site pipeline, not the tarball: generated next to its sources and embedded in the site (implementation in #70 and #87), no longer written to `inst/extdata/`. + +dataspice conformance means the four CSVs use dataspice's exact column schemas with one row per unit (file-distribution for access, variable for attributes), so dataspice tooling stays usable for anyone who wants it. It does not mean using dataspice's interactive editors, and it does not keep the dataspice package as a dependency; conformance is to the file format only. + +## Consequences + +- **#68**: `update_metadata()` becomes the one call: scaffolds missing files without prompting, populates all mappings above, regenerates the JSON-LD, reports blanks (geographic, temporal, unitText). Idempotent. It writes the dataspice-format files itself, without the dataspice package. +- **#69**: `update_gsheet_metadata()` is removed; the Google Sheet is not a canonical source. +- **#70**: `generate_jsonld()` is rewritten: `@context` https://schema.org, name/description/license/version/datePublished from DESCRIPTION (no `lubridate::today()`), creator array from Authors@R, contactPoint from the maintainer, distribution rows from access.csv with MIME types. +- **#71**: `update_metadata()` stays exported alongside `update_citation()`; the helpers (`update_biblio()`, `update_access()`, `update_attributes()`, `add_metadata()`, `add_creator()`, `generate_jsonld()`) go internal or are absorbed. +- **#87**: the dataspice CSVs stay as the staging layer under `data/metadata/`; the JSON-LD leaves `inst/extdata/`. +- **#72**: the metadata decisions remove three Imports (dataspice here, googlesheets4 via #69, lubridate via #70), taking Imports from 16 to 13 before the core cuts. +- **#47**: closed by this document; `add_metadata()` is consolidated and auto-populated, not deleted in isolation. + +## Out of scope + +Zenodo automation (#56, v1.2.0), the org website catalog, any new external service, and the mechanics of org configuration (#81) beyond naming funder and publisher as future config values. diff --git a/inst/templates/README.Rmd b/inst/templates/README.Rmd index 7d2b61d..5cbb907 100644 --- a/inst/templates/README.Rmd +++ b/inst/templates/README.Rmd @@ -121,7 +121,7 @@ library({{{packagename}}}) ## License Data are available as -[CC-BY](https://github.com/openwashdata/%7B%7B%7Bpackagename%7D%7D%7D/blob/main/LICENSE.md). +[CC-BY](https://github.com/openwashdata/{{{packagename}}}/blob/main/LICENSE.md). ## Citation diff --git a/man/use_brand.Rd b/man/use_brand.Rd new file mode 100644 index 0000000..b71a827 --- /dev/null +++ b/man/use_brand.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_brand.R +\name{use_brand} +\alias{use_brand} +\title{Install or refresh the openwashdata brand in the active package} +\usage{ +use_brand(ref = "main", pkgdown = TRUE, source = NULL) +} +\arguments{ +\item{ref}{Character. Git reference (branch or tag) of +openwashdata/brand to copy from. Defaults to \code{"main"}.} + +\item{pkgdown}{Logical. Should \verb{_pkgdown.yml} be wired to use the +brand via bslib? Defaults to \code{TRUE}.} + +\item{source}{Character. Advanced: an alternative source for the +brand files, either a local directory or a URL prefix. When \code{NULL} +(the default), the raw GitHub content of openwashdata/brand at +\code{ref} is used. Mainly useful for tests and offline work.} +} +\value{ +Invisibly, a character vector of the files written or +updated (empty when everything was already current). +} +\description{ +\code{use_brand()} copies the openwashdata brand definition (\verb{_brand.yml}) +and the logo files it references from the central +\href{https://github.com/openwashdata/brand}{openwashdata/brand} repository +into the package root. Re-running the function refreshes an existing +copy and reports which files changed, so consuming packages stay in +sync with the central definition. + +Brand values are never edited locally: change them in +openwashdata/brand first, then refresh consumers with \code{use_brand()}. +} +\details{ +With \code{pkgdown = TRUE} (the default), an existing \verb{_pkgdown.yml} is +pointed at the brand through bslib (\code{template.bslib.brand}), so the +next \code{\link[pkgdown:build_site]{pkgdown::build_site()}} renders the site with the brand fonts +and colors. The wiring rewrites \verb{_pkgdown.yml} through the yaml +package, which does not preserve comments in that file. When no +\verb{_pkgdown.yml} exists, the wiring is skipped with a hint to run +\code{\link[=setup_website]{setup_website()}} first. Building the wired site requires the +brand.yml package (bslib asks for it at build time); it is listed in +Suggests and installed on demand. +} +\examples{ +\dontrun{ +# Install the brand and wire the pkgdown site +use_brand() + +# Refresh later, without touching _pkgdown.yml +use_brand(pkgdown = FALSE) +} +} diff --git a/tests/testthat/test_setup_readme.R b/tests/testthat/test_setup_readme.R index f97abfb..cfbd486 100644 --- a/tests/testthat/test_setup_readme.R +++ b/tests/testthat/test_setup_readme.R @@ -34,3 +34,17 @@ test_that("setup_readme(force = TRUE) overwrites an existing README.Rmd", { expect_no_error(setup_readme(force = TRUE)) expect_false(identical(readLines("README.Rmd"), "# OLD README")) }) + +test_that("setup_readme() substitutes the package name in the license link (#101)", { + create_local_package() + rlang::local_interactive(FALSE) + d1 <- data.frame(id = 1:3, name = c("A", "B", "C")) + usethis::use_data(d1) + setup_readme() + license <- grep("LICENSE.md", readLines("README.Rmd"), fixed = TRUE, value = TRUE) + expect_length(license, 1) + expect_false(grepl("%7B", license, fixed = TRUE)) + expect_match(license, + paste0("openwashdata/", desc::desc_get("Package"), "/blob/main/LICENSE.md"), + fixed = TRUE) +}) diff --git a/tests/testthat/test_update_citation.R b/tests/testthat/test_update_citation.R index 8088b00..1efcccd 100644 --- a/tests/testthat/test_update_citation.R +++ b/tests/testthat/test_update_citation.R @@ -31,6 +31,17 @@ test_that("update_citation() leaves no .bk1 backup files behind (#60)", { expect_length(list.files(".", pattern = "\\.bk[0-9]+$", recursive = TRUE), 0) }) +test_that("update_citation() adds CITATION.cff to .Rbuildignore (#102)", { + create_local_package() + rlang::local_interactive(FALSE) + desc::desc_set("Date", "2026-07-23") + suppressMessages(update_citation()) + expect_true(file.exists(".Rbuildignore")) + expect_true("^CITATION\\.cff$" %in% readLines(".Rbuildignore")) + suppressMessages(update_citation()) + expect_length(grep("CITATION", readLines(".Rbuildignore"), fixed = TRUE), 1) +}) + test_that("add_citation_badge() errors clearly without the badges-end marker", { create_local_package() writeLines(c("# pkg", "no badge markers here"), "README.Rmd") diff --git a/tests/testthat/test_use_brand.R b/tests/testthat/test_use_brand.R new file mode 100644 index 0000000..94c440a --- /dev/null +++ b/tests/testthat/test_use_brand.R @@ -0,0 +1,75 @@ +options(usethis.quiet = TRUE) +# TEST use_brand --------------------------------------------------------------- + +make_brand_source <- function(dir = tempfile("brandsrc")) { + dir.create(file.path(dir, "logos"), recursive = TRUE) + writeLines( + c( + "meta:", + " name: openwashdata", + "color:", + " palette:", + " owd-purple: \"#5b195b\"", + " primary: owd-purple", + "logo:", + " images:", + " icon: logos/icon.png", + " small: icon" + ), + file.path(dir, "_brand.yml") + ) + writeBin(as.raw(1:8), file.path(dir, "logos", "icon.png")) + dir +} + +test_that("use_brand installs the brand and referenced logos", { + create_local_package() + rlang::local_interactive(FALSE) + src <- make_brand_source() + written <- use_brand(source = src, pkgdown = FALSE) + expect_true(file.exists("_brand.yml")) + expect_true(file.exists("logos/icon.png")) + expect_setequal(written, c("_brand.yml", "logos/icon.png")) +}) + +test_that("use_brand is idempotent and reports refreshed files", { + create_local_package() + rlang::local_interactive(FALSE) + src <- make_brand_source() + use_brand(source = src, pkgdown = FALSE) + second <- use_brand(source = src, pkgdown = FALSE) + expect_length(second, 0) + # A change in the central source must reach the consumer on refresh. + writeBin(as.raw(9:16), file.path(src, "logos", "icon.png")) + third <- use_brand(source = src, pkgdown = FALSE) + expect_identical(third, "logos/icon.png") +}) + +test_that("use_brand wires an existing _pkgdown.yml to the brand", { + create_local_package() + rlang::local_interactive(FALSE) + src <- make_brand_source() + writeLines(c("template:", " bootstrap: 5"), "_pkgdown.yml") + written <- use_brand(source = src) + config <- yaml::read_yaml("_pkgdown.yml") + expect_identical(config$template$bslib$brand, "_brand.yml") + expect_true("_pkgdown.yml" %in% written) + # A second run leaves the wiring untouched. + expect_false("_pkgdown.yml" %in% use_brand(source = src)) +}) + +test_that("use_brand skips the pkgdown wiring when no _pkgdown.yml exists", { + create_local_package() + rlang::local_interactive(FALSE) + src <- make_brand_source() + expect_no_error(use_brand(source = src)) + expect_false(file.exists("_pkgdown.yml")) +}) + +test_that("use_brand errors clearly on a missing source file", { + create_local_package() + rlang::local_interactive(FALSE) + src <- tempfile("emptysrc") + dir.create(src) + expect_error(use_brand(source = src, pkgdown = FALSE), "not found") +})