# File src/library/tools/R/news.R # Part of the R package, https://www.R-project.org # # Copyright (C) 1995-2024 The R Core Team # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # A copy of the GNU General Public License is available at # https://www.R-project.org/Licenses/ ## .build_news_db_from_R_NEWS <- ## function() ## { ## db <- readNEWS(chop = "keepAll") ## ## This currently is a list of x.y lists of x.y.z lists of ## ## categories list of entries. ## flatten <- function(e) ## cbind(rep.int(names(e), lengths(e)), ## unlist(lapply(e, ## function(s) { ## ## Also remove leading white space and ## ## trailing blank lines. ## lapply(s, ## function(e) ## sub("[[:space:]]*$", "", ## paste(sub("^ ", "", e), ## collapse = "\n"))) ## }), ## use.names = FALSE)) ## db <- lapply(Reduce(c, db), flatten) ## db <- do.call(rbind, Map(cbind, names(db), db)) ## ## Squeeze in an empty date column. ## .make_news_db(cbind(db[, 1L], NA_character_, db[, -1L]), ## logical(nrow(db))) ## } .build_news_db <- function(package, lib.loc = NULL, format = NULL, reader = NULL) { dir <- system.file(package = package, lib.loc = lib.loc) ## Or maybe use find.package()? ## ## We had planned to eventually add support for DESCRIPTION ## News/File ## News/Format ## News/Reader ## News/Reader@R ## entries. But now that there are NEWS.Rd and NEWS.md, there ## seems little point in providing format/reader support ... ## ## Look for new-style inst/NEWS.Rd installed as NEWS.Rd ## If not found, look for NEWS.md. ## If not found, look at old-style ## NEWS inst/NEWS ## installed as NEWS (and ignore ChangeLog files). nfile <- file.path(dir, "NEWS.Rd") if(file_test("-f", nfile)) return(.build_news_db_from_package_NEWS_Rd(nfile)) nfile <- file.path(dir, "NEWS.md") if(file_test("-f", nfile)) return(.build_news_db_from_package_NEWS_md(nfile)) nfile <- file.path(dir, "NEWS") if(!file_test("-f", nfile)) return(invisible()) ## Return NULL for now, no message that there is no NEWS or ## ChangeLog file. if(!is.null(format)) .NotYetUsed("format", FALSE) if(!is.null(reader)) .NotYetUsed("reader", FALSE) reader <- .news_reader_default reader(nfile) } .news_reader_default <- function(file) { .collapse <- function(s) paste(s, collapse = "\n") lines <- readLines(file, warn = FALSE) ## Re-encode if necessary. if(any(ind <- is.na(nchar(lines, allowNA = TRUE)))) { dir <- dirname(file) if(basename(dir) == "inst") dir <- dirname(file) ## This should now contain the DESCRIPTION file. encoding <- if(file.exists(dfile <- file.path(dir, "DESCRIPTION"))) .read_description(dfile)["Encoding"] else NA if(!is.na(encoding)) lines[ind] <- iconv(lines[ind], encoding, "") ## Last resort. if(anyNA(nchar(lines[ind], allowNA = TRUE))) lines[ind] <- iconv(lines[ind], "", "", sub = "byte") } ## Save what we read in case we cannot figure out the news, in which ## case we simply return one entry with the whole text. olines <- lines ## Get rid of underlines and friends. lines <- lines[!grepl("^[[:space:]]*[[:punct:]]*[[:space:]]*$", lines)] ## Determine lines containing version numbers, without being too ## liberal. re_valid_package_name <- .standard_regexps()$valid_package_name re_v <- sprintf("^([[:space:]]*(%s)|(%s))(%s).*$", paste0("CHANGES? *(IN|FOR).*VERSION *", "|", "CHANGES? *(IN|FOR|TO) *"), sprintf(paste(## TeachingDemos pomp ouch "NEW IN .*", ## HyperbolicDist nls2 proto "VERSION:? *", "%s +", ## E.g., lattice: ## Changes in lattice 0.17 "CHANGES IN %s +", ## sv* "== Changes in %s +", ## tcltk2 "== Version +", ## R2WinBUGS "update *", "v *", "", sep = "|"), re_valid_package_name, re_valid_package_name, re_valid_package_name), .standard_regexps()$valid_package_version ) ## Some people use ## $PACKAGE version $VERSION ## Let us try handling this later, or ask people to write their own ## readers. ind <- grepl(re_v, lines, ignore.case = TRUE) if(!any(ind)) return(.make_news_db(cbind(NA_character_, NA_character_, NA_character_, .collapse(olines)))) ## Could add an empty list of bad chunks (as none were found). ## Everything before the first version line is a header which will ## be dropped. if(!ind[1L]) { pos <- seq_len(which.max(ind) - 1L) lines <- lines[-pos] ind <- ind[-pos] } ## Try catching date entries at the end of version lines as well. re_d <- sprintf("^.*(%s)[[:punct:][:space:]]*$", "[[:digit:]]{4}-[[:digit:]]{2}-[[:digit:]]{2}") ## Could try to allow for non ISO date specs ... ## Version lines determine the chunks, which after the version line ## should either start with a line tag (category) or an itemize ## "bullet". chunks <- split(lines, cumsum(ind)) do_chunk <- function(chunk, header = NA_character_) { ## Process a single chunk. ## If there is no category header, the first line is the version ## line, after which the next non blank line should start with a ## line tag (category) or an itemize "bullet". if(!is.na(header)) date <- NA_character_ else { txt <- chunk[1L] header <- sub(re_v, "\\6", txt, ignore.case = TRUE) date <- if(grepl(re_d, txt, perl = TRUE)) sub(re_d, "\\1", txt, perl = TRUE) else NA_character_ } lines <- chunk[-1L] s <- .collapse(lines) if(grepl("^[[:space:]]*([o*+-])", s)) { sep <- sub("^[[:space:]]*([o*+-]).*$", "\\1", s) ire <- sprintf("^[[:space:]]*([%s])[[:space:]]+", sep) ind <- grepl(ire, lines) list(entries = sapply(split(lines, cumsum(ind)), function(s) sub(ire, "", .collapse(sub("^\t?", "", s))) ), header = header, chunk = chunk, date = date) } else { ## Categories should be non-empty starting in column 1. re_c <- "^([[:alpha:]].*)[[:space:]]*$" ind <- grepl(re_c, lines) ## If we detect neither bullet items nor categories, the ## chunk is in a different format than we can recognize. ## Return no entries, and have the finisher give the whole ## chunk and push it onto the bad chunk list. if(!any(ind)) { list(entries = character(), header = header, chunk = chunk, date = date) } else { pos <- cumsum(ind) > 0 list(entries = Map(do_chunk, split(lines[pos], cumsum(ind)[pos]), sub("[[:punct:]]*$", "", sub(re_c, "\\1", lines[ind]))), header = header, chunk = chunk, date = date) } } } out <- lapply(chunks, do_chunk) ## Now assemble pieces. reporter <- function(x) { warning(gettextf("Cannot process chunk/lines:\n%s", .collapse(paste0(" ", x))), domain = NA, call. = FALSE) NULL } finisher <- function(x) { entries <- x$entries version <- x$header date <- x$date if(is.list(entries)) { do.call(rbind, lapply(entries, function(x) { entries <- x$entries bad <- if(!length(entries)) { reporter(x$chunk) entries <- sub("^[[:space:]]*", "", .collapse(x$chunk[-1L])) TRUE } else FALSE cbind(version, date, x$header, entries, bad) })) } else { bad <- if(!length(entries)) { reporter(x$chunk) entries <- sub("^[[:space:]]*", "", .collapse(x$chunk[-1L])) TRUE } else FALSE cbind(version, date, NA_character_, entries, bad) } } out <- do.call(rbind, lapply(out, finisher)) ## Try to remove a common 'exdent' from the entries. entries <- out[, 4L] exdent <- unlist(lapply(gregexpr("\n *", entries), attr, "match.length")) exdent <- exdent[exdent > 1L] if(length(exdent)) { out[, 4L] <- gsub(sprintf("\n%s", strrep(" ", min(exdent) - 1L)), "\n", entries) } .make_news_db(out[, -5L, drop = FALSE], as.logical(out[, 5L])) } .make_news_db <- function(x, bad = NULL, classes = NULL) { ## Expect x to be a character matrix giving at least ## version date category text ## in its first 4 columns. ## Could of course check for this using ## if(!is.character(x) || ncol(x) < 4L) out <- data.frame(x, row.names = NULL, stringsAsFactors = FALSE) ## Note that we cannot do ## dimnames(out) <- list(NULL, ## c("Version", "Date", "Category", "Text")) colnames(out)[1L : 4L] <- c("Version", "Date", "Category", "Text") if(!is.null(bad)) attr(out, "bad") <- bad class(out) <- unique(c(classes, "news_db", "data.frame")) out } ## Transform NEWS.Rd Rd2txt_NEWS_in_Rd_options <- list(sectionIndent = 0L, sectionExtra = 2L, minIndent = 4L, code_quote = FALSE, underline_titles = FALSE) Rd2txt_NEWS_in_Rd <- function(f, out = "", outputEncoding = "UTF-8") { if (endsWith(f, ".rds")) f <- readRDS(f) Rd2txt(f, out, stages = c("install", "render"), outputEncoding = outputEncoding, options = Rd2txt_NEWS_in_Rd_options, macros = file.path(R.home("share"), "Rd", "macros", "system.Rd")) } Rd2HTML_NEWS_in_Rd <- function(f, out, ...) { if (endsWith(f, ".rds")) f <- readRDS(f) Rd2HTML(f, out, stages = c("install", "render"), macros = file.path(R.home("share"), "Rd", "macros", "system.Rd"), ...) } Rd2pdf_NEWS_in_Rd <- function(f, pdf_file) { if (endsWith(f, ".rds")) f <- readRDS(f) f2 <- tempfile() ## See the comments in ?texi2dvi about spaces in paths f3 <- if(grepl(" ", Sys.getenv("TMPDIR"))) file.path("/tmp", "NEWS.tex") else file.path(tempdir(), "NEWS.tex") out <- file(f3, "w") Rd2latex(f, f2, stages = c("install", "render"), outputEncoding = "UTF-8", writeEncoding = FALSE, macros = file.path(R.home("share"), "Rd", "macros", "system.Rd")) cat("\\documentclass[", Sys.getenv("R_PAPERSIZE"), "paper]{book}\n", "\\usepackage[hyper]{Rd}\n", "\\usepackage[utf8]{inputenc}\n", "\\usepackage{graphicx}\n", "\\setkeys{Gin}{width=0.7\\textwidth}\n", .file_path_to_LaTeX_graphicspath(file.path(R.home("doc"), "html")), "\n", "\\hypersetup{pdfpagemode=None,pdfstartview=FitH}\n", "\\begin{document}\n", "\\chapter*{}\\sloppy\n", "\\begin{center}\n\\huge\n", "NEWS for ", R.version$version.string, "\n", "\\end{center}\n", sep = "", file = out) writeLines(readLines(f2), out) writeLines("\\end{document}", out) close(out) od <- setwd(dirname(f3)) on.exit(setwd(od)) ## avoid broken texi2pdf scripts: this is simple LaTeX ## and emulation suffices texi2pdf("NEWS.tex", quiet = TRUE, texi2dvi = "emulation") setwd(od); on.exit() invisible(file.copy(file.path(dirname(f3), "NEWS.pdf"), pdf_file, overwrite = TRUE)) } ## Transform old-style plain text NEWS file to Rd. news2Rd <- function(file, out = stdout(), codify = FALSE) { ## For add-on packages, the given NEWS file should be in the root ## package source directory or its 'inst' subdirectory, so that we ## can use the DESCRIPTION metadata to obtain the package name and ## encoding. file <- file_path_as_absolute(file) if(file_test("-d", file)) { dir <- file dfile <- file.path(dir, "DESCRIPTION") if(!file_test("-f", dfile)) stop("DESCRIPTION file not found") file <- file.path(dir, "inst", "NEWS") if(!file_test("-f", file)) { file <- file.path(dir, "NEWS") if(!file_test("-f", file)) stop("NEWS file not found") } } else { dir <- dirname(file) dfile <- file.path(dir, "DESCRIPTION") if(!file_test("-f", dfile)) { if((basename(dir) != "inst") || !file_test("-f", dfile <- file.path(dirname(dir), "DESCRIPTION"))) stop("DESCRIPTION file not found") } } ## No longer support taking NEWS files without correponding ## DESCRIPTION file as being from R itself (PR #16556). meta <- .read_description(dfile) wto <- function(x) writeLines(x, con = out, useBytes = TRUE) cre <- "(\\W|^)(\"[[:alnum:]_.]*\"|[[:alnum:]_.:]+\\(\\))(\\W|$)" if(is.character(out)) { out <- file(out, "wt") on.exit(close(out)) } if(!isOpen(out, "wt")) { open(out, "wt") on.exit(close(out)) } ## had if(format == "R") { ## and this was } else { format == "default" : { news <- .news_reader_default(file) bad <- attr(news, "bad") if(!length(bad)) stop("No news found in given file using package default format.") if(any(bad)) { bad <- news$Text[bad] stop("Could not extract news from the following text chunks:\n", paste(sprintf("\nChunk %s:\n%s", format(seq_along(bad)), bad), collapse = "\n")) } encoding <- meta["Encoding"] package <- meta["Package"] texts <- toRd(news$Text) if(codify) texts <- gsub(cre, "\\1\\\\code{\\2}\\3", texts) ## Note that .news_reader_default re-encodes ... if(!is.na(encoding)) texts <- iconv(texts, to = encoding, sub = "byte", mark = FALSE) news$Text <- texts wto(c("\\name{NEWS}", sprintf("\\title{News for Package '%s'}", package))) if(!is.na(encoding)) wto(sprintf("\\encoding{%s}", encoding)) ## Similar to print.news_db(): vchunks <- split(news, news$Version) ## Re-order according to decreasing version. vchunks <- vchunks[order(as.numeric_version(names(vchunks)), decreasing = TRUE)] dates <- vapply(vchunks, function(v) v$Date[1L], "") if(any(ind <- !is.na(dates))) names(vchunks)[ind] <- sprintf("%s (%s)", names(vchunks)[ind], dates[ind]) vheaders <- sprintf("\\section{Changes in %s version %s}{", package, names(vchunks)) for(i in seq_along(vchunks)) { wto(vheaders[i]) vchunk <- vchunks[[i]] if(all(!is.na(category <- vchunk$Category) & nzchar(category))) { ## need to preserve order of headings. cchunks <- split(vchunk, factor(category, levels = unique(category))) cheaders <- sprintf(" \\subsection{%s}{", names(cchunks)) for(j in seq_along(cchunks)) { wto(c(cheaders[j], " \\itemize{", paste(" \\item", gsub("\n", "\n ", cchunks[[j]]$Text, fixed=TRUE)), " }", " }")) } } else { wto(c(" \\itemize{", paste(" \\item", gsub("\n", "\n ", vchunk$Text, fixed=TRUE)), " }")) } wto("}") } } } .build_news_db_from_R_NEWS_Rd <- function(file = NULL, Rfile = "NEWS.rds") { x <- if(is.null(file)) readRDS(file.path(R.home("doc"), Rfile)) else { ## Expand \Sexpr et al now because this does not happen when using ## fragments. macros <- initialRdMacros() prepare_Rd(parse_Rd(file, macros = macros), stages = "install") } db <- .extract_news_from_Rd(x) skip <- c("CHANGES in previous versions", "LATER NEWS", "OLDER NEWS") db <- db[!(db[,1L] %in% skip),,drop = FALSE] ## Squeeze in an empty date column. .make_news_db(cbind(sub("^CHANGES IN (R )?(VERSION )?", "", db[, 1L]), NA_character_, db[, 2L], Text = sub("\n*$", "", db[, 3L]), HTML = db[, 4L]), NULL, "news_db_from_Rd") } .build_news_db_from_package_NEWS_Rd <- function(file) { macros <- initialRdMacros() x <- prepare_Rd(parse_Rd(file, macros = macros, encoding = "UTF-8"), stages = "install") db <- .extract_news_from_Rd(x) ## Post-process section names to extract versions and dates. re_v <- sprintf(".*version[[:space:]]+(%s).*$", .standard_regexps()$valid_package_version) reDt <- "[[:digit:]]{4}-[[:digit:]]{2}-[[:digit:]]{2}" rEnd <- "[[:punct:][:space:]]*$" re_d1 <- sprintf(paste0("^.*(%s)", rEnd), reDt) ## or ending with '(YYYY-MM-DD, )' re_d2 <- sprintf(paste0("^.*\\((%s)[[:punct:]] .*\\)", rEnd), reDt) nms <- db[, 1L] ind <- grepl(re_v, nms, ignore.case = TRUE) if(!all(ind)) warning(gettextf("Cannot extract version info from the following section titles:\n%s", paste0(" ", unique(nms[!ind]), collapse = "\n")), domain = NA, call. = FALSE) .make_news_db(cbind(ifelse(ind, sub(re_v, "\\1", nms, ignore.case = TRUE), NA_character_), ifelse(grepl(re_d1, nms, perl = TRUE), sub(re_d1, "\\1", nms, perl = TRUE), ifelse(grepl(re_d2, nms, perl = TRUE), sub(re_d2, "\\1", nms, perl = TRUE), NA_character_)), db[, 2L], Text = sub("\n*$", "", db[, 3L]), HTML = db[, 4L]), NULL, "news_db_from_Rd") } .extract_news_from_Rd <- function(x) { get_section_names <- function(x) sapply(x, function(e) .Rd_get_text(e[[1L]])) get_item_texts <- function(x) { ## Currently, chunks should consist of a single \itemize list ## containing the news items. Notify if there is more than one ## such list, and stop if there is none. pos <- which(RdTags(x) == "\\itemize") if(!length(pos)) { stop(gettextf("Malformed NEWS.Rd file:\nChunk starting\n %s\ncontains no \\itemize.", substr(sub("^[[:space:]]*", "", .Rd_deparse(x)), 1L, 60L)), domain = NA) } else if(length(pos) > 1L) { warning(gettextf("Malformed NEWS.Rd file:\nChunk starting\n %s\ncontains more than one \\itemize.\nUsing the first one.", substr(sub("^[[:space:]]*", "", .Rd_deparse(x)), 1L, 60L)), domain = NA, call. = FALSE) pos <- pos[1L] } x <- x[[pos]] out <- file() on.exit(close(out)) Rd2txt_options <- Rd2txt_NEWS_in_Rd_options Rd2txt_options$width <- 72L ## Extract and process \item chunks: y <- split(x, cumsum(RdTags(x) == "\\item")) y <- y[names(y) != "0"] if(!length(y)) { warning(gettextf("Malformed NEWS.Rd file:\nChunk starting\n %s\ncontains no \\item.", substr(sub("^[[:space:]]*", "", .Rd_deparse(x)), 1L, 60L)), domain = NA, call. = FALSE) return(matrix(character(), 0L, 2L, dimnames = list(NULL, c("Text", "HTML")))) } do.call(rbind, lapply(y, function(e) { ## Drop \item. e <- e[-1L] ## Convert to text. Rd2txt(e, fragment = TRUE, out = out, options = Rd2txt_options) one <- paste(readLines(out, warn = FALSE), collapse = "\n") ## Need warn = FALSE to avoid warning about ## incomplete final line for e.g. 'cluster'. ## Convert to HTML. Rd2HTML(e, fragment = TRUE, out = out) two <- paste(readLines(out, warn = FALSE), collapse = "\n") cbind(Text = one, HTML = two) })) } cbind_safely <- function(u, v) cbind(rep_len(u, NROW(v)), v) x <- x[RdTags(x) == "\\section"] y <- Map(cbind_safely, get_section_names(x), lapply(x, function(e) { z <- e[[2L]] ind <- RdTags(z) == "\\subsection" if(any(ind)) { z <- z[ind] do.call(rbind, Map(cbind_safely, get_section_names(z), lapply(z, function(e) get_item_texts(e[[2L]])))) } else { cbind_safely(NA_character_, get_item_texts(z)) } })) y <- do.call(rbind, y) ## Sanitze HTML. s <- trimws(y[, "HTML"]) i <- which(startsWith(s, "

