R/site.R

Defines functions tweak_options is_index reorder_input find_input yml_config fuse_book nav_menu filter_outdated set_site_options fuse_site

Documented in fuse_book fuse_site

#' Fuse R Markdown documents individually under a directory
#'
#' Run [fuse()] on R Markdown documents individually to generate a website.
#'
#' If a directory contains a config file `_litedown.yml`, which has a YAML field
#' `site`, the directory will be recognized as a site root directory. The YAML
#' field `output` will be applied to all R Markdown files (an individual R
#' Markdown file can provide its own `output` field in YAML to override the
#' global config). For example:
#'
#' ```
#' ---
#' site:
#'   rebuild: "outdated"
#'   pattern: "[.]R?md$"
#' output:
#'   litedown::html_format:
#'     meta:
#'       css: ["@default"]
#'       include_before: "[Home](/) [About](/about.html)"
#'       include_after: "© 2024 | [Edit]($input$)"
#' ---
#' ```
#'
#' The option `rebuild` determines whether to rebuild `.Rmd` files. Possible
#' values are:
#'
#' - `newfile`: Build an input file if it does not have a `.html` output file.
#'
#' - `outdated`: Rebuild an input file if the modification time of its `.html`
#' output file is newer than the input.
#' @param input The root directory of the site, or a vector of input file paths.
#' @return Output file paths (invisibly).
#' @export
fuse_site = function(input = '.') {
  info = NULL; preview = FALSE
  inputs = if (length(input) == 1 && dir.exists(input)) {
    info = proj_info('', input)
    find_input(input, TRUE, info$yaml[['site']][['pattern']])
  } else {
    info = proj_info(input[1])
    preview = is_roaming() && length(input) == 1
    input
  }
  output = with_ext(inputs, '.html')
  cfg = merge_list(list(rebuild = 'outdated'), info$yaml[['site']])
  b = cfg[['rebuild']]
  if (b == 'outdated') b = 0
  i = if (is.numeric(b)) filter_outdated(inputs, output, b) else {
    if (b == 'newfile') !file_exists(output) else TRUE
  }
  opts = yaml_field(info$yaml, 'html', c('meta', 'options'))
  opts[['meta']] = merge_list(list(
    css = c("@default", "@article", '@site'),
    js = c("@sidenotes", "@appendix", "@toc-highlight"),
    include_before = nav_menu(info), include_after = format(Sys.Date(), '© %Y')
  ), opts[['meta']])
  opts[['options']] = merge_list(
    list(embed_resources = FALSE, toc = TRUE), opts[['options']]
  )
  out = lapply(inputs[i], function(x) {
    res = if (grepl('[.]md$', x)) {
      opts = set_site_options(opts, x); on.exit(options(opts))
      mark(x, full_output)
    } else {
      Rscript_call(
        function(x, opts, set, flag, output) {
          set(opts, x, list(litedown.roaming = flag))
          litedown::fuse(x, output, envir = globalenv())
        },
        list(x, opts, set_site_options, is_roaming(), full_output),
        fail = paste('Failed to run litedown::fuse() on', x)
      )
    }
    # resolve / to relative paths
    if (!is.na(info$root)) {
      up = xfun::relative_path(info$root, dirname(x))
      if (up == '.') up = ''
      res = match_replace(res, ' (href|src)(=")/', function(z) {
        gsub('/$', up, z)
      })
    }
    if (preview) res else write_utf8(res, with_ext(x, '.html'))
  })
  if (preview) {
    if (i) out[[1]] else xfun::file_string(output)
  } else invisible(output)
}

# set global options litedown.html.[meta|options] read from _litedown.yml
set_site_options = function(opts, input, extra = NULL) {
  m = opts[['meta']]
  for (i in c('include_before', 'include_after')) {
    if (!is.character(m[[i]])) next
    tag = if (i == 'include_before') 'nav' else 'footer'
    x = mark(I(one_string(m[[i]], test = TRUE)))
    x = sprintf('<%s>%s</%s>', tag, x, tag)
    m[[i]] = sub_vars(x, list(input = I(input)))
  }
  opts[['meta']] = m
  options(c(set_names(opts, paste0('litedown.html.', names(opts))), extra))
}

