diff --git a/DESCRIPTION b/DESCRIPTION index fb291e3..0e8daa5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: jekylldown Title: Create, Build and Serve 'Jekyll' Websites from 'R Markdown' and 'Quarto' -Version: 0.3.3 +Version: 0.3.4 Authors@R: person("Allan", "Quadros", email = "allanvcq@gmail.com", role = c("aut", "cre")) @@ -17,7 +17,6 @@ Description: Do for 'Jekyll' what 'blogdown' does for 'Hugo': create sites License: MIT + file LICENSE Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 Imports: cli, fs, @@ -37,3 +36,4 @@ Config/testthat/edition: 3 SystemRequirements: Ruby (>= 3.0), jekyll, bundler URL: https://github.com/allanvc/jekylldown BugReports: https://github.com/allanvc/jekylldown/issues +Config/roxygen2/version: 8.1.0 diff --git a/NAMESPACE b/NAMESPACE index 269404e..a644960 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,7 @@ # Generated by roxygen2: do not edit by hand export(add_css) +export(add_feed) export(add_footer_credit) export(add_mathjax) export(build_site) @@ -22,3 +23,4 @@ export(set_theme_skin) export(set_theme_style) export(stop_server) export(use_pages_workflow) +export(use_r_bloggers) diff --git a/NEWS.md b/NEWS.md index bbe2932..b21cecd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,24 @@ +# jekylldown 0.3.4 + +* New `add_feed()`: renders the site's Atom feeds from a site-level copy + of jekyll-feed's template (`_includes/atom-feed.xml`), patched so that + al-folio's `title: blank` convention yields the author's full name as + the feed title -- the plugin alone printed the literal word "blank". + Optional per-category feeds (`feed/.xml`, full text, only + the posts carrying that category) on any theme. The template is taken + from the jekyll-feed gem installed for the site (the version pinned in + `Gemfile.lock`), falling back to that version's tag on GitHub and then + to a copy shipped with the package, and the include records its + provenance so a later gem upgrade regenerates it. The patch targets one + anchor line and aborts with a clear message if upstream moves it. +* New `use_r_bloggers()`: the site side of an R-Bloggers submission in + one call -- an R-only feed via `add_feed(category = "R")`, the link + back to R-Bloggers under the blog header on al-folio (a snippet to + place on other themes), and the feed URL to submit. +* `new_site(theme = "al-folio")` and, through it, `migrate_hugo()` call + `add_feed()` so fresh al-folio sites never publish a feed titled + "blank". + # jekylldown 0.3.3 * Pandoc-style attributes on links (`[x](u){target="_blank"}`) are now diff --git a/R/feed.R b/R/feed.R new file mode 100644 index 0000000..8cf6316 --- /dev/null +++ b/R/feed.R @@ -0,0 +1,444 @@ +# Atom feeds rendered from a site-level copy of jekyll-feed's template. +# +# Why a copy: al-folio's `_config.yml` uses `title: blank` to mean "build +# the site title from first/middle/last name". The theme's layouts +# understand that convention; jekyll-feed does not, and prints the +# literal word "blank" as the feed title. Changing `title` instead would +# alter other theme output (al-folio adds `journal = {}` to the +# BibTeX it generates for posts), so the fix lives in the feed template. +# +# Where the copy comes from, in order: the jekyll-feed gem installed for +# the site (the version pinned in Gemfile.lock -- what the local build +# and the deploy workflow render with); that same version's tag on +# GitHub when the gem is not installed yet; the copy shipped in +# inst/jekyll-feed/ as a last resort. The patch is a textual replacement +# of one anchor line, and aborts loudly if upstream ever moves it. +# +# jekyll-feed skips generating any feed whose file already exists in the +# site, so `feed.xml` and `feed/<category>.xml` become one-line pages +# that render the include. The template needs only core Liquid filters, +# so the pages work even on sites without the plugin. + +feed_include <- "atom-feed.xml" +feed_anchor <- "{% assign title = site.title | default: site.name %}" +feed_marker <- "jekylldown: rendered from jekyll-feed" +feed_bundled_version <- "0.17.0" + +rb_begin <- "<!-- >>> jekylldown r-bloggers -->" +rb_close <- "<!-- <<< jekylldown r-bloggers -->" + +#' Atom feeds with a correct title, optionally per category +#' +#' Writes a site-level copy of jekyll-feed's template to +#' `_includes/atom-feed.xml`, patched so that al-folio's `title: blank` +#' convention yields the author's full name as the feed title (the +#' plugin alone prints the literal word "blank"), and the feed pages that +#' render it: `feed.xml` for every post, plus `feed/<category>.xml` for +#' each `category` -- a full-text feed restricted to the posts whose +#' `categories` front-matter entry contains that value (matched +#' verbatim, so `"R"` and `"r"` are different categories). Aggregators +#' such as R-Bloggers require exactly that kind of feed; see +#' [use_r_bloggers()] for the one-call setup. +#' +#' The template is taken from the jekyll-feed gem installed for the site +#' -- the version pinned in `Gemfile.lock`, which is what both the local +#' build and the deploy workflow render with -- so it follows the +#' plugin's development instead of freezing a copy inside jekylldown. +#' When the gem is not installed yet, that version's tag is fetched from +#' GitHub; offline, the copy shipped with the package is used (it is a +#' complete template, not a degraded one: the feed pages need no plugin +#' at all). The include records where it came from; re-running the +#' function after a gem upgrade regenerates it from the new version, and +#' `force = TRUE` regenerates it unconditionally. An `atom-feed.xml` +#' you wrote yourself (no jekylldown marker) is never touched. +#' +#' [new_site()] and [migrate_hugo()] already call this on al-folio +#' sites; call it yourself on existing ones, or on any theme where you +#' want per-category feeds. +#' +#' @param category Optional character vector of post categories, one +#' feed each at `feed/<category>.xml`. `NULL` (the default) writes the +#' main feed only. +#' @param dir Site root, or any directory inside it -- like +#' [build_site()], the function climbs to the enclosing site. +#' @param force Regenerate `_includes/atom-feed.xml` even when it is +#' current. +#' @return Invisibly, the paths of the feed pages written or confirmed +#' (the main feed first), with the include's path as attribute +#' `"include"`. +#' @examples +#' \dontrun{ +#' add_feed() # fix the feed title on an al-folio site +#' add_feed(category = "R") # plus /feed/R.xml with the R posts only +#' } +#' @seealso [use_r_bloggers()] +#' @export +add_feed <- function(category = NULL, dir = ".", force = FALSE) { + abort_if_site_path(category, "add_feed") + root <- site_root(dir) + inc <- ensure_feed_include(root, force = force) + pages <- c(feed_page(root, NULL), + vapply(category, function(x) feed_page(root, x), "")) + cli::cli_alert_success( + "Feed{?s} in place: {.file {fs::path_rel(pages, root)}}.") + invisible(structure(unname(pages), include = inc)) +} + +#' Set a site up for R-Bloggers +#' +#' R-Bloggers (\url{https://www.r-bloggers.com/}) aggregates R posts from +#' a full-text feed that contains R content only, and asks for a link +#' back to it on the blog. This function does the site side of that +#' setup in one call: an R-only feed at `feed/<category>.xml` through +#' [add_feed()] (which also fixes the feed title on al-folio sites), +#' and, on al-folio, a line under the blog header linking to the +#' category, to R-Bloggers and to the feed (a marker-delimited block in +#' `_pages/blog.md`, replaced on re-runs). On other themes the HTML +#' snippet is printed for you to place. It then prints the feed URL to +#' submit at \url{https://www.r-bloggers.com/add-your-blog/} once the +#' site is published. +#' +#' Only posts whose front matter carries the category enter the feed: +#' `new_post("...", categories = "R")`, or `categories: R` by hand. +#' Posts about anything else stay out, which is what R-Bloggers asks. +#' +#' @param category The category that marks R posts; `"R"` by default. +#' @inheritParams add_feed +#' @return Invisibly, a list with `feed` (the feed page's path), `url` +#' (the public feed URL, from the site's `url` and `baseurl`) and +#' `link` (the page the link was added to, or `NULL`). +#' @examples +#' \dontrun{ +#' use_r_bloggers() +#' } +#' @export +use_r_bloggers <- function(category = "R", dir = ".") { + abort_if_site_path(category, "use_r_bloggers") + if (!is.character(category) || length(category) != 1 || + !nzchar(trimws(category))) { + cli::cli_abort("{.arg category} must be a single category name.") + } + root <- site_root(dir) + feed <- add_feed(category, dir = root)[2] + rel <- sprintf("/feed/%s.xml", category) + url <- site_page_url(root, rel) + link <- rb_add_link(root, category, rel) + + cli::cli_bullets(c( + "*" = "Only posts with {.code categories: {category}} enter the feed + ({.code new_post(..., categories = \"{category}\")}).", + "*" = "Build, commit and publish the site, then submit the feed at + {.url https://www.r-bloggers.com/add-your-blog/}:", + " " = "{.url {url}}" + )) + invisible(list(feed = feed, url = url, link = link)) +} + +# --- the include --------------------------------------------------------- + +# Write (or refresh) _includes/atom-feed.xml. Returns its path. +ensure_feed_include <- function(root, force = FALSE) { + inc <- file.path(root, "_includes", feed_include) + if (file.exists(inc)) { + have <- feed_include_version(xfun::read_utf8(inc)) + if (is.na(have)) { + cli::cli_alert_info( + "{.file _includes/{feed_include}} is not managed by jekylldown + (no marker) -- left as is.") + return(inc) + } + if (!force) { + gem <- feed_gem_template(root) + if (is.null(gem) || identical(gem$version, have)) return(inc) + cli::cli_alert_info( + "jekyll-feed {gem$version} is installed; regenerating the include + written from {have}.") + } + } + tpl <- feed_template(root) + lines <- patch_feed_template(tpl$lines, tpl$version, tpl$source) + fs::dir_create(dirname(inc)) + xfun::write_utf8(lines, inc) + cli::cli_alert_success( + "Wrote {.file _includes/{feed_include}} from jekyll-feed + {tpl$version} ({tpl$source}).") + inc +} + +# Version recorded in an include we wrote; NA when the marker is absent. +feed_include_version <- function(lines) { + m <- regmatches(lines, regexpr( + sprintf("%s ([0-9]+(?:[.][0-9]+)*)", feed_marker), lines, perl = TRUE)) + if (!length(m)) return(NA_character_) + sub(sprintf("^%s ", feed_marker), "", m[1]) +} + +# The template, from the best available source: +# list(lines, version, source) with source one of "gem", "github", +# "bundled". +feed_template <- function(root) { + gem <- feed_gem_template(root) + if (!is.null(gem)) return(gem) + + version <- lock_feed_version(root) + if (!is.null(version)) { + lines <- fetch_feed_template(version) + if (!is.null(lines)) { + return(list(lines = lines, version = version, source = "github")) + } + } + bundled <- system.file("jekyll-feed", + sprintf("feed-%s.xml", feed_bundled_version), + package = "jekylldown", mustWork = TRUE) + reason <- if (is.null(version)) { + "The jekyll-feed gem is not installed" + } else { + "The jekyll-feed gem is not installed and GitHub could not be reached" + } + cli::cli_alert_info( + "{reason}; using the copy of its template shipped with jekylldown + ({feed_bundled_version}). Re-run {.fn add_feed} after + {.fn bundle_install} to pick up the site's own version.") + list(lines = xfun::read_utf8(bundled), version = feed_bundled_version, + source = "bundled") +} + +# Version pinned in the site's Gemfile.lock, or NULL. +lock_feed_version <- function(root) { + lock <- file.path(root, "Gemfile.lock") + if (!file.exists(lock)) return(NULL) + lines <- xfun::read_utf8(lock) + pat <- "^\\s*jekyll-feed \\(([0-9]+(?:[.][0-9]+)*)\\)\\s*$" + hit <- grep(pat, lines, perl = TRUE, value = TRUE) + if (!length(hit)) return(NULL) + sub(pat, "\\1", hit[1], perl = TRUE) +} + +# The template of the installed gem, or NULL. The gem directory under +# jekylldown's isolated GEM_HOME is tried first (the locked version when +# known, else the newest); then `bundle show`, which also covers gems +# installed elsewhere by the user's own bundler setup. +feed_gem_template <- function(root) { + version <- lock_feed_version(root) + dirs <- Sys.glob(file.path(jd_gem_home(), "gems", "jekyll-feed-*")) + dirs <- dirs[grepl("jekyll-feed-[0-9]+(?:[.][0-9]+)*$", dirs, perl = TRUE)] + if (length(dirs)) { + vers <- sub("^.*jekyll-feed-", "", dirs) + pick <- if (!is.null(version) && version %in% vers) { + dirs[match(version, vers)] + } else { + dirs[order(numeric_version(vers), decreasing = TRUE)][1] + } + file <- file.path(pick, "lib", "jekyll-feed", "feed.xml") + if (file.exists(file)) { + return(list(lines = xfun::read_utf8(file), + version = sub("^.*jekyll-feed-", "", pick), + source = "gem")) + } + } + dir <- bundle_show(root, "jekyll-feed") + if (!is.null(dir)) { + file <- file.path(dir, "lib", "jekyll-feed", "feed.xml") + if (file.exists(file)) { + return(list(lines = xfun::read_utf8(file), + version = sub("^.*jekyll-feed-", "", basename(dir)), + source = "gem")) + } + } + NULL +} + +# `bundle show <gem>` in the site: the gem's directory, or NULL when +# bundler is unavailable, the site has no lockfile, or the gem is missing. +bundle_show <- function(root, gem) { + if (!file.exists(file.path(root, "Gemfile.lock"))) return(NULL) + bundle <- find_cmd("bundle") + if (is.null(bundle)) return(NULL) + sc <- shell_cmd(bundle, c("show", gem)) + res <- tryCatch( + processx::run(sc$cmd, sc$args, wd = root, env = c("current", jd_env()), + error_on_status = FALSE, timeout = 60), + error = function(e) NULL) + if (is.null(res) || res$status != 0) return(NULL) + dir <- trimws(utils::tail(strsplit(res$stdout, "\n", fixed = TRUE)[[1]], 1)) + if (nzchar(dir) && dir.exists(dir)) dir else NULL +} + +# The template at a jekyll-feed release tag on GitHub, or NULL (offline, +# unknown tag). Never the default branch: it may be ahead of the plugin +# the site actually runs. +fetch_feed_template <- function(version) { + url <- sprintf( + "https://raw.githubusercontent.com/jekyll/jekyll-feed/v%s/lib/jekyll-feed/feed.xml", + version) + tmp <- tempfile(fileext = ".xml") + on.exit(unlink(tmp), add = TRUE) + ok <- tryCatch( + suppressWarnings(utils::download.file(url, tmp, quiet = TRUE, + mode = "wb")) == 0, + error = function(e) FALSE) + if (!ok || !file.exists(tmp)) return(NULL) + lines <- xfun::read_utf8(tmp) + if (!any(grepl("^<\\?xml", lines))) return(NULL) + lines +} + +# Apply the title patch and stamp the provenance marker. The anchor is +# the plugin's own title assignment; if upstream moves it, this is where +# the package should fail, loudly, instead of writing a broken feed. +patch_feed_template <- function(lines, version, source) { + i <- which(trimws(lines) == feed_anchor) + if (length(i) != 1) { + cli::cli_abort(c( + "The jekyll-feed template ({version}, {source}) does not have the + line jekylldown patches: {.code {feed_anchor}}.", + "i" = "jekyll-feed changed its template; jekylldown needs an update. + Please report this at + {.url https://github.com/allanvc/jekylldown/issues}.", + "i" = "Meanwhile, write {.file _includes/{feed_include}} by hand + (any file there without the jekylldown marker is left alone)." + )) + } + indent <- sub("^(\\s*).*$", "\\1", lines[i]) + block <- paste0(indent, c( + "{% comment %}", + " jekylldown: al-folio sets `title: blank` to mean \"use the author's", + " name\"; jekyll-feed alone prints the literal \"blank\" as the title.", + "{% endcomment %}", + "{% if site.title == 'blank' %}", + " {% capture title %}{{ site.first_name }} {{ site.middle_name }} {{ site.last_name }}{% endcapture %}", + " {% assign title = title | normalize_whitespace %}", + "{% else %}", + paste0(" ", feed_anchor), + "{% endif %}")) + lines <- append(lines[-i], block, after = i - 1) + + # the marker goes right after the XML declaration, never before it: + # anything ahead of <?xml ?> makes the document ill-formed + marker <- sprintf( + "{%% comment %%}%s %s (%s). Regenerate with jekylldown::add_feed(force = TRUE); edits here are lost then.{%% endcomment %%}", + feed_marker, version, source) + decl <- grep("^<\\?xml", lines) + append(lines, marker, after = if (length(decl)) decl[1] else 0) +} + +# --- the pages ----------------------------------------------------------- + +# feed.xml (category NULL) or feed/<category>.xml. An existing file that +# does not render our include is the user's own and is left alone. +feed_page <- function(root, category = NULL) { + path <- if (is.null(category)) { + file.path(root, "feed.xml") + } else { + file.path(root, "feed", paste0(category, ".xml")) + } + if (file.exists(path)) { + if (!any(grepl(feed_include, xfun::read_utf8(path), fixed = TRUE))) { + cli::cli_alert_info( + "{.file {fs::path_rel(path, root)}} exists and is not rendered from + {.file _includes/{feed_include}} -- left as is.") + } + return(path) + } + head <- if (is.null(category)) { + "# Main Atom feed (every post)." + } else { + sprintf("# Atom feed with only the posts whose `categories` include \"%s\".", + category) + } + fm <- c( + "---", + head, + "# Rendered from _includes/atom-feed.xml, written by jekylldown::add_feed().", + "layout: null", + "sitemap: false", + "collection: posts", + if (!is.null(category)) sprintf("category: \"%s\"", category), + "---", + sprintf("{%%- include %s -%%}", feed_include)) + fs::dir_create(dirname(path)) + xfun::write_utf8(fm, path) + path +} + +# Public URL of a site path, from `url:` and `baseurl:` in _config.yml; +# the bare path when the config has no url. +site_page_url <- function(root, path) { + config <- file.path(root, "_config.yml") + lines <- if (file.exists(config)) xfun::read_utf8(config) else character() + value <- function(key) { + v <- grep(sprintf("^%s:", key), lines, value = TRUE) + if (!length(v)) return("") + v <- sub(sprintf("^%s:\\s*", key), "", v[1]) + v <- sub("\\s+#.*$", "", v) + gsub("^[\"']|[\"']$", "", trimws(v)) + } + url <- sub("/+$", "", value("url")) + base <- sub("/+$", "", value("baseurl")) + if (!nzchar(url)) return(paste0(base, path)) + paste0(url, base, path) +} + +# --- the R-Bloggers link ------------------------------------------------- + +# al-folio: a marker-delimited paragraph under the blog page's header +# bar (after the front matter when the header block is not found). Other +# themes: print the snippet and return NULL. +rb_add_link <- function(root, category, feed_rel) { + theme <- site_theme(root) + page <- file.path(root, "_pages", "blog.md") + if (theme != "al-folio" || !file.exists(page)) { + cli::cli_alert_info(c( + "R-Bloggers asks for a link back on the blog; add this where your + theme lists posts:")) + cli::cli_code(rb_snippet(category, feed_rel, archive = NULL, + icons = FALSE)) + return(NULL) + } + archive <- sprintf("/blog/category/%s/", slugify(category)) + block <- c(rb_begin, + rb_snippet(category, feed_rel, archive, icons = TRUE), + rb_close) + lines <- xfun::read_utf8(page) + b <- which(lines == rb_begin) + e <- which(lines == rb_close) + if (length(b) && length(e)) { + lines <- append(lines[-(b[1]:e[1])], block, after = b[1] - 1) + } else { + lines <- append(lines, c("", block), after = rb_insert_after(lines)) + } + xfun::write_utf8(lines, page) + cli::cli_alert_success( + "Link to R-Bloggers added to {.file _pages/blog.md}.") + page +} + +# Line after which the link goes: the `{% endif %}` that closes the +# header-bar block, else the end of the front matter. +rb_insert_after <- function(lines) { + header <- grep('class="header-bar"', lines, fixed = TRUE) + if (length(header)) { + endif <- grep("^\\s*\\{%\\s*endif\\s*%\\}", lines) + endif <- endif[endif > header[1]] + if (length(endif)) return(endif[1]) + } + fm <- which(lines == "---") + if (length(fm) >= 2) fm[2] else 0 +} + +rb_snippet <- function(category, feed_rel, archive, icons) { + cat_html <- if (is.null(archive)) { + category + } else { + sprintf("<a href=\"{{ '%s' | relative_url }}\">%s</a>", archive, category) + } + rss <- if (icons) '<i class="fa-solid fa-rss fa-sm"></i> ' else "" + c( + '<p class="text-center jekylldown-r-bloggers">', + if (icons) ' <i class="fa-brands fa-r-project fa-sm"></i>', + sprintf(" Posts in the %s category are syndicated on", cat_html), + ' <a href="https://www.r-bloggers.com/" target="_blank" rel="noopener">R-Bloggers</a>', + sprintf(" • <a href=\"{{ '%s' | relative_url }}\">%s%s feed</a>", + feed_rel, rss, category), + "</p>") +} diff --git a/R/new_site.R b/R/new_site.R index e57fdd2..659ee92 100644 --- a/R/new_site.R +++ b/R/new_site.R @@ -34,6 +34,10 @@ #' [migrate_hugo()] always starts from a scrubbed site. The other #' themes start empty regardless. #' @return The normalized site path, invisibly. +#' @details On al-folio the feed is rendered from a site-level copy of +#' jekyll-feed's template (see [add_feed()]), so that the theme's +#' `title: blank` convention yields the author's name as the feed +#' title instead of the literal word "blank". #' @examples #' \dontrun{ #' # a minimal blog with the locally generated minima theme (no network) @@ -76,6 +80,13 @@ new_site <- function(dir, theme = c("minima", "al-folio", "chirpy", # later) -- the credit can always be added afterwards by hand suppressMessages(tryCatch(add_footer_credit(root), error = function(e) invisible())) + # al-folio's `title: blank` convention makes jekyll-feed print "blank" + # as the feed title; render the feed from a patched site-level copy of + # the plugin's template (see add_feed()) + if (theme == "al-folio") { + suppressMessages(tryCatch(add_feed(dir = root), + error = function(e) invisible())) + } write_build_script(root) if (sample) write_sample_post(root) diff --git a/README.Rmd b/README.Rmd index ecaf789..066b245 100644 --- a/README.Rmd +++ b/README.Rmd @@ -262,6 +262,15 @@ add_css(".profile img { border-radius: 50%; }", id = "avatar") # footer credit ("Built from R with jekylldown X.Y.Z."), added by # new_site() and version-refreshed by build_site() add_footer_credit() # remove_footer_credit() undoes it + +# Atom feeds rendered from a site-level copy of jekyll-feed's template: +# on al-folio the feed title becomes your name instead of the literal +# "blank" (new_site() does this already); per-category feeds on any theme +add_feed(category = "R") # -> /feed/R.xml, the R posts only, full text + +# the R-Bloggers setup in one call: the R-only feed, the link back to +# R-Bloggers on the blog page, and the feed URL to submit +use_r_bloggers() ``` To undo the accent, call `set_theme_color(NULL)`; deleting the diff --git a/README.md b/README.md index f6cc46a..394ceeb 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,15 @@ add_css(".profile img { border-radius: 50%; }", id = "avatar") # footer credit ("Built from R with jekylldown X.Y.Z."), added by # new_site() and version-refreshed by build_site() add_footer_credit() # remove_footer_credit() undoes it + +# Atom feeds rendered from a site-level copy of jekyll-feed's template: +# on al-folio the feed title becomes your name instead of the literal +# "blank" (new_site() does this already); per-category feeds on any theme +add_feed(category = "R") # -> /feed/R.xml, the R posts only, full text + +# the R-Bloggers setup in one call: the R-only feed, the link back to +# R-Bloggers on the blog page, and the feed URL to submit +use_r_bloggers() ``` To undo the accent, call `set_theme_color(NULL)`; deleting the diff --git a/inst/jekyll-feed/feed-0.17.0.xml b/inst/jekyll-feed/feed-0.17.0.xml new file mode 100644 index 0000000..9068836 --- /dev/null +++ b/inst/jekyll-feed/feed-0.17.0.xml @@ -0,0 +1,112 @@ +<?xml version="1.0" encoding="utf-8"?> +{% if page.xsl %} + <?xml-stylesheet type="text/xml" href="{{ '/feed.xslt.xml' | absolute_url }}"?> +{% endif %} +<feed xmlns="http://www.w3.org/2005/Atom" {% if site.lang %}xml:lang="{{ site.lang }}"{% endif %}> + <generator uri="https://jekyllrb.com/" version="{{ jekyll.version }}">Jekyll</generator> + <link href="{{ page.url | absolute_url }}" rel="self" type="application/atom+xml" /> + <link href="{{ '/' | absolute_url }}" rel="alternate" type="text/html" {% if site.lang %}hreflang="{{ site.lang }}" {% endif %}/> + <updated>{{ site.time | date_to_xmlschema }}</updated> + <id>{{ page.url | absolute_url | xml_escape }}</id> + + {% assign title = site.title | default: site.name %} + {% if page.collection != "posts" %} + {% assign collection = page.collection | capitalize %} + {% assign title = title | append: " | " | append: collection %} + {% endif %} + {% if page.category %} + {% assign category = page.category | capitalize %} + {% assign title = title | append: " | " | append: category %} + {% endif %} + + {% if title %} + <title type="html">{{ title | smartify | xml_escape }} + {% endif %} + + {% if site.description %} + {{ site.description | xml_escape }} + {% endif %} + + {% if site.author %} + + {{ site.author.name | default: site.author | xml_escape }} + {% if site.author.email %} + {{ site.author.email | xml_escape }} + {% endif %} + {% if site.author.uri %} + {{ site.author.uri | xml_escape }} + {% endif %} + + {% endif %} + + {% if page.tags %} + {% assign posts = site.tags[page.tags] %} + {% else %} + {% assign posts = site[page.collection] %} + {% endif %} + {% if page.category %} + {% assign posts = posts | where: "categories", page.category %} + {% endif %} + {% unless site.show_drafts %} + {% assign posts = posts | where_exp: "post", "post.draft != true" %} + {% endunless %} + {% assign posts = posts | sort: "date" | reverse %} + {% assign posts_limit = site.feed.posts_limit | default: 10 %} + {% for post in posts limit: posts_limit %} + + {% assign post_title = post.title | smartify | strip_html | normalize_whitespace | xml_escape %} + + {{ post_title }} + + {{ post.date | date_to_xmlschema }} + {{ post.last_modified_at | default: post.date | date_to_xmlschema }} + {{ post.id | absolute_url | xml_escape }} + {% assign excerpt_only = post.feed.excerpt_only | default: site.feed.excerpt_only %} + {% unless excerpt_only %} + + {% endunless %} + + {% assign post_author = post.author | default: post.authors[0] | default: site.author %} + {% assign post_author = site.data.authors[post_author] | default: post_author %} + {% assign post_author_email = post_author.email | default: nil %} + {% assign post_author_uri = post_author.uri | default: nil %} + {% assign post_author_name = post_author.name | default: post_author %} + + + {{ post_author_name | default: "" | xml_escape }} + {% if post_author_email %} + {{ post_author_email | xml_escape }} + {% endif %} + {% if post_author_uri %} + {{ post_author_uri | xml_escape }} + {% endif %} + + + {% if post.category %} + + {% elsif post.categories %} + {% for category in post.categories %} + + {% endfor %} + {% endif %} + + {% for tag in post.tags %} + + {% endfor %} + + {% assign post_summary = post.description | default: post.excerpt %} + {% if post_summary and post_summary != empty %} + + {% endif %} + + {% assign post_image = post.image.path | default: post.image %} + {% if post_image %} + {% unless post_image contains "://" %} + {% assign post_image = post_image | absolute_url %} + {% endunless %} + + + {% endif %} + + {% endfor %} + diff --git a/man/add_feed.Rd b/man/add_feed.Rd new file mode 100644 index 0000000..4136de2 --- /dev/null +++ b/man/add_feed.Rd @@ -0,0 +1,62 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/feed.R +\name{add_feed} +\alias{add_feed} +\title{Atom feeds with a correct title, optionally per category} +\usage{ +add_feed(category = NULL, dir = ".", force = FALSE) +} +\arguments{ +\item{category}{Optional character vector of post categories, one +feed each at \verb{feed/.xml}. \code{NULL} (the default) writes the +main feed only.} + +\item{dir}{Site root, or any directory inside it -- like +\code{\link[=build_site]{build_site()}}, the function climbs to the enclosing site.} + +\item{force}{Regenerate \verb{_includes/atom-feed.xml} even when it is +current.} +} +\value{ +Invisibly, the paths of the feed pages written or confirmed +(the main feed first), with the include's path as attribute +\code{"include"}. +} +\description{ +Writes a site-level copy of jekyll-feed's template to +\verb{_includes/atom-feed.xml}, patched so that al-folio's \code{title: blank} +convention yields the author's full name as the feed title (the +plugin alone prints the literal word "blank"), and the feed pages that +render it: \code{feed.xml} for every post, plus \verb{feed/.xml} for +each \code{category} -- a full-text feed restricted to the posts whose +\code{categories} front-matter entry contains that value (matched +verbatim, so \code{"R"} and \code{"r"} are different categories). Aggregators +such as R-Bloggers require exactly that kind of feed; see +\code{\link[=use_r_bloggers]{use_r_bloggers()}} for the one-call setup. +} +\details{ +The template is taken from the jekyll-feed gem installed for the site +-- the version pinned in \code{Gemfile.lock}, which is what both the local +build and the deploy workflow render with -- so it follows the +plugin's development instead of freezing a copy inside jekylldown. +When the gem is not installed yet, that version's tag is fetched from +GitHub; offline, the copy shipped with the package is used (it is a +complete template, not a degraded one: the feed pages need no plugin +at all). The include records where it came from; re-running the +function after a gem upgrade regenerates it from the new version, and +\code{force = TRUE} regenerates it unconditionally. An \code{atom-feed.xml} +you wrote yourself (no jekylldown marker) is never touched. + +\code{\link[=new_site]{new_site()}} and \code{\link[=migrate_hugo]{migrate_hugo()}} already call this on al-folio +sites; call it yourself on existing ones, or on any theme where you +want per-category feeds. +} +\examples{ +\dontrun{ +add_feed() # fix the feed title on an al-folio site +add_feed(category = "R") # plus /feed/R.xml with the R posts only +} +} +\seealso{ +\code{\link[=use_r_bloggers]{use_r_bloggers()}} +} diff --git a/man/jekylldown-package.Rd b/man/jekylldown-package.Rd index 1204c19..a6c26b3 100644 --- a/man/jekylldown-package.Rd +++ b/man/jekylldown-package.Rd @@ -19,5 +19,10 @@ Useful links: \author{ \strong{Maintainer}: Allan Quadros \email{allanvcq@gmail.com} +Authors: +\itemize{ + \item Allan Quadros \email{allanvcq@gmail.com} +} + } \keyword{internal} diff --git a/man/knit_post.Rd b/man/knit_post.Rd index 6f4cb4d..7b663ef 100644 --- a/man/knit_post.Rd +++ b/man/knit_post.Rd @@ -27,7 +27,7 @@ and cross-references. \code{NULL} (the default) takes the post's \description{ Knits a single \code{.Rmd} with the conventions inherited from Yihui Xie's knitr-jekyll: front matter preserved, output fenced for kramdown/rouge -via \code{\link[knitr:output_hooks]{knitr::render_jekyll()}}, figures written to +via \code{\link[knitr:render_jekyll]{knitr::render_jekyll()}}, figures written to \verb{assets/img/posts//} and referenced through \code{{{ site.baseurl }}} so they resolve wherever the site is mounted. } diff --git a/man/new_site.Rd b/man/new_site.Rd index 311cb42..3530840 100644 --- a/man/new_site.Rd +++ b/man/new_site.Rd @@ -57,6 +57,11 @@ tooling, keeping the GitHub Pages deploy workflow; (\url{https://github.com/mmistakes/minimal-mistakes}), generated locally as a gem-based site (see \code{\link[=set_theme_skin]{set_theme_skin()}} for its skins). } + +On al-folio the feed is rendered from a site-level copy of +jekyll-feed's template (see \code{\link[=add_feed]{add_feed()}}), so that the theme's +\code{title: blank} convention yields the author's name as the feed +title instead of the literal word "blank". } \examples{ \dontrun{ diff --git a/man/serve_site.Rd b/man/serve_site.Rd index 28c68f9..3657043 100644 --- a/man/serve_site.Rd +++ b/man/serve_site.Rd @@ -14,7 +14,7 @@ Default \code{TRUE} in interactive sessions.} \item{port}{Port to serve on; a random free port by default.} -\item{...}{Passed on to \code{\link[servr:httd]{servr::httw()}} (e.g. \code{daemon}, \code{interval}) +\item{...}{Passed on to \code{\link[servr:httw]{servr::httw()}} (e.g. \code{daemon}, \code{interval}) when \code{background = FALSE}.} } \value{ diff --git a/man/use_r_bloggers.Rd b/man/use_r_bloggers.Rd new file mode 100644 index 0000000..7505e1d --- /dev/null +++ b/man/use_r_bloggers.Rd @@ -0,0 +1,42 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/feed.R +\name{use_r_bloggers} +\alias{use_r_bloggers} +\title{Set a site up for R-Bloggers} +\usage{ +use_r_bloggers(category = "R", dir = ".") +} +\arguments{ +\item{category}{The category that marks R posts; \code{"R"} by default.} + +\item{dir}{Site root, or any directory inside it -- like +\code{\link[=build_site]{build_site()}}, the function climbs to the enclosing site.} +} +\value{ +Invisibly, a list with \code{feed} (the feed page's path), \code{url} +(the public feed URL, from the site's \code{url} and \code{baseurl}) and +\code{link} (the page the link was added to, or \code{NULL}). +} +\description{ +R-Bloggers (\url{https://www.r-bloggers.com/}) aggregates R posts from +a full-text feed that contains R content only, and asks for a link +back to it on the blog. This function does the site side of that +setup in one call: an R-only feed at \verb{feed/.xml} through +\code{\link[=add_feed]{add_feed()}} (which also fixes the feed title on al-folio sites), +and, on al-folio, a line under the blog header linking to the +category, to R-Bloggers and to the feed (a marker-delimited block in +\verb{_pages/blog.md}, replaced on re-runs). On other themes the HTML +snippet is printed for you to place. It then prints the feed URL to +submit at \url{https://www.r-bloggers.com/add-your-blog/} once the +site is published. +} +\details{ +Only posts whose front matter carries the category enter the feed: +\code{new_post("...", categories = "R")}, or \code{categories: R} by hand. +Posts about anything else stay out, which is what R-Bloggers asks. +} +\examples{ +\dontrun{ +use_r_bloggers() +} +} diff --git a/tests/testthat/test-feed.R b/tests/testthat/test-feed.R new file mode 100644 index 0000000..009e3b5 --- /dev/null +++ b/tests/testthat/test-feed.R @@ -0,0 +1,213 @@ +bundled_template <- function() { + xfun::read_utf8(system.file("jekyll-feed", "feed-0.17.0.xml", + package = "jekylldown", mustWork = TRUE)) +} + +# A site whose isolated GEM_HOME holds a fake jekyll-feed gem carrying +# the bundled template, so the "gem" path runs without Ruby and without +# the network. R_USER_DATA_DIR redirects tools::R_user_dir(). +local_site <- function(gem_version = "0.17.0", lock = gem_version, + config = c("title: blank", "first_name: Ada", + "middle_name: ", "last_name: Lovelace", + "url: https://ada.example.org", + "baseurl: \"\"", "theme: al-folio"), + env = parent.frame()) { + data <- withr::local_tempdir(.local_envir = env) + withr::local_envvar(c(R_USER_DATA_DIR = data), .local_envir = env) + if (!is.null(gem_version)) { + # GEM_HOME layout: /gems/-/ + gem <- file.path(jekylldown:::jd_gem_home(), "gems", + paste0("jekyll-feed-", gem_version), "lib", "jekyll-feed") + fs::dir_create(gem) + xfun::write_utf8(bundled_template(), file.path(gem, "feed.xml")) + } + site <- withr::local_tempdir(.local_envir = env) + xfun::write_utf8(config, file.path(site, "_config.yml")) + if (!is.null(lock)) { + xfun::write_utf8(c("GEM", " specs:", sprintf(" jekyll-feed (%s)", lock), + " jekyll (>= 3.7, < 5.0)"), + file.path(site, "Gemfile.lock")) + } + site +} + +test_that("the title patch applies to jekyll-feed's template", { + out <- jekylldown:::patch_feed_template(bundled_template(), "0.17.0", "gem") + # nothing may precede the XML declaration; the marker follows it + expect_match(out[1], "^<\\?xml") + expect_match(out[2], "jekylldown: rendered from jekyll-feed 0.17.0 \\(gem\\)") + expect_equal(jekylldown:::feed_include_version(out), "0.17.0") + # the anchor survives only inside the else branch of the blank check + anchor <- which(trimws(out) == jekylldown:::feed_anchor) + expect_length(anchor, 1) + expect_match(out[anchor - 1], "\\{% else %\\}") + expect_true(any(grepl("site.title == 'blank'", out, fixed = TRUE))) + expect_true(any(grepl("site.first_name", out, fixed = TRUE))) + # the rest of the template is intact + expect_true(any(grepl(""), inc) + suppressMessages(add_feed(dir = site, force = TRUE)) + expect_false(any(grepl("stray", readLines(inc), fixed = TRUE))) +}) + +test_that("hand-written include and feed pages are left alone", { + site <- local_site() + fs::dir_create(file.path(site, "_includes")) + xfun::write_utf8("mine", + file.path(site, "_includes", "atom-feed.xml")) + xfun::write_utf8(c("---", "---", "custom"), + file.path(site, "feed.xml")) + expect_message(add_feed("R", dir = site), "not managed by jekylldown") + expect_equal(readLines(file.path(site, "_includes", "atom-feed.xml")), + "mine") + expect_equal(readLines(file.path(site, "feed.xml"))[3], "custom") + # the category page is still written, rendering the (user's) include + expect_true(file.exists(file.path(site, "feed", "R.xml"))) +}) + +test_that("without a gem or a lockfile the bundled copy is used (offline)", { + site <- local_site(gem_version = NULL, lock = NULL) + expect_message(add_feed(dir = site), "shipped with jekylldown") + lines <- readLines(file.path(site, "_includes", "atom-feed.xml")) + expect_match(lines[2], "jekyll-feed 0.17.0 \\(bundled\\)") +}) + +test_that("the lockfile version is read from the specs, not the dependencies", { + site <- local_site(gem_version = NULL, lock = NULL) + xfun::write_utf8(c("GEM", " specs:", + " jekyll-feed (0.17.0)", + " jekyll (>= 3.7, < 5.0)", + " minima (2.5.1)", + " jekyll-feed (~> 0.9)"), + file.path(site, "Gemfile.lock")) + expect_equal(jekylldown:::lock_feed_version(site), "0.17.0") +}) + +test_that("the installed jekyll-feed gem still has the anchor line", { + # the drift detector: fails as soon as the real gem changes its template + gem <- Sys.glob(file.path(jekylldown:::jd_gem_home(), "gems", + "jekyll-feed-*", "lib", "jekyll-feed", "feed.xml")) + skip_if(!length(gem), "jekyll-feed gem not installed") + for (f in gem) { + version <- sub("^.*jekyll-feed-([0-9.]+)/.*$", "\\1", f) + out <- jekylldown:::patch_feed_template(xfun::read_utf8(f), version, "gem") + expect_equal(jekylldown:::feed_include_version(out), version) + } +}) + +test_that("use_r_bloggers adds the feed and the link on al-folio", { + site <- local_site() + fs::dir_create(file.path(site, "_pages")) + blog <- file.path(site, "_pages", "blog.md") + xfun::write_utf8(c( + "---", "layout: default", "permalink: /blog/", "---", + "", + '
', + "", + "{% if blog_name_size > 0 %}", + '
', + "

