Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ Imports:
tools,
usethis (>= 2.2.3),
utils (>= 4.3.3),
stringr
Suggests:
stringr,
yaml
Suggests:
brand.yml,
knitr,
remotes,
rmarkdown,
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export(update_citation)
export(update_description)
export(update_gsheet_metadata)
export(update_metadata)
export(use_brand)
importFrom(utils,head)
16 changes: 16 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions R/update_citation.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
164 changes: 164 additions & 0 deletions R/use_brand.R
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ reference:
contents:
- setup_readme
- setup_website
- use_brand
60 changes: 60 additions & 0 deletions dev/metadata-2026-08/decision-canonical-sources.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion inst/templates/README.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions man/use_brand.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions tests/testthat/test_setup_readme.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
11 changes: 11 additions & 0 deletions tests/testthat/test_update_citation.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading