Skip to contents

If you are a biostatistician porting a SAS template to R, start at Track A. If you are here for testing, CI, or package infrastructure, start at Track B.


Development environment setup

Prerequisites

  • R ≥ 4.1
  • RStudio (recommended) or any R-capable editor
  • Git

Installing development dependencies

Clone the repository, then install the development dependencies declared in DESCRIPTION. The install_deps() call picks up both Imports (needed at runtime) and Suggests (needed to run tests and build vignettes), so your local environment matches what CI sees.

# Clone (first time only)
# git clone https://github.com/ehrlinger/hvtiPlotR.git

# Install devtools if needed
install.packages("devtools")

# Install all Imports + Suggests from DESCRIPTION
devtools::install_deps(dependencies = TRUE)

The development workflow loop

These four calls cover the full cycle for any code change: reload, redocument, test, then check. You will run load_all() and test() constantly; document() whenever you edit roxygen comments; check() before opening a PR.

# Load the package into the current session without installing
devtools::load_all()

# Regenerate NAMESPACE and .Rd files from roxygen comments
devtools::document()

# Run all tests
devtools::test()

# Full R CMD CHECK (must pass before merging)
devtools::check()

# Build vignettes only
devtools::build_vignettes()

devtools::load_all() re-sources all R/*.R files and makes every exported function available immediately, much faster than install.packages() during development.


Track A: Porting a SAS template

This track walks through adding a brand-new plot by porting an existing SAS template. We use a fictional template tp.np.bmi.avrg_curv.binary.sas as the running example, and port it the way every new plot family in the package is built (hazard_plot(), survival_difference_plot() and nnt_plot() are legacy single-call functions kept for compatibility, not a pattern to copy): a constructor, hv_bmi_curve(), that validates the data and returns an hv_data object, and a plot.hv_bmi_curve() method that turns that object into a bare ggplot. R/spaghetti-plot.R is the real file to keep open beside you; the example below follows it step for step.

Step 1: Understand the SAS template output

Before writing any R code, identify:

  1. What datasets does the template produce? Most tp.np.* templates export a predict dataset (the fitted curve) and a means dataset (binned patient-level summaries). Export these to CSV from SAS to use as your development data.

  2. What are the SAS column names? Document the mapping in the constructor’s roxygen @description block. For example:

    SAS column R column Meaning
    iv_bmi time x-axis variable (BMI)
    mean_curv estimate predicted probability
    cll_p68 lower 68 % CI lower bound
    clu_p68 upper 68 % CI upper bound
  3. Which existing R function is closest? Check _pkgdown.yml and the plot-functions vignette. You may only need to extend an existing function rather than create a new one. For this template, hv_nonparametric() already covers most of the ground, so in real life you would extend it. We build a new pair here only to show the pattern.

Step 2: Create the R source file

Create R/bmi-curve-plot.R. Filenames are kebab-case and end in -plot.R where the concept is a plot (R/spaghetti-plot.R, R/nonparametric-curve-plot.R). One concept lives in one file: the sample-data generator, the hv_<concept>() constructor, and its print and plot methods together.

The work splits in two, and the split is the point. The constructor checks the data and records which columns play which role; it draws nothing. The plot method reads that record back and draws; it never re-validates column names. A caller can print the object, inspect $data, or plot it several ways without repeating the checks.

The constructor

# File: R/bmi-curve-plot.R

#' Prepare average BMI curve data for plotting
#'
#' Validates the fitted curve output from a nonparametric analysis of a
#' binary outcome against BMI (or any continuous covariate) and returns an
#' `hv_bmi_curve` object. Call [plot.hv_bmi_curve()] on the result to draw
#' it. Ports `tp.np.bmi.avrg_curv.binary.sas`.
#'
#' SAS column mapping:
#' - `time` is `iv_bmi` (BMI on the x-axis)
#' - `estimate` is `mean_curv` (predicted probability)
#' - `lower` is `cll_p68` (68 % CI lower)
#' - `upper` is `clu_p68` (68 % CI upper)
#'
#' @param data         Data frame of fitted curve output, one row per x value.
#' @param x_col        Name of the x-axis column. Default `"time"`.
#' @param estimate_col Name of the predicted value column. Default `"estimate"`.
#' @param lower_col    Name of the lower CI column, or `NULL` for no ribbon.
#'   Default `NULL`.
#' @param upper_col    Name of the upper CI column, or `NULL`. Default `NULL`.
#'
#' @return An object of class `c("hv_bmi_curve", "hv_data")`; call `plot()`
#'   on it to draw the figure. The list contains:
#'   - `$data`: the validated input data frame.
#'   - `$meta`: named list of the column names above, plus `n_points` and
#'     `n_missing`.
#'   - `$tables`: empty list.
#'
#' @seealso [plot.hv_bmi_curve()] to draw the figure,
#'   [sample_bmi_curve_data()] for example data,
#'   [theme_hv_manuscript()] for the publication theme.
#'
#' @references SAS template: `tp.np.bmi.avrg_curv.binary.sas`.
#'
#' @family BMI curve
#'
#' @examples
#' dat <- sample_bmi_curve_data(n = 500)
#' bc  <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
#' bc  # prints the column mapping
#'
#' @export
hv_bmi_curve <- function(data,
                         x_col        = "time",
                         estimate_col = "estimate",
                         lower_col    = NULL,
                         upper_col    = NULL) {
  .check_df(data)
  .check_cols(data, c(x_col, estimate_col, lower_col, upper_col))
  incomplete <- .count_incomplete(data, c(x_col, estimate_col))

  new_hv_data(
    data = as.data.frame(data),
    meta = list(
      x_col        = x_col,
      estimate_col = estimate_col,
      lower_col    = lower_col,
      upper_col    = upper_col,
      n_points     = nrow(data),
      n_missing    = incomplete$n_missing
    ),
    tables   = list(),
    subclass = "hv_bmi_curve"
  )
}

new_hv_data() in R/hvti-data.R is the only way to build the return value. It guarantees the three slots every hv_data object carries ($data, $meta, $tables) and sets the class to c("hv_bmi_curve", "hv_data"), so the base methods in that file act as the fallback for anything your subclass does not define. .check_df(), .check_cols() and .count_incomplete() live in R/validators.R; use them rather than writing your own stop() calls, so the error messages match the rest of the package.

The print and plot methods

#' Print an hv_bmi_curve object
#'
#' @param x   An `hv_bmi_curve` object from [hv_bmi_curve()].
#' @param ... Ignored.
#' @return `x`, invisibly.
#' @export
print.hv_bmi_curve <- function(x, ...) {
  m <- x$meta
  cat("<hv_bmi_curve>\n")
  cat(sprintf("  N points    : %d\n", m$n_points))
  cat(sprintf("  x / estimate: %s / %s\n", m$x_col, m$estimate_col))
  if (!is.null(m$lower_col))
    cat(sprintf("  CI columns  : %s / %s\n", m$lower_col, m$upper_col))
  invisible(x)
}

#' Plot an hv_bmi_curve object
#'
#' Draws the average curve, with a confidence ribbon when the constructor
#' was given CI columns.
#'
#' @param x          An `hv_bmi_curve` object.
#' @param line_width Width of the curve line. Default `1.0`.
#' @param alpha      Transparency of the ribbon in \eqn{[0,1]}. Default `0.2`.
#' @param ...        Ignored; present for S3 consistency.
#'
#' @return A bare [ggplot2::ggplot()] object; compose with `+` to add
#'   scales, labels, and [theme_hv_manuscript()].
#'
#' @seealso [hv_bmi_curve()] to build the data object.
#'
#' @family BMI curve
#'
#' @examples
#' dat <- sample_bmi_curve_data(n = 500)
#' bc  <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
#' plot(bc) +
#'   ggplot2::scale_y_continuous(labels = scales::percent) +
#'   ggplot2::labs(x = "BMI (kg/m2)", y = "Prevalence of AF") +
#'   theme_hv_manuscript()
#'
#' @importFrom ggplot2 ggplot aes geom_line geom_ribbon
#' @importFrom rlang .data
#' @export
plot.hv_bmi_curve <- function(x, line_width = 1.0, alpha = 0.2, ...) {
  .check_alpha(alpha)
  m <- x$meta

  p <- ggplot2::ggplot(
    x$data,
    ggplot2::aes(x = .data[[m$x_col]], y = .data[[m$estimate_col]])
  )

  if (!is.null(m$lower_col) && !is.null(m$upper_col)) {
    p <- p + ggplot2::geom_ribbon(
      ggplot2::aes(ymin = .data[[m$lower_col]], ymax = .data[[m$upper_col]]),
      alpha = alpha
    )
  }

  p + ggplot2::geom_line(linewidth = line_width)
}

The conventions these two functions follow are the ones in the conventions table in CONTRIBUTING.md:

  • Column names are strings, passed as x_col = "time", and stored in $meta for the plot method to read back. Never use enquo() or { }; they make column names opaque to the caller.
  • .data[[col]] does the tidy evaluation. It needs @importFrom rlang .data.
  • No colors or themes are applied inside either function. The caller adds scale_color_*(), labs() and theme_hv_manuscript() afterwards, as the examples show.
  • The plot method returns p, not print(p) or p + theme(...).

S3 registration comes from the plain @export tag on print.hv_bmi_curve() and plot.hv_bmi_curve(). roxygen2 recognizes the generic.class name and writes S3method(plot,hv_bmi_curve) into NAMESPACE, next to S3method(plot,hv_spaghetti). You do not need @method.

Step 3: Add a sample-data generator

Add sample_bmi_curve_data() to the top of the same file, as sample_spaghetti_data() sits at the top of R/spaghetti-plot.R. The generator should:

  • Accept n (patient count), the x range, and seed.
  • Return a data frame whose column names match the constructor defaults (time, estimate, lower, upper), not the SAS column names.
  • Produce realistic-looking data at a plausible scale.
#' Sample BMI Curve Data
#'
#' Simulates the fitted curve output from a nonparametric BMI analysis,
#' matching the column layout expected by [hv_bmi_curve()].
#'
#' @param n        Number of simulated patients (controls CI width).
#'   Default `500`.
#' @param bmi_min  Lower end of the BMI range. Default `18`.
#' @param bmi_max  Upper end of the BMI range. Default `50`.
#' @param n_points Number of points on the prediction grid. Default `200`.
#' @param seed     Random seed. Default `42`.
#'
#' @return A data frame with columns `time` (BMI grid), `estimate`,
#'   `lower`, `upper`.
#'
#' @seealso [hv_bmi_curve()]
#'
#' @examples
#' dat <- sample_bmi_curve_data(n = 300)
#' head(dat)
#'
#' @importFrom stats plogis qnorm
#' @export
sample_bmi_curve_data <- function(n        = 500,
                                  bmi_min  = 18,
                                  bmi_max  = 50,
                                  n_points = 200,
                                  seed     = 42L) {
  set.seed(seed)
  z   <- stats::qnorm(0.84)          # 68 % CI is about 1 SD
  bmi <- seq(bmi_min, bmi_max, length.out = n_points)
  est <- stats::plogis(-2 + 0.05 * (bmi - 30))
  se  <- sqrt(est * (1 - est) / (n * 0.02))

  data.frame(
    time     = bmi,
    estimate = est,
    lower    = pmax(est - z * se, 0),
    upper    = pmin(est + z * se, 1)
  )
}

Step 4: Write roxygen documentation

Roxygen markdown is enabled in this package (Roxygen: list(markdown = TRUE) in DESCRIPTION), so backticks and [fn()] links render as written. Each exported function needs:

Tag Constructor plot method Notes
Description Yes Yes Paragraph after the title, or @description; names the SAS template and column mapping
@param Yes Yes One per argument; include the default
@return Yes Yes Constructor: the class and its three slots. Method: a bare ggplot
@seealso Yes Yes Link the pair to each other and to the sample_*() generator
@references Yes No The exact SAS template filename(s)
@family Yes Yes Same family name on both, so their help pages cross-link
@examples Yes Yes Must run; use \donttest{} for slow examples
@importFrom As needed As needed Declare every function used from other packages
@export Yes Yes On the method it also registers the S3 method

Then regenerate NAMESPACE and man/:

devtools::document()

Check that NAMESPACE now carries export(hv_bmi_curve), export(sample_bmi_curve_data), S3method(plot,hv_bmi_curve) and S3method(print,hv_bmi_curve). Commit NAMESPACE and man/ with the source change.

Step 5: Register in _pkgdown.yml

The reference: index is explicit, and pkgdown errors on an exported topic missing from it. List the constructor, both methods and the generator, in the same order the existing sections use:

- title: "Nonparametric Covariate Curves"
  desc: >
    Average curves plotted against a continuous covariate (BMI, age, etc.)
    rather than against time. Ports `tp.np.bmi.avrg_curv.binary.sas`.
  contents:
  - hv_bmi_curve
  - plot.hv_bmi_curve
  - print.hv_bmi_curve
  - sample_bmi_curve_data

If an existing section fits, add the four lines there instead of starting a new one.

Step 6: Add a worked example to the plot-functions vignette

Open vignettes/plot-functions.qmd and add a new top-level section before the “Draft Footnotes” section. Show both steps, so a reader sees the object before the figure:

# BMI Curve Plot

`hv_bmi_curve()` prepares a fitted nonparametric average curve of a binary
outcome against BMI; `plot()` draws it ...


::: {.cell}

```{.r .cell-code}
dat <- sample_bmi_curve_data(n = 500)
bc  <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
bc
plot(bc) + ggplot2::labs(x = "BMI (kg/m2)", y = "Prevalence of AF") +
  theme_hv_manuscript()
```
:::

Step 7: Add a row to the SAS migration guide

In vignettes/sas-migration-guide.qmd, add a row to the template lookup table. The third column names the constructor:

| `tp.np.bmi.avrg_curv.binary.sas` | np | `hv_bmi_curve()` | [BMI curve](#np-bmi) |

Then add the corresponding section with a runnable example further down in the # Nonparametric temporal trends family.

Step 8: Write tests

Create tests/testthat/test_bmi_curve_plot.R. Test files use an underscore, test_*.R, and the name follows the source file.

A plot test has to prove the plot carries data. A ggplot whose every layer holds zero rows still has class "ggplot" and still renders an empty panel, so expect_s3_class(p, "ggplot") on its own is a smoke test, not coverage. tests/testthat/helper-plot-data.R provides expect_plot_has_data(), which runs ggplot2::ggplot_build() and fails when a data layer is empty. testthat loads helper-*.R files before the tests, so the helper is available without a source() call. Its arguments tighten the check:

  • min_rows: every data layer must hold at least this many rows.
  • geoms: each geom class named here must appear in the plot, for example "GeomRibbon".
  • min_groups: at least one data layer must split into this many groups, which catches a stratified plot that collapsed to one line.

Reference lines (GeomHline, GeomVline, GeomAbline) do not count as data layers, though each must still draw at least one row.

library(testthat)
library(hvtiPlotR)

dat <- sample_bmi_curve_data(n = 100, n_points = 50, seed = 1L)

test_that("sample_bmi_curve_data returns the constructor's default columns", {
  expect_s3_class(dat, "data.frame")
  expect_named(dat, c("time", "estimate", "lower", "upper"))
  expect_equal(nrow(dat), 50)
})

test_that("hv_bmi_curve returns an hv_data object with the column mapping", {
  bc <- hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")
  expect_s3_class(bc, c("hv_bmi_curve", "hv_data"))
  expect_equal(bc$meta$estimate_col, "estimate")
  expect_identical(bc$tables, list())
})

test_that("hv_bmi_curve errors on a missing column", {
  expect_error(hv_bmi_curve(dat, x_col = "no_such_col"), "column")
})

test_that("plot.hv_bmi_curve draws the curve from every data row", {
  p <- plot(hv_bmi_curve(dat))
  expect_plot_has_data(p, min_rows = nrow(dat), geoms = "GeomLine")
})

test_that("plot.hv_bmi_curve adds a ribbon carrying data when CI columns are given", {
  p <- plot(hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper"))
  expect_plot_has_data(p, min_rows = nrow(dat),
                       geoms = c("GeomRibbon", "GeomLine"))
})

test_that("print.hv_bmi_curve output is stable", {
  expect_snapshot(print(hv_bmi_curve(dat, lower_col = "lower", upper_col = "upper")))
})

At minimum, a new plot needs these tests:

Test What to check
Sample data shape Class, column names, and number of rows
Constructor object expect_s3_class(obj, "hv_data") and the $meta entries the plot method reads
Error on bad input expect_error(hv_<concept>(dat, x_col = "no_such_col"))
Plot carries data expect_plot_has_data(plot(obj), min_rows = ...); a bare class check does not count
Optional layers expect_plot_has_data(..., geoms = "GeomRibbon") for each layer an argument switches on

The package uses testthat edition 3. The first devtools::test() run writes the expect_snapshot() baseline to tests/testthat/_snaps/bmi_curve_plot.md; commit that file with the test. When a later change alters the output on purpose, review and accept the new snapshot rather than deleting the file:

devtools::test(filter = "bmi_curve")
testthat::snapshot_review()   # inspect the diff
testthat::snapshot_accept()   # accept the intended change

Step 9: Update NEWS.md

Add a bullet under the # hvtiPlotR (unreleased) heading at the top of NEWS.md, adding that heading if it is not already there:

* Added `hv_bmi_curve()`, its `plot()` and `print()` methods, and
  `sample_bmi_curve_data()`: a nonparametric average curve of a binary
  outcome against a continuous covariate (BMI).
  Ports `tp.np.bmi.avrg_curv.binary.sas`.

Step 10: Final checklist before opening a PR

Run these commands in order before pushing your branch. All must complete cleanly: every test passing, and zero errors, zero warnings and zero notes from check(). Then open a pull request against main on GitHub.

devtools::document()   # regenerate NAMESPACE + .Rd without errors
devtools::test()       # all tests pass
lintr::lint_package()  # zero lints; CI fails on any
devtools::check()      # 0 errors, 0 warnings, 0 notes

Track B: Package infrastructure

Package structure overview

The tree below shows where each piece of the package lives. The two directories you will touch most are R/ (one file per plot family) and tests/testthat/ (one test file per source file). Everything under man/ and NAMESPACE is auto-generated; do not edit those by hand.

hvtiPlotR/
├── R/                    # Source: one file per plot family
├── man/                  # Auto-generated .Rd files — do not edit manually
├── tests/
│   └── testthat/         # One test_*.R per source file
├── vignettes/            # Quarto (.qmd) vignettes
├── inst/                 # Bundled files (extdata/, incl. hv_ppt_template.pptx)
├── DESCRIPTION           # Package metadata and dependency declarations
├── NAMESPACE             # Auto-generated — do not edit manually
├── _pkgdown.yml          # Website reference and articles layout
└── NEWS.md               # User-visible changelog

NAMESPACE and man/*.Rd are both auto-generated by devtools::document() from the roxygen comments in R/*.R. Never edit these files by hand.

Adding a dependency

  • Runtime dependency (used inside a plot function): add to Imports in DESCRIPTION and declare with @importFrom pkg fn in the roxygen block.
  • Vignette/test only: add to Suggests. Wrap any code that uses it in if (requireNamespace("pkg", quietly = TRUE)) { ... } or use #| eval: false in the vignette chunk.
# Check before adding — is it already available in base R or an existing Import?
# Keep Imports lean; every new dependency adds installation weight.

Code style and conventions

File and function naming

Item Convention Example
Source files kebab-case.R bmi-curve-plot.R
Constructors hv_<concept>() hv_bmi_curve()
Plot methods plot.hv_<concept>() plot.hv_bmi_curve()
Sample generators sample_ prefix sample_bmi_curve_data()
Internal helpers . prefix .bmi_compute_ci()

Internal helpers (prefixed with .) should have @keywords internal and no @export, so they will not appear in NAMESPACE or generate .Rd files.

The bare-ggplot pattern

Every plot.hv_<concept>() method follows five rules:

  1. Draw model-output data held in the hv_data object (not raw patient data).
  2. Take every column reference from a string argument (x_col =, etc.) stored in $meta.
  3. Use .data[[col]] for tidy evaluation inside aes().
  4. Return an unstyled ggplot: no scale_*(), no labs(), no theme.
  5. Not call print() or invisible().

Return the bare ggplot and callers can layer scales, labels, and a theme on top with the + composition grammar covered in vignettes/plot-decorators.qmd.

Tidy evaluation

We pass column names as strings (e.g., x_col = "time") and reference them inside aes() with .data[[col]]. This keeps the caller’s interface explicit and avoids the non-standard evaluation pitfalls that come with enquo() or bare symbols. The .data pronoun must be declared in the roxygen block with @importFrom rlang .data.

# Correct — column name is a string; .data masks the data frame
ggplot2::aes(x = .data[[x_col]], y = .data[[estimate_col]])

# Wrong — bare symbol; fails when x_col is a variable
ggplot2::aes(x = x_col, y = estimate_col)

# Wrong — enquo; column name is opaque to the caller
ggplot2::aes(x = !!rlang::enquo(x_col))

Always declare .data in the roxygen block:

#' @importFrom rlang .data

Testing

Test file layout

Each source file R/my-plot.R should have a corresponding tests/testthat/test_my_plot.R. The minimum set of tests for a new plot is the table in Step 8 of Track A; the one that matters most is a data-carrying assertion. expect_plot_has_data() in tests/testthat/helper-plot-data.R builds the plot with ggplot_build() and fails when a layer holds no rows, which expect_s3_class(p, "ggplot") cannot catch.

Snapshot tests

The tests/testthat/_snaps/ directory stores snapshot outputs for expect_snapshot() tests. To update snapshots after an intentional change:

testthat::snapshot_review()   # review diffs interactively
testthat::snapshot_accept()   # accept all pending diffs

Running checks

Use devtools::test() for fast, interactive feedback during development. Switch to devtools::check() (or the more verbose rcmdcheck::rcmdcheck()) when you want the full R CMD CHECK sweep, including example execution and vignette builds.

devtools::test()               # run all tests
devtools::test(filter = "bmi") # run tests matching "bmi"
devtools::check()              # full R CMD CHECK
rcmdcheck::rcmdcheck(          # more detailed output
  args = "--no-manual",
  error_on = "warning"
)

What R CMD CHECK validates

  • All examples in @examples blocks run without error.
  • All exported functions have documentation.
  • NAMESPACE matches the actual exports.
  • No undefined global variables (lintr / R CMD CHECK NOTE).
  • Vignette chunks marked eval: true run successfully.

Vignette conventions

Vignettes live in vignettes/ as .qmd files; Quarto builds them (VignetteBuilder: quarto). A few things to keep in mind:

  • All code chunks that read files, write files, or access a network must have #| eval: false.
  • Use here::here() for file paths in eval-false chunks (not hard-coded absolute paths).
  • Use system.file("extdata", "hv_ppt_template.pptx", package = "hvtiPlotR") for the bundled PPT template, never a hard-coded path.
  • After adding a new vignette, register it in _pkgdown.yml under articles.

Releasing a new version

  1. Update the version in DESCRIPTION (follow semantic versioning: MAJOR.MINOR.PATCH).
  2. Rename the # hvtiPlotR (unreleased) heading in NEWS.md to # hvtiPlotR X.Y.Z.
  3. Run devtools::check(); you need zero errors, zero warnings, and zero notes before tagging.
  4. Tag the release commit: git tag -a vX.Y.Z -m "Release X.Y.Z".
  5. Push the tag: git push origin vX.Y.Z.
  6. The pkgdown GitHub Action picks up the tag and rebuilds the documentation site.

Session info

R version 4.6.1 (2026-06-24)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.5 LTS

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8
 [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8
 [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C
[10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C

time zone: UTC
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base

loaded via a namespace (and not attached):
 [1] compiler_4.6.1  fastmap_1.2.0   cli_3.6.6       tools_4.6.1
 [5] htmltools_0.5.9 otel_0.2.0      yaml_2.3.12     rmarkdown_2.32
 [9] knitr_1.52      jsonlite_2.0.0  xfun_0.61       digest_0.6.39
[13] rlang_1.3.0     evaluate_1.0.5