Skip to content

Commit

Permalink
Improve O'Reilly workflow
Browse files Browse the repository at this point in the history
* Save .md and .asciidoc files
* Inline regexps
* Commit ascii doc to git to track changes
  • Loading branch information
hadley committed Jan 28, 2021
1 parent 1920ab3 commit f22afc3
Show file tree
Hide file tree
Showing 30 changed files with 9,968 additions and 24 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,3 @@ README.html
cran-logs
shiny_bookmarks
scaling-testing
_oreilly
63 changes: 40 additions & 23 deletions _build.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,70 @@
library(tidyverse)
library(fs)

dir_delete("_oreilly")
dir_create("_oreilly")
Sys.setenv(CI = "true") # don't rebuild demos

chapters <- setdiff(yaml::read_yaml("_bookdown.yml")$rmd_files, "index.Rmd")

# Build book --------------------------------------------------------------

format <- rmarkdown::md_document(variant = "markdown-fenced_code_attributes-raw_attribute", ext = ".asciidoc")
format$pandoc$args <- c(format$pandoc$args, "--wrap=none")

render_clean <- function(path, ...) {
message("Rendering ", path)
callr::r(function(...) rmarkdown::render(...), list(path, ...), spinner = TRUE)
}

format <- rmarkdown::md_document(variant = "markdown-fenced_code_attributes-raw_attribute")
format$pandoc$args <- c(format$pandoc$args, "--wrap=none")

Sys.setenv(CI = "true") # don't rebuild demos
chapters %>% walk(
render_clean,
output_format = format,
output_dir = "_oreilly"
)

# Convert from md to asciidoc ---------------------------------------------
replace_lines <- function(file, pattern, replacement) {
str_replace_all(file, regex(pattern, multiline = TRUE), replacement)
}

regexp <- googlesheets4::read_sheet("1b3j_fgnN19uvIG7XhSS7zepBTZO5vhbuEOXB5a_oT4Q")
regexp$Pattern <- gsub("\\\\n", "\n", regexp$Pattern)
regexp$Replacement <- gsub("\\\\n", "\n", regexp$Replacement)