filter_outdated = function(x, x2, n) {
  m1 = file.mtime(x); m2 = file.mtime(x2); is.na(m2) | m1 - m2 > n
}

# build a nav menu from filenames under root directory
nav_menu = function(info) {
  if (is.na(info$root)) return('[Home](/index.html)')
  files = find_input(info$root, FALSE, info$yaml[['site']][['pattern']])
  b = basename(files)
  x = gsub('[-_]', ' ', sans_ext(ifelse(is_index(b), 'home', b)))
  sprintf(
    '[%s](/%s)', tools::toTitleCase(x),
    if (is_roaming()) paste0(b, '?preview=1') else with_ext(b, '.html')
  )
}

#' Fuse multiple R Markdown documents to a single output file
#'
#' This is a helper function to [fuse()] `.Rmd` files and convert all their
#' Markdown output to a single output file, which is similar to
#' `bookdown::render_book()`, but one major differences is that all HTML output
#' is written to one file, instead of one HTML file per chapter.
#'
#' If the output format ([html_format()] or [latex_format()]) needs to be
#' customized, the settings should be written in the config file
#' `_litedown.yml`, e.g.,
#'
#' ```
#' ---
#' output:
#'   litedown::html_format:
#'     options:
#'       toc:
#'         depth: 4
#'   litedown::latex_format:
#'     meta:
#'       documentclass: "book"
#' ```
#'
#' In addition, you can configure the book via the `book` field, e.g.,
#'
#' ```
#' ---
#' book:
#'   new_session: true
#'   subdir: false
#'   pattern: "[.]R?md$"
#'   chapter_before: "Information before a chapter."
#'   chapter_after: "This chapter was generated from `$input$`."
#' ---
#' ```
#'
#' The option `new_session` specifies whether to render each input file in the
#' current R session or a separate new R session; `chapter_before` and
#' `chapter_after` specify text to be added to the beginning and end of each
#' file, respectively, which accepts some variables (e.g., `$input$` is the
#' current input file path).
#' @inheritParams fuse
#' @param input A directory or a vector of file paths. By default, all
#'   `.Rmd`/`.md` files under the current working directory are used as the
#'   input, except for filenames that start with `.` or `_` (e.g., `_foo.Rmd`),
#'   or `.md` files with the same base names as `.Rmd` files (e.g., `bar.md`
#'   will not be used if `bar.Rmd` exists). For a directory `input`, the file
#'   search will be recursive if `input` ends with a slash (i.e.,
#'   sub-directories will also be searched). If a file named `index.Rmd` or
#'   `index.md` exists, it will always be treated as the first input file. Input
#'   files can also be specified in the config file `_litedown.yml` (in the
#'   `input` field under `book`).
#' @return An output file path or the output content, depending on the `output`
#'   argument.
#' @export
fuse_book = function(input = '.', output = NULL, envir = parent.frame()) {
  # when input is c(dir, file1, file2, ...), we find book files under dir, but
  # only preview file1, file2, ...
  if (dir.exists(input[1])) {
    preview = input[-1]; input = input[1]
    # in roaming mode, need to store cross-refs because the book may be
    # partially rendered (in which case we can't resolve refs to other chapters)
    if (is_roaming()) {
      .env$current_book = input; on.exit(.env$current_book <- NULL, add = TRUE)
    }
  } else preview = NULL

  yaml = NULL
  # search for book files or read from config if input is a dir
  if (length(input) == 1 && dir.exists(input)) {
    yaml = yml_config(input)
    cfg = yaml[['book']]
    input = file.path(input, cfg[['input']]) %|%
      find_input(input, cfg[['subdir']] %||% grepl('/$', input), cfg[['pattern']])
  } else {
    # if input files are provided directly, read config from the dir of first file
    cfg = if (length(input)) {
      yaml = yml_config(dirname(input[1]))
      yaml[['book']]
    }
  }
  if (length(input) == 0) stop('No input was provided or found.')

  full = is_output_full(output)
  format = detect_format(output, yaml)
  output = auto_output(input[1], output, format)
  cfg = merge_list(list(
    new_session = FALSE, chapter_before = '', chapter_after = "Source: `$input$`"
  ), cfg)

  # provide a simpler way to configure timing in YAML; only env vars are
  # inherited in new R sessions, so we attach the timing path to R_LITEDOWN_TIME
  if (is.character(p <- cfg$time)) {
    # treat relative path as a path relative to the first input's cache dir
    if (xfun::is_rel_path(p))
      p = file.path(paste0(sans_ext(normalize_path(input[1])), '__cache'), p)
    vars = xfun::set_envvar(c(R_LITEDOWN_TIME = p))
    on.exit(xfun::set_envvar(vars), add = TRUE)
    if (file_exists(p)) {
      # filter out data from input files that do not belong to the book
      d = readRDS(p)
      if (!all(i <- d$source %in% input)) {
        d = d[i, ]; saveRDS(d, p)
      }
    } else xfun::dir_create(dirname(p))
  }

  res = lapply(preview %|% input, function(x) {
    out = if (grepl('[.]md$', x)) read_utf8(x) else {
      fmt = paste0('markdown:', format)  # generate intermediate markdown output
      if (cfg$new_session) {
        Rscript_call(fuse, list(x, fmt))
      } else {
        fuse(x, fmt, NULL, envir)
      }
    }
    # remove YAML in the preview mode since we only need the body
    if (length(preview)) out = xfun::yaml_body(split_lines(out))$body

    if (format != 'html') return(out)
    # add input filenames to the end for HTML output and wrap each file in a div
    info = function(cls) c(
      sprintf('::: {.chapter-%s .side .side-right}', cls),
      sub_vars(cfg[[sprintf('chapter_%s', cls)]], list(input = I(x))), ':::'
    )
    # for the first input, the fenced Divs should be inserted after YAML
    h = if (length(preview) == 0 && x == input[1]) {
      out = split_lines(out)
      if (length(i <- xfun:::locate_yaml(out)) >= 2) {
        i = seq_len(i[2]); h = out[i]; out = out[-i]
        h
      }
    }
    c(
      h, sprintf('::: {.chapter .body data-source="%s"}', x),
      info('before'), '', out, '', info('after'), ':::'
    )
  })
  tweak_options(format, yaml, list(
    body_class = '', css = c("@default", "@article", "@book"),
    js = c("@sidenotes", "@appendix", "@toc-highlight")
  ), toc = length(preview) == 0)
  fuse_output(input[1], output, unlist(res), full)
}

