Skip to content

Commit

Permalink
close #24: add preliminary support for building the full package docu…
Browse files Browse the repository at this point in the history
…mentation as a single-file book

also close #22
  • Loading branch information
yihui committed Oct 1, 2024
1 parent a6caa3f commit f442634
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 1 deletion.
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: litedown
Type: Package
Title: A Lightweight Version of R Markdown
Version: 0.2.3
Version: 0.2.4
Authors@R: c(
person("Yihui", "Xie", role = c("aut", "cre"), email = "[email protected]", comment = c(ORCID = "0000-0003-0645-5666")),
person()
Expand Down
4 changes: 4 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export(html_format)
export(latex_format)
export(mark)
export(markdown_options)
export(pkg_citation)
export(pkg_desc)
export(pkg_manual)
export(pkg_news)
export(reactor)
export(roam)
export(sieve)
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# CHANGES IN litedown VERSION 0.3

- Added helper functions `pkg_desc()`, `pkg_news()`, `pkg_citation()`, and `pkg_manual()` to get various package information for building the full package documentation as a single-file book (thanks, @jangorecki @llrs #24, @TimTaylor #22).

- Added back/forward/refresh/print buttons to the toolbar in the `litedown::roam()` preview interface.

- Set `options(bitmapType = 'cairo')` in `fuse()` if `capabilities('cairo')` is TRUE, which will generate smaller bitmap plot files (e.g., `.png`) than using `quartz` or `Xlib`, and is also a safer option for `fuse()` to be executed in parallel (rstudio/rmarkdown#2561).
Expand Down
183 changes: 183 additions & 0 deletions R/package.R
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,186 @@ vig_filter = function(ifile, encoding) {
})
structure(split_lines(unlist(res)), control = '-H -t')
}

#' Get the package description, news, citation, and manual pages
#'
#' Helper functions to retrieve various types of package information that can be
#' put together as the full package documentation like a \pkg{pkgdown} website.
#' These functions can be called inside any R Markdown document.
#' @param name The package name (by default, it is automatically detected from
#' the `DESCRIPTION` file if it exists in the current working directory or
#' upper-level directories).
#' @return A character vector (HTML or Markdown) that will be printed as is
#' inside a code chunk of an R Markdown document.
#'
#' `pkg_desc()` returns an HTML table containing the package metadata.
#' @export
#' @examples
#' litedown::pkg_desc()
#' litedown::pkg_news()
#' litedown::pkg_citation()
#' litedown::pkg_manual()
pkg_desc = function(name = detect_pkg()) {
d = packageDescription(name, fields = c(
'Title', 'Version', 'Description', 'Depends', 'Imports', 'Suggests',
'License', 'URL', 'BugReports', 'VignetteBuilder', 'Authors@R', 'Author'
))
# remove single quotes on words (which are unnecessary IMO)
for (i in c('Title', 'Description')) d[[i]] = sans_sq(d[[i]])
# format authors
if (is.na(d[['Author']])) d$Author = one_string(by = ',', format(
eval(xfun::parse_only(d[['Authors@R']])), include = c('given', 'family', 'role')
))
d[['Authors@R']] = NULL
# convert URLs to <a>, and escape HTML in other fields
for (i in names(d)) d[[i]] = if (!is.na(d[[i]])) {
if (i %in% c('URL', 'BugReports')) {
sans_p(commonmark::markdown_html(d[[i]], extensions = 'autolink'))
} else xfun:::escape_html(d[[i]])
}
d = unlist(d)
res = paste0(
'<table class="table-full"><tbody>\n', paste0(
'<tr>', paste0('\n<td>', names(d), '</td>'),
paste0('\n<td>', d, '</td>'), '\n</tr>', collapse = '\n'
), '\n</tbody></table>'
)
new_asis(res)
}

#' @param path Path to the `NEWS.md` file. If empty, [news()] will be called to
#' retrieve the news entries.
#' @param recent The number of recent versions to show. By default, only the
#' latest version's news entries are retrieved. To show the full news, set
#' `recent = 0`.
#' @return `pkg_news()` returns the news entries.
#' @rdname pkg_desc
#' @export
pkg_news = function(name = detect_pkg(), path = detect_news(name), recent = 1, ...) {
if (path == '') {
db = news(package = name, ...)
if (recent > 0) db = head(db, recent)
res = NULL
for (v in unique(db$Version)) {
df = db[db$Version == v, ]
res = c(
res, paste('## Changes in version', v, '{-}'), '',
if (all(df$Category == '')) paste0(df$HTML, '\n') else paste0(
'### ', df$Category, '{-}\n\n', df$HTML, '\n\n'
), ''
)
}
} else {
res = read_utf8(path)
if (recent > 0 && length(h <- grep('^# ', res)) >= 2)
res = res[h[1]:(h[1 + recent] - 1)]
# lower heading levels: # -> ##, ## -> ###, etc, and unnumber them
res = sub('^(## .+)', '#\\1 {-}', res)
res = sub('^(# .+)', '#\\1 {-}', res)
}
new_asis(res)
}

#' @return `pkg_citation()` returns the package citation in both the plain-text
#' and BibTeX formats.
#' @rdname pkg_desc
#' @export
pkg_citation = function(name = detect_pkg()) {
res = uapply(citation(name), function(x) {
x = tweak_citation(x)
unname(c(format(x, bibtex = FALSE), fenced_block(toBibtex(x), 'latex')))
})
new_asis(res)
}

# dirty hack to add year if missing, and remove header
tweak_citation = function(x) {
cls = class(x)
x = unclass(x)
attr(x[[1]], 'header') = attr(x, 'package') = NULL
if (is.null(x[[1]]$year)) x[[1]]$year = format(Sys.Date(), '%Y')
class(x) = cls
x
}

#' @return `pkg_manual()` returns all manual pages of the package in HTML.
#' @rdname pkg_desc
#' @export
pkg_manual = function(name = detect_pkg()) {
# all help pages on one HTML page
res = ''
con = textConnection('res', 'w', local = TRUE, encoding = 'UTF-8')
tools::pkg2HTML(name, hooks = list(pkg_href = function(pkg) {
path = if (pkg %in% xfun::base_pkgs()) 'r/%s/' else 'cran/%s/man/'
sprintf(paste0('https://rdrr.io/', path), pkg)
}), out = con, include_description = FALSE)
close(con)
res = gsub(" (id|class)='([^']+)'", ' \\1="\\2"', res) # ' -> "

# extract topic names and put them in the beginning (like a TOC)
env = asNamespace(name)
r1 = '^(<h2 [^>]+>)(.*?</h2>)'
r2 = '(?<=<span id="topic[+])([^"]+)(?="></span>)'
i = grepl(r1, res) & grepl(r2, res, perl = TRUE)
toc = uapply(match_full(res[i], r2), function(topics) {
fn = uapply(topics, function(x) {
if (is.function(env[[x]])) paste0(x, '()') else x # add () after function names
})
a = paste0(name, '-package')
if (!a %in% topics) a = topics[1]
sprintf('<a href="#%s"><code>%s</code></a>', a, fn)
})
fn = sub('.*<code>(.+)</code>.*', '\\1', toc)
toc = toc[order(fn)]; fn = fn[order(fn)]
g = toupper(substr(fn, 1, 1))
g[!g %in% LETTERS] = 'misc'
toc = split(toc, g) # group by first character
toc = unlist(mapply(function(x, g) {
c('<p>', sprintf('<b>-- <kbd>%s</kbd> --</b>', g), x, '</p>')
}, toc, names(toc)))

res = one_string(res)
res = gsub('<nav .*</nav>|\n+?<hr>\n+?', '', res) # remove topic nav and hr
res = gsub('.*?(<h2 .*)</main>.*', '\\1', res) # extract reference sections
res = gsub('<h3>', '<h3 class="unnumbered">', res, fixed = TRUE)
res = gsub('<code style="white-space: pre;">', '<code>', res, fixed = TRUE)
res = gsub('(<code[^>]*>)\\s+', '\\1', res)
res = gsub('\\s+(</code>)', '\\1', res)
res = gsub('&#8288;', '', res, fixed = TRUE)
res = gsub('<table>', '<table class="table-full">', res, fixed = TRUE)

# resolve links to specific man pages on https://rdrr.io
r = '/([^/]+)/(man/)?#topic[+]([^"]+)"'
al = character() # cache aliases
res = match_replace(res, r, function(x) {
x1 = gsub(r, '\\1', x)
x2 = gsub(r, '\\2', x)
x3 = gsub(r, '\\1::\\3', x)
if (any(i <- is.na(al[x3]))) lapply(unique(x1), function(pkg) {
path = system.file('help', 'aliases.rds', package = pkg)
if (!file_exists(path)) return()
db = readRDS(path)
names(db) = sprintf('%s::%s', pkg, names(db))
al[names(db)] <<- db
})
# for a topic, look up its html page name from the alias db
x4 = al[x3]
sprintf('/%s/%s%s"', x1, x2, ifelse(is.na(x4), '', paste0(x4, '.html')))
})
new_asis(c(toc, res))
}

detect_pkg = function() {
if (is.null(root <- xfun::proj_root(rules = head(xfun::root_rules, 1)))) stop(
"Cannot automatically detect the package root directory from '", getwd(), "'. ",
"You must provide the package name explicitly."
)
desc = read_utf8(file.path(root, 'DESCRIPTION'))
name = grep_sub('^Package: (.+?)\\s*$', '\\1', desc)[1]
structure(name, path = root)
}

detect_news = function(name) {
if (isTRUE(file_exists(path <- file.path(attr(name, 'path'), 'NEWS.md'))))
path else system.file('NEWS.md', package = name)
}
1 change: 1 addition & 0 deletions R/preview.R
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ file_resp = function(x, raw) {
if (ext == 'md') mark_full(x) else fuse(x, full_output, envir = globalenv())
))
} else {
# TODO: use xfun::mime_type() in v0.48
type = xfun:::guess_type(x)
if (!raw && is_text_file(ext, type) &&
!inherits(txt <- xfun::try_silent(read_utf8(x, error = TRUE)), 'try-error')) {
Expand Down
38 changes: 38 additions & 0 deletions docs/06-site.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,44 @@ use `.md` or `.R` files if you want), and additional information to be included
before/after each chapter, in which you can use some variables such as
`$input$`, which is the path of each input file.

### R package documentation

R package developers can build the full package documentation as a book. A
series of helper functions have been provided in **litedown** to get various
information about the package, such as the package description (`pkg_desc()`),
news (`pkg_news()`), citation (`pkg_citation()`), and all manual pages
(`pkg_manual()`). You can call these functions in code chunks to print out the
desired information. For example, you may put them in the appendix
(@sec-appendices):

```` md
# Appendix {.appendix}

# Package Metadata

```{r, echo=FALSE}
litedown::pkg_desc()
```

To cite the package:

```{r, echo=FALSE}
litedown::pkg_citation()
```

# News

```{r, echo=FALSE}
litedown::pkg_news(recent = 0) # show full news
```

# Manual pages

```{r, echo=FALSE}
litedown::pkg_manual()
```
````

## Websites

The `_litedown.yml` file should contain a top-level field named `site`, and you
Expand Down
4 changes: 4 additions & 0 deletions inst/resources/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ table {
th, td { padding: 5px; }
thead, tfoot, tr:nth-child(even) { background: #eee; }
}
.table-full {
width: 100%;
td { vertical-align: baseline; }
}
blockquote {
color: #666;
margin: 0;
Expand Down
53 changes: 53 additions & 0 deletions man/pkg_desc.Rd

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

0 comments on commit f442634

Please sign in to comment.