") & !endsWith(s, "

")) if(length(i)) { z <- s[i] j <- which((lengths(gregexpr("", z)) %% 2L) > 0L) if(length(j)) s[i[j]] <- paste0(z[j], "

") } y[, "HTML"] <- s y } .build_news_db_from_package_NEWS_md <- function(f) { md <- readLines(f, encoding = "UTF-8", warn = FALSE) ## Maybe complain? if(!length(md)) return() ## Handle YAML header. if(md[1L] == "---") { for(pos in seq.int(2L, length(md))) if(md[pos] == "---") break md[seq_len(pos)] <- "" } doc <- commonmark::markdown_xml(md, extensions = TRUE, sourcepos = TRUE) doc <- xml2::xml_ns_strip(xml2::read_xml(doc)) nodes <- xml2::xml_children(doc) # Need xml2::xml_root()? ## Inline for efficiency. .markdown_text <- commonmark::markdown_text .markdown_html <- commonmark::markdown_html .xml_attr <- xml2::xml_attr .xml_name <- xml2::xml_name .xml_text <- xml2::xml_text get_text_and_HTML <- function(sp) { ## Sourcepos sp already split into l1 c2 l2 c2, for legibility: l1 <- sp[1L]; c1 <- sp[2L]; l2 <- sp[3L]; c2 <- sp[4L] txt <- if(l1 < l2) { c(substring(md[l1], c1), md[seq.int(from = l1 + 1L, length.out = l2 - l1 - 1L)], substring(md[l2], 1L, c2)) } else substring(md[l1], c1, c2) c(.markdown_text(txt, width = 72L), .markdown_html(txt)) } do_vchunk <- function(nodes) { ## Get version and date from heading. version <- .xml_text(nodes[[1L]]) nodes <- nodes[-1L] if(!length(nodes)) return(rbind(c(version, "", "", ""))) ## Unlike news in Rd where we (currently) insist on all news to ## be given as items in itemize lists, for md we only split news ## in version chunks according to category. If the chunks has ## headings, we take those with the same level as the first one ## to start category chunks, and everything before the first ## such heading as a chunk with an empty category (empty instead ## of missing to make querying more convenient). If there are ## no headings, we have a single version chunk with no (empty) ## category. ind <- .xml_name(nodes) == "heading" pos <- which(ind) if(length(pos)) { lev <- .xml_attr(nodes[pos], "level") ind[pos] <- (lev == lev[1L]) if((pos[1L]) > 1L) { ini <- seq_len(pos[1L] - 1L) out <- list(do_cchunk(nodes[ini], FALSE)) nodes <- nodes[-ini] ind <- ind[-ini] } else out <- list() out <- c(out, lapply(split(nodes, cumsum(ind)), do_cchunk, TRUE)) cbind(version, do.call(rbind, out)) } else { rbind(c(version, do_cchunk(nodes, FALSE))) } } do_cchunk <- function(nodes, heading) { ## See above: if the category chunk has a heading, we extract ## the category from it. Otherwise, the category is empty. if(heading) { category <- .xml_text(nodes[[1L]]) nodes <- nodes[-1L] } else { category <- "" } if(!length(nodes)) return(c(category, "", "")) ## Compute text and HTML by converting everything from the start ## of the first sourcepos to the end of the last sourcepos. sp <- c(.xml_attr(nodes[[1L]], "sourcepos"), .xml_attr(nodes[[length(nodes)]], "sourcepos")) ## (If there is one node, nodes[c(1L, length(nodes))] would give ## that node only once. Could also special case ...) sp <- as.integer(unlist(strsplit(sp, "[:-]"))[c(1L, 2L, 7L, 8L)]) c(category, get_text_and_HTML(sp)) } ind <- .xml_name(nodes) == "heading" pos <- which(ind) if(!length(pos)) return() ## Skip leading headings until we find one from which we can extract ## a version number. Then drop everything ahead of this, and take ## all headings with the same level to start version chunks. re_v <- sprintf("(^|.*[[:space:]]+)[vV]?(%s).*$", .standard_regexps()$valid_package_version) while(length(pos) && !grepl(re_v, .xml_text(nodes[[pos[1L]]]))) pos <- pos[-1L] if(!length(pos)) return() lev <- .xml_attr(nodes[pos], "level") ind[pos] <- (lev == lev[1L]) if(pos[1L] > 1L) { ini <- seq_len(pos[1L] - 1L) nodes <- nodes[-ini] ind <- ind[-ini] } vchunks <- split(nodes, cumsum(ind)) db <- do.call(rbind, lapply(vchunks, do_vchunk)) ## Very similar to .build_news_db_from_package_NEWS_Rd() ... ## Post-process section names to extract versions and dates. reDt <- "[[:digit:]]{4}-[[:digit:]]{2}-[[:digit:]]{2}" rEnd <- "[[:punct:][:space:]]*$" re_d1 <- sprintf(paste0("^.*(%s)", rEnd), reDt) ## or ending with '(YYYY-MM-DD, )' re_d2 <- sprintf(paste0("^.*\\((%s)[[:punct:]] .*\\)", rEnd), reDt) nms <- db[, 1L] ind <- grepl(re_v, nms, ignore.case = TRUE) if(!all(ind)) warning(gettextf("Cannot extract version info from the following section titles:\n%s", paste0(" ", unique(nms[!ind]), collapse = "\n")), domain = NA, call. = FALSE) .make_news_db(cbind(ifelse(ind, sub(re_v, "\\2", nms, ignore.case = TRUE), NA_character_), ifelse(grepl(re_d1, nms, perl = TRUE), sub(re_d1, "\\1", nms, perl = TRUE), ifelse(grepl(re_d2, nms, perl = TRUE), sub(re_d2, "\\1", nms, perl = TRUE), NA_character_)), db[, 2L], Text = sub("\n*$", "", db[, 3L]), HTML = db[, 4L]), NULL, "news_db_from_md") } format.news_db_from_md <- function(x, ...) { do_vchunk <- function(vchunk) { z <- unlist(Map(c, vchunk$Category, vchunk$Text), use.names = FALSE) z[nzchar(z)] } vchunks <- split(x, x$Version) ## Re-order according to decreasing version. vchunks <- vchunks[order(numeric_version(names(vchunks), strict = FALSE), decreasing = TRUE)] if(!length(vchunks)) return(character()) dates <- vapply(vchunks, function(v) v$Date[1L], "") vheaders <- format(sprintf("Changes in version %s%s", names(vchunks), ifelse(is.na(dates), "", sprintf(" (%s)", dates))), justify = "centre", width = 72L) Map(c, vheaders, lapply(vchunks, do_vchunk), USE.NAMES = FALSE) } .news_db_has_no_bad_entries <- function(x) { (is.null(bad <- attr(x, "bad")) || (length(bad) == NROW(x)) && !any(bad)) }