{{ site.blog_name }}

", + "
", + " {% endif %}", + "", + "{% assign featured_posts = site.posts | where: \"featured\", \"true\" %}" + ), blog) + + res <- suppressMessages(use_r_bloggers(dir = site)) + expect_equal(res$url, "https://ada.example.org/feed/R.xml") + expect_true(file.exists(file.path(site, "feed", "R.xml"))) + expect_equal(normalizePath(res$link), normalizePath(blog)) + + lines <- readLines(blog) + b <- which(lines == jekylldown:::rb_begin) + expect_length(b, 1) + # right after the endif that closes the header bar + expect_match(lines[b - 2], "endif") + expect_true(any(grepl("r-bloggers.com", lines, fixed = TRUE))) + expect_true(any(grepl("/feed/R.xml", lines, fixed = TRUE))) + expect_true(any(grepl("/blog/category/r/", lines, fixed = TRUE))) + + # idempotent + suppressMessages(use_r_bloggers(dir = site)) + expect_length(which(readLines(blog) == jekylldown:::rb_begin), 1) +}) + +test_that("use_r_bloggers prints the snippet on other themes", { + site <- local_site(config = c("title: My blog", "theme: minima", + "url: https://b.example.org", + "baseurl: /blog")) + res <- NULL + expect_message(res <- use_r_bloggers(dir = site), "link back") + expect_null(res$link) + expect_equal(res$url, "https://b.example.org/blog/feed/R.xml") + expect_true(file.exists(file.path(site, "feed", "R.xml"))) +}) + +test_that("bad category arguments are rejected", { + site <- local_site() + expect_error(use_r_bloggers(c("R", "S"), dir = site), "single category") + expect_error(use_r_bloggers(site), "looks like a site directory") +}) diff --git a/vignettes/jekylldown.Rmd b/vignettes/jekylldown.Rmd index 2c6bb11..ca6db4d 100644 --- a/vignettes/jekylldown.Rmd +++ b/vignettes/jekylldown.Rmd @@ -240,6 +240,38 @@ add_mathjax() add_footer_credit() # remove_footer_credit() undoes it ``` +## Feeds, and R-Bloggers + +Jekyll sites get their Atom feed from the jekyll-feed plugin, which +reads the site title from `title:` in `_config.yml`. al-folio sets +`title: blank` there, a convention its own layouts translate into your +first, middle and last name -- but the plugin does not, so the feed of +a stock al-folio site is titled, literally, "blank". `add_feed()` +renders the feeds from a site-level copy of the plugin's template with +that one case handled (`new_site()` and `migrate_hugo()` call it on +al-folio sites already). The copy is taken from the jekyll-feed gem +installed for the site, the version pinned in `Gemfile.lock`, so it +follows the plugin instead of freezing a copy in the package; the +include records the version it came from, and re-running `add_feed()` +after a gem upgrade regenerates it. + +The same call makes per-category feeds, on any theme: `feed/R.xml` +with only the posts whose `categories` include `R`, full text. That is +exactly what [R-Bloggers](https://www.r-bloggers.com/) asks for, so the +whole setup is one call: + +```{r} +use_r_bloggers() # category = "R" by default +``` + +It writes the R-only feed, adds the link back to R-Bloggers under the +blog header (al-folio; other themes get the snippet printed), and +prints the feed URL to submit at + once the site is published. +Only posts carrying the category enter the feed -- +`new_post("...", categories = "R")` -- which keeps everything else you +write out of the aggregator, as its rules require. + `set_theme_color("red")` remains as a shorthand for setting the accent, and `set_theme_color(NULL)` removes the override again. For a full reset, delete the site-local stylesheet (`assets/css/main.scss`;