# read the config file _litedown.yml
yml_config = function(d) {
  if (file_exists(cfg <- file.path(d, '_litedown.yml')))
    xfun::yaml_load(read_utf8(cfg))
}

site_pattern = '[.][Rq]?md$'

# find input files under a directory
find_input = function(d, deep = grepl('/$', d), pattern = NULL) {
  if (!is.character(pattern)) pattern = site_pattern
  x = list.files(d, pattern, full.names = TRUE, recursive = deep)
  # exclude .* and _* files
  x = x[!grepl('^[_.]', basename(x))]
  # exclude readme
  x = x[tolower(basename(sans_ext(x))) != 'readme']
  # for .md files, don't include them if they have .Rmd/.qmd files
  b = sans_ext(x); i = file_ext(x) == 'md'
  x = x[!i | !(b %in% sub('[.][Rq]md$', '', x))]
  x = reorder_input(x)
  x = sub('^[.]/+', '', x)
  x
}

# move index.[Rq]md to the first
reorder_input = function(x) {
  i = is_index(x)
  index = x[i][which.min(nchar(x[i]))]  # take the shortest path
  c(index, setdiff(x, index))
}

is_index = function(x) sans_ext(basename(x)) == 'index'

# temporarily set global metadata and options (inheriting from index.Rmd)
tweak_options = function(format, yaml, meta = NULL, toc = TRUE, options = NULL) {
  nms = paste0('litedown.', format, c('.meta', '.options'))
  defaults = list(
    merge_list(
      .Options[[nms[1]]], meta, yaml_field(yaml, format, 'meta')
    ),
    merge_list(
      .Options[[nms[2]]], options,
      list(toc = toc, number_sections = TRUE, embed_resources = FALSE),
      yaml_field(yaml, format, 'options')
    )
  )
  names(defaults) = nms
  opts = options(defaults)
  xfun::exit_call(function() options(opts))
}

Try the litedown package in your browser

Any scripts or data that you put into this service are public.

litedown documentation built on Oct. 17, 2024, 1:06 a.m.