munge_file <- function(path) {
# Regular expressions mostly contributed by Nicholas Adams, O'Reilly
md2asciidoc <- function(path) {
file <- read_file(path)

for (i in seq_len(nrow(regexp))) {
file <- str_replace_all(file, regex(regexp$Pattern[[i]], multiline = TRUE), regexp$Replacement[[i]])
}

write_file(file, path)
# Headings with and without ids
file <- replace_lines(file, '(^# )(.*?)(\\{#)(.*?)(\\})', '[[\\4]]\n== \\2') # Chapter heading with ID
file <- replace_lines(file, '(^## )(.*?)(\\{#)(.*?)(\\})', '[[\\4]]\n=== \\2') # A-Head with ID
file <- replace_lines(file, '(^## )(.*?)', '=== \\2') # A-Head no ID
file <- replace_lines(file, '(^### )(.*?)(\\{#)(.*?)(\\})', '[[\\4]]\n==== \\2') # B-Head with ID
file <- replace_lines(file, '(^### )(.*?)', '==== \\2') # B-Head no ID

# Code blocks
file <- replace_lines(file, '(^ *)(```)(.*?)(\n)((.|\n)*?)(```)', '\\1[source,\\3]\n\\1----\n\\5----')

# Figures
file <- replace_lines(file, '(<img src=")(.*?)(")(.*?)(/>)(\n)(<p class="caption">)\n(.*?)\n(</p>)', '.\\8\nimage::\\2["\\8"]')
file <- replace_lines(file, '(<img src=")(.*?)(")(.*?)(/>)', 'image::\\2[]')
file <- replace_lines(file, '(::: \\{.figure\\})((.|\n)*?)(:::)', '\\2') # Remove figures

# Cross refs
file <- replace_lines(file, '(Section )(\\\\@ref\\()(.*?)(\\))', '<<\\3>>') # Section
file <- replace_lines(file, '(Chapter )(\\\\@ref\\()(.*?)(\\))', '<<\\3>>') # Chapter
file <- replace_lines(file, '(Figure )(\\\\@ref\\()(fig:)(.*?)(\\))', '<<fig-\\4>>') # Figures

# Other formatting
file <- replace_lines(file, '(::: \\{.rmdnote\\})((.|\n)*?)(:::)', '****\\2****') # Sidebar
file <- replace_lines(file, '(<https:)(.*?)(>)', 'https:\\2[]') # Links
file <- replace_lines(file, '(\\[.*?\\])(\\()(https?:)(.*?)(\\))', '\\3\\4\\1') # Links with anchor text
file <- replace_lines(file, '(\\[\\^.*?\\])((.|\n)*?)(\\1: )(.*?)(\n)', 'footnote:[\\5]\\2') # Footnotes

write_file(file, path_ext_set(path, ".asciidoc"))
}

asciidoc <- dir_ls("_oreilly/", glob = "*.asciidoc")
asciidoc %>% walk(munge_file)

path_ext_set(chapters, ".md") %>%
path("_oreilly", .) %>%
walk(md2asciidoc)

# indented code blocks - done
# captions - done
# parts - ([[unique_part_id]]\n[part])

# Copy additional resources -----------------------------------------------
library(dplyr)

resources <- tibble(chapter = chapters) %>%
rowwise(chapter) %>%
Expand Down
1 change: 1 addition & 0 deletions _oreilly/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.md
186 changes: 186 additions & 0 deletions _oreilly/action-bookmark.asciidoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
[[action-bookmark]]
== Bookmarking

By default, Shiny apps have one major drawback compared to most web sites: you can't bookmark the app to return to the same place in the future or share your work with someone else with a link in an email. That's because, by default, Shiny does not expose the current state of the app in its URL. Fortunately, however, you can change this behaviour with a little extra work and this chapter will show you how.

[source, r]
----
library(shiny)
----

=== Basic idea

Let's take a simple app that we want to make bookmarkable. This app draws Lissajous figures, which replicate the motion of a pendulum. This app can produce a variety of interesting patterns that you might want to share.

[source, r]
----
ui <- fluidPage(
sidebarLayout(
sidebarPanel(
sliderInput("omega", "omega", value = 1, min = -2, max = 2, step = 0.01),
sliderInput("delta", "delta", value = 1, min = 0, max = 2, step = 0.01),
sliderInput("damping", "damping", value = 1, min = 0.9, max = 1, step = 0.001),
numericInput("length", "length", value = 100)
),
mainPanel(
plotOutput("fig")
)
)
)
server <- function(input, output, session) {
t <- reactive(seq(0, input$length, length = input$length * 100))
x <- reactive(sin(input$omega * t() + input$delta) * input$damping ^ t())
y <- reactive(sin(t()) * input$damping ^ t())
output$fig <- renderPlot({
plot(x(), y(), axes = FALSE, xlab = "", ylab = "", type = "l", lwd = 2)
}, res = 96)
}
----

<!-- TODO: add demo -->

There are three things we need to do to make this app bookmarkable:

1. Add a `bookmarkButton()` to the UI. This generates a button that the user clicks to generate the bookmarkable URL.

2. Turn `ui` into a function. You need to do this because bookmarked apps have to replay the bookmarked values: effectively, Shiny modifies the default `value` for each input control. This means there's no longer a single static UI but multiple possible UIs that depend on parameters in the URL; i.e. it has to be a function.

3. Add `enableBookmarking = "url"` to the `shinyApp()` call.

Making those changes gives us:

[source, r]
----
ui <- function(request) {
fluidPage(
sidebarLayout(
sidebarPanel(
sliderInput("omega", "omega", value = 1, min = -2, max = 2, step = 0.01),
sliderInput("delta", "delta", value = 1, min = 0, max = 2, step = 0.01),
sliderInput("damping", "damping", value = 1, min = 0.9, max = 1, step = 0.001),
numericInput("length", "length", value = 100),
bookmarkButton()
),
mainPanel(
plotOutput("fig")
)
)
)
}
----

[source, r]
----
shinyApp(ui, server, enableBookmarking = "url")
----

If you play around with the app and bookmark a few interesting states, you'll see that the generated URLs look something like this:

- `http://127.0.0.1:4087/?_inputs_&damping=1&delta=1&length=100&omega=1`
- `http://127.0.0.1:4087/?_inputs_&damping=0.966&delta=1.25&length=100&omega=-0.54`
- `http://127.0.0.1:4087/?_inputs_&damping=0.997&delta=1.37&length=500&omega=-0.9`

To understand what's happening, let's take the first URL and tease it apart into pieces:

- `http://` is the "protocol" used to communicate with the app. This will always be `http` or `https`.

- `127.0.0.1` is the IP address of the server; `127.0.0.1` is a special address that always points to your own computer.

- `4087` is a randomly assigned "port". Normally, different apps get different IP addresses, but that's not possible when you're hosting multiple apps on your own computer.

- Everything after `?` is a parameter. Each parameter is separated by `&`, and if you break it apart you can see the values of each input in the app:

- `damping=1`
- `delta=1`
- `length=100`
- `omega=1`

So "generating a bookmark" means recording the current values of the inputs in the parameters of URL.

==== Updating the URL

Instead of providing an explicit button, another option is to automatically update the URL in the browser. This allows your users to use the user bookmark command in their browser, or copy and paste the URL from the location bar.

Automatically updating the URL requires a little boilerplate in the server function:

[source, r]
----
# Automatically bookmark every time an input changes
observe({
reactiveValuesToList(input)
session$doBookmark()
})
# Update the query string
onBookmarked(updateQueryString)
----

Which gives us an updated server function as follows:

[source, r]
----
server <- function(input, output, session) {
t <- reactive(seq(0, input$length, length = input$length * 100))
x <- reactive(sin(input$omega * t() + input$delta) * input$damping ^ t())
y <- reactive(sin(t()) * input$damping ^ t())
output$fig <- renderPlot({
plot(x(), y(), axes = FALSE, xlab = "", ylab = "", type = "l", lwd = 2)
}, res = 96)
observe({
reactiveValuesToList(input)
session$doBookmark()
})
onBookmarked(updateQueryString)
}
----

[source, r]
----
shinyApp(ui, server, enableBookmarking = "url")
----

You could now remove the bookmark button if you wanted.

==== Storing richer state

So far we've used `enableBookmarking = "url"` which stores the state directly in the URL. This a good place to start because it's very simple and works everywhere you might deploy your Shiny app. As you can imagine, however, the URL is going to get very long if you have a large number of inputs, and it's obviously not going to be able to capture an uploaded file.

For these cases, you might instead want to use `enableBookmarking = "server"`, which saves the state to an `.rds` file on the server. This always generates a short, opaque, URL but requires additional storage on the server. If you try it out locally with:

[source, r]
----
shinyApp(ui, server, enableBookmarking = "server")
----

You'll see that the bookmark button generates URLs like:

- `http://127.0.0.1:4087/?_state_id_=0d645f1b28f05c97`
- `http://127.0.0.1:4087/?_state_id_=87b56383d8a1062c`
- `http://127.0.0.1:4087/?_state_id_=c8b0291ba622b69c`

Which are paired with matching directories in your working directory:

- `shiny_bookmarks/0d645f1b28f05c97`
- `shiny_bookmarks/87b56383d8a1062c`
- `shiny_bookmarks/c8b0291ba622b69c`

The main drawbacks with server bookmarking is that it requires files to be saved on the server, and it's not obvious how long these need to hang around for. If you're bookmarking complex state and you never delete these files, your app is going to take up more and more disk space over time. If you do delete the files, some old bookmarks are going to stop working.

=== Bookmarking challenges

Automated bookmarking relies on the reactive graph. It seeds the inputs with the saved values then replays all reactive expressions and outputs, which will yield the same app that you see, as long as your app's reactive graph is straightforward. This section briefly covers some of the cases which need a little extra care:

- If your app uses random numbers, the results might be different even if all the inputs are the same. If it's really important to always generate the same numbers, you'll need to think about how to make your random process reproducible. The easiest way to do this is use `repeatable()`; see the documentation for more details.

- If you have tabs and want to bookmark and restore the active tab, make sure to supply an `id` in your call to `tabsetPanel()`.

- If there are inputs that should not be bookmarked, e.g. they contain private information that shouldn't be shared, include a called to `setBookmarkExclude()` somewhere in your server function. For example, `setBookmarkExclude(c("secret1", "secret2"))` will ensure that the `secret1` and `secret2` inputs are not bookmarked.

- If you are manually managing reactive state in your own `reactiveValues()` object (as we'll discuss in Chapter XYZ), you'll need to use the `onBookmark()` and `onRestore()` callbacks to manually save and load your additional state. See https://shiny.rstudio.com/articles/advanced-bookmarking.html[*Advanced Bookmarking*] for more details.

=== Exercises

1. Generate app for visualising the results of https://ambient.data-imaginist.com/reference/noise_simplex.html[ambient::noise_simplex()]. Your app should allow the user to control the frequency, fractal, lacunarity, and gain, and be bookmarkable. How can you ensure the image looks exactly the same when reloaded from the bookmark? (Think about what the `seed` argument implies).
2. Make a simple app that lets you upload a csv file and then bookmark it. Upload a few files and then look in `shiny_bookmarks`. How do the files correspond to the bookmarks? (Hint: you can use `readRDS()` to look inside the cache files that Shiny is generating).
Loading

0 comments on commit f22afc3

Please sign in to comment.