diff --git a/.Rbuildignore b/.Rbuildignore new file mode 100644 index 00000000..b7a1db3f --- /dev/null +++ b/.Rbuildignore @@ -0,0 +1,10 @@ +^renv$ +^renv\.lock$ +^data-raw$ +^dev_history\.R$ +^load_server_docker\.R$ +^library$ +^rstudio_prefs$ +^LICENSE\.md$ +^\.github$ +^Dockerfile$ diff --git a/.Rprofile b/.Rprofile new file mode 100644 index 00000000..81b960f5 --- /dev/null +++ b/.Rprofile @@ -0,0 +1 @@ +source("renv/activate.R") diff --git a/.github/.gitignore b/.github/.gitignore new file mode 100644 index 00000000..2d19fc76 --- /dev/null +++ b/.github/.gitignore @@ -0,0 +1 @@ +*.html diff --git a/.github/workflows/deploy_bookdown.yml b/.github/workflows/deploy_bookdown.yml new file mode 100644 index 00000000..fc668ede --- /dev/null +++ b/.github/workflows/deploy_bookdown.yml @@ -0,0 +1,94 @@ +on: + push: + branches: + - master + +name: renderbook + +jobs: + bookdown: + + name: Render-Book + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + + - uses: r-lib/actions/setup-r@v1 + with: + crayon.enabled: 'FALSE' + r-version: '3.6.1' + + - uses: actions/cache@v1 + with: + path: ~/.local/share/renv + key: ${{ runner.os }}-renv-${{ hashFiles('**/renv.lock') }} + restore-keys: | + ${{ runner.os }}-renv- + + - uses: r-lib/actions/setup-pandoc@v1 + + - name: Install tinytex + uses: r-lib/actions/setup-tinytex@master + + - name: Install sysreq + run: sudo apt update && sudo apt install -y gdal-bin git-core libcairo2-dev libgdal-dev libgeos-dev libgeos++-dev libgit2-dev libpng-dev libssh2-1-dev libssl-dev libudunits2-dev libxml2-dev make pandoc pandoc-citeproc zlib1g-dev libmagick++-dev libssl-dev libsasl2-dev + + - name: Pulling hexmake + run: docker pull colinfay/hexmake + + - uses: nanasess/setup-chromedriver@master + + - name: Install rmarkdown, bookdown and sysfonts + run: Rscript -e 'install.packages(c("sysfonts", "rmarkdown","bookdown"), Ncpus = 4)' + + - name: Spell check + run: Rscript before-build-spellcheck.R + env: + EMAIL: ${{ secrets.EMAIL }} # must be a verified email + GH_TOKEN: ${{ secrets.TOKEN }} # https://github.com/settings/tokens + + - name: Before build + run: Rscript prep.R + + - name: Render Book + run: docker pull colinfay/hexmake && Rscript -e 'bookdown::render_book("index.Rmd")' + + - name: Build redirect + run: Rscript redirect.R + + - uses: actions/upload-artifact@v1 + with: + name: _site + path: _site/ + +# Need to first create an empty gh-pages branch +# see https://pkgdown.r-lib.org/reference/deploy_site_github.html +# and also add secrets for a GITHUB_PAT and EMAIL to the repository +# gh-action from Cecilapp/GitHub-Pages-deploy + checkout-and-deploy: + runs-on: ubuntu-latest + needs: bookdown + steps: + - name: Checkout + uses: actions/checkout@master + - name: Download artifact + uses: actions/download-artifact@v1.0.0 + with: + # Artifact name + name: _site # optional + # Destination path + path: _site # optional + - name: Deploy to GitHub Pages + uses: Cecilapp/GitHub-Pages-deploy@v3 + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} + with: + email: ${{ secrets.EMAIL }} + build_dir: _site # optional + cname: engineering-shiny.org # optional + jekyll: no # optional + + + \ No newline at end of file diff --git a/.github/workflows/deploy_bookdown_wip.yml b/.github/workflows/deploy_bookdown_wip.yml new file mode 100644 index 00000000..e6a5414c --- /dev/null +++ b/.github/workflows/deploy_bookdown_wip.yml @@ -0,0 +1,61 @@ +on: + push: + branches: + - wip + +name: renderbook + +jobs: + bookdown: + + name: Render-Book + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + + - uses: r-lib/actions/setup-r@v1 + with: + crayon.enabled: 'FALSE' + + - uses: actions/cache@v1 + with: + path: ~/.local/share/renv + key: ${{ runner.os }}-renv-${{ hashFiles('**/renv.lock') }} + restore-keys: | + ${{ runner.os }}-renv- + + - uses: r-lib/actions/setup-pandoc@v1 + + - name: Install tinytex + uses: r-lib/actions/setup-tinytex@master + + - name: Install sysreq + run: sudo apt update && sudo apt install -y gdal-bin git-core libcairo2-dev libgdal-dev libgeos-dev libgeos++-dev libgit2-dev libpng-dev libssh2-1-dev libssl-dev libudunits2-dev libxml2-dev make pandoc pandoc-citeproc zlib1g-dev libmagick++-dev libssl-dev libsasl2-dev + + - name: Pulling hexmake + run: docker pull colinfay/hexmake + + - name: Install Chromium + run: | + sudo apt-get update + sudo apt-get install software-properties-common + sudo add-apt-repository ppa:canonical-chromium-builds/stage + sudo apt-get update + sudo apt-get install chromium-browser + + - name: Install rmarkdown, bookdown and sysfonts + run: Rscript -e 'install.packages(c("sysfonts", "rmarkdown","bookdown"), Ncpus = 4)' + + - name: Before build + run: Rscript before-build.R + + - name: Render Book + run: Rscript -e 'bookdown::render_book("index.Rmd", output_dir = "_book/wip")' + + - uses: actions/upload-artifact@v1 + with: + name: _book + path: _book/ + diff --git a/.gitignore b/.gitignore index 5b6a0652..9d7b3510 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,16 @@ .Rhistory .RData .Ruserdata +_bookdown_files/ +docs/ +packages.bib +fcache/ +load_server_docker.R +library/ +rstudio_prefs +building-shiny-apps-workflow* +!building-shiny-apps-workflow.Rproj +_book/* +!engineering-production-grade-shiny-apps.Rproj +golex/ +todoedit diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..281cf0c4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +language: r +cache: packages +os: linux +dist: bionic + +env: + global: + - MAKEFLAGS="-j 2" + +jobs: + include: + - r: 3.6 + +install: + - Rscript -e 'if(dir.exists("cache")){unlink("cache")}' + - Rscript -e 'install.packages("remotes")' + - Rscript -e "install.packages('tinytex')" + - Rscript -e "tinytex::install_tinytex()" + - Rscript -e 'remotes::install_github("ThinkR-open/golem", ref = "dev")' + - Rscript -e 'remotes::install_local(force = TRUE)' + +script: + - if [ $TRAVIS_BRANCH == "wip" ] ; then make html_wip; fi + - if [ $TRAVIS_BRANCH == "master" ] ; then make html_master; fi + - if [ $TRAVIS_BRANCH == "wip" ] ; then make pdf; fi + +after_success: + - if ([ $TRAVIS_PULL_REQUEST == "false" ] && [ $TRAVIS_BRANCH == "wip" ]) ; then bash deploy.sh; fi + - if ([ $TRAVIS_PULL_REQUEST == "false" ] && [ $TRAVIS_BRANCH == "master" ]) ; then bash deploy.sh; fi + + diff --git a/00-app-presentation.Rmd b/00-app-presentation.Rmd new file mode 100644 index 00000000..f36a6343 --- /dev/null +++ b/00-app-presentation.Rmd @@ -0,0 +1,99 @@ +# Foreword {.unnumbered} + +As a long-time R user (since Version `2.0.0` back in 2004), I have seen more than a few "game-changing" advancements which transformed my entire workflow and opened the doors to new possibilities I never imagined. +One of those came in late 2012 when RStudio released `{shiny}` to the R community. +I was absolutely floored by the very notion that I could create not just a web interface, but a dynamic web interface, all through R code! +To give a little perspective, the only web interfaces I had built before Shiny were very utilitarian PHP-powered sites with a MySQL database back-end to summarize local state parks data near my graduate school's location, and let's just say those projects would not win any awards for web design! + +I certainly experienced the longtime adage of learning the hard way as I began to create Shiny apps at my day job and for personal projects. +Over the first year or so of my time with Shiny, I created small apps that revealed the potential it could bring, and it is still amazing that I somehow stitched those together without fully understanding the nuances of reactivity, optimal UI designs, and other software-development principles that a classically trained statistician and Linux enthusiast never knew about! +Things began to click in my mind bit by bit (especially after attending the first (and only) Shiny Developer Conference), and I found myself with the task of creating not just simple prototypes, but large-scale software products meant for **production** use. +Oh my, what have I gotten myself into? + +While being a frequent visitor to the Shiny mailing list and the helpful `shiny` tag on Stack Overflow, I felt a serious lack of resources addressing the optimal techniques, best practices, and practical advice of taking my Shiny apps to production. +And then, one of the most transformative events in my R usage occurred. +During the 2019 `rstudio::conf`, I was checking out the excellent poster session and found the [Building Big Shiny Apps](https://thinkr-open.github.io/rstudioconf2019) poster presented by Colin Fay. +I had known Colin as a fellow curator for the [RWeekly](https://rweekly.org/) project and knew he had done some work with Shiny, but during his walkthrough I always had this loud voice in my head saying "Hey, Colin knows exactly what I've been thinking about!" This was the first time I saw the important challenges any Shiny app developer in this space will undoubtedly encounter stated in language I could relate to, even with me being new to the software development mindset. +Needless to say, I had tremendous fun talking Shiny and all things R with Colin and Vincent Guyader at the conferences, trying to soak up all of their insights and advice every chance I could. + +Colin and I both agreed in our Shiny Developer Series [episode](https://shinydevseries.com/post/episode-2-golem) that creating resources for this audience was an important step in the evolution of sharing best practices with Shiny. +Fast forward to today, and you are now reading a tremendous resource aimed squarely at the R users in our world who have embarked on creating production-level applications. +*Engineering Production-Grade Shiny Apps* contains an excellent blend of both Shiny-specific topics (many of which have not been addressed in previous books about Shiny) and practical advice from software development that fit in nicely with Shiny apps. +You will find many nuggets of wisdom sprinkled throughout these chapters. +It's very hard to pick favorites, but certainly one that felt like a moment of enlightenment was the concept of building triggers and watchers to define your own patterns of object invalidation. +Now I use that technique in every app I create! +Of course, one of the key pillars holding the foundation of this book is the [`{golem}`](https://thinkr-open.github.io/golem) package, and I have found that the time I invested to learn the ins and outs of creating applications with `{golem}` has paid off significantly for creating my complex applications, especially with multi-person development teams. +As I was finishing my writing of this Foreword, my four-year-old son asked me, "Why does `{golem}` create nice things, Daddy?" Well, this book is easily the best way to explain that answer! +I hope reading *Engineering Production-Grade Shiny Apps* helps you on your journey to creating large Shiny applications! + +> Eric Nantz - Host of the R-Podcast and the Shiny Developer Series + + +# Application presentation {.unnumbered} + +This book uses a series of applications as examples. + +## `{hexmake}` {.unnumbered} + +`{hexmake}` is an application that has been designed to build hex logos. +It was built by Colin, and it serves two main purposes: it helps the creation of a logo, but mainly it serves as an example of some complex features you can use inside a `{shiny}` application (image manipulation, custom CSS, linking to an external database, save and restore, etc.). + +Figure \@ref(fig:00-app-presentation-1) is a screenshot of this application. + +(ref:hexmakefront) The `{hexmake}` application. + +```{r 00-app-presentation-1, echo=FALSE, fig.cap="(ref:hexmakefront)", out.width="100%"} +knitr::include_graphics("img/hexmake.png") +``` + +The app is available at [engineering-shiny.org/hexmake/](https://engineering-shiny.org/hexmake/). + +The code is available at [github.com/ColinFay/hexmake](https://github.com/ColinFay/hexmake). + +## `{tidytuesday201942}` {.unnumbered} + +`{tidytuesday201942}` is an application using the dataset from week 42 of `tidytuesday` 2019. +It was built by Colin, and it serves as an example of an app built from scratch using bootstrap 4. + +Figure \@ref(fig:00-app-presentation-2) is a screenshot of this application. + +(ref:tidytuesday) The `{tidytuesday201942}` application. + +```{r 00-app-presentation-2, echo=FALSE, fig.cap="(ref:tidytuesday)", out.width="100%"} +knitr::include_graphics("img/tidytuesdayapp.png") +``` + +The app is available at [engineering-shiny.org/tidytuesday201942/](https://engineering-shiny.org/tidytuesday201942/). + +The code is available at [github.com/ColinFay/tidytuesday201942](https://github.com/ColinFay/tidytuesday201942). + +## `{minifying}` {.unnumbered} + +`{minifying}` is an application to minify CSS, JavaScript, HTML, and JSON files. +It was built built by Colin as a use case for the workflow of this book. +You will find the details of how this app was constructed in the Appendix, "*Use case: Building an App, from Start to Finish*". + +Figure \@ref(fig:00-app-presentation-3) is a screenshot of this application. + +(ref:minifying) The `{minifying}` application. + +```{r 00-app-presentation-3, echo=FALSE, fig.cap="(ref:tidytuesday)", out.width="100%"} +knitr::include_graphics("img/minifying.png") +``` + +The app is available at [engineering-shiny.org/minifying/](https://engineering-shiny.org/minifying/). +The code is available at [github.com/ColinFay/minifying](https://github.com/ColinFay/minifying). + +## Other applications {.unnumbered} + +- `{shinipsumdemo}` is an application built by Cervan as an example for `{shinipsum}`, available at [engineering-shiny.org/shinipsumdemo/](https://engineering-shiny.org/shinipsumdemo/). + +- `{golemhtmltemplate}` is an application built by Colin as an example for `{shinipsum}` and `shiny::htmlTemplate()`, available at [engineering-shiny.org/golemhtmltemplate/](https://engineering-shiny.org/golemhtmltemplate/). + +- `{databasedemo}` is an application built by Cervan using an external database, available at [engineering-shiny.org/databasedemo/](https://engineering-shiny.org/databasedemo/). + +- `{grayscale}` is an application built by Cervan using an external html template, available at [engineering-shiny.org/grayscale/](https://engineering-shiny.org/grayscale/). + +- `{bs4dashdemo}` is an application built by Cervan with the `{bs4dash}` package, available at [engineering-shiny.org/bs4dashdemo/](https://engineering-shiny.org/bs4dashdemo/). + +- `{shinyfuture}` is an application built by Cervan as an example of using `{promises}` and `{future}` inside a `{shiny}` app, available at [engineering-shiny.org/shinyfuture/](https://engineering-shiny.org/shinyfuture/). diff --git a/01-big-shiny.Rmd b/01-big-shiny.Rmd new file mode 100644 index 00000000..eb249777 --- /dev/null +++ b/01-big-shiny.Rmd @@ -0,0 +1,603 @@ +\mainmatter + +# (PART) Building Successful {shiny} Apps {.unnumbered} + +# About Successful `{shiny}` Apps {#successful-shiny-app} + +## A (very) short introduction to `{shiny}` + +If you are reading this page, chances are you already know what a `{shiny}` application (sometimes shortened as "`{shiny}` app") is—**a web application that communicates with R, built in R, and working with R**. +The beauty of `{shiny}` [@R-shiny] is that it makes it easy for someone already familiar with R to create a small app in a matter of hours. +With small and minimal `{shiny}` apps, no knowledge of HTML (HyperText Markup Language), CSS(Cascading Style Sheets) or JavaScript is required, and you do not have to think about technical elements that usually come with web applications—for example, you do not have to think about the port the application is served on: `{shiny}` picks one for you.[^big-shiny-1] +Same goes for serving external dependencies: the application comes with its set of CSS and JavaScript dependencies that a common `{shiny}` developer does not need to worry about. +And that is probably one of the main reasons why this package has become so successful over the years—**with very little training, you can rapidly create a proof-of-concept (PoC) for a data product, showcase an algorithm, or present your results in an elegant and accessible user interfaces**. + +[^big-shiny-1]: Of course you can specify one if you need to, but by default the package picks one. + +The first version of `{shiny}` was published in 2012. +Since then, it has been one of the top projects of the RStudio team. +At the time of writing these lines (April 2020), there are more than 4700 commits in the master branch of the GitHub repository, made by 46 contributors. +It is now downloaded around 400K times a month, according to [cranlogs](https://cranlogs.r-pkg.org/badges/shiny), and has `r length(devtools::revdep("shiny"))` reverse dependencies (i.e. packages that depend on it), according to `revdep("shiny")` from `{devtools}` [@R-devtools]. + +If you are very new to `{shiny}`, this book might feel a little bit overwhelming: we will be discussing some advanced `{shiny}` and software engineering methods, best practices and structural ideas for sending `{shiny}` applications to production. +This book relies on the assumption that you already know how to build basic `{shiny}` applications, and that you want to push your `{shiny}` skills to the next level: in other words, you are ready to move from the Proof of Concept to the production-grade application. +If you are very new to `{shiny}`, we suggest you start with the [_Mastering Shiny_ book](https://mastering-shiny.org/) before reading the present book. + +Ready to start engineering production-grade `{shiny}` apps? + +## What is a complex `{shiny}` application? {#complex-shiny} + +> One of the unfortunate things about reality is that it often poses complex problems that demand complex solutions.\ +> +> _The Art of Unix Programming_ [@ericraymond2003] + +### Reaching the cliff of complexity + +Building a `{shiny}` application seems quite straightforward when it comes to small prototypes or proof of concepts: after a few hours of practice and documentation reading, most R developers can have a small working application.\ +But things change when your application reaches "the cliff of complexity",[^big-shiny-2] i.e. that moment when the application reaches a state when it can be qualified as "complex". + +[^big-shiny-2]: We borrow this term from Charity Major, as heard in *Test in Production with Charity Majors* CoRecursive #019, _Aug 31, 2018_. + +But what do we mean by complexity? +Getting a clear definition is not an easy task [^big-shiny-3] as it very much depends on who is concerned and who you are talking to. +But a good definition can be found in *The DevOps Handbook* [@genekim2016]: "One of the defining characteristics of a complex system is that it **defies any single person's ability to see the system as a whole and understand how all the pieces fit together**. Complex systems typically have a high degree of interconnectedness of tightly coupled components, and system-level behavior cannot be explained merely in terms of the behavior of the system components." (Our bold.) + +[^big-shiny-3]: Ironic, right? + +Or as noted in _Refactoring at Scale_ [@lemaire2020], "It becomes nearly impossible to reason about the effect a change might have when applied uniformly across a sprawling, complex system. Many tools exist to identify code smells or automatically detect improvements within subsections of code, but we are largely unable to automate human reasoning about how to restructure large applications, in codebases that are growing at an increasingly rapid pace." + +Building on top of these quotes, let's try to come up with a definition that will serve us in the context of engineering `{shiny}` applications. + +When building software, we can think of complexity from two points of view: the complexity as it is seen by the developer, and the complexity as it is seen by the customer/end user.[^big-shiny-4] + +[^big-shiny-4]: From *The Art of Unix Programming*, "Chapter 13: Speaking of Complexity" [@ericraymond2003]. + +- For the code, **bugs are harder to anticipate**: it is hard to think about all the different paths the software can follow and difficult to identify bugs because they are deeply nested in the numerous routines the app is doing. + It is also hard to think about what the state of your app is at a given moment because of the numerous inputs and outputs your app contains. + +- **From the user perspective, the more complex an app is, the steeper the learning curve**. + Indeed, the user will have to invest more time learning how the app works, and will be even more disappointed if ever they realize this time has been a waste. + +Let's dive a little bit more into these **two types of complexity**. + +#### A. Developer complexity {.unnumbered} + +An app is to be considered complex when it is so large in terms of size and functionality that it makes it impossible to reason about it at once, and **developers must rely on tools and methods to understand and handle this complexity**: for example, when it comes to `{shiny}`, you will rely on tools like the `{golem}` [@R-golem] framework, introduced throughout this book, to handle some implementation, development and deployment complexity. +This book will introduce a clear methodology that comes with a series of conventions, which are crucial when it comes to building and maintaining complex systems: by imposing a formalized structure for software, it enhances its readability, lowers the learning curve for newcomers, and reduces the risk of errors inherent in repetitive tasks. + +This type of complexity is called *implementation complexity*. +One of the goals of this book is to present a methodology and toolkit that will help you reduce this form of complexity. + +#### B. Customer and user complexity {.unnumbered} + +Customers and end users see complexity as *interface complexity*. + +Interface complexity can be driven by a lot of elements, for example, the probability of making an error while using the app, the difficulty in understanding the logical progression in the app, the presence of unfamiliar behavior or terms, visual distractions, etc. +This book will also bring you strategy to help you cope with the need for simplification when it comes to designing an interface. + +### Balancing complexities + +There is an inherent tension between these two sources of complexity, as designing an app means finding a good balance between implementation and interface complexity. +**Lowering one source of complexity usually means increasing the other, and managing an application project means knowing where to draw the line**. +This usually requires restraining yourself from implementing too many features, and still creating an application that is easy to use, and that fits the requirements you have received. + +For example, there is something common in `{shiny}` applications: what we can call the "too much reactivity pattern". +In some cases, developers try to make everything reactive: *e.g.*, three sliders and a drop-down input, all updating a single plot. +This behavior lowers the interface complexity: users do not have to really think about what they are doing, they move sliders, change the inputs, and boom! +the plot updates. +But this kind of pattern can make the application perform too much computation, for example, because users rarely go to the value they need on their first try: they usually miss the value they actually want to select. + +One solution can be to delay reactivity or to cache things so that R computes fewer things. +But that comes with a cost: handling delayed reactivity and caching elements increase implementation complexity. +Another solution is to add an "update plot" button, which updates the plot only when the user clicks on it. +This pattern makes it easier to control reactivity from the implementation side. +But this can make the interface a little bit more complex for the users, who have to perform another action, on top of changing their inputs. +We will argue in this book that not enough reactivity is better than too much reactivity, as the latter increases computation time, and relies on the assumption that the user makes the right action on the first try. + +Another good example is `{shiny}`'s `dateRangeInput()` function. +This function requires the user to choose a start date and an end date. +However, the function allows the user to choose a start date which is before the end (that is the behavior of the JavaScript plugin used in `{shiny}` to create this input). +But allowing this behavior leads to bugs, notably in a context of full reactivity. +Handling this special case is completely doable: with a little bit of craft, you can watch what the user inputs and throw an error if the start is after the end.[^big-shiny-5] +On one hand, that solution increases implementation complexity, while on the other hand, allowing this naive behavior requires the user to think carefully about what they are selecting, thus increasing the interface complexity. + +[^big-shiny-5]: See [shiny/issues/2043\#issuecomment-525640738](https://github.com/rstudio/shiny/issues/2043#issuecomment-525640738){target="_blank"} for an example. + +What should we do? +It's up to you: deciding where to draw the line between interface and implementation complexity very much depends on the project, but that is something that you should keep in mind throughout the project's life. + +### Assessing code complexity + +On the developer side, you will want to **reduce code complexity so that everybody involved in the coding process is able to create a mental model of how the application works**. +On the user side, you will want to **reduce interface complexity so that everybody comes out of using your application with a good user experience**. + +Reducing complexity first comes with being able to identify its potential sources, be it in your application codebase or in the specifications describing how the application should work. +Finding these sources of complexity is not an easy task, as it requires some programming knowledge to identify bottlenecks, basic UX (User Experience) skills to implement a good interface, and of course a project management methodology to structure the whole life of your application. + +All these points will be addressed in this book. +But before that, let's dive into code complexity. + +#### A. Codebase size {.unnumbered} + +The total number of lines of code, and the number of files, can be good clue of potential complexity, but only if used as an order of magnitude (for example, a 10,000-line codebase is potentially more complex than a 100-line codebase), but should not be relied on if used strictly, even more if you try to reduce the number of lines by sacrificing code readability. + +R is very permissive when it comes to indentation and line breaks, and, unlike JavaScript or CSS, it is generally not minified.[^big-shiny-6] +In R, the number of lines of code depends on your coding style and the packages you are using. For example, the `{tidyverse}` [@tidyverse2019] style guide encourages the use of `%>%` (called "pipe"), with one function by line, producing more lines in the end code: "`%>%` should always have a space before it, and should usually be followed by a new line" ([tidyverse style guide](https://style.tidyverse.org/pipes.html){target="_blank"}). +So you can expect a "tidyverse-centric" package to contain more lines of code, yet the pipe itself has been thought of as a tool to lower code complexity by enhancing its readability.[^big-shiny-7] + +[^big-shiny-6]: The minification process is the process of removing all blank characters and putting everything on one line so that the file in the output is much smaller. + +[^big-shiny-7]: Note though that some users find using the pipe more complex. + +For example, the two following pieces of code do the same thing. +Both have a different number of lines, and a different level of reading complexity. + +```{r 01-big-shiny-1, eval = FALSE} +library(dplyr, warn.conflicts = FALSE) +# With pipe +iris %>% + group_by(Species) %>% + summarize(mean_sl = mean(Sepal.Length)) +# Without the pipe +summarize(group_by(iris, Species), mean_sl = mean(Sepal.Length)) +``` + +Also, there is no limit in the way you can indent your code. + +```{r 01-big-shiny-2, eval = FALSE} +# Putting one symbol by line +iris[ + 1 + : + 5, + "Species" +] +``` + +Six lines of code for something that could also be written in one line. + +```{r 01-big-shiny-3, eval = FALSE} +# Same code but everything is on the same line +iris[1:5, "Species"] +``` + +In other words, using this kind of writing style can make the codebase larger in term of lines, without really adding complexity to the general program. + +Another drawback of this metric is that it focuses on numbers instead of readability, and in the long run, yes, readability matters. +As noted in *The Art of Unix Programming*, "Pressure to keep the codebase size down by using extremely dense and complicated implementation techniques can cause a cascade of implementation complexity in the system, leading to an un-debuggable mess" [@ericraymond2003]. + +Still, this metric can be useful to reinforce what you have learned from other metrics. +It is rather unlikely that you will find this "extreme" coding style we showed above, and even if it might not make sense to compare two codebases that just differ by 1% or 2 % of lines of code, it is very likely that a codebase which is ten, one hundred, one thousand times larger is a more complex software. + +Another good metric related to code complexity is the number of files in the project: R developers tend to split their functions into several files, so the more files you will find in a project, the larger the codebase is. +And numerous files can also be a sign of maintenance complexity, as it may be harder to reason about an app logic that is split into several files than about something that fits into one linear code inside one file.[^big-shiny-8] +On the other hand, one big 10,000-line file which is standing alone in the project is not a good sign either. + +[^big-shiny-8]: To handle the complexity of splitting into files, you can set filenames to follow the structure of the project. +This pattern is developed in another part of this book, where we explain the conventions used in `{golem}`. + +If you want to use the number-of-lines metric, you can do it from R with the `{cloc}` [@R-cloc] package, available at . + +```{r 01-big-shiny-4, include = FALSE} +if (!requireNamespace("cloc")){ + remotes::install_github("hrbrmstr/cloc") +} +``` + +```{r 01-big-shiny-5, eval = FALSE} +# Install {cloc} from GitHub +remotes::install_github("hrbrmstr/cloc") +``` + +For example, let's compare a rather big package (`{shiny}`) with a small one (`{attempt}` [@R-attempt]): + +```{r 01-big-shiny-6, eval = FALSE } +library(cloc) +# Using dplyr to manipulate the results +library(dplyr, warn.conflicts = FALSE) + +# Computing the number of lines of code +# for various CRAN packages +shiny_cloc <- cloc_cran( + "shiny", + .progress = FALSE, + repos = "http://cran.irsn.fr/" +) +attempt_cloc <- cloc_cran( + "attempt", + .progress = FALSE, + repos = "http://cran.irsn.fr/" +) + +clocs <- bind_rows( + shiny_cloc, + attempt_cloc +) + +# Summarizing the number of line of code inside each package +clocs %>% + group_by(pkg) %>% + summarise( + loc = sum(loc) + ) +``` + +```{r echo = FALSE} +structure(list(pkg = c("attempt", "shiny"), loc = c(6486L, 175376L +)), row.names = c(NA, -2L), class = c("tbl_df", "tbl", "data.frame" +)) +``` + + +```{r eval = FALSE} +# Summarizing the number of files inside each package +clocs %>% + group_by(pkg) %>% + summarise( + files = sum(file_count) + ) + +``` + +```{r echo = FALSE} +structure(list(pkg = c("attempt", "shiny"), files = c(64L, 736L +)), row.names = c(NA, -2L), class = c("tbl_df", "tbl", "data.frame" +)) +``` + + + +Here, with these two metrics, we can safely assume that `{shiny}` is a more complex package than `{attempt}`. +If you want to compute the same prefix for a local package/repository, the `cloc_pkg()` function can be used. +For example, here is how to compute the cloc metric for the `{hexmake}` application: + +```{r 01-big-shiny-8, echo = FALSE} +if ( + dir.exists( + file.path( + tempdir(), + "hexmake" + ) + ) +){ + unlink( + file.path( + tempdir(), + "hexmake" + ), + recursive = TRUE, + force = TRUE + ) +} +``` + +```{r 01-big-shiny-9, eval = FALSE} +# Calling the function on the {hexmake} +# application Git repository +hexmake_cloc <- cloc_git( + "https://github.com/ColinFay/hexmake" +) +hexmake_cloc +``` + +```{r echo = FALSE} +hexmake_cloc <- structure(list(source = c("hexmake", "hexmake", "hexmake", "hexmake", +"hexmake", "hexmake", "hexmake", "hexmake", "hexmake", "hexmake" +), language = c("JSON", "R", "Markdown", "CSS", "Dockerfile", +"JavaScript", "Rmd", "HTML", "YAML", "SUM"), file_count = c(1L, +34L, 5L, 1L, 2L, 2L, 2L, 1L, 1L, 49L), file_count_pct = c(0.0102040816326531, +0.346938775510204, 0.0510204081632653, 0.0102040816326531, 0.0204081632653061, +0.0204081632653061, 0.0204081632653061, 0.0102040816326531, 0.0102040816326531, +0.5), loc = c(3844L, 2345L, 95L, 76L, 45L, 31L, 18L, 14L, 8L, +6476L), loc_pct = c(0.296788140827671, 0.181053119209389, 0.00733477455219271, +0.00586781964175417, 0.00347436689314392, 0.00239345274861025, +0.00138974675725757, 0.00108091414453366, 0.000617665225447807, +0.5), blank_lines = c(0L, 268L, 50L, 16L, 3L, 2L, 46L, 0L, 0L, +385L), blank_line_pct = c(0, 0.348051948051948, 0.0649350649350649, +0.0207792207792208, 0.0038961038961039, 0.0025974025974026, 0.0597402597402597, +0, 0, 0.5), comment_lines = c(0L, 669L, 0L, 0L, 0L, 1L, 65L, +0L, 0L, 735L), comment_line_pct = c(0, 0.455102040816327, 0, +0, 0, 0.000680272108843537, 0.0442176870748299, 0, 0, 0.5)), class = c("tbl_df", +"tbl", "data.frame"), row.names = c(NA, -10L)) +hexmake_cloc +``` + + +One thing that this package also allows is counting the number of lines of commented code: it's usually a good sign to see that a package has comments in its code-base, as it will allow to work more safely in the future, provided that this metric doesn't reveal that large portions of the application are "commented code" (as opposed to "code comments"). +For example, here we can see that `{hemake}` has `r dplyr::filter(hexmake_cloc, language == "R") %>% dplyr::pull(loc)` lines of R code, which come with `r dplyr::filter(hexmake_cloc, language == "R") %>% dplyr::pull(comment_lines)` lines of code comments. + +#### B. Cyclomatic complexity {.unnumbered} + +Cyclomatic complexity is a software engineering measure which **allows us to define the number of different linear paths a piece of code can take**. +The higher the number of paths, the harder it can be to have a clear mental model of this function. + +Cyclomatic complexity is computed based on a control-flow graph [^big-shiny-9] representation of an algorithm, as can be seen on Figure \@ref(fig:01-big-shiny-10). +For example, here is a simple control flow for an `ifelse` statement *(The following paragraph details the algorithm implementation, feel free to skip it if you are not interested in the implementation details)*. + +[^big-shiny-9]: A control flow graph is a graph representing all the possible paths a piece of code can take while it is executed. + +(ref:controlflowcap) Control-flow graph representation of an algorithm. + +```{r 01-big-shiny-10, echo=FALSE, fig.cap="(ref:controlflowcap)", out.width='100%'} +knitr::include_graphics("img/controlflow.png") +``` + +The complexity number is then computed by taking the number of nodes, subtracting the number of edges, and adding twice the number of connected components of this graph. +The algorithm is then $M = E − N + 2P$, where $M$ is the measure, $E$ the number of edges, $N$ the number of nodes and $2P$ is twice the number of connected components. +We will not go deep into this topic, as there are a lot things going on in this computation and you can find much documentation about this online. +Please refer to the bibliography for further readings about the theory behind this measurement. + +In R, the cyclomatic complexity can be computed using the `{cyclocomp}` [@R-cyclocomp] package. +You can get it from `CRAN` with: + +```{r 01-big-shiny-11, include=FALSE} +# for writers +if (!requireNamespace("cyclocomp")) {install.packages("cyclocomp")} +``` + +```{r 01-big-shiny-12, eval = FALSE} +# Install the {cyclocomp} package +install.packages("cyclocomp") +``` + +The `{cyclocomp}` package comes with three main functions: `cyclocomp()`, `cyclocomp_package()`, and `cyclocomp_package_dir()`. +While developing your application, the one you will be interested in is `cyclocomp_package_dir()`: building successful shiny apps with the `{golem}` framework means you will be building your app as a package (we will get back on that later). + +Here is, for example, the cyclomatic complexity of the default golem template (assuming it is located in a `golex/` subdirectory): + +```{r 01-big-shiny-13, include = FALSE} +remotes::install_local("golex", upgrade = "never") +if (!dir.exists("golex")){ + source("golembuild.R") +} +``` + +```{r 01-big-shiny-14, eval=FALSE} +# Launch the {cyclocomp} package, and compute the +# cyclomatic complexity of "golex", +# A blank {golem} project with one module skeleton +library(cyclocomp) +cyclocomp_package_dir("golex") %>% + head() +``` + +```{r 01-big-shiny-15, echo=FALSE} +cyclo_golex <- readr::read_rds(here::here("dataset", "cyclo_golex.rds")) +head(cyclo_golex) +``` + +```{r 01-big-shiny-16, include = FALSE} +#remove.packages("golex") +``` + +And the one from another small application: + +```{r 01-big-shiny-17, include = FALSE} +if (!requireNamespace("tidytuesday201942")) { + remotes::install_github("ColinFay/tidytuesday201942") +} +``` + +```{r 01-big-shiny-18, eval=FALSE} +# Same metric, but for the application +# {tidytuesday201942}, available at +# https://engineering-shiny.org/tidytuesday201942.html +cyclocomp_package("tidytuesday201942") %>% + head() +``` + +```{r 01-big-shiny-19, echo=FALSE} +cyclo_tidytuesday <- readr::read_rds(here::here("dataset", "cyclo_tidytuesday.rds")) +head(cyclo_tidytuesday) +``` + +And, finally, the same metric for `{shiny}`: + +```{r 01-big-shiny-20, include = FALSE} +if (!requireNamespace("shiny")) { + remotes::install_cran("shiny") +} +``` + +```{r 01-big-shiny-21, eval=FALSE} +# Computing this metric for the {shiny} package +cyclocomp_package("shiny") %>% + head() +``` + +```{r 01-big-shiny-22, echo=FALSE} +cyclo_shiny <- readr::read_rds(here::here("dataset", "cyclo_shiny.rds")) +head(cyclo_shiny) +``` + +And, bonus, this `cyclocomp_package()` function can also be used to retrieve the number of functions inside the package. + +As The Clash said, "What are we gonna do now?" +You might have heard this saying: "if you copy and paste a piece of code twice, you should write a function", so you might be tempted to do that. +Indeed, splitting code into smaller pieces lowers the local cyclomatic complexity, as smaller functions have lower cyclomatic complexity. +But that is just at a local level, and it can be a suboptimal option: having a very large number of functions calling each other can make it harder to navigate through the codebase. + +In the end of the day, splitting into smaller functions is not a magic solution because: + +- the global complexity of the app is not lowered by splitting things into pieces (just local complexity) and +- tThe deeper the call stack, the harder it can be to debug. + +#### C. Other measures for code complexity {.unnumbered} + +Complexity can come from other sources: **insufficient code coverage, dependencies that break the implementation, relying on old packages**, or a lot of other things. + +We can use the `{packageMetrics2}` [@R-packageMetrics2] package to get some of these metrics: for example, the number of dependencies, the code coverage, the number of releases and the date of the last one, etc., and the number of lines of code and the cyclomatic complexity. + +```{r 01-big-shiny-23, include = FALSE} +if (!requireNamespace("packageMetrics2")) { + remotes::install_github("MangoTheCat/packageMetrics2") +} +``` + +At the time of writing these lines, the package is not on CRAN and can be installed using the following line of code: + +```{r 01-big-shiny-24, echo = FALSE, include = FALSE} +# Installing {packageMetrics2} from GitHub +remotes::install_github("MangoTheCat/packageMetrics2") +``` + +This package can now be used to assess the dependencies we use in our application. +To do that, let's create a small function that computes this metric and returns a tibble: + +```{r 01-big-shiny-25 } +library() +# A function to turn the output of the metrics into a data.frame +frame_metric <- function(pkg){ + metrics <- package_metrics(pkg) + tibble::tibble( + n = names(metrics), + val = metrics, + expl = list_package_metrics()[names(metrics)] + ) +} +``` + +```{r 01-big-shiny-26, echo=FALSE, eval=FALSE} +db <- memoise::cache_filesystem(here::here("fcache/")) +frame_metric <- memoise::memoise(frame_metric, cache = db) +``` + +And run the metric for `{golem}`, + +```{r 01-big-shiny-2-bis, include = FALSE, cache=TRUE} +#f_golem <- frame_metric("golem") +# saveRDS(f_golem, "data-raw/f_golem.RDS") +``` + +```{r 01-big-shiny-27, cache=TRUE, warning=FALSE, eval = FALSE} +# Using this function with{golem} +frame_metric("golem") +``` + +```{r 01-big-shiny-3-bis, echo = FALSE} +readRDS("data-raw/f_golem.RDS") +``` + +And `{shiny}` + +```{r 01-big-shiny-4-bis, include = FALSE} +# f_shiny <- frame_metric("shiny") +# saveRDS(f_shiny, "data-raw/f_shiny.RDS") +``` + +```{r 01-big-shiny-29, cache=TRUE, warning=FALSE, eval = FALSE} +# Using this function with {shiny} +frame_metric("shiny") +``` + +```{r 01-big-shiny-5-bis, echo = FALSE} +readRDS("data-raw/f_shiny.RDS") +``` + +If you are building your `{shiny}` application with `{golem}`, you can use the `DESCRIPTION` file, which contains the list of dependencies, as a starting point to explore these metrics for your dependencies, for example, using `{desc}` [@R-desc] or `{attachment}` [@R-attachment]: + +```{r 01-big-shiny-31 } +# Get the dependencies from the DESCRIPTION file. +# You can use one of these two functions to list +# the dependencies of your package, +# and compute the metric for each dep +desc::desc_get_deps("golex/DESCRIPTION") +``` + +\newpage + +```{r} +# See also +attachment::att_from_description("golex/DESCRIPTION") +``` + + +Some important metrics to watch there are as follow: + +- Test coverage: the more the better, as a large code coverage should imply that bugs are more easily caught. +- The number of downloads: a largely downloaded package will likely be less prone to bug, as it will be used by a large user base. +- Number of dependencies: the more a package has dependencies, the more likely it is that at some point it time, something in the dependency graph will break. +- Dates of first publish on CRAN, last publish, and updates: a package actively maintained is a good sign.[^big-shiny-10] + +[^big-shiny-10]: Even if this is not an absolute rule, some packages haven't been updated for a long time but are still completely reliable. + +#### D. Complexity assessment checklist {.unnumbered} + +To sum up, here is a quick checklist of things to check to assess the complexity of your application: + +- [ ] Running the metrics from `{cloc}`, to get an idea of the number of files, their diversity in terms of extensions (for example `{hexmake}` also has JSON, JavaScript, and YAML files), and the ratio of comments for the code. + Remember that having only one big R file is a red flag, and so is having zero code comments. + +- [ ] Assess the cyclomatic complexity of the package containing your application. + Remember that the more a function scores on this metric, the more complex it will be to debug it. + +- [ ] Check the package common metrics using `{packageMetrics2}`, notably for the dependencies you are including in your package. + Metrics to look for are test coverage, number of downloads, number of dependencies, and date of first release and last release. + +### Production-grade software engineering + +Complexity is still frowned upon by a lot of developers, notably because it has been seen as something to avoid according to the Unix philosophy. +But there are dozens of reasons why an app can become complex: for example, the question your app is answering is quite complex and involves a lot of computation and routines. +The resulting app is rather ambitious and implements a lot of features, etc. +There is a chance that if you are reading this page, you are working or are planning to work on a complex `{shiny}` app. +And this is not necessarily a bad thing! +`{shiny}` apps can definitely be used to implement production-grade [^big-shiny-11] software, but production-grade software implies production-grade software engineering. +To make your project a success, you need to use tools that reduce the complexity of your app and ensure that your app is resilient to aging. + +[^big-shiny-11]: By production-grade, we mean a software that can be used in a context where people use it for doing their job, and where failures or bugs have real-life consequences. + +In other words, production-grade `{shiny}` apps require working with a software engineering mindset, which is not always an easy task in the R world: many R developers have learned this language as a tool for doing data analysis, building model, and making statistics; not really as a tool for building software. + +The use of R has evolved since its initial version released in 1995, and using this programming language as a tool to build software for production is still a challenge, even `r lubridate::year(Sys.Date()) - 1995` years after its first release. +And still today, for a lot of R users, the software is still used as an "experimentation tool", where production quality is one of the least concerns. +But the rise of `{shiny}` (among other packages) has drastically changed the potential of R as a language for production software engineering: its ease of use is also one of the reasons why the language is now used outside academia, in more "traditional" software engineering teams. + +This changing context requires different mindsets, skills, and tools. + +With `{shiny}`, as we said before, it is quite easy to prototype a simple app, without any "hardcore" software engineering skills. +And when we are happy with our little proof of concept, we are tempted to add something new. +And another. +And another. +And **without any structured methodology, we are almost certain to reach the cliff of complexity very soon and end up with a codebase that is hardly (if ever) ready to be refactored to be sent to production**. + +The good news is that building a complex app with R (or with any other language) is not an impossible task. +But this requires planning, rigor, and correct engineering. +This is what this book is about: how to organize your `{shiny}` app in a way that is time and code efficient, and how to use correct engineering to make your app a success. + +## What is a successful `{shiny}` app? + +Defining what "successful" means is not an easy task, but we can extract some common patterns when it comes to applications that would be considered successful. + +### It exists + +First of all, an app is successful if it was delivered. +In other words, **the developer team was able to move from specification to implementation to testing to delivering**. +This is a very engineering-oriented definition of success, but it is a pragmatic one: an app that never reaches the state of usability is not a successful app, and something along the way has blocked the process of finishing the software. + +This condition implies a lot of things, but mostly it implies that the team members were able to organize themselves in an efficient way, so that they were able to work together in making the project a success. +Anybody that has already worked on a codebase as a team knows it is not an easy task. + +### It is accurate + +The project is a success if the application was delivered, and if **it answers the question it is supposed to answer, or serves the purpose it is supposed to serve**. +Delivering is not the only thing to keep in mind: you can deliver a working app but it might not work the way it is supposed to work. + +Just as before, accuracy means that between the moment the idea appears in someone's mind and the moment the app is actually ready to be used, everybody was able to work together toward a common goal, and now that this goal is reached, we are also certain that the answers we get from the application are accurate, and that users can rely on the application to make decisions. + +### It is usable + +Being usable means that the app was delivered, it serves the purpose, and it is user-friendly. + +Unless you are just coding for the joy of coding, there will always be one or more end users. +And **if these people cannot use the application because it is too hard to use, too hard to understand, because it is too slow or there is no inherent logic in how the user experience is designed, then it is inappropriate to call the app a success**. + +### It is immortal + +Of course, "immortal" is a little bit far-fetched, but when designing the application, you should aim for robustness through the years, by engineering a (theoretically) immortal application. + +Planning for the future is a very important component of a successful `{shiny}` app project. +Once the app is out, it is successful if it can **exist in the long run, with all the hazards that this implies**: new package versions that could potentially break the codebase, sudden calls for the implementation of new features in the global interface, changing key features of the UI (User Interface) or the back-end, not to mention passing the codebase along to someone who has not worked on the first version, and who is now in charge of developing the next version.[^big-shiny-12] +And this, again, is hard to do without effective planning and efficient engineering. + +[^big-shiny-12]: In fact, this new person might simply be you, a month from now. + And *"You'll be there in the future too, maintaining code you may have half forgotten under the press of more recent projects. When you design for the future, the sanity you save may be your own.* [@ericraymond2003]. diff --git a/02-planning-ahead.Rmd b/02-planning-ahead.Rmd new file mode 100644 index 00000000..ec979e17 --- /dev/null +++ b/02-planning-ahead.Rmd @@ -0,0 +1,136 @@ +# Planning Ahead {#planning-ahead} + +## Working with a "long-term" mindset + +> Rome ne fut pas faite toute en un jour. +> +> *French proverb* + +### Prepare for success + +Whatever your ambitions for your `{shiny}` application, you should take time today to set robust foundations that will save a lot of time in the future. + +A common thing you will hear about `{shiny}` is that it is a good prototyping tool. +This cannot be denied. +Building a Proof of Concept (PoC) for an app is relatively straightforward if you compare to what is needed to build applications in other languages. +With `{shiny}`, you can build an "it works on my machine" web application in a couple of hours, and show it to your team, your boss, your investors. +Thanks to the way `{shiny}` was designed, you do not have to care about websockets, ports, HTML (HyperText Markup Language), JavaScript libraries, and all the things that are elegantly bundled into `{shiny}`. + +Hence, you can have a quick, hacky application that will work on your machine, and very rapidly. +But that is not the way you should start. +Indeed, starting with hacky foundations will lead to two possibilities: + +- You will have to **rewrite everything from scratch** to have a robust application for production. +- If you do not want to rewrite all the code, you will **get stuck with a legacy codebase** for the application, built on top of hacky functions, and sent to production using hacky solutions. + +Either way, that is a **heavy technical debt**. + +`{shiny}` is a good tool for prototyping, but there is no harm in starting your application on solid ground, even for a prototype: **the sooner you start with a robust framework the better, and the longer you wait the harder it gets to convert your application to a production-ready one**. +The larger the codebase, the harder it is to untangle everything and make it work. + +In this book, we will present a framework called `{golem}`, which is a toolbox for building production-grade `{shiny}` applications. +Even if `{golem}` is focused on production, there is no reason not to use it for your proof of concepts: starting a new `{golem}` project is relatively straightforward, and even if you do not use the advanced features, you can use it for very small apps. +The benefit of starting straight inside a `{golem}` application really outweighs the cost. +We hear a lot the question "When should I switch to `{golem}`?" The answer is simple: do not switch to `{golem}`, start with it. +That way, you are getting ready for complexity, and if, one day, you need to turn this small app into a production app, the foundations are there. + +### Develop with the KISS principle + +> The KISS principle states that most systems work best if they are kept simple rather than made complicated; therefore, simplicity should be a key goal in design, and unnecessary complexity should be avoided.\ +> +> *KISS principle, Wikipedia article* () + +The KISS principle, as "Keep It Simple, Stupid", should drive the implementation of features in the application to allow anyone in the future, including original developers, to take over on the development. + +The story behind this principle is supposed to be that Kelly Johnson, lead engineer at the Lockheed Skunk Works, gave his workers a set of very common tools and said that every airplane should be repairable with these tools, and these tools only, so that repairing an aircraft should be possible for any average engineer. + +This should be a principle to keep in mind when building applications. +Indeed, large-scale `{shiny}` projects can lead to many people working on the codebase, for a long period of time. +**A large team means a variety of skills**, with some common ground in `{shiny}` development, but potentially various levels when it comes to R, web development, or production engineering. +When choosing how and what to implement, **try to make a rule to go for the simplest solution**,[^planning-ahead-1] *i.e.* the one that any common `{shiny}` developer would be able to understand and maintain. +If you go for an exotic solution or a complex technology, be sure that you are doing it for a good reason: unknown or hard-to-grasp technology reduces the chance of finding someone that will be able to maintain that piece of code in the future, and reduce the smoothness of collaboration, as "*Code you can easily comprehend elevates absolutely everyone on your team, no matter their tenure or experience level*" [@lemaire2020]. + +[^planning-ahead-1]: Which might not be the most "elegant" solution, but production code requires pragmatism. + +## Working as a team: Tools and structure + +Working as a team, whatever the coding project, requires adequate tools, discipline and organization. +Complex `{shiny}` apps usually imply that several people will work on the application. +For example, at [ThinkR](//rtask.thinkr.fr), 3 to 4 people usually work in parallel on the same application, but there might be more people involved on larger projects. +**The choice of tools and how the team is structured is crucial for a successful application**. + +### From the tools point of view + +#### A. Version control and test all things {.unnumbered} + +To get informed about a code break during development, you will need to write tests for your app, and use continuous integration (CI) so that you are sure this is automatically detected.[^planning-ahead-2] +When you are working on a complex application, chances are that you will be working on it for a significant period of time, meaning that you will write code, modify it, use it, go back to it after a few weeks, change some other things, and probably break things. +**Breaking things is a natural process of software engineering, notably when working on a piece of code during a long period**. Remember the last chapter where we explained that complex applications are too large to be understood fully? +Adding code that breaks the codebase will happen with complex apps, so the sooner you take measures to solve these changes, the better. + +[^planning-ahead-2]: Relying on automatic tooling for monitoring the codebase is way safer than relying on developers to do manual checks every time they commit code. + +As you cannot prevent code from breaking, you should at least get the tooling to: + +- **Be informed that the codebase is broken**: this is the role of tests combined with CI. +- **Be able to identify changes between versions, and potentially, get back in time to a previous codebase**: this is the role of version control. + +We will go deeper into testing and version control in [chapter 14](#build-yourself-safety-net). + +#### B. Small is beautiful {.unnumbered} + +Building an application with multiple small and independent pieces will lighten your development process and your mental load. +The previous chapter introduced the notion of complexity in size, where the app grows so large that it is very hard to have a good grasp of it. +**A large codebase implies that the safe way to work is to split the app into pieces**. + +Splitting a `{shiny}` project is made possible by following two methods: + +- **Extract your core "non-reactive" functions, which we will also call the "business logic", and include them in external files**, so that you can work on these outside of the app. + Working on independent functions to implement features will prevent you from relaunching the whole application every time you need to add something new. + +- **Split your app into `{shiny}` modules**, so that your app can be though of as a tree, making it possible for every developer to concentrate on one node, and only one, instead of having to think about the global infrastructure when implementing features. + +Figure \@ref(fig:02-planning-ahead-1) is, for example, a representation of a `{shiny}` application with modules and sub-modules. +You will not be able to decipher the text inside the node, but the idea is to give you a sense of how a `{shiny}` application with modules can be organized and split into smaller pieces that are all related to each other in a tree form. + +(ref:apptreecap) Representation of a `{shiny}` application with its modules and sub-modules. + +```{r 02-planning-ahead-1, echo=FALSE, fig.cap="(ref:apptreecap)", out.width='100%'} +knitr::include_graphics("img/app_tree.png") +``` + +We will get back to `{shiny}` modules and how to organize your project in the next chapter. + +### From the team point of view + +We recommend that you define two kinds of developers: a unique person (or maybe two) to be in charge of supervising the whole project and developers that will work on specific features. +Note that this is how a project should be organized in a perfect world: in practice, a lot of `{shiny}` projects are managed by one developer who is in charge of managing the project, interacting with the client, and building the whole codebase. + +#### A. A person in charge {.unnumbered} + +The person in charge of the development will have **a complete view of the entire project and manage the team so that all developers implement features that fit together**. + +With complex applications, it can be hard to have the complete understanding of what the entire app is doing. +Most of the time, it is not necessary for all developers to have this complete picture. +By defining one person in charge, this "manager" will have to get the whole picture: what each part of the software is doing, how to make everything work together, avoid development conflicts, and of course check that, at the end of the day, the results returned by the built application are the correct ones. + +The project manager will be the one that kicks off the project, and writes the first draft of the application. +If you follow this book's workflow, this person will first create a `{golem}` project, fill in the information, and define the application structure by providing the main modules, and potentially work on the prototyped UI of the app. + +Once the skeleton of the app is created, this person in charge will list all the things that have to be done. +We strongly suggest that you use `Git` with a graphical interface (GitLab, GitHub, Bitbucket, etc.) as the graphical interface to help you manage the project. +These tasks are defined as issues, and will be closed during development. +These interfaces can also be used to set continuous integration. + +If the team follows a `git flow` (described in Chapter \@ref(version-control)), the manager will also be in charge of reviewing and accepting the pull/merge requests to the main `dev` branch if they solve the associated issues. + +Do not worry if this sounds like a foreign language to you, we will get back to this method later in this book (Chapter \@ref(version-control)). + +#### B. Developers {.unnumbered} + +**Developers will focus on small features**. +If the person in charge has correctly separated the work between developers of the team, they will be focusing on one or more parts of the application, but do not need to know every single bit of what the application is doing. +In a perfect world, the application is split in various `{shiny}` modules, one module equals one file, and each member of the team will be assigned to the development of one or more modules. + +It is simpler to work in this context where one developer is assigned to one module, although we know that in reality it may be a little more complex, and several members of the team might go back and forth working on a common module. +But the person in charge will be there to help make all the pieces fit together. diff --git a/03-structure.Rmd b/03-structure.Rmd new file mode 100644 index 00000000..297ca30f --- /dev/null +++ b/03-structure.Rmd @@ -0,0 +1,967 @@ +# Structuring Your Project {#structuring-project} + +## `{shiny}` app as a package + +```{r 03-structure-1, include=FALSE} +library(shiny) +``` + +In the next chapter you will be introduced to the `{golem}` [@R-golem] package, **an opinionated framework for building production-ready `{shiny}` applications**. +This framework will be used a lot throughout this book, and it relies on the idea that every `{shiny}` application should be built as an R package. + +But in a world where `{shiny}` applications are mostly created as a series of files, why bother with a package? + +### What is in a production-grade `{shiny}` app? + +You probably haven't realized it yet, but if you have built a significant (in terms of size or complexity of the codebase) `{shiny}` application, chances are you have been using a package-like structure without knowing it. + +Think about your last `{shiny}` application, which was created as a single-file (`app.R`) or two-file app (`ui.R` and `server.R`). +On top of these files, what do you needed to make it **a production-ready application**, and why is a package the perfect infrastructure? + +#### A. It has metadata {.unnumbered} + +First of all, **metadata**. +In other words, all the necessary information for something that runs in production: the name of the app, the version number (which is crucial to any serious, production-level project), what the application does, who to contact if something goes wrong, etc. + +This is what you will get when using a package `DESCRIPTION` file. + +#### B. It handles dependencies {.unnumbered} + +Second, you need to find a way to **handle the dependencies**. +When you want to push your app into production, you do not want to have this conversation with the IT team: + +- *IT: Hey, I tried to `source("app.R")` as you said, but I got an error.* + +- *R-dev: What is the error?* + +- *IT: It says "could not find package 'shiny'".* + +- *R-dev: Ah yes, you need to install {shiny}. Try to run `install.packages("shiny")`.* + +- *IT: OK nice. What else?* + +- *R-dev: Let me think, try also `install.packages("DT")`... good? Now try `install.packages("ggplot2")`, and ...* + +- *[...]* + +- *IT: Ok, now I source the 'app.R', right?* + +- *R-dev: Sure!* + +- *IT: Ok so it says "could not find function `runApp()`"* + +- *R-dev: Ah, you got to do `library(shiny)` at the beginning of your script. And `library(purrr)`, and `library(jsonlite)`, and...* + +For example here, the `library(purrr)` and `library(jsonlite)` will lead to a NAMESPACE conflict on the `flatten()` function that can cause you some debugging headaches (trust us, we have been there before). +It would be cool if we could have a `{shiny}` app that only imports specific functions from a package, right? + +We cannot stress enough that **dependencies matter**. +You need to handle them, and handle them correctly if you want to ensure a smooth deployment to production. +This dependency management is native when you use a package structure: the packages your application depends on are listed in the `DESCRIPTION`, and the functions/packages you need to import are listed in the `NAMESPACE` file. + +#### C. It's split into functions {.unnumbered} + +Third, let's say you are building a big application. +Something with thousands of lines of code. +You cannot build this large application by writing one or two files, as it is simply impossible to maintain in the long run or use on a daily basis. +If we are developing a large application, we should split everything into smaller files. +And maybe we can store those files in a specific directory. + +This is what is done with a package, with the `R/` folder. + +#### D. It has documentation {.unnumbered} + +Last but not least, we want our app to live long and prosper, which means we need to document it. + +Documenting your `{shiny}` app involves explaining features to the end users and also to the future developers (chances are this future developer will be you). +The good news is that using the R package structure helps you leverage the common tools for documentation in R: + +- A `README` file that you will put at the root of your package, which will document how to install the package, and some information about how to use the package. Note that in many cases developers go for a `.md` file (short for markdown) because this format is automatically rendered on services like GitHub, GitLab, or any other main version control system. + +- `Vignettes` are longer-form documentation that explains in more depth how to use your app. They are also useful if you need to detail the core functions of the application using a static document, notably for prototyping and/or for exchanging with the client. We will get back to `Vignettes` in Chapter \@ref(building-ispum-app) when we will talk about prototyping. + +- Function documentation. Every function in your package should come with its own documentation, even if it is just for your future self. "Exported" functions, the one which are available once you run `library(myapp)`, should be fully documented and will be listed in the package help page. Internal functions need less documentation, but documenting them is the best way to be sure you can come back to the app in a few months and still know why things are the way they are, what the pieces of the apps are used for, and how to use these functions.[^structure-1] + +- If needed, you can build a `{pkgdown}` website, that can either be deployed on the web or kept internally. It can contain installation steps for I.T., internal features use for developers, a user guide, etc. + +[^structure-1]: `{roxygen2}` comes with a `@noRd` tag that prevents the documentation from being built. + This allows you to still write the documentation using the same tags as the exported function, without the internal functions being documented in the end package. + For example, that is why, by default, the modules built with `{golem}` version \> 0.2.0 come with `@noRd`: you should document them, but chances are your do not need to export them. + +#### E. It's tested {.unnumbered} + +The other thing we need for our application to be successful in the long run is a testing infrastructure, so that we are sure we are not introducing any regression during development. + +**Nothing should go to production without being tested. Nothing.** + +Testing production apps is a broad question that we will come back to in another chapter, but let's talk briefly about why using a package structure helps with testing. + +Frameworks for package testing are robust and widely documented in the R world, and if you choose to embrace the "`{shiny}` app as a package" structure, you do not have to put in any extra-effort for testing your application back-end: use a canonical testing framework like `{testthat}` [@R-testthat]. +Learning how to use it is not the subject of this chapter, so feel free to refer to the documentation, and see also Chapter 5 of the [workshop: "Building a Package that Lasts"](https://speakerdeck.com/colinfay/building-a-package-that-lasts-erum-2018-workshop?slide=107). + +We will come back to testing in Chapter 11: "Build Yourself a Safety Net". + +#### F. There is a native way to build and deploy it {.unnumbered} + +Oh, and it would also be nice if people could get a `tar.gz` and install it on their computer and have access to a local copy of the app! +Or if we could install that on the server without any headache! + +When adopting the package structure, you can use classical tools to locally install your `{shiny}` application, i.e. as any other R package, built as a `tar.gz`. +On a server, be it RStudio products or as Docker containers, the package infrastructure will also allow you to leverage the native R tools to build, install and launch R code. + +More about this in Chapter \@ref(deploy). + +#### `{shiny}` app as a package, a checklist {.unnumbered} + +Let's sum up what we need for our application: + +- [ ] **Metadata** and **dependencies**, which is what you get from the `DESCRIPTION` + `NAMESPACE` files of a package. + Even more useful is the fact that you can do "selective namespace extraction" inside a package, *i.e.* you can say "I want this function from this package". + +- [ ] A **bite-size code-base, aka functions**. + This is done through `.R` files, stored in the `R/` directory, which is the way a package is organized. + +- [ ] **Documentation** can be done using Vignettes, Readme, and native R package documentation. + +- [ ] **Testing toolkit**, done using the native `R CMD Check` and other packages like `{testthat}` [@R-testthat]. + +- [ ] **Installation process**, which is possible using the package infrastructure. + +### Resources + +In the rest of this book, we will assume you are comfortable with building an R package. +If you need to read some resources before continuing, feel free to have a look at these links: + +- [R packages](http://r-pkgs.had.co.nz/) + +- [Building a Package that Lasts](https://speakerdeck.com/colinfay/building-a-package-that-lasts-erum-2018-workshop) + +- [Writing R Extensions](https://cran.r-project.org/doc/manuals/r-release/R-exts.html#Creating-R-packages) + +- [R Package Primer - a Minimal Tutorial](https://kbroman.org/pkg_primer/) + +## Using `{shiny}` modules + +Modules are one of the most powerful tools for building `{shiny}` applications in a maintainable and sustainable manner. + +### Why `{shiny}` modules? + +Small is beautiful. +Being able to properly cut a codebase into small modules will help developers build a mental model of the application (Remember ["What is a complex `{shiny}` application?"](#complex-shiny)). +But what are `{shiny}` modules? + +> `{shiny}` modules address the namespacing problem in `{shiny}` UI (User Interface) and server logic, adding a level of abstraction beyond functions.\ +> +> _Modularizing Shiny app code_ () + +Let us first untangle this quote with an example about the `{shiny}` namespace problem. + +#### A. The one million "Validate" buttons problem {.unnumbered} + +A big `{shiny}` application usually requires reusing pieces of the UI/server, which makes it hard to name and identify similar inputs and outputs. + +`{shiny}` requires its outputs and inputs to have a **unique id**. +And, unfortunately, we cannot bypass that: when you send a plot **from R to the browser**, i.e. from the `server` to the `ui`, the browser needs to know exactly where to put this element. +This "exactly where" is handled through the use of an `id`. +Ids are not `{shiny}` specific: they are at the very root of the way web pages work. +Understanding all of this is not the purpose of this chapter: just remember that `{shiny}` input and output ids **have** to be unique, just as any id on a web page, so that the browser knows where to put what it receives from R, and R knows what to listen to from the browser. +The need to be unique is made a little bit complex by the way `{shiny}` handles the names, as it shares a global pool for all the id names, with no native way to use namespaces. +Wait, namespaces? + +Namespaces are a computer science concept created to handle a common issue: how to share the same name for a variable in various places of your program without them conflicting. +In other words, how to use an object called `my_dataset` several times in the program, and still be sure that it is correctly used depending on the context. + +R itself has a system for namespaces; this is what packages do and why you can have `purrr::flatten` and `jsonlite::flatten` on the same computer and inside the same script: the function names are the same, but the two exist in different namespaces, and the behavior of both functions can be totally different as the symbol is evaluated inside two different namespaces. +If you want to learn more about namespaces, please refer to the [7.4 Special environments](https://adv-r.hadley.nz/environments.html#special-environments) chapter from *Advanced R*, or turn to any computer science book: namespaces are pretty common in any programming language. + +That is what modules are made for: **creating small namespaces where you can safely define `ids` without conflicting with other `ids` in the app.** Why do we need to do that? +Think about the number of times you have created an "OK" or "validate" button. +How have you been handling that so far? +By creating `validate1`, `validate2`, and so on and so forth. +But if you think about it, you are mimicking a namespacing process: a `validate` in namespace `1`, another in namespace `2`. + +Consider the following `{shiny}` application: + +```{r 03-structure-2, eval=FALSE} +library(shiny) +ui <- function() { + fluidPage( + # Define a first sliderInput(), + # with an id that we will postfix with `1` + # in order to make it unique + sliderInput( + inputId = "choice1", + label = "choice 1", + min = 1, max = 10, value = 5 + ), + # Define a first actionButton(), + # with an id that we will postfix with `1` + # in order to make it unique + actionButton( + inputId = "validate1", + label = "Validate choice 1" + ), + # We define here a second sliderInput, + # and need its id to be unique, so we + # postfix it with 2 + sliderInput( + inputId = "choice2", + label = "choice 2", + min = 1, max = 10, value = 5 + ), + # We define here a second actionButton, + # and need its id to be unique, so we + # postfix it with 2 + actionButton( + inputId = "validate2", + label = "Validate choice 2" + ) + ) +} + +server <- function(input, output, session) { + + # Observing the first series of inputs + # Whenever the user clicks on the first validate button, + # the value of choice1 will be printed to the console + observeEvent( input$validate1 , { + print(input$choice1) + }) + + # Same as the first observeEvent, except that we are + # observing the second series + observeEvent( input$validate2 , { + print(input$choice2) + }) +} + +shinyApp(ui, server) +``` + +This, of course, is an approach that works. +Well, it works as long as your codebase is small. +But how can you be sure that you are not creating `validate6` on line 55 and another on line 837? +How can you be sure that you are deleting the correct combination of UI/server components if they are named that way? +Also, how do you work smoothly in a context where you have to scroll from `sliderInput("choice1")` to `observeEvent( input$choice1 , {})` which might be separated by thousands of lines? + +#### B. Working with a bite-sized codebase {.unnumbered} + +Build your application through multiple smaller applications that are easier to understand, develop and maintain, using `{shiny}` [@R-shiny] modules. + +We assume that you know the saying that "if you copy and paste something more than twice, you should make a function". +In a `{shiny}` application, how can we refactor a partially repetitive piece of code so that it is reusable? + +Yes, you guessed right: using shiny modules. +**`{shiny}` modules aim at three things: simplify "id" namespacing, split the codebase into a series of functions, and allow UI/Server parts of your app to be reused. Most of the time, modules are used to do the two first. In our case, we could say that 90% of the modules we write are never reused;[^structure-2] they are here to allow us to split the codebase into smaller, more manageable pieces**. + +[^structure-2]: Most of the time, pieces / panels of the app are too unique to be reused elsewhere. + +With `{shiny}` modules, you will be writing a combination of UI and server functions. +Think of them as small, standalone `{shiny}` apps, which handle a fraction of your global application. +If you develop R packages, chances are you have split your functions into series of smaller functions. +With `{shiny}` modules, you are doing the exact same thing: with just a little bit of tweaking, you can split your application into a series of smaller applications. + +### When to use `{shiny}` modules + +No matter how big your application is, it is always safe to start modularizing from the very beginning. +The sooner you use modules, the easier downstream development will be. +It is even easier if you are working with `{golem}`, which promotes the use of modules from the very beginning of your application. + +"*Yes, but I just want to write a small app, nothing fancy.*" + +A production app almost always started as a small proof of concept. +Then, the small PoC becomes an interesting idea. +Then, this idea becomes a strategic asset. +And before you know it, your not-that-fancy app needs to become larger and larger. +So, you will be better off starting on solid foundations from the very beginning. + +### A practical walkthrough + +An example is worth a thousand words, so let's explore the code of a very small `{shiny}` application that is split into modules. + +#### A. Your first `{shiny}` module {.unnumbered} + +Let's try to transform the above example (the one with two sliders and two action buttons) into an application with a module. +Note that the following code will work only for `{shiny}` version 1.5.0 and after. + +```{r 03-structure-3, eval = FALSE} +# Re-usable module +choice_ui <- function(id) { + # This ns <- NS structure creates a + # "namespacing" function, that will + # prefix all ids with a string + ns <- NS(id) + tagList( + sliderInput( + # This looks the same as your usual piece of code, + # except that the id is wrapped into + # the ns() function we defined before + inputId = ns("choice"), + label = "Choice", + min = 1, max = 10, value = 5 + ), + actionButton( + # We need to ns() all ids + inputId = ns("validate"), + label = "Validate Choice" + ) + ) +} + +choice_server <- function(id) { + # Calling the moduleServer function + moduleServer( + # Setting the id + id, + # Defining the module core mechanism + function(input, output, session) { + # This part is the same as the code you would put + # inside a standard server + observeEvent( input$validate , { + print(input$choice) + }) + } + ) +} + + +# Main application +library(shiny) +app_ui <- function() { + fluidPage( + # Call the UI function, this is the only place + # your ids will need to be unique + choice_ui(id = "choice_ui1"), + choice_ui(id = "choice_ui2") + ) +} + +app_server <- function(input, output, session) { + # We are now calling the module server functions + # on a given id that matches the one from the UI + choice_server(id = "choice_ui1") + choice_server(id = "choice_ui2") +} + +shinyApp(app_ui, app_server) + +``` + +Let's stop for a minute and decompose what we have here. + +The **server** function of the module (`mod_server()`) is pretty much the same as before: you use the same code as the one you would use in any server part of a `{shiny}` application. + +The **ui** function of the module (`mod_ui()`) requires specific things. +There are two new things: `ns <- NS(id)` and `ns(inputId)`. +That is where the namespacing happens. +Remember the previous version where we identified our two "validate" buttons with slightly different namespaces: `validate1` and `validate2`? +Here, we create namespaces with the `ns()` function, built with `ns <- NS(id)`. +This line, `ns <- NS(id)`, is added on top of all module UI functions and will allow building namespaces with the module id. + +To understand what it does, let us try and run it outside `{shiny}`: + +```{r 03-structure-4 } +# Defining the id +id <- "mod_ui_1" +# Creating the internal "namespacing" function +ns <- NS(id) +# "namespace" the id +ns("choice") +``` + +And here it is, our namespaced `id`! + +Each call to a module with `choice_server()` requires a different `id` argument that will allow creating various internal namespaces to prevent from id conflicts.[^structure-3] +Then you can have as many `validate` ids as you want in your whole app, as long as this input has a unique id inside your module. + +[^structure-3]: Well, of course you can still have inner module id conflicts, but they are easier to avoid, detect, and fix. + +##### Note for `{shiny}` \< 1.5.0 {.unnumbered} + +Released on 2020-06-23, the version 1.5.0 of `{shiny}` introduced a new way to write shiny modules, using a new function called `moduleServer()`. +This new function was introduced to make the couple `ui_function` / `server_function` more obvious, where the old version used to require a `callModule(server_function, id)` call. +This `callModule` notation is still valid (at least at the time of writing these lines), but we chose to go for the `moduleServer()` notation in this book. +Most of the applications that are used as examples in this book have been built before this new function though, so when you will read their code, you will find the `callModule` implementation. + +#### B. Passing arguments to your modules {.unnumbered} + +`{shiny}` modules will potentially be reused and may need a specific user interface and inputs. +This requires using extra arguments to generate the UI and server. +As the UI and server are functions, you can set parameters that will be used to configure the internals of the result. + +As you can see, the **app\_ui** contains a series of calls to the + `mod_ui(unique_id, ...)` function, allowing additional arguments like any other function: + +```{r 03-structure-5, eval=FALSE} +mod_ui <- function(id, button_label) { + ns <- NS(id) + tagList( + actionButton(ns("validate"), button_label) + ) +} + +# Printing the HTML for this piece of UI +mod_ui("mod_ui_1", button_label = "Validate ") +mod_ui("mod_ui_2", button_label = "Validate, again") +``` + + + + + + + +The **app\_server** side contains a series of `mod_server(unique_id, ...)`, also allowing additional parameters, just like any other function. + +As a live example, we can have a look at [mod\_dataviz.R](https://github.com/ColinFay/tidytuesday201942/blob/master/R/mod_dataviz.R#L17) from the `{tidytuesday201942}` [@R-tidytuesday201942] `{shiny}` application, available at . +Figure \@ref(fig:03-structure-6) is a screenshot of this application. + +This application contains 6 tabs, 4 of them being pretty much alike: a side bar with inputs, a main panel with a button, and the plot. +This plot can be, depending on the tab, a scatterplot, a histogram, a boxplot, or a barplot. + +This is a typical case where you should reuse modules: if several parts are relatively similar, it is easier to bundle it inside a reusable module, and condition the UI/server with function arguments. + +(ref:tidytuesdayappcap) Snapshot of the `{tidytuesday201942}` `{shiny}` application. + +```{r 03-structure-6, echo=FALSE, fig.cap="(ref:tidytuesdayappcap)", out.width="100%"} +knitr::include_graphics("img/tidytuesdayapp.png") +``` + +Let's extract some pieces of this application to show how (and why) you would parametrize your module. + +```{r 03-structure-7, eval = FALSE} +mod_dataviz_ui <- function( + id, + type = c("point", "hist", "boxplot", "bar") +) { + # Setting a header with the specified type of graph + h4( + sprintf( "Create a geom_%s", type ) + ), + # [ ... ] + # We want to allow a coord_flip only with barplots + if (type == "bar"){ + checkboxInput( + ns("coord_flip"), + "coord_flip" + ) + }, + # [ ... ] + # We want to display the bins input only + # when the type is histogram + if (type == "hist") { + numericInput( + ns("bins"), + "bins", + 30, + 1, + 150, + 1 + ) + }, + # [ ... ] + # The title input will be added to the graph + # for every type of graph + textInput( + ns("title"), + "Title", + value = "" + ) +} +``` + +And in the module server: + +```{r 03-structure-8, eval = FALSE} +mod_dataviz_server <- function( + input, + output, + session, + type +) { + # [ ... ] + if (type == "point") { + # Defining the server logic when the type is point + # When the type is point, we have access to input$x, + # input$y, input$color, and input$palette, + # so we reuse them here + ggplot( + big_epa_cars, + aes( + .data[[input$x]], + .data[[input$y]], + color = .data[[input$color]]) + ) + + geom_point()+ + scale_color_manual( + values = color_values( + 1:length( + unique( + pull( + big_epa_cars, + .data[[input$color]]) + ) + ), + palette = input$palette + ) + ) + } + # [ ... ] + if (type == "hist") { + # Defining the server logic when the type is hist + # When the type is point, we have access to input$x, + # input$color, input$bins, and input$palette + # so we reuse them here + ggplot( + big_epa_cars, + aes( + .data[[input$x]], + fill = .data[[input$color]] + ) + ) + + geom_histogram(bins = input$bins)+ + scale_fill_manual( + values = color_values( + 1:length( + unique( + pull( + big_epa_cars, + .data[[input$color]] + ) + ) + ), + palette = input$palette + ) + ) + } +} +``` + +Then, the UI of the entire application is: + +```{r 03-structure-9 } +app_ui <- function() { + # [...] + tagList( + fluidRow( + # Setting the first tab to be of type point + id = "geom_point", + mod_dataviz_ui( + "dataviz_ui_1", + type = "point" + ) + ), + fluidRow( + # Setting the second tab to be of type point + id = "geom_hist", + mod_dataviz_ui( + "dataviz_ui_2", + type = "hist" + ) + ) + ) +} +``` + +And the `app_server()` of the application: + +```{r 03-structure-10 } +app_server <- function(input, output, session) { + # This app has been built before shiny 1.5.0, + # so we use the callModule structure + # + # We here call the server module on their + # corresponding id in the UI, and set the + # parameter of the server function to + # match the correct type of input + callModule(mod_dataviz_server, "dataviz_ui_1", type = "point") + callModule(mod_dataviz_server, "dataviz_ui_2", type = "hist") +} +``` + +### Communication between modules + +One of the hardest part of using modules is sharing data across them. +There are at least three approaches: + +- Returning a `reactive` function +- The "stratégie du petit r" (to be pronounced with a French accent of course) +- The "stratégie du grand R6" + +#### A. Returning values from the module {.unnumbered} + +One common approach is to return a `reactive` function from one module**, and pass it to another** in the general `app_server()` function. + +Here is an example that illustrates this pattern. + +```{r 03-structure-11, eval = FALSE} +# Module 1, which will allow to select a number +choice_ui <- function(id) { + ns <- NS(id) + tagList( + # Add a slider to select a number + sliderInput(ns("choice"), "Choice", 1, 10, 5) + ) +} + + +choice_server <- function(id) { + moduleServer( id, function(input, output, session) { + # We return a reactive function from this server, + # that can be passed along to other modules + return( + reactive({ + input$choice + }) + ) + } + ) +} + +# Module 2, which will display the number +printing_ui <- function(id) { + ns <- NS(id) + tagList( + # Insert the number modified in the first module + verbatimTextOutput(ns("print")) + ) +} + +printing_server <- function(id, number) { + moduleServer(id, function(input, output, session) { + # We evaluate the reactive function + # returned from the other module + output$print <- renderPrint({ + number() + }) + } + ) +} + +# Application +library(shiny) +app_ui <- function() { + fluidPage( + choice_ui("choice_ui_1"), + printing_ui("printing_ui_2") + ) +} + +app_server <- function(input, output, session) { + # choice_server() returns a value that is then passed to + # printing_server() + number_from_first_mod <- choice_server("choice_ui_1") + printing_server( + "printing_ui_2", + number = number_from_first_mod + ) +} + +shinyApp(app_ui, app_server) +``` + +This strategy works well, but for large `{shiny}` apps it might be hard to handle large lists of reactive outputs / inputs and to keep track of how things are organized. +It might also create some reactivity issues, as a lot of `reactive` function calls is harder to control, or lead to too much computation from the server. + +#### B. The "stratégie du petit r" {.unnumbered} + +In this strategy, we **create a global `reactiveValues` list that is passed along through other modules**. +The idea is that it allows you to be less preoccupied about what your module takes as input and what it outputs. +You can think of this approach as creating a small, internal database that is passed along through all the modules of your application. + +Below, we create a "global" (in the sense that it is initiated at the top of the module hierarchy) `reactiveValues()` object in the `app_server()` function. +It will then go through all modules, passed as a function argument. + +```{r 03-structure-12, eval = FALSE} +# Module 1, which will allow to select a number +choice_ui <- function(id) { + ns <- NS(id) + tagList( + # Add a slider to select a number + sliderInput(ns("choice"), "Choice", 1, 10, 5) + ) +} + +choice_server <- function(id, r) { + moduleServer( + id, + function(input, output, session) { + # Whenever the choice changes, the value inside r is set + observeEvent( input$choice , { + r$number_from_first_mod <- input$choice + }) + + } + ) +} + +# Module 2, which will display the number +printing_ui <- function(id) { + ns <- NS(id) + tagList( + # Insert the number modified in the first module + verbatimTextOutput(ns("print")) + ) +} + +printing_server <- function(id, r) { + moduleServer( + id, + function(input, output, session) { + # We evaluate the reactiveValue element modified in the + # first module + output$print <- renderPrint({ + r$number_from_first_mod + }) + } + ) +} + +# Application +library(shiny) +app_ui <- function() { + fluidPage( + choice_ui("choice_ui_1"), + printing_ui("printing_ui_2") + ) +} + +app_server <- function(input, output, session) { + # both servers take a reactiveValue, + # which is set in the first module + # and printed in the second one. + # The server functions don't return any value per se + r <- reactiveValues() + choice_server("choice_ui_1", r = r) + printing_server("printing_ui_2", r = r) +} + +shinyApp(app_ui, app_server) +``` + +The good thing about this method is that whenever you add something in one module, it is immediately available in all other modules where `r` is present. +The downside is that it can make it harder to reason about the app, as the input/content of the `r` is not specified anywhere unless you explicitly document it: the parameter to your server function being "r" only, you need to be a little bit more zealous when it comes to documenting it. + +Note that if you want to share your module, for example in a package, you should document the structure of the `r`. +For example: + + #' @param r a `reactiveValues()` list with a + `number_from_first_mod` element in it. + #' This `r$number_from_first_mod` will be + printed to the `print` output. + +#### C. The "stratégie du grand R6" {.unnumbered} + +Similar to the "stratégie du petit r", we can create an R6 object, which is passed along inside the modules. + +R6 objects, created using the package of the same name, are "traditional" object-oriented programming implementations in R. +An R6 object is a data structure that can hold in itself data and functions. +Its particularity is that if **it's modified inside a function, this modified value is kept outside the function in which it's called, making it a powerful tool to manage data across the application**. + +As this R6 object is not a reactive object and is not meant to be used as such, uncontrolled reactivity of the application is reduced, thus reduces the complexity of handling chain reactions across modules. +Of course, you need to have another special tool in your app to trigger elements. +All this will be explained in detail in Chapter \@ref(common-app-caveats) of this book, and you can find an example of this pattern inside the [`{hexmake}`](https://github.com/ColinFay/hexmake/blob/master/R/R6.R) [@R-hexmake] application. + +If you are eager to know more about what `{R6}` is and how it works, we suggest the chapter on this subject in *Advanced R* [@hadleywickham2019], [R6](https://adv-r.hadley.nz/r6.html). + +#### D. Other approaches: About `{tidymodules}` {.unnumbered} + +`{tidymodules}` [@R-tidymodules] is a package that helps in building shiny modules using an object-oriented paradigm, based on `{R6}`. +It allows you to automatically take care of namespaces, and makes sharing data across modules easier. +With this approach, as modules are objects, you can also define inheritance between modules. + +The ["Getting Started"](https://opensource.nibr.com/tidymodules/articles/tidymodules.html) page for `{tidymodules}` offers a presentation of how you can build modules using this approach, so feel free to refer to it if you want to know more, and to dive into the article for the advanced features offered by this package. + +## Structuring your app + +### Business logic and application logic + +A shiny application has two main components: the application logic and the business logic. + +- **Application logic is what makes your `{shiny}` app interactive**: structure, buttons, tables, interactivity, etc. + These components are not specific to your core business. + You could use them for any other line of work or professional context. + This has no other use case than your interactive application. + It is not meant to be used outside your app, and you would not use them in a markdown report for instance. + +- **Business logic includes the components with the core algorithms and functions that make your application specific to your area of work**. + You can recognize these elements as the ones that can be run outside any interactive context. + This is the case for specific computations and algorithms, custom plots or `geom` for `{ggplot2}` [@R-ggplot2], specific calls to a database, etc. + +These two components do not have to live together. +And in reality, they should not live together if you want to keep your sanity when you are building an app. +If you keep all components together in the same file, you will end up having to rerun the app from scratch and spend five minutes clicking everywhere just to be sure you have correctly set the color palette for the graph on the last `tabPanel()`. + +Trust us, we have been there, and it is not pretty. + +What is the way to go? +Extract the business function from the reactive functions. +Literally. +Compare this pattern: + +```{r 03-structure-13, eval=FALSE} +library(shiny) +library(dplyr) +# A simple app that returns a table +ui <- function() { + tagList( + tableOutput("tbl"), + sliderInput("n", "Number of rows", 1, 50, 25) + ) +} + +server <- function(input, output, session) { + output$tbl <- renderTable({ + # Writing all the business logic for the table manipulation + # inside the server + mtcars %>% + # [...] %>% + # [...] %>% + # [...] %>% + # [...] %>% + # [...] %>% + top_n(input$n) + }) +} + +shinyApp(ui, server) +``` + +To this one: + +```{r 03-structure-14, eval = FALSE} +library(shiny) +library(dplyr) + +# Writing all the business logic for the table manipulation +# inside an external function +top_this <- function(tbl, n) { + tbl %>% + # [...] %>% + # [...] %>% + # [...] %>% + # [...] %>% + top_n(n) +} + +# A simple app that returns a table +ui <- function() { + tagList( + tableOutput("tbl"), + sliderInput("n", "Number of rows", 1, 50, 25) + ) +} + + +server <- function(input, output, session) { + output$tbl <- renderTable({ + # We call the previously declared function inside the server + # The business logic is thus defined outside the application + top_this(mtcars, input$n) + }) +} + +shinyApp(ui, server) +``` + +Both scripts do the exact same thing. +The difference is that the second code can be easily explored without having to relaunch the app. +You will be able to build a reproducible example to explore, illustrate, and improve `top_this()`. +This function can be tested, documented, and reused outside the application. +Moreover, this approach lowers the cognitive load when debugging: you either debug an application issue, or a business logic issue. +You never debug both at the same time. + +Even more, think about the future: how likely are the colors or the UI subject to change, compared to how likely the core algorithms are to change? +As said in *The Art of Unix Programming*, "*Fashions in the look and feel of GUI toolkits may come and go, but raster operations and compositing are forever*" [@ericraymond2003]. +In other words, the core back-end, once consolidated, will potentially stay unchanged forever. +On the other hand, the front-end might change: new colors, new graphic designs, new interactions, new visualization libraries, etc. +Whenever this happens, you will be happy you have separated the business logic from the application logic, as you will have to change less code. + +How to do that? +Add your application logic in a file (typically, a module), and the business logic in another R script (typically starting with `fct_` or `utils_`). +You can even write the business logic inside another package, making these functions really reusable outside your application. + +### Small is beautiful (bis repetita) + +There are a lot of reasons for splitting your application into smaller pieces, including the fact that it is easier to maintain, easier to decipher, and it facilitates collaboration. + +There is nothing harder to maintain than a `{shiny}` app only made of a unique 1000-line long `app.R` file. +Well, there still is the 10000-line long `app.R` file, but you get the idea. +**Long scripts are almost always synonymous with complexity when it comes to building software**. +Of course, small and numerous scripts do not systematically prevent codebase complexity, but they do simplify collaboration and maintenance, and divide the application logic into smaller, easier-to-understand bits of code. + +In practice, big files are complex to handle and make development harder. +Here is what happens when you work on an application for production: + +- You will work during a long period of time (either in one run or split across several months) on your codebase. + Hence, you will have to get back to pieces of code you wrote a long time ago. + +- You will possibly develop with other developers. + Maintaining a codebase when several people work on the same directory is already a complex thing: from time to time you might work on the same file separately, a situation where you will have to be careful about what and how to merge things when changes are implemented. + It is almost impossible to work together on the same file throughout the project without losing your mind: even more if this file is thousands of lines long. + +- You will implement numerous features. + Numerous features imply a lot of UI and server interactions. + In an `app.R` file containing thousands of lines, it is very hard to match the UI element with its server counterpart. + When the UI is on line 50 and the server on line 570, you will be scrolling a lot when working on these elements. + +### Conventions matter + +In this section you will find a suggestion for a naming convention for your app files that will help you and your team be organized. + +Splitting files is good. +Splitting files using a defined convention is better. +Why? +Because using a common convention for your files helps the other developers (and potentially you) to know exactly what is contained in a specific file, making it easier to navigate through the codebase, be it for newcomers or for developers already familiar with the software. + +As developed in _Refactoring at Scale_ [@lemaire2020], lacking a defined file structure when it comes to the codebase leads to slower productivity in the long run, notably when new engineers join the team: engineers with a knowledge of the file structure have learned how to navigate through the codebase, but new comers will find it hard to understand how everything is organized. +And of course, in the long run, even developers with a knowledge of the structure can get lost, even more if they haven't worked on the project for months. + +> Because it's easier to maintain the status quo, instead of proactively beginning to organize related files [...], engineers instead learn to navigate the increasingly sprawling code. +> New engineers introduced to the growing chaos raise a warning flag and encourage the team to begin splitting up the code, but these concerns fall to deaf ears [...]. +> Eventually, the codebase reaches a critical mass where the persistent lack of organization has dramatically slowed productivity across the engineering team. +> Only then does the team take the time to draft a plan for grooming the codebase, at which point the number of variables to consider is far greater than had they made a concerted effort to tackle the problem months (or even years) earlier.\ +> +> _Refactoring at Scale_ [@lemaire2020] + +Using a convention allows everyone to know where to look when debugging, refactoring, or implementing new features. +For example, if you follow `{golem}`'s convention (which is the one developed in this section), you will know immediately that a file starting with `mod_` contains a module. +If you take over a project, look in the `R/` folder, and see files starting with these three letters, you will know immediately that these files contain modules. + +Here is our proposition for a convention defining how to split your application into smaller pieces. + +First of all, put everything into an `R/` folder. +If you build your app using the `{golem}` framework, this is already the case. +We use the package convention to hold the functions of our application. + +The naming convention in `{golem}` is the following: + +- `app_*.R` (typically `app_ui.R` and `app_server.R`) contain the top-level functions defining your user interface and your server function. + +- `fct_*` files contain the business logic, which are potentially large functions. + They are the backbone of the application and may not be specific to a given module. + They can be added using `{golem}` with the `add_fct("name")` function. + +- `mod_*` files contain a unique module. + Many `{shiny}` apps contain a series of tabs, or at least a tab-like pattern, so we suggest that you number them according to their step in the application. + Tabs are almost always named in the user interface, so that you can use this tab name as the file name. + For example, if you build a dashboard where the first tab is called "Import", you should name your file `mod_01_import.R`. + You can create this file with a module skeleton using `golem::add_module("01_import")`. + +- `utils_*` are files that contain utilities, which are small helper functions. + For example, you might want to have a `not_na`, which is `not_na <- Negate(is.na)`, a `not_null`, or small tools that you will be using application-wide. + Note that you can also create `utils` for a specific module. + +- `*_ui_*`, for example `utils_ui.R`, relates to the user interface. + +- `*_server_*` are files that contain anything related to the application's back-end. + For example, `fct_connection_server.R` will contain functions that are related to the connection to a database, and are specifically used from the server side. + +Note that when building a module file with `{golem}`, you can also create `fct_` and `utils_` files that will hold functions and utilities for this specific module. +For example, `golem::add_module("01_import", fct = "readr", utils = "ui")` will create `R/mod_01_import.R`, `R/mod_01_import_fct_readr.R` and `R/mod_01_import_utils_ui.R`. + +Of course, as with any convention, you might occasionally feel like deviating from the general pattern. +Your app may not have that many functions, or maybe the functions can all fit into one `utils_` file. +But whether you have one or thousands of files, it is always a good practice to stick to a formalized pattern as much as possible. diff --git a/04-golem.Rmd b/04-golem.Rmd new file mode 100644 index 00000000..f41e78bc --- /dev/null +++ b/04-golem.Rmd @@ -0,0 +1,635 @@ +# Introduction to `{golem}` {#golem} + +```{=html} + +``` + + +```{r 04-golem-1, include = FALSE} +library(golem) +knitr::opts_chunk$set(comment = "") +``` + +The `{golem}` [@R-golem] package is a framework for building production-grade `{shiny}` applications. +Many of the patterns and methodologies described in this book are linked to `{golem}` and packages from the [golemverse](http://golemverse.org/). +Of course, all the advice developed in this book will still be valid even if you do not plan to use `{golem}`. + +We have quickly introduced `{golem}` in the last chapter, and we will come back to this package many times in the following chapters. +Let's start with an introduction to this package. +Note that the version used at the time of writing this book is `r packageVersion("golem")`. + +## What is `{golem}`? + +*Note: The current version of `{golem}` used when writing this book is `r packageVersion("golem")`, and some of the features presented in this book might not be available if you are using an older version, or be a little bit different if you have a newer version. Feel free to browse the package NEWS.* + +`{golem}` is a **toolkit for simplifying the creation, development and deployment of a `{shiny}` application**. +It focuses on building applications that will be sent to production, but of course starting with `{golem}` from the very beginning is also possible, even recommended: it is easier to start with `{golem}` than to refactor your codebase to fit into the framework. + +The stable release can be found on CRAN and installed with: + +```{r 04-golem-2, eval=FALSE} +install.packages("golem") +``` + +The `{golem}` development version can be found on GitHub and installed with: + +```{r 04-golem-3, eval=FALSE} +remotes::install_github("Thinkr-open/golem") +``` + +The version of the package used while writing this book is: + +```{r 04-golem-5} +packageVersion("golem") +``` + +The motivation behind `{golem}` is that building a proof-of-concept application is easy, but **things change when the application becomes larger and more complex, and especially when you need to send that app to production**. +Until recently there has not been any real framework for building and deploying production-grade `{shiny}` apps. +This is where `{golem}` comes into play: **offering `{shiny}` developers a toolkit for making a stable, easy-to-maintain, and robust production web application with R**. +`{golem}` has been developed to abstract away the most common engineering tasks (for example, module creation, addition and linking of an external CSS or JavaScript file, etc.), so you can focus on what matters: building the application. +Once your application is ready to be deployed, `{golem}` guides you through testing and brings tools for deploying to common platforms. + +Some things to keep in mind before using `{golem}`: + +- A `{golem}` application is contained inside a package. + Knowing how to build a package is heavily recommended. + The good news is also that everything you know about package development can be applied to `{golem}`. + +- A `{golem}` app works better if you are working with `shiny modules`. + Knowing how modules work is also recommended but not necessary. + +## Understanding `{golem}` app structure + +**A `{golem}` application is an R package**. +Having an R package architecture is perfectly suited for production-ready programs, as we developed in the previous chapter. + +Let's focus on the architecture of the default `{golem}` app, and present the role that each file plays and how you can use (or not use) each of them. + +```{r 04-golem-6, include=FALSE} +library(magrittr) +``` + +You can create a `{golem}` project, here called `golex`, with RStudio "New project" creation or with command line. + +```{r 04-golem-7, eval=FALSE} +golem::create_golem("golex") +``` + +The project will start with this specific architecture: + +```{r 04-golem-8, eval = FALSE} +# Listing the files from the `golex` project using {fs} +fs::dir_tree("golex") +``` + + +```{r include = FALSE} +fs::dir_delete("golex") +golem::create_golem("golex", open = FALSE) +``` + + +```{r echo = FALSE} +fs::dir_tree("golex") + +``` + +```{r include = FALSE} +fs::dir_delete("golex") +source("golembuild.R") +``` + + + +If you are familiar with building R packages, this structure will look familiar to you. +And for a good reason: a `{golem}` app IS a package. + +### `DESCRIPTION` and `NAMESPACE` + +The `DESCRIPTION` and `NAMESPACE` are **standard package files** (*i.e.* they are not `{golem}`-specific). +In `DESCRIPTION`, you will add a series of metadata about your package, for example, who wrote the package, what is the package version, what is its goal, who to complain to if things go wrong, and also information about external dependencies, the license, the encoding, and so forth. + +This `DESCRIPTION` file will be filled automatically by the first function you will run in `dev/01_start.R`, and by other functions from the `dev/` scripts. +In other words, most of the time you will not interact with it directly, but through wrappers from `{golem}` and `{usethis}` [@R-usethis] which are listed in the `dev` scripts. + +The `NAMESPACE` file is the file you will NEVER edit by hand! +**It defines how to interact with the rest of the package**: what functions to import and from which package and what functions to export, *i.e.* what functions are available to the user when you do `library(golex)`. +This file will be built when running the documenting process in your R package, i.e. when doing `devtools::document()`, or more specifically in our case `golem::document_and_reload()`. +This process will build the `man/` files and fill the `NAMESPACE`, by scanning the `{roxygen}` tags in your `.R` files. + +If you want to learn more about these files, here are some resources you can refer to: + +- [Writing R Extensions—The DESCRIPTION file](https://cran.r-project.org/doc/manuals/r-release/R-exts.html#The-DESCRIPTION-file) +- [Writing R Extensions—Package namespaces](https://cran.r-project.org/doc/manuals/r-release/R-exts.html#Package-namespaces) +- [R Packages—Package metadata](https://r-pkgs.org/description.html#) +- [R Packages—Namespace](https://r-pkgs.org/namespace.html) +- [Building a package that lasts—eRum 2018 workshop](https://speakerdeck.com/colinfay/building-a-package-that-lasts-erum-2018-workshop) + +### R/ + +The `R/` folder is **the standard folder where you will store all your app functions**. +When you start your project with `{golem}`, this folder is pre-populated with three `.R` files: `app_config.R`, `app_server.R`, `app_ui.R` and `run_app.R`. + +During the process of building your application, all the core functionalities of your app will be stored in this `R/` directory, which is the standard way to store functions when using the R package framework. +Note that these files are the "core" features of your application itself, and that other .R files also exists. +For example, when you will need to deploy your application on RStudio platforms, `{golem}` will create an `app.R` at the root of your directory.[^golem-1] +The `dev/` folder also contains `.R` scripts, and they are inside this folder as they should not live inside the `R/` folder: they are utilitarian files used during development, not core functionalities of your application. + +[^golem-1]: `{golem}` will automatically add this file to the `.Rbuildignore` file, i.e. make it be ignored by the package build process. + +Inside these `.R` files, contained inside the `R/` folder, you will find the content of your modules (the one added with `golem::add_modules()`) and the utilitarian/business logic functions, built with `golem::add_utils()` and `golem::add_fct()`. + +Note also that this folder must not contain any sub-folders. + +#### app\_config.R {.unnumbered} + +``` {.r} +#' Access files in the current app +#' +#' NOTE: If you manually change your package +#' name in the DESCRIPTION, don't forget to change it here too, +#' and in the config file. For a safer name change mechanism, +#' use the `golem::set_golem_name()` function. +#' +#' @param ... character vectors, specifying subdirectory +#' and file(s) within your package. +#' The default, none, returns the root of the app. +#' +#' @noRd +app_sys <- function(...){ + system.file(..., package = "golex") +} + + +#' Read App Config +#' +#' @param value Value to retrieve from the config file. +#' @param config GOLEM_CONFIG_ACTIVE value. +#' If unset, R_CONFIG_ACTIVE. If unset, "default". +#' @param use_parent Logical, +#' scan the parent directory for config file. +#' +#' @noRd +get_golem_config <- function( + value, + config = Sys.getenv( + "GOLEM_CONFIG_ACTIVE", + Sys.getenv( + "R_CONFIG_ACTIVE", + "default" + ) + ), + use_parent = TRUE +){ + config::get( + value = value, + config = config, + # Modify this if your config file is somewhere else: + file = app_sys("golem-config.yml"), + use_parent = use_parent + ) +} +``` + +The `app_config.R` file contains internal mechanics for `{golem}`, notably for referring to values in the `inst/` folder, and to get values from the config file in the `inst/` folder. +Keep in mind that if ever you need to change the name of your application, you will need to change it inside the `DESCRIPTION`, but also inside the `app_sys()` function. +To make this process easier, you can use the `golem::set_golem_name()`, which will perform both these actions, plus setting the name inside the config file. + +#### app\_server.R {.unnumbered} + +``` {.r} +#' The application server-side +#' +#' @param input,output,session Internal parameters for {shiny}. +#' DO NOT REMOVE. +#' @import shiny +#' @noRd +app_server <- function( input, output, session ) { + # Your application server logic + +} +``` + +The `app_server.R` file **contains the function for the server logic**. +If you are familiar with the classic "ui.R/server.R" approach, this function can be seen as a replacement for the content of the function you have in your `server.R`. + +Building a complex `{shiny}` application commonly implies using `{shiny}` modules. +If so, you will be adding there a series of `callModule()`, the ones you will get on the very bottom of the file created with `golem::add_module()`. + +You will also find global elements from your server-logic: top-level `reactiveValues()`, connections to databases, setting options, and so forth. + +#### app\_ui.R {.unnumbered} + +``` {.r} +#' The application User-Interface +#' +#' @param request Internal parameter for `{shiny}`. +#' DO NOT REMOVE. +#' @import shiny +#' @noRd +app_ui <- function(request) { + tagList( + # Leave this function for adding external resources + golem_add_external_resources(), + # Your application UI logic + fluidPage( + h1("golex") + ) + ) +} +``` + +This piece of the `app_ui.R` is designed to **receive the counterpart of what you put in your server**. +Everything here is to be put after the `r readLines("golex/R/app_ui.R")[11]` line. +Just as with their server counterparts, the UI side of these elements are the ones from the bottom of the file you are creating with `golem::add_module()`. + +By default, `{golem}` uses a `fluidPage()`, which is the most commonly used `{shiny}` [@R-shiny] template. +If ever you want to use `navBarPage()`, this is where you will define it: replace one with the other, and you will be good to go. +You can also define any other template page, for example with an `htmlTemplate()`. +For an example of an application built using an `htmlTemplate`, please visit [engineering-shiny.org/grayscale/](https://engineering-shiny.org/grayscale/), or [engineering-shiny.org/golemhtmltemplate/](https://engineering-shiny.org/golemhtmltemplate/): both these applications are built on top of an external html template. + +If you're tempted to do that, be aware that `fluidPage()` comes with a series of CSS/JS elements, and if you plan on not using a default `{shiny}` `*Page()` function, you will need to add your own CSS. + +``` {.r} +#' Add external Resources to the Application +#' +#' This function is internally used to add external +#' resources inside the Shiny application. +#' +#' @import shiny +#' @importFrom golem add_resource_path activate_js +#' @importFrom golem favicon bundle_resources +#' @noRd +golem_add_external_resources <- function(){ + + add_resource_path( + 'www', app_sys('app/www') + ) + + tags$head( + favicon(), + bundle_resources( + path = app_sys('app/www'), + app_title = 'cloop' + ) + # Add here other external resources + # for example, you can add + # shinyalert::useShinyalert() + ) +} +``` + +The second part of this file contains the `golem_add_external_resources()` function, which is used to add, well, external resources. +You may have noticed that this function is to be found above in the file, in the `app_ui()` function. +This function is used for **linking to external files inside your applications**: notably the files you will create with `golem::add_css_file()` and friends. + +In `golem_add_external_resources()`, you can also define a custom `resourcesPath`. +The first line (the one with `add_resource_path()`) is the one allowing the `inst/app/www` folder to be mounted and be available at `www` with your app when you launch it. +That link makes it possible for `{golem}` to bundle the CSS and JavaScript files automatically. + +The other part of this function, starting with `tags$head`, creates a `` tag for your application. +This `` tag is a pretty standard tag, which is used in HTML to define a series of metadata about your app. +**The last part of this function, the one with `bundle_resources()`, links all the CSS and JavaScript files contained in `inst/app/www` to your application, so you don't have to link them manually**. + +And finally, if you want to add other elements to the `` of your application (for example, by calling `shinyalert::useShinyalert()` or `cicerone::use_cicerone()` as in `{hexmake}`),[^golem-2] you can add these calls after the `bundle_resources()` function. +Note that as all these elements are inside a `tags$head()`, they are to be treated as a list, so separated by commas. + +[^golem-2]: See + +#### run\_app.R {.unnumbered} + +``` {.r} +#' Run the Shiny Application +#' +#' @param ... arguments to pass to golem_opts. +#' See `?golem::get_golem_options` for more details. +#' @inheritParams shiny::shinyApp +#' +#' @export +#' @importFrom shiny shinyApp +#' @importFrom golem with_golem_options +run_app <- function( + onStart = NULL, + options = list(), + enableBookmarking = NULL, + uiPattern = "/", + ... +) { + with_golem_options( + app = shinyApp( + ui = app_ui, + server = app_server, + onStart = onStart, + options = options, + enableBookmarking = enableBookmarking, + uiPattern = uiPattern + ), + golem_opts = list(...) + ) +} +``` + +The `run_app()` function is the one that you will use to launch the app.[^golem-3] + +[^golem-3]: Very technically speaking, it is the `print()` from the object outputed by `run_app()` that launches the app, but this is another story. + +The body of this function is wrapped inside `with_golem_options()`, which allows you to pass arguments to the `run_app()` function, which can be called later on with `golem::get_golem_options()`. +**The idea here is that you can pass arguments to this function, and that arguments will be later used inside your application to display a specific version of the application**. +Using this `with_golem_options()` function simplifies the parameterization of `{shiny}` applications, be it during development, when deployed on a server, or when shared as a package. + +Here are some examples of what you can pass to your shiny application using this pattern: + +- `run_app(user_country = "france")` and `run_app(user_country = "germany")` to launch the application and show the data for a specific country. + +- `run_app(with_mongo = TRUE)` to launch the application with or without a MongoDB back-end (example taken from `{hexmake}`). + +- `run_app(dataset = iris)` will make the dataset available with `golem::get_golem_options("dataset")`, so your user can launch the function from their package using a dataset they have created/loaded + +### `golem-config` + +#### app\_config.R {.unnumbered} + +Inside the `R/` folder is the `app_config.R` file. +This file is designed to handle two things: + +- `app_sys()` is a wrapper around `system.file(package = "golex")`, and allows you to quickly refer to the files inside the `inst/` folder. + For example, `app_sys("x.txt")` points to the `inst/x.txt` file inside your package. + +- `get_golem_config()` helps you manipulate the config file located at `inst/golem-config.yml`. + +#### Manipulating `golem-config.yml` {.unnumbered} + +Here is what the default config file looks like: + +```{r 04-golem-15, echo = FALSE, comment= ""} +readLines( + "golex/inst/golem-config.yml" + ) %>% + #strwrap(width = 55) %>% + #grkstyle::grk_style_text() %>% + glue::as_glue() +``` + +It is based on the `{config}` [@R-config] format, and allows you to define contexts, with values associated with these specific contexts. +For example, in the default example: + +- `default.golem_name`, `default.golem_version`, and `default.app_prod` are usable across the whole life of your golem app: while developing, and also when in production. +- `production.app_prod` might be used for adding elements that are to be used once the app is in production. +- `dev.golem_wd` is in a `dev` config because **the only moment you might reliably use this config is while developing your app**. Use the `app_sys()` function if you want to rely on the package path once the app is deployed. + +These options are globally set with: + +```{r 04-golem-16, eval = FALSE} +# This functions sets all the default options for your project +set_golem_options() +``` + +``` +── Setting {golem} options in `golem-config.yml` ────── +✓ Setting `golem_wd` to /Users/colin/golex +You can change golem working directory with +set_golem_wd('path/to/wd') +✓ Setting `golem_name` to golex +✓ Setting `golem_version` to 0.0.0.9000 +✓ Setting `app_prod` to FALSE +── Setting {usethis} project as `golem_wd` ──────────── +``` + +The functions reading the options in this config file are: + +```{r 04-golem-17, eval = FALSE} +# Get the values from the config file +get_golem_name() +``` + +``` +[1] "golex" +``` + + +```{r, eval = FALSE} +get_golem_wd() +``` + +``` +[1] "/Users/colin/golex" +``` + + +```{r, eval = FALSE} +get_golem_version() +``` + +``` +[1] "0.0.0.9000" +``` + + +You can set these with: + +```{r 04-golem-18, eval = FALSE} +# Get the values in the config file +set_golem_name("this") +``` + +``` +✓ Setting `golem_name` to this +``` + +```{r, eval = FALSE} +set_golem_version("0.0.1") +``` + +``` +✓ Setting `golem_version` to 0.0.1 +``` + + +```{r, eval = FALSE} +# Get the values from the config file +get_golem_name() +``` + +``` +[1] "this" +``` + + +```{r, eval = FALSE} +get_golem_version() +``` + +``` +[1] "0.0.1" +``` + + +If you are already familiar with the `{config}` package, you can use this file just as any config file. + +`{golem}` comes with an `amend_golem_config()` function to add elements to it. + +```{r 04-golem-19, eval = FALSE} +# Add a key in the default configuration +amend_golem_config( + key = "MONGODBURL", + value = "localhost" +) +# Add a key in the production configuration +amend_golem_config( + key = "MONGODBURL", + value = "0.0.0.0", + config = "production" +) +``` + +In `R/app_config.R`, you will find a `get_golem_config()` function that allows you to retrieve config from this config file: + +```{r 04-golem-20, eval = FALSE} +# Retrieve the value of `where` +get_golem_config( + "MONGODBURL" +) +``` + +``` +[1] "localhost" +``` + + +```{r, eval = FALSE} +get_golem_config( + "MONGODBURL", + config = "production" +) +``` + +``` +[1] "0.0.0.0" +``` + +You can also use an environment variable (default `{config}` behavior): + +```{r 04-golem-21, eval = FALSE} +Sys.setenv("GOLEM_CONFIG_ACTIVE" = "production") +get_golem_config( + "MONGODBURL" +) +``` + +``` +[1] "0.0.0.0" +``` + +The good news is that if you don't want/need to use `{config}`, you can safely ignore this file, just leave it where it is: it is used internally by the `{golem}` functions. + +#### `golem_config` vs `golem_options` {.unnumbered} + +There are two ways to configure golem apps: + +- The `golem_opts` in the `run_app()` function +- The `golem-config.yml` file + +The big difference between these two is that the golem options from `run_app()` are meant to be configured during runtime: you will be doing `run_app(val = "this")`, whereas the `golem-config` is meant to be used in the back-end, and will not be linked to the parameters passed to `run_app()` (even if this is technically possible, this is not the main objective). + +It is also linked to the `GOLEM_CONFIG_ACTIVE` and `R_CONFIG_ACTIVE` environment variables. + +The idea is also that the `golem-config.yml` file is shareable across `{golem}` projects (`golem_opts` are application specific), and will be tracked by version control systems. + +For example, let's imagine we want to deploy the `{hexmake}` application on two RStudio Connect instances, but that both need a different MongoDB configuration when it comes to port, db, and collection name. +To do that, you can take several approaches: + +- Set these values as `run_app()` parameters, but that means that you have to maintain one `app.R` for each server to which you will deploy. +- Set everything as environment variables, but that means that you have to do it for every server, and that there is no centralized way to keep track of these variables. +- Set the values in `golem-config.yaml`, and then set a value for the `GOLEM_CONFIG_ACTIVE` environment variable in the environment in which the app is deployed. + +This last solution is a convenient one if you want to easily re-deploy your application on various servers without having to (re)set the values for each environment. +Note, though, that it shouldn't be used to store sensitive data (for example users and passwords). + +Here is the config file that would illustrate what we just said (we have removed the other golem-related entries for the sake of clarity): + +``` {.yaml} +default: + url: mongo + mongoport: 12345 + mongodb: users + mongocollecton: hex +server1: + url: mongo + mongoport: 6543 + mongodb: users + mongocollecton: hex +server2: + url: mongo + mongoport: 9876 + mongodb: shiny + mongocollecton: hexmake +server2: + url: 127.0.0.1 + mongoport: 3214 + mongodb: connect + mongocollecton: app1 +``` + +Using this configuration file, you can then deploy the very same app on the two servers, and configure what is going to be read by the application by setting an environment variable inside the RStudio Connect interface, as shown in Figure \@ref(fig:04-golem-22). + +(ref:envvarconnect) Setting an environment variable in RStudio Connect. + +```{r 04-golem-22, echo=FALSE, fig.cap="(ref:envvarconnect)", out.width='100%'} +knitr::include_graphics("img/GOLEM_CONFIG_ACTIVE.png") +``` + +### `inst/app/www/` + +The `inst/app/www/` folder contains all files that are made available **at application run time**. +Any web application has external files that allow it to run.[^golem-4] +For example, `{shiny}` and its `fluidPage()` function bundles a series of CSS and JavaScript files, notably the `Bootstrap` library, or `jQuery`. These external files enhance your app: CSS for the design part and JavaScript for the interactive part (more or less). +On top of that, you can add your own files: your own design with CSS, or your own JavaScript content (as we will see in the last chapters of this book). +In order to work, you have to include a link to these files somewhere in the UI. +This is what `golem_add_external_resources()` is made for: linking the external resources that you will build with the following functions. + +[^golem-4]: Some web pages do not need any external sources, as they do not have any design and are plain HTML, but generally speaking we will not call this format a web application. + +- `golem::add_css_file()` +- `golem::add_js_file()` +- `golem::add_js_handler()` +- `golem::use_external_css_file()` +- `golem::use_external_js_file()` +- `golem::use_favicon()` + +Be aware that these files are available under `www/` at **application run time**, *i.e.* the `www/` folder is available via your browser, not via R when it runs/generates your application. +In other words, you can use the `www` prefix in the HTML generated in your UI, which is read by your browser, not from the R/server side. +If you want to link to a file that is read during **application generation**, you will need to use the `app_sys()` function with, for example, `includeMarkdown( app_sys("app/www/howto.md") )`. + +We encourage you to add any new external file (e.g. pictures) in the `inst/app/www` folder, so that you can later use it in the UI with the common `www` prefix. +Another common pattern would be: + +- Adding images in `inst/app/img` +- Calling `addResourcePath( 'img', system.file('app/img', package = 'golex') )` +- Adding elements to your UI with `tags$img(src = "img/name.png")` + +### `dev/` + +The `dev/` folder is to be used as a **notebook for your development process: you will find here a series of functions that can be used throughout your project**. + +The content of these files are specific to `{golem}` here, but the concept of using a script to store all development steps is not restricted to a `{shiny}` application: it could easily be done for any package, and this is something we recommend that you do. +The functions inside these files are the ones used to do some setup, like `usethis::use_mit_license()` or `usethis::use_vignette("my-analysis")`, and add testing infrastructure, like `usethis::use_test("my-function")` or `devtools::check()`. +You will also find functions to populate the application like `golem::add_module("my-module")` or `golem::add_js_file("my-script")`. +And finally, there are functions you will need once your application is ready: `pkgdown::build_site()`, `rhub::check_for_cran()` or `golem::add_dockerfile()`. + +We will come back to these files later in this book when we describe in more depth the `{golem}` workflow. + +### `man/` + +The `man/` folder includes **the package documentation**. +It is a common folder automatically filled when you document your app, notably when running the `dev/run_dev.R` script and the `document_and_reload()` function. + +Building documentation for a package is a widely documented subject, and if you want to know more about documentation and how to build it, here are some external links: + +- [R Packages - Object documentation](http://r-pkgs.had.co.nz/man.html) +- [Introduction to roxygen2](https://cran.r-project.org/web/packages/roxygen2/vignettes/roxygen2.html) +- [Building a package that lasts—eRum 2018 workshop](https://speakerdeck.com/colinfay/building-a-package-that-lasts-erum-2018-workshop) diff --git a/05-workflow.Rmd b/05-workflow.Rmd new file mode 100644 index 00000000..f2fcef57 --- /dev/null +++ b/05-workflow.Rmd @@ -0,0 +1,127 @@ +# The Workflow {#workflow} + +Building a robust, production-ready web application will be made easier by following a given workflow. +The one we are advocating is divided in five steps: + ++ Design ++ Prototype ++ Build ++ Strengthen ++ Deploy + +In this chapter, we will give an brief overview of the different steps: the rest of the book will cover each of these steps in more depth. + +Of course, as with any workflow, this one is not a one-size-fits-all solution: all projects are unique, with technical requirements, specific planning and team of coder(s). +But we think that following this workflow will help you get good habits when it comes to structuring your application project, even more if you know from day one that the application you are going to work on is a large application, whether in terms of codebase, complexity, or time. + +Note that the ideas behind this workflow, and its process, could be used outside of a `{shiny}` project: it can be applied to any coding project, even outside of the R world. +Of course, the tools presented in this book are R and `{shiny}` specific, but the general ideas can be bootstrapped to be used outside of this context. + +## Step 1: Design + +The first part of the workflow is the __design__ part. + +This very first step is the one that happens before starting to code: it is the one where you are thinking about the general implementation and features of the application, and where you build the general roadmap for the coding process. +During the process of designing, you will define how the application will be built: somewhere between users' dreams, what is technically possible, and the time you have to build the application. + +This first step is not `{shiny}` or R specific, it is something software engineers do for any software or web application: discuss with the clients,^[We use the term "client" in a loose sense, meaning the person(s) who is/are ordering the application.] + the end users, and the developers who will work on the project. +The idea with this first step is to get a clear idea of what everybody involved in the project wants/is able to do: + ++ From the client/end user's point of view, this step involves working on getting a clear idea of what they want the application to do, and to confront this view with the developers to evaluate what is possible to do, how much time it will take to implement desired features, etc. + ++ From the developer team point of view, this step also involves getting a clear idea of what the client is asking, in other words it involves translating the requirements to technical specifications. +For example, the client might write something like "Save the plot inside a database so that we can search for them later on": from an application user point of view, this is a clear feature, from a developer point of view, this requirement can be translated in many ways. + +This first step actually implies a lot of thinking before coding. +The main goal of this step is to spend time thinking about the application while you still do not have anything implemented, so that you do not discover blocking elements once it is too late, or at least once you already have written a lot of code. +We have all been in a situation during a project where we tell ourselves: "I wish I had known this sooner": working on designing the application before building it helps lowering the chances for this kind of bad surprise. + +This first part of the workflow will span three chapters: + ++ "UX Matters", Chapter \@ref(ux-matters), is a chapter where we introduce the concepts of "User eXperience" (UX), and why it is a crucial concept when you are building your application. +This chapter will cover the importance of simplicity when creating web applications, the danger of trying to implement too many components (aka "feature creep"), and finally we will introduce some general rules about web accessibility. +These topics are vast topics, and a lot of literature and online resources exist for all these subjects: further readings and resources are linked inside each section. + ++ Chapter \@ref(dont-rush-into-coding), "Don't rush into coding" underlines why "coding first" might not be the best strategy when it comes to building a production application. +We will also quickly introduce concept maps, and list some of the common questions you might want to ask the people involved in the project. + ++ Finally, this first part of the workflow covers a gentle introduction to CSS, which might be a crucial skill to master when it comes to sending an application to production: either your clients already have a CSS template that they want to include in the application, or they want their application to have the color and design that match the one from the company. +Also, when building a professional application, chances are that you will want your app to stand out from the crowd: hence a little bit of CSS. +This part is included in the design part because it is something that you might want to think about from the very beginning: for example, some companies have pre-existing `{shiny}` templates, they might want to include specific fonts, logo, icons, etc. +These are things better known before starting to code: it is easier to start working inside a `{shiny}` template than migrating an existing code to a template. + +## Step 2: Prototype + +The __prototype__ part is the one during which you will build the front-end and the back-end, but separately. + +As you may know, a `{shiny}` application is an interface (the front-end, or "UI") used to communicate information to the end users that are computed on the server side (the back-end, or "server"). + +To start on solid ground, you need to build the two (front and back) separately. + +On one hand, work on the general appearance, without working on any actual algorithmic implementation: position of the inputs and outputs, general design, interactions, etc.; everything that does not rely on computation on the back-end. +This "UI first" approach will be made possible for `{shiny}` with notably one package, `{shinipsum}` [@R-shinipsum]. + +On the other hand, you (or someone from your team), will be working on building the back-end logic, which comprises the actual outputs that are going to be displayed, the algorithm that will compute results, and all the elements that do not need an interactive runtime to work. +For this point, you can use what we call a "Rmd-first" approach, by combining R functions with the writing of vignettes that describe the internals of the application. +This part of the workflow will be developed in two chapters: + ++ Chapter (\@ref(setting-up-for-success)), "Setting up for success with `{golem}`", will cover the basics of getting started with the `{golem}` package so that you can start your prototyped application with solid foundation. + ++ Chapter (\@ref(building-ispum-app)), "Building an “ipsum-app”" will cover the importance of prototyping when it comes to building applications, then present `{shinipsum}` and `{fakir}`, and finally will introduce how you can use the "Rmd First" methodology to prototype your application back-end. + +## Step 3: Build + +The __build__ part is the one where you will combine the business (or back-end) logic with the front-end. +In this third part, you will work on the core engine of the application, making the business logic work inside the interactive logic of your application. + +This step of the workflow is cover in _Building app with `{golem}`_ (\@ref(build-app-golem)), a chapter that presents the various functions you can use to build your application, _i.e_ the one you will be using to combine your back-end and front-end. + +In this step, we will cover: + ++ How to handle dependencies in your project, _i.e_ how to use external libraries inside your project ++ How to organize modules and functions inside your project ++ How to add tests for the back-end of your application (testing will be covered in more depth in Step 4) ++ How to document your application and its codebase, and how to add code coverage and continuous integration ++ How to leverage the internal `dev` functions from `{golem}` to modify the behavior of specific functions based on an `option()` + +## Step 4: Strengthen + +The __strengthen__ part covers how to ensure your application is immortal, in the sense that we defined in Chapter \@ref(successful-shiny-app) of this book. + +In this part, we will go through unit tests, reproducible development environments, version control, and continuous integration in the context of `{shiny}` applications. +Building a solid testing suite is crucial to the success of a project, as it allows a project to be stable in the long run, be it when you will want to add new feature or refactor existing code: + +> Refactoring requires we be able to confidently ensure that behavior remains identical at every iteration. We can increase our confidence that nothing has changed by writing a suite of tests (unit, integration, end-to-end), and we should not seriously consider moving forward with any refactoring effort until we’ve established sufficient test coverage. +> +> _Refactoring at Scale_ [@lemaire2020] + +This step of the workflow will span over chapters. + ++ The first one, "Build yourself a safety net" (Chapter \@ref(build-yourself-safety-net)), details how to build a testing environment for your `{shiny}` application, be it for testing the back-end or the front-end. +In this chapter, you will be introduced to `{testthat}` for testing your application back-end, tools that are more linked to testing the front-end like NodeJS `puppeteer` module, `{shinytest}` and `{crrry}` for testing interactive logic, `{shinyloadtest}` and `{dockerstats}` for testing your application load. +This chapter will also cover `{renv}` and `Docker`, two essential tools for developing in a reproducible environment. + ++ In Chapter \@ref(version-control), "Version Control", you will be introduced to `git` and to automated testing using continuous integration (CI) platforms like Travis CI or GitHub Actions. + +## Step 5: Deploy + +To __deploy__ is to send your application into production once it is built. + +Being exhaustive here would be an impossible task: there are countless ways to make your application accessible to its targeted users, but we will try to cover some basics in this part. +And of course, where and how you will be deploying your application depends on a lot of parameters. +For example, who are the end users, and how do they want to use your application? +If the end users are familiar with R and use it on a daily basis, they might be looking for an application that runs with `library(app)`, _i.e_ they need the application to be available as an R package they can install on their machine. +If the end users are not coders, they might need the application to be available only as a web application, so they just have to open a browser and navigate to a URL. +Both these cases raise other questions: how can you make the package available on a repository so that R users can get it with `install.packages()`? +If the application is to be made available on a URL, how will it be deployed? +What deployment server is available to you, or to the company ordering the application? +These questions (and more) will be covered in the __deploy__ part of this book. + +In this part, we will present a series of methods to prepare your application to be deployed on various environments, notably: + ++ Sharing your application as a package so that it can be installed manually, through GitHub, or shared on a package repository like the CRAN or BioConductor ++ Sending it to an RStudio platform ++ Building a Docker image to serve your app on a cloud provider + +This step of the workflow is covered in the Chapter \@ref(deploy), "Deploy your application". diff --git a/06-ux-matters.Rmd b/06-ux-matters.Rmd new file mode 100644 index 00000000..a7ec0f1a --- /dev/null +++ b/06-ux-matters.Rmd @@ -0,0 +1,808 @@ +# (PART) Step 1: Design {.unnumbered} + +# UX Matters {#ux-matters} + +Let's state the truth: no matter how complex and innovative your back-end is, your application is bad if your user experience (UX) is bad. +That's the hard truth. +We have a natural tendency, as R-coders, to be focused on the back-end, i.e. the server part of the application, which is perfectly normal—chances are you did not come to R to design front-ends.[^ux-matters-2] + +[^ux-matters-2]: The front-end is the visual part of your application - the one your user interacts with - as opposed to the back-end, which is what is installed on the server, the part the end user does not see. + In `{shiny}`, front-end corresponds to the UI, while back-end, to the server. + +However, **if people cannot understand how to use your application, or if your application front-end does not work at all, your application is not successful no matter how innovative and incredible the computation algorithms in the back-end are**. + +As you are building a complex, production-grade `{shiny}` application, do not underestimate the necessity for a successful front-end - it is, after all, the first thing (and probably the only thing) that the end users of your web application will see. +However, our natural back-end/server logic as R developers can play against us in the long run - **by neglecting the UI and the UX, you will make your application less likely to be adopted among your users, which is a good way to fail your application project**. + +## Simplicity is gold + +> Simplify, then add lightness. +> +> _Colin Chapman CBE, Founder of Lotus Cars_ () + +Aiming for simplicity is a hard thing, but some rules will help you build a better UX, paving the way for a successful application. + +There are mainly two contexts where you will be building a web app with R: for professional use (*i.e.,* people will rely on the app to do their job), or for fun (*i.e.,* people will just use the app as a distraction). + +But both cases have something in common: people will want the app to be usable, **easily** usable. + +If people use your app in a professional context, they do not want to fight with your interface, read complex manuals, or lose time understanding what they are supposed to do and how they are supposed to use your application, at least when it comes to the core usage of the application. +This core usage needs to be "self-explanatory", in the sense that, **if possible, the main usage of the application does not require reading the manual**; On the other hand, more advanced/rarely used features will need more detailed documentation. + +In other words, they want an efficient tool, something that - beyond being accurate - is easy to grasp. +In a professional context, when it comes to "business applications", remember that the quicker you understand the interface, the better the user experience. +Think about all the professional applications and software that you have been ranting about during your professional life, all these cranky user interfaces you did not understand and/or need to relearn every time you use them. +You do not want your app to be one of these applications. + +On the other hand, if users open your app for fun, they are not going to fight against your application; they are just going to give up if the app is too complex to use. +Even a game has to appear easy to use when the users open it. + +In this section, we will review two general principles: the "don't make me think" principle, which states that **interfaces should be as self-explanatory as possible**, and the "rule of least surprise", which states that elements should behave the way they are commonly expected to behave. +These two rules aim at solving one issue: the bigger the cognitive load of your app, the harder it will be for the end user to use it on a daily basis. + +### How we read the web: Scanning content + +One big lie we tell ourselves as developers is that the end user will use the app the way we designed it to be used (though to be honest, this is not true for any software). +We love to think that when faced with our app, the users will carefully read the instructions and make a rational decision based on careful examination of the inputs before doing what we expect them to do. +But the harsh truth is, that it is not what happens. + +First of all, users rarely carefully read all the instructions: they **scan** and perform the first action that more or less matches what they need to do, i.e., they **satisfice** (a portmanteau of satisfy and suffice); a process shown in Figure \@ref(fig:06-ux-matters-1). +Navigating the web, users try to optimize their decision, not by making the decision that would be "optimal", but by doing the first action that is sufficiently satisfactory in relevance. +They behave like that for a lot of reasons, but notably because they want to be as quick as possible on the web, and because the cost of being wrong is very low most of the time - even if you make the wrong decision on a website, chances are that you are just a "return" or "cancel" button away from canceling your last action. + +(ref:scanningcap) How we design a web page versus how a user will really scan it. +From [@stevekrug2014]. + +```{r 06-ux-matters-1, echo=FALSE, fig.cap="(ref:scanningcap)", out.width="100%"} +knitr::include_graphics("img/scanning.png") +``` + +For example, let's have a look at the user interface of `{hexmake}` [@R-hexmake], a `{shiny}` app for building hex stickers, available at (see Figure \@ref(fig:00-app-presentation-3) for a screenshot of this application). + +(ref:hexmakecap) Snapshot of the `{hexmake}` `{shiny}` application on . + +```{r 06-ux-matters-2, echo=FALSE, fig.cap="(ref:hexmakecap)", out.width="100%"} +knitr::include_graphics("img/hexmake.png") +``` + +What will be your reading pattern for this application? +What is the first thing you will do when using this app? + +There is an inherent logic in the application: each sub-menu is designed to handle one specific part of your sticker. +The second menu is the one used to download the sticker, and the last menu is the one used to open the "how to" of the app. +When opening this app, will your first move be to open the "How to"? +Will you open all the sub-menus and select the most "logical" one to start with? +Chances are that by reading this line, you think you will do that. +But in reality, we behave less rationally than we'd like to think. +What we do most of the time is click on the first thing that matches what we are here to do. +For example, most of the time we will first change the package name or upload an image before even opening the "about" section of this app. + +Once users have scanned the page, they perform the first action that seems reasonable, or as coined in "Rational Choice and the Structure of the Environment" by Herbert A. Simon, "**organisms adapt well enough to 'satisfice'; they do not, in general, optimize."**. +In other words, **"As soon as we find a link that seems like it might lead to what we're looking for, there's a very good chance that we'll click it"** (_Don't Make Me Think_, [@stevekrug2014]). + +What that also means is that user might perform what you'd expect to be "irrational" choices. +As they are scanning your application, they might do something unexpected, or use a part of your app in a way that you would not expect it to be used. +For example, if you are creating an app that is designed to take input data that has to be filled in following a specific form, you **need** to check that this requirement is fulfilled, or you will end up debugging errors on uncommon entries. + +This is a pretty common thing about apps and about software in general: you have to expect users to use your product in ways you would not have expected, in ways that might seem absurd to you. +This is called "defensive programming" - you prevent the application from being used in an unexpected way, and instead of relying on the end user to be rational with their choice, we "defend" our function from unexpected inputs. + +For example, consider this small app: + +```{r 06-ux-matters-3, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # Designing an interface that lets the + # user select a species from iris, + # then display a plot() of this dataset + selectInput( + "species", + "Choose one or more species", + choices = unique(iris$Species), + multiple = TRUE, + selected = unique(iris$Species)[1] + ), + plotOutput("plot") + ) +} + +server <- function( + input, + output, + session +){ + # Taking the species as input, and returning the plot + # of the filtered dataset + output$plot <- renderPlot({ + plot( + iris[ iris$Species %in% input$species, ] + ) + }) +} + +shinyApp(ui, server) +``` + +What is wrong with this app? +Probably nothing from a developer point of view - there is a label stating that one should select one or more elements from the drop-down list, and then something is plotted below. +Pretty standard. +But what happens if the drop-down is empty? +Our first thought would be that this would never happen, as it is explicitly specified that there should be one or more elements selected. +In fact, chances are that even with this label, users will eventually end up with an empty `selectInput()`, leading to the printing of a red error where the plot should be. +We are lucky here, as the error only prevents the plot from being displayed; other errors could make the application crash. + +What should we do? +**Adopt a defensive programming mindset**. +Every time you create interactive elements, inputs and outputs, or things the user might interact with, ask yourself: "What if that [crazy thing] happens? How do I handle the case where the minimal viable requirements for my app are not met?" +And in fact, you should not be focusing on that only for the user side - the back-end should also be examined for potential unexpected behaviors. +For example, if your `{shiny}` app relies on a database connection, you should gracefully check that the connection is possible, and if it is not, send a message to your user that the database is not reachable, and that they should either restart the app or come back in a few minutes. + +In fact, this is a crucial thing when it comes to making your app successful: **you should always fail gracefully and informatively**. +That means that even when your R code fails, the whole app should not fail. +If the R code fails for some reason, the user should either get nothing back or an informative bug message, not be faced with a grayish version of the application.[^ux-matters-3] +Note that using external widgets, like the one from the `{DT}` package (or any other that binds to an external JavaScript library), can make this principle harder to apply: as you have less control over what is happening when using this widget, gracefully handling errors can be tricky. +Indeed, `{DT}` sometimes returns errors that originates from the user's browser, so that has nothing to do with R. +In that case, it might be hard to catch this error and gracefully manage it. +The only upside of this error is that it does not crash the whole application. + +[^ux-matters-3]: If you want something different from this grayish screen when `{shiny}` fails, you can have a look at the `{sever}` package [@R-sever], which allows to implement custom disconnected screen and error messages. + +Because of the way `{shiny}` is designed, a lot of R errors will make the `{shiny}` app fail completely. +If you have not thought about this upfront, that means that a user might use the app for 10 minutes, do a series of specifications, enter parameters and data, only for the app to completely crash at some point. +The user has to then restart from scratch, because there is no native way - from there - to restart from where the app has crashed. +This is a very important thing to keep in mind when building `{shiny}` apps: **once the app has failed, there is no easy way to natively get it back to the moment just before it crashed**, meaning that your users might lose a significant amount of the time they have spent configuring the app. + +One good practice is to try, as much as possible, to **wrap all server calls in some form of try-catch** pattern. +That way, you can, for example, send a notification to the user if the process fails, either using a `{shiny}` [@R-shiny] notification function, an external package like `{shinyalert}` [@R-shinyalert], or a custom JavaScript alert like [notify.js](https://github.com/ColinFay/notifyjsexample). +Here is a pseudo-code pattern for this using the `{attempt}` [@R-attempt] package: + +```{r 06-ux-matters-4, eval = FALSE} +library(shiny) +ui <- function(){ + # Here, we would define the interface + tagList( + # [...] + ) +} + +server <- function( + input, + output, + session +){ + # We are attempting to connect to the database, + # using a `connect_db()` connection + conn <- attempt::attempt({ + connect_db() + }) + # if ever this connection failed, we notify the user + # about this failed connection, so that they can know + # what has gone wrong + if (attempt::is_try_error(conn)){ + # Notify the user + send_notification("Could not connect") + } else { + # Continue computing if the connection was successful + continue_computing() + } +} + +shinyApp(ui, server) +``` + +### Building a self-evident app (or at least self-explanatory) + +One of the goals of a usable app is to make it self-evident, and fall back to a self-explanatory app if the first option is too complex a goal. +What is the difference between the two? + +- self-evident: "Not needing to be demonstrated or explained; obvious." [lexico.com](https://www.lexico.com/en/definition/self_evident) + +- self-explanatory: "Easily understood; not needing explanation." + +The first is that the app is designed in such a way that there is no learning curve to using it. +A self-explanatory app has a small learning curve, but it is designed in a way that will make the user understand it in a matter of seconds. + +Let's, for example, get back to our `{tidytuesday201942}` [@R-tidytuesday201942] application available at [connect.thinkr.fr/tidytuesday201942](https://connect.thinkr.fr/tidytuesday201942/). +By itself, this application is not self-evident: you need to have a certain amount of of background knowledge before understanding what this application was designed for. +For example, you might need to have a vague sense of what `tidytuesday` is. +If you do not, you will have to read the home text, which will help you understand what this is. +Then, if we have a look at the menu elements, we see that these are a series of functions from `{ggplot2}` [@R-ggplot2]: without any background about the package, you might find it difficult to understand what this app actually does. + +Yet, if you want to understand what this app is designed for, you will find enough information either on the home page or in the About section, with external links if needed. +And of course, when building apps, context matters. +The `{tidytuesday201942}` app is one that has been developed in the context of `tidytuesday`, an online weekly event for learning data analysis, mainly through the use of `{tidyverse}` packages. +There is a good chance visitors of the app will already know what `{ggplot2}` is when visiting the app. + +#### A. About the "Rule of Least Surprise" {.unnumbered} + +This rule is also known as "Principle of Least Astonishment." + +> Rule of Least Surprise: In interface design, always do the least surprising thing. +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +When we are browsing the web, **we have a series of pre-conceptions about what things are and what they do**. +For example, we expect an underline text to be clickable, so there is a good chance that if you use underlined text in your app, the user will try to click on it. +Usually, the link is also colored differently from the rest of the text. +The same goes for the pointer of the mouse, which usually switches from an arrow to a small hand with a finger up. +A lot of other conventions exist on the web, and you should endeavor to follow them: a clickable link should have at least one of the properties we just described—and if it is neither underlined nor colored and does not change the pointer when it is hovered, chances are that the user will not click on it. + +Just imagine for a second if our "Download" button in the `{tidytuesday201942}` app did not actually download the graph you had generated. +Even more, imagine if this button did not download the graph but something else. +How would you feel about this experience? + +And it is not just about links: almost every visual element on a web page is surrounded by conventions. +Buttons should have borders. +Links should appear clickable. +Bigger texts are headers, the bigger the more important. +Elements that are "visually nested" are related. + +Of course, this is not an absolute rule, and there is always room for creativity when it comes to design, but you should keep in mind that too much surprise can lead to users being lost when it comes to understanding how to use the application. + +Weirdly enough, that is an easy thing to spot when we arrive on a web page or an app: it can either feel "natural", or you can immediately see that something is off. +The hard thing is that it is something you spot when you are a new-comer: developing the app makes us so familiar with the app that we might miss when something is not used the way it is conventionally used.[^ux-matters-4] + +[^ux-matters-4]: For a good summary of these, see "The cranky user: The Principle of Least Astonishmen" + +Let's exemplify this with the "Render" button from the [`{tidytuesday201942}`](https://connect.thinkr.fr/tidytuesday201942/) application. +This app is built on top of Bootstrap 4, which has no CSS class for a `{shiny}` action button.[^ux-matters-5] +Result: without any further CSS, the buttons do not come out as buttons, making it harder to decipher that they are actually buttons. +Compare the native design shown in Figure \@ref(fig:06-ux-matters-5) to the one with a little bit of CSS (which is the one online) shown in Figure \@ref(fig:06-ux-matters-6). + +[^ux-matters-5]: `{shiny}` is built on top of Bootstrap 3, and the action buttons are of class `btn-default`, which was removed in Bootstrap 4. + +(ref:tidytuesdaybutton1cap) Snapshot of `{tidytuesday201942}` without borders around the "Render Plot" button. + +```{r 06-ux-matters-5, echo=FALSE, fig.cap="(ref:tidytuesdaybutton1cap)", out.width="100%"} +knitr::include_graphics("img/tidytuesdaybutton1.png") +``` + +(ref:tidytuesdaybutton2cap) Snapshot of `{tidytuesday201942}` with borders around the "Render Plot" button. + +```{r 06-ux-matters-6, echo=FALSE, fig.cap="(ref:tidytuesdaybutton2cap)", out.width="100%"} +knitr::include_graphics("img/tidytuesdaybutton2.png") +``` + +Yes, it is subtle, yet the second version of the button is clearer to understand. + +Least surprise is crucial to make the user experience a good one: users rarely think that if something is behaving unexpectedly on an app, it is because of the app: they will usually think it is their fault. +Same goes for the application failing or behaving in an unexpected way: most users think they are "doing it wrong", instead of blaming the designer of the software. + +> When users are astonished they usually assume that they have made a mistake; they are unlikely to realize that the page has astonished them. +> They are more likely to feel that they are at fault for not anticipating the page. +> Don't take advantage of this; making users feel stupid is not endearing. +> +> _The cranky user: The Principle of Least Astonishment_ () + +#### B. Thinking about progression {.unnumbered} + +If there is a progression in your app, you should **design a clear pattern of moving forward**. +If you need to bring your user from step 1 to step 7, you need to guide them through the whole process, and it can be as simple as putting "Next" buttons on the bottom of each page. + +Inside your app, this progression has to be clear, even more if step n+1 relies on the inputs from n. +A good and simple way to do that is to hide elements at step n+1 until all the requirements are fulfilled at step n. +Indeed, you can be sure that if step 2 relies on step 1 and you did not hide step 2 until you have everything you need, users will go to step 2 too soon. + +Another way to help this readability is to ensure some kind of linear logic through the app: step 1, data upload, step 2, data cleaning, step 3, data visualization, step 4, exporting the report. +And organized your application around this logic, from left to right / right to left, or from top to bottom. + +Let's compare `{tidytuesday201942}` to `{hexmake}`—one has a clear progression, `{hexmake}`, and has been designed as such: the upper menus design the stickers, and then once they are filled you can download them. +There is a progression here, from top to bottom. +On the other hand, `{tidytuesday201942}` does not have a real progression inside it: you can navigate from one tab to the other at will. +Hence there are no visual clues of progression on that app. + +#### C. Inputs and errors {.unnumbered} + +You're the one developing the app, so of course you are conscious of all the inputs that are needed to complete a specific task. +But your users might be new to the app; distracted while reading, they might not clearly understand what they are doing, maybe they do not really want to use your app but are forced to by their boss. +Or maybe your app is a little bit hard to understand, so it is hard to know what to do at first. + +When building your app, you should **make sure that if an input is necessary, it is made clear inside the app that it is**. +One way to do this is simply by hiding UI elements that cannot be used until all the necessary inputs are there: for example, if a plot fails at rendering unless you have provided a selection, do not try to render this plot unless the selection is done. +If you are building a dashboard and tab 2 needs specific inputs from tab 1, and tab 3 specific inputs from tab 2, then be sure that tabs 2 and 3 are not clickable/available until all the required inputs are filled. +That way, you can help the user navigate through the app, by reducing the cognitive load of having to be sure that everything is correctly set up: if it is not clickable, that is because something is missing. + +And do this for all the elements in your app: for example, with `{hexmake}`, we start with filled fields and a hex sticker which is ready, so that even if you start with the download part, the application would still work. +If we had chosen another pattern, such as making the user fill in everything before being able to download, we would have needed to make downloading impossible until all fields are filled. +Another example from this application is the use of a MongoDB back-end to store the hex stickers: if the application is launched with `with_mongo` set to FALSE, the user will not see any buttons or field that refers to this option. + +Think about all the times when you are ordering something on the internet, and need to fill specific fields before being able to click on the "Validate" button. +Well, apply that approach to your app; that will prevent unwanted mistakes. + +Note that when using the `golem::use_utils_ui()` function, you will end with a script of UI tools, one being `with_red_star`, which adds a little red star at the end of the text you are entering, a common pattern for signifying that a field is mandatory: + +```{r 06-ux-matters-7, echo = FALSE} +with_red_star <- function(text) { + htmltools::tags$span( + HTML( + paste0( + text, + htmltools::tags$span( + style = "color:red", "*" + ) + ) + ) + ) +} +``` + +```{r 06-ux-matters-8 } +with_red_star("Enter your name here") +``` + +Also, be generous when it comes to errors: it is rather frustrating for a user to see an app crash without any explanation about what went wrong. +If something fails or behaves unexpectedly, error messages are a key feature to help your user get on the right track. +And, at the same time, helping them correct themselves after an error is the best way to save you time answering angry emails! + +Let's refactor our app from before, using the `{shinyFeedback}` [@R-shinyFeedback] package. + +```{r 06-ux-matters-9, eval = FALSE} +library(shiny) +library(shinyFeedback) + +ui <- function(){ + tagList( + # Attaching the {shinyFeedback} dependencies + useShinyFeedback(), + # Recreating our selectInput + plot from before + selectInput( + "species", + "Choose one or more species", + choices = unique(iris$Species), + multiple = TRUE, + selected = unique(iris$Species)[1] + ), + plotOutput("plt") + ) +} + +server <- function( + input, + output, + session +){ + output$plt <- renderPlot({ + # If the length of the input is 0 + # (i.e. nothing is selected),we show + # a feedback to the user in the form of a text + # If the length > 0, we remove the feedback. + if (length(input$species) == 0){ + showFeedbackWarning( + inputId = "species", + text = "Select at least one Species" + ) + } else { + hideFeedback("species") + } + # req() allows to stop further code execution + # if the condition is not a truthy. + # Hence if input$species is NULL, the computation + # will be stopped here. + req(input$species) + plot( + iris[ iris$Species %in% input$species, ] + ) + }) +} + +shinyApp(ui, server) +``` + +Here, as a user, it is way easier to understand what went wrong: we have moved from a red error `Error: need finite 'xlim' values` to a pop-up explaining what went wrong in the way the user configured the app. +Perfect way to reduce your bug tracker incoming tickets! + +This is a way to do it natively in `{shiny}`, but note that you can also use the `{shinyAlert}` package to implement alerts. +It is also possible to build your own with a little bit of HTML, CSS and JavaScript, as shown in [the `notifyjsexample` repository](https://github.com/ColinFay/notifyjsexample). + +## The danger of feature-creep + +### What is feature-creep? + +> Even more often (at least in the commercial software world) excessive complexity comes from project requirements that are based on the marketing fad of the month rather than the reality of what customers want or software can actually deliver. +> Many a good design has been smothered under marketing's pile of "checklist features"—features that, often, no customer will ever use. +> And a vicious circle operates; the competition thinks it has to compete with chrome by adding more chrome. +> Pretty soon, massive bloat is the industry standard and everyone is using huge, buggy programs not even their developers can love. +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +Feature-creep is the process of **adding features to the app that complicate the usage and the maintenance of the product, to the point that extreme feature-creep can lead to the product being entirely unusable and completely impossible to maintain**. +This movement always starts well-intentioned: easier navigation, more information, more visualizations, modifiable elements, and so on and so forth. +It can come from project managers or devs, but users can also be responsible for asking for more and more features in the app. +If you are working in a context where the app specifications were designed by the users, or where you regularly meet the users for their feedback, they will most often be asking for more than what is efficiently implementable. +Behind feature-creep, there is always a will to make the user experience better, but adding more and more things most often leads to a slower app, worse user experience, steeper learning curve, and all these bad states that you do not want for your app. + +Let's take a rather common data analytic process: querying the data, cleaning it, then plotting and summarizing it. +And let's say that we want to add to this a simple admin dashboard that tracks what the users do in the app. +It's pretty tempting to think of this as a single entity and throw the whole codebase into one big project and hope for the best. +But let's decompose what we have for a minute: one task is querying and cleaning, one other is analyzing, and one other is administration. +What is the point of having one big app for these three different tasks? +Splitting this project into three smaller apps will keep you from having a large app which is harder to maintain, and that might not perform as well. +Indeed, if you put everything into the same app, you will have to add extra mechanisms to prevent the admin panel from loading if your user simply wants to go to the extraction step, and inversely, a user visiting the admin panel probably does not need the extraction and analysis back-end to be loaded when they simply want to browse the way other users have been using the app. + +> Rule of Parsimony: Write a big program only when it is clear by demonstration that nothing else will do. +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +But let's focus on a smaller scope, and think about some things that can be thought of as feature-creeping your `{shiny}` app. + +### Too much reactivity + +When designing an app, you will be designing the way users will navigate through the app. +And most of the time, we design with the idea that the users will perform a "correct selection" pattern. +Something like: "The user will select 40 on the `sliderInput()` and the plot will update automatically. Then the user will select the element they need in the `selectInput()` and the plot will update automatically*". +When in reality what will happen is: "*The user will click on the slider, aim at 40 but will reach 45, then 37, then 42, before having the right amount of 40. Then they will select something in the `selectInput()`, but chances are, not the correct one from the first time." + +In real-life usage, **people make mistakes while using the app** (and even more when discovering the application): they do not move the sliders to the right place, so if the application reacts to all of the moves, the experience using the app can be bad: in the example above, full reactivity means that you will get 4 "wrong" computations of the plot before getting it right. + +In the `{tidytuesday201942}` application example, let's imagine that all the elements on the left automatically update the plot: especially in the context of a learning tool, reacting to any configuration change will launch a lot of useless computation, slowing the app in the long run, and making the user experience poorer. + +(ref:uxtidytuesdayappcap) Snapshot of the `{tidytuesday201942}` `{shiny}` application. + +```{r 06-ux-matters-10, echo=FALSE, fig.cap="(ref:uxtidytuesdayappcap)", out.width="100%"} +knitr::include_graphics("img/tidytuesdayapp.png") +``` + +What should we do? +Prevent ourselves from implementing "full reactivity": instead, we will add a user input that will launch the computation. +The simplest solution iss a button so that the user signals to the application that now they are ready for the application to compute what they have parameterized. + +### Too much interactivity + +Users **love** interactive elements. +Maybe too much. +If you present a user with a choice between a simple graph and a dynamic one, chances are that they will spontaneously go for the dynamic graph. +Yet, dynamic is not always the solution, and for several reasons. + +#### A. Speed {.unnumbered} + +Dynamic elements are slower to render than fixed ones. +Most of the time (if not always), rendering dynamic elements means that you will bind some external libraries, and maybe you will have to make R convert data from one format to another. +For example, rendering a `{ggplot2}` plot will be faster than rendering a `ggplotly()` plot, which has to convert from one format to another.[^ux-matters-6] + +[^ux-matters-6]: Well, maybe the native `{plotly}` [@plotly2020] implementation is faster, but you get the spirit. + +That being said, not all visualization libraries are created equal, and choosing interactive visualization will not automatically lead to poorer performance: just keep in mind that this can happen. + +Finally, if you do choose to use an interactive library for your application, try to, if possible, stick with one: it's easier for you as a developer as it will lower the potential conflicts between libraries, and for the user, who will have to "learn" only one interactive mechanism. + +#### B. Visual noise {.unnumbered} + +More interactivity can lead to an element being less straightforward to understand. +Think for a minute about the `{plotly}` outputs, as seen on Figure \@ref(fig:06-ux-matters-11). +They are awesome if you need this kind of interactivity, but for a common plot there might be too many things to understand. +Instead of focusing on the data, a lot of things show: buttons to zoom, to do selection, to export in png, and things like that. +With this kind of graph, users might lose some time focusing on understanding what the buttons do and why they are there, instead of focusing on what matters: getting insights from the data. + +(ref:plotlycap) Output of a `{plotly}` output, with all available buttons shown. + +```{r 06-ux-matters-11, echo=FALSE, fig.cap="(ref:plotlycap)", out.width="100%"} +knitr::include_graphics("img/plotly.png") +``` + +Of course, these features are awesome if you need them: exploring data interactively is a fundamental strength for an application when the context is right. +But if there is no solid reason for using an interactive table, use a standard HTML table. +In other words, do not make things interactive if there is no value in adding interactivity; for example, if you have a small table and the users do not need to sort the table, filter, or navigate in pages, `datatable()` from `{DT}` [@R-DT] will add more visual noise than adding value to the application. + +Adding interactivity widgets (in most cases) means adding visual elements to your original content: in other words, you are adding visual components that might distract the user from focusing on the content of the information. + +To sum up, a good rule to live by is that you should not add a feature for the sake of adding a feature. + +> Less is more. +> +> _Ludwig Mies van der Rohe_ () + +## Web accessibility + +### About accessibility + +When building professional `{shiny}` applications, you have to keep in mind that, potentially, this app will be consumed by a large audience. +**A large audience means that there is a chance that your app will be used by people with visual, mobility, or maybe cognitive disabilities**.[^ux-matters-7] +Web accessibility deals with the process of making the web available to people with disabilities. + +[^ux-matters-7]: And of course, other type of disabilities. + +> The Web is fundamentally designed to work for all people, whatever their hardware, software, language, location, or ability. +> When the Web meets this goal, it is accessible to people with a diverse range of hearing, movement, sight, and cognitive ability. +> +> _Accessibility in Context - The Web Accessibility Initiative_ () + +When learning to code a web app through "canonical" courses, you will be introduced to web accessibility very early. +For example, you can learn about this straight from the first chapter of [learn.freecodecamp.org](https://learn.freecodecamp.org/). +The first course, "Responsive Web Design Certification", has a chapter on web accessibility just after the one on HTML and CSS. + +### Making your app accessible + +#### A. Hierarchy {.unnumbered} + +Headers are not just there to make your application more stylish. +`

` to `

` are there so they can create a hierarchy inside your web page: `

` being more important (hierarchically speaking) than `

`. +In a perfectly designed website, you would only have one header of level 1, a small number of level 2 headers, more headers of level 3, etc. +These elements are used by screen readers (devices used by blind people) to understand how the page is organized. + +Hence, you should not rely on the header level for styling: do not use an `

` because you need a larger title somewhere in your app. +If you want to increase the size of a header, use CSS, which we will see in an upcoming chapter. + +#### B. HTML element: Semantic tags, and tag metadata {.unnumbered} + +In HTML, there are two kinds of elements: the ones without "meanings" like `
` or ``, and the ones which are considered meaningful, like `` or `<article>`. +The second ones are called "semantic tags", as they have a specific meaning in the sense that they define what they contain. +Same thing as with headers; these elements are crucial for the screen readers to understand what the page contains. + +```{r 06-ux-matters-12, eval = FALSE} +library(htmltools) +# Using the `article` tag for a better semantic +tags$article( + tags$h2("Title"), + tags$div("Content") +) +``` + +One other HTML method you can use is tag attributes as metadata. +Tag attributes are complementary elements you can add to a tag to add information: most of the time, you will be using it to add a CSS class, an identifier, or maybe some events like `onclick`.[^ux-matters-8] +But these can also be used to add, for example, an alternate text to an image: this `alt` being the one which is read when the image is not available, either because the page could not reach the resource, or because the person navigating the app is using a screen-to-speech technology. +To do this, we can use the `tagAppendAttributes()` function from `{shiny}`, which allows us to add attributes to an HTML element. + +[^ux-matters-8]: See the JavaScript chapter. + +```{r 06-ux-matters-13, eval = FALSE} +library(shiny) +library(magrittr) +ui <- function(){ + # Generating a UI with one plot + tagList( + plotOutput("plot") %>% + # Adding the `alt` attribute to our plot + tagAppendAttributes(alt = "Plot of iris") + ) +} + +server <- function( + input, + output, + session +){ + # Generating the plot from the server side, + # no modification here + output$plot <- renderPlot({ + plot(iris) + }) +} + +shinyApp(ui, server) +``` + +What makes these two things similar (semantic tags and tag metadata) is that they are both unseen by users without any impairment: if the image is correctly rendered and the user is capable of reading images, chances are that this user will see the image. +But these elements are made for people with disabilities, and especially users who might be using screen-to-speech technologies: these visitors use a software that scans the textual content of the page and reads it, and that helps navigate through the page. + +This navigation is also crucial when it comes to screen-to-speech technology: such software will be able to read the `<title>` tag, jump to the `<nav>`, or straight to the `<article>` on the page. +Hence the importance of structuring the page: these technologies need the app to be built in a structured way, so that it is possible to jump from one section to another, and other common tasks a fully capable user will commonly do. + +Some other tags exist and can be used for semantic purpose: for example, `<address>`, `<video>`, or `<label>`. + +#### C. Navigation {.unnumbered} + +Your app user might also have mobility impairment. +For example, some with Parkinson's disease might be using your app, or someone with a handicap making it harder for them to move their hand and click. +For these users, moving an arm to grab the mouse might be challenging, and they might be navigating the web using their keyboard only. + +When building your app, thinking about how these users will be able to use it is crucial: maybe there are so many buttons to which they need to **move their mouse and eventually click** that they will not be able to use it. +As much as possible, make everything doable with a keyboard: for example, if you have a `textInput()` with a validation button below, allow the user to validate by pressing ENTER on their keyboard. +This can, for example, be done with the `{nter}` package, which is available only on GitHub[^ux-matters-9] at the time of writing these lines. + +[^ux-matters-9]: <https://github.com/JohnCoene/nter> + +```{r 06-ux-matters-14, eval=FALSE} +# Adapted from https://github.com/JohnCoene/nter +library(nter) +library(shiny) + +ui <- fluidPage( + # Setting a text input and a button + textInput("text", ""), + # This button will be clicked when 'Enter' is pressed in + # the textInput text + actionButton("send", "Do not click hit enter"), + verbatimTextOutput("typed"), + # define the rule + nter("send", "text") +) + +server <- function(input, output) { + + r <- reactiveValues() + + # Define the behavior on click + observeEvent( input$send , { + r$printed <- input$text + }) + + # Render the text + output$typed <- renderPrint({ + r$printed + }) +} + +shinyApp(ui, server) +``` + +#### D. Color choices {.unnumbered} + +Color blindness is also a common impairment when it comes to web accessibility. +And it is a rather common deficiency: according to [colourblindawareness.org](http://www.colourblindawareness.org/), "color (color) blindness (color vision deficiency, or CVD) affects approximately 1 in 12 men (8%) and 1 in 200 women in the world". + +Keeping in mind this prevalence of color blindness is even more important in the context of `{shiny}`, where we are developing data science products, which most often include data visualization. +If designed wrong, dataviz can be unreadable for some specific type of color blindness. +That is why we recommend using the `viridis` [@R-viridis] palette, which has been created to be readable by the most common types of color blindness. + +Here are, for example, a visualization through the lens of various typed of color blindness: + +```{r 06-ux-matters-15 } +# This function generates a plot for an +# internal matrix, and takes a palette as +# parameter so that we can display the +# plot using various palettes, as the +# palette should be a function +with_palette <- function(palette) { + x <- y <- seq(-8 * pi, 8 * pi, len = 40) + r <- sqrt(outer(x^2, y^2, "+")) + z <- cos(r^2) * exp(-r / (2 * pi)) + filled.contour( + z, + axes = FALSE, + color.palette = palette, + asp = 1 + ) +} + +``` + +With the `jet.colors` palette from `{matlab}` [@R-matlab] + +(ref:jetcolors) Original view of `jet.colors` palette from `{matlab}`. + +```{r 06-ux-matters-16, fig.cap="(ref:jetcolors)"} +with_palette(matlab::jet.colors) +``` + +See Figure \@ref(fig:06-ux-matters-16). + +(ref:viridis) Original view of `viridis` palette from `{viridis}`. + +```{r 06-ux-matters-17, fig.cap='(ref:viridis)'} +with_palette(viridis::viridis) +``` + +See Figure \@ref(fig:06-ux-matters-17). + +Even without color blindness, it's already way more readable. +But let's now use the `{dichromat}` [@R-dichromat] package to simulate color blindness. + +```{r 06-ux-matters-18 } +library(dichromat) +``` + +- Simulation of deuteranopia with `jet.colors` and `viridis` + +(ref:jetcolorsdeutan) View of `jet.colors` palette for a deuteranopian. + +```{r 06-ux-matters-19, fig.cap="(ref:jetcolorsdeutan)"} +deutan_jet_color <- function(n){ + cols <- matlab::jet.colors(n) + dichromat(cols, type = "deutan") +} +with_palette( deutan_jet_color ) +``` + +See Figure \@ref(fig:06-ux-matters-19). + +(ref:viridisdeutan) View of `viridis` palette for a deuteranopian. + +```{r 06-ux-matters-20, fig.cap="(ref:viridisdeutan)"} +deutan_viridis <- function(n){ + cols <- viridis::viridis(n) + dichromat(cols, type = "deutan") +} +with_palette( deutan_viridis ) +``` + +See Figure \@ref(fig:06-ux-matters-20). + +- Simulation of protanopia with `jet.colors` and `viridis` + +(ref:jetcolorsprotan) View of `jet.colors` palette for a protanopian. + +```{r 06-ux-matters-21, fig.cap="(ref:jetcolorsprotan)"} +protan_jet_color <- function(n){ + cols <- matlab::jet.colors(n) + dichromat(cols, type = "protan") +} +with_palette( protan_jet_color ) +``` + +See Figure \@ref(fig:06-ux-matters-21). + +(ref:viridisprotan) View of `viridis` palette for a protanopian. + +```{r 06-ux-matters-22, fig.cap="(ref:viridisprotan)"} +protan_viridis <- function(n){ + cols <- viridis::viridis(n) + dichromat(cols, type = "protan") +} +with_palette( protan_viridis ) +``` + +See Figure \@ref(fig:06-ux-matters-22). + +- Simulation of tritanopia with `jet.colors` and `viridis` + +(ref:jetcolorstritan) View of `jet.colors` palette for a tritanopian. + +```{r 06-ux-matters-23, fig.cap="(ref:jetcolorstritan)", out.width="100%"} +tritan_jet_color <- function(n){ + cols <- matlab::jet.colors(n) + dichromat(cols, type = "tritan") +} +with_palette( tritan_jet_color ) +``` + +See Figure \@ref(fig:06-ux-matters-23). + +(ref:viridistritan) View of `viridis` palette for a tritanopian. + +```{r 06-ux-matters-24, fig.cap="(ref:viridistritan)", out.width="100%"} +tritan_viridis <- function(n){ + cols <- viridis::viridis(n) + dichromat(cols, type = "tritan") +} +with_palette( tritan_viridis ) +``` + +See Figure \@ref(fig:06-ux-matters-24). + +As you can see, the `viridis` palette always gives a more readable graph than the `jet.colors` one. +And, on the plus side, it looks fantastic. +Do not hesitate to try and use it! + +### Evaluating your app accessibility and further reading + +#### A. Emulate vision deficiency using Google Chrome {.unnumbered} + +Google Chrome has a built-in feature that allows you to simulate some vision deficiency. +To access this feature, open your developer console, then open the "More Tools" \> "Rendering" menu. +There, you will find at the very bottom an input called "Emulate vision deficiencies", which will allow you to simulate Blurred vision, and four types of color blindness. + +For example, Figure \@ref(fig:06-ux-matters-25) and Figure \@ref(fig:06-ux-matters-26) emulate blurred vision or deuteranopia on the `{hexmake}` app. + +(ref:blurredvision) Emulating blurred vision with Google Chrome. + +```{r 06-ux-matters-25, echo=FALSE, fig.cap="(ref:blurredvision)", out.width="100%"} +knitr::include_graphics("img/blurred-vision.png") +``` + +(ref:deuto) Emulating deuteranopia with Google Chrome. + +```{r 06-ux-matters-26, echo=FALSE, fig.cap="(ref:deuto)", out.width="100%"} +knitr::include_graphics("img/deuto.png") +``` + +#### B. External tools {.unnumbered} + +There are several tools on the web that can evaluate the accessibility of your web page. +You can also use a Google Chrome built-in tool called `Lighthouse` (we will come back to it in the Testing chapter). + +- [IBM Equal Access Toolkit](https://github.com/IBMa/equal-access) is an open source tool to monitor the accessibility of a web application and comes with Google Chrome and Firefox Extensions. + +- [Evaluating Web Accessibility](https://www.w3.org/WAI/test-evaluate/) comes with lengthy reports and advice about checking the accessibility of your website. + +- <https://www.webaccessibility.com/> has an online checker for web page accessibility, and allows you to freely test 5 pages. +The result of a test on the `{hexmake}` application can be seen on Figure \@ref(fig:06-ux-matters-27). + +(ref:accessibilitycap) Web accessibility results for the `{hexmake}` application. + +```{r 06-ux-matters-27, echo=FALSE, fig.cap="(ref:accessibilitycap)", out.width="100%"} +knitr::include_graphics("img/hexmakeaccessibility.png") +``` + +Note that you can also add a Chrome or Firefox extension for <https://www.webaccessibility.com>, making it more straightforward to run your accessibility tests. +It also comes with tools for Java and JavaScript, and notably with a NodeJS module, so it can be used programmatically, for example, in your Continuous Integration suite. diff --git a/07-step-by-step-design.Rmd b/07-step-by-step-design.Rmd new file mode 100644 index 00000000..d7e78340 --- /dev/null +++ b/07-step-by-step-design.Rmd @@ -0,0 +1,205 @@ +# Don't Rush into Coding {#dont-rush-into-coding} + +## Designing before coding + +> You have to believe that software design is a craft worth all the intelligence, creativity, and passion you can muster. +> Otherwise you will not look past the easy, stereotyped ways of approaching design and implementation; **you will rush into coding when you should be thinking**. +> You'll carelessly complicate when you should be relentlessly simplifying—and you'll wonder why your code bloats and debugging is so hard.\ +> +> _The Art of UNIX Programming_ [@ericraymond2003] (Our bold.) + +### The urge to code + +At the moment you receive the specifications for your app, it is tempting to rush into coding. +And that is perfectly normal: we're R developers because we love building software, so as soon as a problem emerges, our brain starts thinking about technical implementation, packages, pieces of code, and all these things that we love to do when we are building an application. + +But **rushing into coding from the very beginning is not the safest way to go**. +Focusing on technical details from the very beginning can make you miss the big picture, be it for the whole app if you are in charge of the project, or for the piece of the whole app that you have been assigned. +Have you ever faced a situation in a coding project where you tell yourself "Oh, I wish I had realized this sooner, because now I need to refactor a lot of my code for this specific thing"? +Yes, we all have been in this situation: realizing too late that the thing we have implemented does not work with another feature we discover along the road. +And what about "Oh I wish I had realized sooner that this package existed before trying to implement my own functions to do that!"[^step-by-step-design-1] +Same thing: we're jumping straight into solving a programming problem when someone else has open-sourced a solution to this very same problem. + +[^step-by-step-design-1]: Given the dynamic of the R community, there is no way to completely avoid this: new packages are created and publish every day, so there is no way to be aware of everything. + But trying to assess what exists before jumping into coding will definitely save you some time in the long run. + +Of course, implementing your own solution might be a good thing in specific cases: avoiding heavy dependencies, incompatible licensing, the joy of the intellectual challenge, but **when building production software, it is safer to go for an existing solution if there is one and it fits in the project: existing packages/software that are widely used by the community and by the industry benefit from wider testing, wider documentation, and a larger audience if you need to ask questions**. +And of course, it saves time, be it immediately or in the long run: re-using an existing solution allows you to save time re-implementing it, so you save time today, but it also prevents you from having to detect and correct bugs, saving you time tomorrow.[^step-by-step-design-2] + +[^step-by-step-design-2]: Of course, it is not an absolute rule: you might also inherit from the bug created by the open source solution. + +Note also that assessing that a dependency/technology is a good choice for an application is not an easy task: there is a difference between *thinking* something will be the good choice and *knowing* that this choice is the correct one. +Most of the time, when faced with a new technology, it makes sense to take some time to write a small prototype that tests the features we want to use. +This process of prototyping small applications to test features is made easier notably by using the `{shinipsum}` package, which we will see in Chapter \@ref(building-ispum-app). + +Before rushing into coding, take some time to conceptualize your application/modules on a piece of paper. +That will help you get the big picture of the piece of code you will be writing: what are the inputs, what are the outputs, what packages/services can you use inside your application, how will it fit in the rest of the project, and so on and so forth. + +### Knowing where to search + +Being a good developer is knowing where to search, and what to search for. +Here is a non-exhaustive list of places you can look if you are stuck/looking for existing packages. + +#### R and `{shiny}` {.unnumbered} + +- [CRAN Task View: Web Technologies and Services](https://cran.r-project.org/web/views/WebTechnologies.html) and [CRAN Task View: Databases with R](https://cran.r-project.org/web/views/Databases.html), which will be useful for interacting with web technologies and databases. +- [The cloudyr project](https://cloudyr.github.io/), which focuses on cloud services and R. +- [METACRAN](https://r-pkg.org/), which is a search engine for R packages. +- [GitHub search using `language:R`](https://github.com/search?q=language%3AR): When doing a search on GitHub, do not forget to add the language-specific tag. +- [RStudio Community](https://community.rstudio.com/c/shiny/8) has a series of posts about `{shiny}`: questions, announcements, best practices, etc. + +#### Web {.unnumbered} + +- [Mozilla developer center](https://developer.mozilla.org/) is one of the most comprehensive resource platforms when it comes to web technologies (HTML, CSS, and JavaScript) +- [Google Developer Center](https://developers.google.com/) also has a series of resources that can be helpful when it comes to web technologies. +- [FreeCodeCamp](https://www.freecodecamp.org/) contains more than 2000 hours of free courses about web technologies, plus a blog and forum. + +### About concept map + +Using a concept map to think about your app can be a valuable method to help you grasp the big picture of your application. + +Concept maps are a widely used tool, in the software engineering world and in many other fields. +The idea with concept maps is to take a piece of paper (or a digital tool) and **draw all the concepts that come to mind for a specific topic, and all the relationships that link these concepts together**. +Drawing a concept map is a way to organize the knowledge of a specific topic. + +When doing this for a piece of software, we are not trying to add technical details about the way things are implemented: we are listing the various "actors" (the concepts) around our app, with the relationships they have. +For example, Figure \@ref(fig:07-step-by-step-design-1) is a very simple concept map of the `{hexmake}` [@R-hexmake] app. + +(ref:hexmakeconceptmap) `{hexmake}` concept map, built with XMind (<https://www.xmind.net>). + +```{r 07-step-by-step-design-1, echo=FALSE, fig.cap="(ref:hexmakeconceptmap)", out.width="100%"} +knitr::include_graphics("img/hexmakemap.png") +``` + +As you can see, we are not detailing the technical implementations: we are not writing the external database specification, the connection process, how the different modules interact with each other, etc. +The goal of a concept map is to think about the big picture, to see the "who and what" of the application. +Here, creating this concept map helps us list the flow of the app: there is a user that wants to configure a hex, built with a default image or with an uploaded one, and once this hex is finished, the user can either download it or register it in a database. +This database can be browsed and restore hex. +The user can also export a `.hex` file, that can restore an app configuration. + +Once this general flow is written down, you can get back to it several times during the process of building the app, but it is also a perfect tool at the end to see if everything is in place: once the application is finished, we can question it: + +- Can we point to any concept and confirm it's there? +- Can we look at every relationship and see they all work as expected? + +Deciding which level of detail you want to put in your concept map depends; "simple" applications probably do not need complex maps. +And that also depends on how precise the specifications are, and how many people are working on the project: the concept map is a valuable tool when it comes to communication, as it allows people involved in the project to have visual clues of the conceptual architecture of the application. + +But beware: very complex maps are also unreadable! +In that case, it might make sense to divide into several concept maps: one with the "big picture", and smaller ones that focus on specific components of your application. + +## Ask questions + +Before starting to code, the safe call will be to ask your team/client (depending on the project) a series of questions just to get a good grasp of the whole project. + +Here is a (non-exhaustive) list of information you might need along the way. + +Side note: Of course, these questions do not cover the core features of the application. +We're pretty sure you have thought about covering this already. +These are more contextual questions which are not directly linked to the application itself, yet that can be useful down the line. + +### About the end users + +Some questions you might ask: + +- Who are the end users of your app? +- Are they tech-literate? +- In which context will they be using your app? +- On what machines (computer, tablet, smartphone, or any other device)? +- Are there any restrictions when it comes to the browser they are using? (For example, are they still using an old version of Internet Explorer?) +- Will they be using the app in their office, on their phone while driving a tractor, in a plant, or while wearing a lab coat? + +Those might seem like weird questions if you are just focusing on the very technical side of the app implementation, but think about where the app will be used: the application used while driving agricultural machines might need fewer interactive things, bigger fonts, simpler interface, fewer details, and more direct information. +If you are building a `{shiny}` app for a team of sellers who are always on the road, chances are they will need an app that they can browse from their mobile. +And developing for mobiles requires a different kind of mindset.[^step-by-step-design-3] + +[^step-by-step-design-3]: For developing an app that is mobile first, you can have a look at the great `{shinyMobile}` [@R-shinyMobile] package made by the amazing Rinterface (<https://rinterface.com/>) team. + +Another good reason why talking to the users is an important step, is that most of the time, **people writing specifications are not the end users and will either request too many features or not enough**. +Do the users really need that much interactive plots? +Do they actually need that much granularity in the information? +Will they really see a `datatable` of 15k lines? +Do they really care about being able to zoom in the `dygraph` so that they can see the point at a minute scale? +To what extent does the app have to be fast? + +Asking these questions is important, because building interactive widgets makes the app a little bit slower, and shoving in a series of unnecessary widgets will make the user experience worse, adding more cognitive load than necessary. +The speed of execution of your app is also an important parameter for your application: getting a sense about the need for speed in your application will allow you to judge whether or not you will have to focus on optimizing code execution. + +On top of that, remember all these things we saw in the last chapter about accessibility: some of your end users might have specific accessibility requirements. + +### Building personas + +The persona is a concept borrowed from design and marketing that refers to fictional characters that will serve as a user type. +In other words**, a persona is a character that represents the "typical" behavior and traits for a group of users that will interact with your product**. + +> A persona consists of a description of a fictional person who represents an important customer or user group for the product, and typically presents information about demographics, behavior, product usage, and product-related goals, tasks, attitudes, etc. +> +> _Quantitative Evaluation of Personas as Information_ [@Chapman2008] + +Using personas during the design process helps you center your focus on the end user, so that you know who you are creating the application for. +Then, while building your application, you can think about how each persona will interact with a given feature: Will they use it? +Will they understand it? +Do we need to add extra information? +Will they find this useful? + +Asking these kinds of questions helps you take a step back from feature implementation and re-focus on what matters: we are building application for someone else, who will eventually use it. + +> The benefits of personas are that they enable designers to envision the end user's needs and wants, remind designers that their own needs are not necessarily the end users' needs, and provide an effective communication tool, which facilitates better design decisions.\ +> +> _Creating and Using Personas in Software Development: Experiences from Practice_ [@Billestrup2014] + +The building of these personas is made easier once you have interacted with the end users, as we suggested in the previous section. +Given the answers to these questions, you will be able to draw some common characteristics about the future users of your application. + +And don't hesitate to detail these fictional characters as "[p]ersonas are considered to be most useful if they are developed as whole characters, described with enough detail for designers and developers to get a feeling of its personality". +[@Billestrup2014] + +### Pre-existing code-base + +From time to time, you are building a `{shiny}` app on top of an existing code-base: either scripts with business logic, a package if you are lucky, or a PoC for a `{shiny}` app. + +These kinds of projects are often referred to as "brownfield projects", in opposition to "greenfield projects", borrowing the terminology from urban planning: **a greenfield project being one where you are building on "evergreen" lands, while a brownfield project is building on lands that were, for example, industrial lands, and which will need to be sanitized, as they potentially contain waste or pollution, constructions need to be destroyed, roads needs to be deviated, and all these things that can make the urban planning process more complex**. +Then, you can extend this to software engineering, where a greenfield project is the one that you start from scratch, and a brownfield project is one where you need to build on top of an existing code-base, implying that you will need to do some extra work before actually working on the project. + +\newpage + +> When transforming brownfield projects, we may face significant impediments and problems, especially when no automated testing exists, or when there is a tightly-coupled architecture that prevents small teams from developing, testing, and deploying code independently. +> +> _The DevOps Handbook_ [@genekim2016] + +Depending on how you chose to handle it, starting from a codebase that is already written can either be very much helping, or you can be shooting yourself in the foot. +Most of the time, `{shiny}` projects are not built as reproducible infrastructures: you will find a series of `library()` calls, no functions structure *per se*, no documentation, and no tests. +In that case, we would advise you to do it "the hard way", or at least what seems to be the hard way: throw the app away and start from scratch. + +Well, not really from scratch: **extract the core business logic of the app and make it a package**. +Take some time with the developer(s) that built the current app, so that you can make them extract the core business logic, i.e. all the pieces of code that do not need a reactive context to run. +Write documentation for this package, work on testing, and once you are done, call it a day: you now have solid ground for building the back-end, and it is built outside of any reactivity, is not linked to any application, and most of the time it can be used outside of the app. +It might actually be more useful than you think: it can serve analysts and data scientists that will benefit from these functions outside of the application, as they can use the business logic functions that are now packaged, and so reusable. + +Existing `{shiny}` projects, in most cases, have not been built by software engineers or web developers—they have been built by data analysts/scientists who wanted to create an interactive PoC for their work. +The good news, then, is that you can expect the core algorithms to be pretty solid and innovative. +But web development is not their strength: and that is perfectly normal, as it is not their core job. +What that implies is that most `{shiny}` PoCs take shortcuts and rely on hacks, especially when it comes to managing reactivity, which is a beautiful concept for small projects but can be very complex to scale if you are not a software engineer by training; even more, given that R is by nature sequential. + +That's why it is better to split the business and app logic from the very beginning (as we have explained in chapter 3): it simplifies the process of refactoring a `{shiny}` PoC into a production-grade `{shiny}` application. + +### Deployment + +There are so many considerations about deployment that it will be very hard to list them all, but keep in mind that **if you do not ask questions about where your application will be deployed from the very beginning, sending it to production might become a painful experience**. +Of course, it is more or less solved if you are deploying with Docker: if it works in a container on your machine, it should work in production, but it is not as simple as that: for example, building a `{shiny}` application that will be used by 10 people is not the same as building an application that needs to scale to 50.000 users. +Learning at the end of the project that "now we need to scale to a very large user base" might prevent the deployment from being successful, as this kind of scale implies specific consideration while building. + +But that is just the tip of the iceberg of things that can happen. +Let's stop for a little story: once upon a time, a team of developers was missioned to build an app, and one feature of the app was to do some API requests. +So far so good, nothing too complicated, until they discovered that the server where the app was going to be deployed does not have access to the internet, making it impossible to issue API requests from the server. +Here, the containers worked on the dev machines, as they had access to the internet. +Once deployed, the app stopped working, and the team lost a couple of days of exchanges with the client, trying to debug the API calls, until we realized that the issue was not with the app, but with the production server itself: and nobody in the team, not the developers or the client, thought about asking about internet access for the server. + +It's even more important to think about the IT side of your application, as the people writing specs and interacting with you might come from the Data Science team, and they might or might not have discussed with the IT team about deploying the app. +There is a chance that they do not have in mind all of what is needed to deploy a `{shiny}` app on their company server. + +For example, maybe your application has a database back-end. +For that, you will need to have access to this database, the correct port should be set, and the permission given to the process that executes the `{shiny}` app to read, and maybe write, to the database. +But, **and for good reason**, database managers do not issue read and write permissions to a database without having examined what the app wants to read, and how and where it will write. +To sum up, if you do not want to have weeks of delay for your app deployment, start the discussion from the very beginning of the project. +That way, even if the process of getting permission to write on the company database takes time, you might have it by the end of the coding marathon. diff --git a/08-step-by-step-prototype.Rmd b/08-step-by-step-prototype.Rmd new file mode 100644 index 00000000..acfab0b4 --- /dev/null +++ b/08-step-by-step-prototype.Rmd @@ -0,0 +1,246 @@ +# (PART) Step 2: Prototype {.unnumbered} + +# Setting up for Success with `{golem}` {#setting-up-for-success} + +Before starting to prototype and build anything, initialize a `{golem}` [@R-golem] project! +This will help you start your application on solid ground, and once the project is ready to be filled, you can start prototyping right inside it. + +The general workflow for "prototype and build" is the following: the project manager sets up a `{golem}` project, where the first steps are filled, the general structure (potentially with `{shiny}` module) is set, and then the project is registered to the version control system. +Once we have this structure, package and modules combined, we can start prototyping the UI inside the module, work on the CSS and JavaScript elements that might be needed, and the back-end functionalities inside Rmarkdown files. +And then, once these two prototyping sides are finished, we work on the integration of everything inside the reactive context. + +In this chapter and in chapter 11, we will be presenting the `{golem}` package in more depth. +`{golem}` is a framework that standardizes the process of building production-ready `{shiny}` applications. + +## Create a `{golem}` + +Once `{golem}` is installed and available on your computer, you can go to File \> New Project... in RStudio, and choose "Package for `{shiny}` app Using golem" input. + +If you want to do it through the command line, you can use: + +```{r 08-step-by-step-prototype-1, eval = FALSE} +# Creating a golem project from the command line +golem::create_golem(path = "path/to/package") +``` + +Once you have that, a new project will be launched. +Here is the structure of this project: + +```{r 08-step-by-step-prototype-2, include=FALSE} +try({ + fs::file_delete( + fs::path( + here::here("golex"), + "inst/app/www/plop.js" + ) + ) + fs::file_delete( + fs::path( + here::here("golex"), + "inst/app/www/script.js" + ) + ) + fs::file_delete( + fs::path( + here::here("golex"), + "inst/app/www/custom.css" + ) + ) + fs::file_delete( + fs::path( + here::here("golex"), + "R/mod_my_first_module.R" + ) + ) +}) + +``` + +```{r 08-step-by-step-prototype-3, comment="", eval = FALSE} +# This is what a default {golem} project looks like +# Listing the files from the `golex` project using {fs} +fs::dir_tree("golex") +``` + +```{r include = FALSE} +fs::dir_delete("golex") +golem::create_golem("golex", open = FALSE) +``` + + +```{r echo = FALSE} +fs::dir_tree("golex") + +``` + +```{r include = FALSE} +fs::dir_delete("golex") +source("golembuild.R") +``` + +If you already have some experience with R packages, most of these files will appear very familiar to you. +That's because a `{golem}` app IS a package, so it uses the standard R package structure (and yes, the good news is that everything you know about R packages will work in a `{golem}`-based application). + +## Setting things up with `dev/01_start.R` + +Once you have created your project, the first file that opens is `dev/01_start.R`. +This file contains a series of commands to run once, at the start of the project. + +### Fill the DESCRIPTION and set options + +First, fill the DESCRIPTION file by adding information about the package that will contain your app: + +```{r 08-step-by-step-prototype-4, eval = FALSE} +golem::fill_desc( + # The Name of the package containing the App + pkg_name = "ipsumapp", + # The Title of the package containing the App + pkg_title = "PKG_TITLE", + # The Description of the package containing the App + pkg_description = "PKG_DESC.", + # Your First Name + author_first_name = "AUTHOR_FIRST", + # Your Last Name + author_last_name = "AUTHOR_LAST", + # Your Email + author_email = "AUTHOR@MAIL.COM", + # The URL of the GitHub Repo (optional) + repo_url = NULL +) +``` + +Then, call the `golem::set_golem_options()` function, which will add information to the `golem-config.yml` file, and set the `{here}` [@R-here] package root sentinel. +`{here}` is an R package designed to handle directory management in R. +When used in combination with `{golem}`, `{here}` helps ensure that everything you do in your console is performed relatively to the root directory of your project: the one containing the `DESCRIPTION` of your application. +That way, even if you change the working directory of your R session to a subfolder, you will still be able to create modules and CSS files in the correct folder. + +### Set common files + +If you want to use the MIT license, add README, a code of conduct, a lifecycle badge, and NEWS. + +```{r 08-step-by-step-prototype-5, eval = FALSE} +# You can set another license here +usethis::use_mit_license( name = "Golem User" ) +# Add a README, Code of Conduct, lifecycle badge and NEWS.md +# file to your application +usethis::use_readme_rmd( open = FALSE ) +usethis::use_code_of_conduct() +usethis::use_lifecycle_badge( "Experimental" ) +usethis::use_news_md( open = FALSE ) +``` + +It's also where you will be invited to use `Git`: + +```{r 08-step-by-step-prototype-6, eval = FALSE} +usethis::use_git() +``` + +### Use recommended elements + +`golem::use_recommended_tests()` and `golem::use_recommended_deps()` sets a default testing infrastructure and adds dependencies to the application. + +### Add utility functions + +These two functions add a file with various functions that can be used along the process of building your app. + +See each file in detail for a description of the functions. + +```{r 08-step-by-step-prototype-7, eval = FALSE} +# These files will create R/golem_utils_ui.R +# and R/golem_utils_server.R +golem::use_utils_ui() +golem::use_utils_server() +``` + +In this file, you will, for example, find `list_to_li()`, which is a function to turn an R list into an HTML list or `with_red_star()`, a function to add a small red star after a UI input, useful for communicating that an input is mandatory. + +### Changing the favicon + +Favicons are the small icons located on the tab of your browser: in the default application, this favicon is the `{golem}` hex. + +If you want to change the default favicon: + +```{r 08-step-by-step-prototype-8, eval = FALSE} +golem::use_favicon( path = "path/to/favicon") +``` + +You're now set! +You've successfully initiated the project and can go to `dev/02_dev.R`. + +## Setting infrastructure for prototyping + +### Add modules in `dev/02_dev.R` + +The `golem::add_module()` function creates a module in the `R` folder. +The file and the modules will be named after the `name` parameter, by adding `mod_` to the R file, and `mod_*_ui` and `mod_*_server` to the UI and server functions. + +```{r 08-step-by-step-prototype-9, eval = FALSE} +# Creating a module skeleton +golem::add_module(name = "my_first_module") +``` + +```{r 08-step-by-step-prototype-10, echo = FALSE} +golem::add_module(name = "my_first_module", here::here("golex"), open = FALSE) +``` + +The new file will contain: + +```{r 08-step-by-step-prototype-11, echo = FALSE, comment=""} +readLines("golex/R/mod_my_first_module.R") %>% + cat(sep = "\n") +``` + +Note that to avoid making errors when putting these into your app, the end of the file will contain code that has to be copied and pasted inside your UI and server functions. + +This is where you will be adding the core of your app. +The first time, these modules will contain prototyped UI for the application, and once the application is ready to be integrated, you will add the core logic here. + +### Add CSS and JS files + +Adding some infrastructure for JavaScript and CSS files from the very beginning can also formalize the set-up: you are giving the rest of your team a specific file where they can write the JavaScript and CSS code. + +```{r 08-step-by-step-prototype-12, eval = FALSE} +golem::add_js_file( "script" ) +``` + +```{r 08-step-by-step-prototype-13, include = FALSE} +golem::add_js_file("script", here::here("golex"), open = FALSE) +``` + +will generate the following file: + +```{r 08-step-by-step-prototype-14, comment="", echo=FALSE} +readLines("golex/inst/app/www/script.js") %>% + cat(sep = "\n") +``` + +Here, you will have an infrastructure for launching JavaScript code once the application is ready (this code is standard `jQuery` format: we will be back to JavaScript at the end of this book). + +```{r 08-step-by-step-prototype-15, eval = FALSE} +golem::add_js_handler( "handlers" ) +``` + +```{r 08-step-by-step-prototype-16, include = FALSE} +golem::add_js_handler("handlers", here::here("golex"), open = FALSE) +``` + +will generate the following file: + +```{r 08-step-by-step-prototype-17, comment="", echo=FALSE} +readLines( "golex/inst/app/www/handlers.js" ) %>% + cat(sep = "\n") +``` + +As you can see, there is already a skeleton for building `{shiny}` JavaScript handlers. + +```{r 08-step-by-step-prototype-18, eval = FALSE} +golem::add_css_file( "custom" ) +``` + +```{r 08-step-by-step-prototype-19, include = FALSE} +golem::add_css_file("custom", here::here("golex"), open = FALSE) +``` + +will create a blank CSS file inside the `inst/app/www` folder. + +Note that as you are building your application with `{golem}`, these files will be linked automatically to your application. diff --git a/09-prototyping.Rmd b/09-prototyping.Rmd new file mode 100644 index 00000000..db4cfa13 --- /dev/null +++ b/09-prototyping.Rmd @@ -0,0 +1,266 @@ +# Building an "ipsum-app" {#building-ispum-app} + +## Prototyping is crucial + +### Prototype, then polish + +> Prototyping first may help keep you from investing far too much time for marginal gains. +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +And yet another rule from _The Art of Unix Programming_: "Rule of Optimization: Prototype before polishing. **Get it working before you optimize it**." +Getting things to work before trying to optimize the app is always a good approach: + +- **Making things work before working on low-level optimization makes the whole engineering process easier**: having a "minimal viable product" that works, even if slowly and not perfectly, gives a stronger sense of success to the project. For example if you are building a vehicle, it feels more of a success to start with a skateboard than with a wheel: you quickly have a product that can be used to move, not waiting for the end of the project before finally having something useful. Building a skateboard helps the developer maintain a sense of accomplishment throughout the life of the project: the quicker you can have a running program, a MVP (Minimum Viable Product, as seen on Figure \@ref(fig:09-prototyping-1)), the better. + +\newpage + +> One of the really nice things about running your program frequently is that you get to see it running, which is fun, ans that's what programming is all about. +> +> _The Unicorn Project_ [@genekim2019] + +(ref:mvp) Building a minimum viable product (MVP). + +```{r 09-prototyping-1, echo=FALSE, fig.cap="(ref:mvp)", out.width="100%"} +knitr::include_graphics("img/mvp.png") +``` + +\newpage + +- **Abstraction is hard, and makes the codebase harder to work with**. You have heard a lot that if you are copying and pasting something more than twice, you should write a function. And with `{shiny}`, if you are writing a piece of the app more than twice, you should write modules. But while these kinds of abstractions are elegant and optimized, they can make the software harder to work on while building it. So before focusing on turning something into a function, make it work first. As said in *R for Data Science* [@hadleywickham2017] about abstraction with `{purrr}` [@R-purrr]: + +> Once you master these functions, you'll find it takes much less time to solve iteration problems. +> But you should never feel bad about using a for loop instead of a map function. +> The map functions are a step up a tower of abstraction, and it can take a long time to get your head around how they work. +> The important thing is that you solve the problem that you're working on, not write the most concise and elegant code (although that's definitely something you want to strive towards!). +> +> _R for Data Science - 21.5 The map functions_ [@hadleywickham2017] + +As a small example, we can refer to the binding module from `{hexmake}` [@R-hexmake]: this module manipulates namespaces, inputs, and `session` to automatically bind inputs to the R6 object containing the image (see implementation [here](https://github.com/ColinFay/hexmake/blob/master/R/mod_binder.R#L29), [here](https://github.com/ColinFay/hexmake/blob/master/R/utils_server.R#L2) and [here](https://github.com/ColinFay/hexmake/blob/master/R/mod_left.R#L60)). +That's an elegant solution: instead of duplicating content, we use functions to automatically bind events. +But that is a higher level of abstraction: we manipulate different levels of namespacing and inputs, making it harder to reason about when you have to change the codebase. + +- It's hard to identify upfront the real bottlenecks of the app. + As long as the app is not in a working state, it is very hard to identify the real pieces of code that need to be optimized. + Chances are that if you ask yourself upfront what the app bottlenecks will be, you will not aim right. + Instead of losing time focusing on specific pieces of code you think need to be optimized, start by having something that works, then optimize the code. + In other words, "Make It Work. Make It Right. Make It Fast", ([KentBeck](https://wiki.c2.com/?MakeItWorkMakeItRightMakeItFast)). + +- It's easier to spot mistakes when you have something that can run. + If a piece of software runs, it is straightforward to check if a change in the codebase breaks the software or not: it either still runs or not. + +### The "UI first" approach + +Using what can be called a "UI first" approach when building an app is in most cases the safest way to go. +And for two main reasons. + +#### A. Agreeing on specifications {.unnumbered} + +First of all, it **helps everybody involved in the application to agree on what the app is supposed to do, and once the UI is set, there should be no "surprise implementation"**. +Well, at least, this is the best way to reduce the number of changes in the app, as the sooner we have a global idea of the app, the better. +It is hard to implement a core new feature once the app is 90% finished, while it would have been way easier to implement it if it had been detected from the very start. +Indeed, implementing core features once the app is very advanced can be critical, as our application might not have been thought to work the way it now needs to work, so adding certain elements might lead to a need for change in the core architecture of the app. +Once we agree on what elements compose the app, there should be no sudden "oh, the app needs to do that thing now, sorry I hadn't realized that before". + +We cannot blame the person ordering the app for not realizing everything needed to build the app: it is really hard to have a mental model of the whole software when we are writing specifications, not to mention when reading them. +On the other hand, having a mock application with the UI really helps us realize what the app is doing and how it works, and to agree with the developer that this is actually what we want our application to do (or realize that this is not something we actually need). + +Prototyping the UI first should require the least possible computation from the server side of your application. +You focus on the appearance of the app: buttons, figures, tables, and graphs, and how they interact with each other. +**At that stage of the design process, you will not be focusing on the correctness of the results or graphs: you will be placing elements on the front-end so that you can be sure that everything is there, even if some buttons do not trigger anything**. +At that point, the idea is to get the people who are ordering the app to think about what they actually need, and there might be a question like "oh, where is the button to download the results in a pdf?". +And at that precise moment is the perfect time for a change in specification. + +#### B. Organizing work {.unnumbered} + +A pre-defined UI allows every person involved in the coding process to know which part of the app they are working on, and to be sure that you do not forget anything. +As you might be working on the app as a team, you will need to find a strategy for efficiently splitting the work among coders. +**It's much easier to work on a piece of the app you can visually identify and integrate in a complete app scenario**. +In other words, it is easier to be told "you will be working on the 'Summary' panel from that mock UI" than "you will be working on bullet points 45 to 78 of the specifications". + +## Prototyping `{shiny}` + +In the next section, you will be introduced to two packages that can be used when prototyping a user interface: `{shinipsum}` [@R-shinipsum] and `{fakir}` [@R-fakir]. + +### Fast UI prototyping with `{shinipsum}` + +When prototyping the UI for an application, we will not be focusing on building the actual computation: **what we need is to create a draft with visual components, so that we can have visual clues about the end result**. +To do that, you can use the `{shinipsum}` package, which has been designed to generate random `{shiny}` [@R-shiny] elements. +If you are familiar with "lorem ipsum", the fake text generator that is used in software design as a placeholder for text, the idea is the same: generating placeholders for `{shiny}` outputs. +For an example of an application built with `{shinipsum}`, please visit [engineering-shiny.org/shinipsum/](https://engineering-shiny.org/shinipsum/), or [engineering-shiny.org/golemhtmltemplate/](https://engineering-shiny.org/golemhtmltemplate/): both these applications should look a little bit different every time you open them! + +`{shinipsum}` can be installed from CRAN with: + +```{r 09-prototyping-2, eval=FALSE} +install.packages("shinipsum") +``` + +You can install this package from GitHub with: + +```{r 09-prototyping-3, eval=FALSE} +remotes::install_github("Thinkr-open/shinipsum") +``` + +This package includes a series of functions that generates random placeholders. +For example, `random_ggplot()` generates random `{ggplot2}` [@R-ggplot2] elements. +If we run this code two times, we should get different results, as seen on Figure \@ref(fig:09-prototyping-5) and Figure \@ref(fig:09-prototyping-6).[^prototyping-1] + +[^prototyping-1]: Well, there is a probability that we will get the same plot twice, and but that is the beauty of randomness. + +```{r 09-prototyping-4} +library(shinipsum) +library(ggplot2) +``` + +(ref:randomplot1) A random plot. + +```{r 09-prototyping-5, fig.cap="(ref:randomplot1)", out.width="100%", warning = FALSE} +random_ggplot() + + labs(title = "Random plot") +``` + +(ref:randomplot2) Another random plot. + +```{r 09-prototyping-6, fig.cap="(ref:randomplot2)", out.width="100%", warning = FALSE} +random_ggplot() + + labs(title = "Random plot") +``` + +Of course, the idea is to combine this with a `{shiny}` interface, for example, `random_ggplot()` will be used with a `renderPlot()` and `plotOutput()`. +And as we want to prototype but still be close to what the app might look like, these functions take arguments that can shape the output: for example, `random_ggplot()` has a `type` parameter that can help you select a specific `{ggplot2}` geom. + + +```{r 09-prototyping-7, eval = FALSE} +library(shiny) +library(shinipsum) +library(DT) +ui <- fluidPage( + h2("A Random DT"), + DTOutput("data_table"), + h2("A Random Plot"), + plotOutput("plot"), + h2("A Random Text"), + tableOutput("text") +) + +server <- function(input, output, session) { + output$data_table <- DT::renderDT({ + random_DT(5, 5) + }) + output$plot <- renderPlot({ + random_ggplot() + }) + output$text <- renderText({ + random_text(nwords = 50) + }) +} +shinyApp(ui, server) +``` + +Figure \@ref(fig:09-prototyping-8) is a screenshot of this application. + + +(ref:shinipsum) An app built with `{shinipsum}`. + +```{r 09-prototyping-8, echo=FALSE, fig.cap="(ref:shinipsum)", out.width="100%"} +knitr::include_graphics("img/shinipsumapp.png") +``` + +Other `{shinipsum}` functions include: + +- tables: + +```{r 09-prototyping-9 } +random_table(nrow = 3, ncol = 3) +``` + +- print outputs: + +```{r 09-prototyping-10 } +random_print(type = "model") +``` + +and text, image, `ggplotly`, `dygraph`, and `DT`. + +`{shinipsum}` is also a good tool if you want to demonstrate what a given UI framework will look like if used in `{shiny}`. +This is, for example, what you find with `{golemhtmltemplate}`, available at [engineering-shiny.org/golemhtmltemplate/](https://engineering-shiny.org/golemhtmltemplate/), which uses a W3 web page template.[^prototyping-2] + +[^prototyping-2]: This application is also a demonstration of how to build a `{golem}` application using `htmltemplate()`. + +### Using `{fakir}` for fake data generation + +Generating random placeholders for `{shiny}` might not be enough: maybe you also need example datasets. + +This can be accomplished using the `{fakir}` package, which was primarily created to provide fake datasets for R tutorials and exercises, but that can easily be used inside a `{shiny}` application. + +At the time of writing these lines, the package is only available on GitHub, and can be installed with: + +```{r 09-prototyping-11, eval=FALSE} +remotes::install_github("Thinkr-open/fakir") +``` + +This package contains three datasets that are randomly generated when you call the corresponding functions: + +- `fake_base_clients()` generates a fake dataset for a ticketing service. + +- `fake_sondage_answers()` is a fake survey about transportation. + +- `fake_visits()` is a fake dataset for the visits on a website. + +```{r 09-prototyping-12 } +library(fakir) +fake_visits(from = "2017-01-01", to = "2017-01-31") +``` + +The idea with these datasets is to combine various formats that can reflect "real-life" datasets: they contain dates, numeric and character variables, and have missing values. +They can also be manipulated with the included `{sf}` [@R-sf] geographical dataset `fra_sf` allowing for map creation. + +Fake datasets created with `{fakir}` can be used to build light examples on the use of the inputs, for filters or interactive maps, or as examples for the internal functions and their corresponding documentation. + +## Building with RMarkdown {#proto-rmdfirst} + +While on one side you are building the user interface, you (or someone from your team) can start working on the back-end implementation. +This implementation should be done out of any reactive logic: the back-end should not depend on any reactive context. +And because documentation is gold, you should start with writing the back-end documentation directly as package documentation: + +- Inside your Vignettes folder: call `usethis::use_vignette()` to create the skeleton for a Vignette, which will then be used as package documentation. + +- In the `inst/` folder, if you prefer not including these RMarkdown files as documentation for the end package. + +Or what we call "Rmd-first". + +### Define the content of the application + +**Rmarkdown files are the perfect spot to sandbox the back-end of your application: inside the file, you don't have to think about any reactive behavior**, as you are just working with plain old R code: data wrangling operations, multi-parameter-based models, summary tables outputs, graphical outputs, etc. + +And the nice thing is that you can share the output of the rendered file as an HTML or PDF to either your client or boss, or anyone involved in the project. +That way, **you can focus on the core algorithm**, not some UI implementation like "I want the button to be blue" when what you need to know is if the output of the model is correct. +In other words, you are applying the rule of the separation of concerns, i.e. you help focus on one part of the application without adding any cognitive load to the person "reading" the outputs. +And, last but not least, if you have to implement changes to the back-end functions, it is way easier to check and to share in a static file than in an application. + +When doing that, the best way is again to separate things: do not be afraid of writing multiple RMarkdown files, one for each part of the end application. +Again, this will help everybody focus on what matters: be it you, your team, or the person ordering the application. + +Building the back-end in Rmd files is also a **good way to make the back-end "application independent"**, in the sense that it helps in documenting how the algorithms you have been building can be used outside of the application. +In many cases, when you are building an application, you are creating functions that contain business logic/domain expertise, and that can, in fact, be used outside of the application. +**Writing these functions and how they work together forces you to think about these functions, and also gives a good starting point for anybody familiar with R that would want to start using this back-end toolkit**. +Of course, as you are building your application as a package, it is way easier now: you can share a package with the application inside it, along with a function to launch the app, but also functions that can be used outside. + +And if you need some data to use as an example, feel free to pick one from `{fakir}`! + +### Using the Rmd files as a laboratory notebook + +Rmd can also be used as the place to keep track of what you have in mind while creating the application: most of the time, you will create the functions inside the `R/` folder, but it might not be the perfect place to document your thought process. +On the other hand, using Markdown as a kind of "software laboratory notebook" to keep track of your idea is a good way to document all the choices you have made about your data wrangling, models, visualization, so that you can use it as a common knowledge-base throughout the application life: you can share this with your client, with the rest of your team, or with anybody involved in the project. + +And also, developing in multiple Rmd files helps the separation of work between multiple developers, and will reduce code conflicts during development. + +### Rmd, Vignettes, and documentation first + +Working with the `{golem}` [@R-golem] framework implies that you will build the application as an R package. +And of course, an R package implies writing documentation: one of the main goals of the Vignettes, in an R package, is to document how to use the package. +And the good news is that when checking a package, i.e. when running `check()` from `{devtools}` [@R-devtools] or `R CMD check`, the Vignettes are going to be built, and the process will fail if at least one of the Vignettes fails to render. +That way, you can use the documentation of the back-end as an extra tool for doing unit testing! + +One radical approach to the "Rmd first" philosophy is to write **everything** in an Rmd from the very beginning of your project: write the function code, their roxygen tags, their tests, etc., then move everything to the correct spot in the package infrastructure once you are happy with everything. +And of course, when you need to add another feature to your app, open a new markdown and start the process of development and documentation again. diff --git a/10-step-by-step-build.Rmd b/10-step-by-step-build.Rmd new file mode 100644 index 00000000..ee89c5ac --- /dev/null +++ b/10-step-by-step-build.Rmd @@ -0,0 +1,303 @@ +# (PART) Step 3: Build {.unnumbered} + +# Building the App with `{golem}` {#build-app-golem} + +Now that the application is prototyped inside a `{golem}` [@R-golem] skeleton, you can work on its integration. +In this step of the workflow, you will be linking the back-end and front-end together, and working on the global engineering of the application: + +- add and organize dependencies +- create and include sub-modules if necessary +- organize utility functions and link them to the module in which they are used +- add testing infrastructure +- link to CI/CD services + +Note that some concepts introduced here will be more extensively explored in the following chapters: the present chapter is a walkthrough of what you will find inside the `02_dev` scripts. + +## Add dependencies + +### Package dependencies + +When you are building a `{shiny}` [@R-shiny] application, you will have to deal with dependencies. +Well, at least with one dependency, `{shiny}`. +But chances are that you will not only be using `{shiny}` inside your application: you will probably call functions from other packages, for example, from `{ggplot2}` [@R-ggplot2] for plotting, or any other package that is necessary for your application to work. + +```{r 10-step-by-step-build-1, echo = FALSE} +paks <- eval(formals(golem::use_recommended_deps)$recommended) +paks <- grep("shiny|golem", paks, invert = TRUE, value = TRUE) +paks <- paste0("`{", eval(formals(golem::use_recommended_deps)$recommended), "}`") +``` + +If you are building your application using `{golem}`, you will have 3 default dependencies: `{golem}` itself, `{shiny}`, and `{config}`. +If you call `golem::use_recommended_deps()` in the first workflow script, you will also have `r knitr::combine_words(paks)` as dependencies to your package.[^step-by-step-build-1] +But what about other dependencies like `{ggplot2}`? These ones need to be added by hand. + +[^step-by-step-build-1]: The idea with this function is to provide a shortcut for adding commonly used dependencies, so that you don't have to do it by hand. + +Here is how to process for a new dependency: + +- Open the `dev/02_dev.R` script. +- Call the `use_package()` function from `{usethis}`: `usethis::use_package("pkg.you.want.to.add")`. +- Detail import mechanism in the related R files. + +### Importing packages and functions + +There are two places where the dependencies of your application need to be managed:[^step-by-step-build-2] the `DESCRIPTION` file and the `NAMESPACE` file. + +[^step-by-step-build-2]: This is not `{shiny}` or `{golem}` specific, but a requirement for any package. + +- The `DESCRIPTION` file dictates which packages have to be installed **when your application is installed**. + For example, when you install `{golem}` on your machine, you will also need other packages that are internally used by `{golem}`. + And your application will also have dependencies: at the very least `{shiny}`, `{golem}`, and `{config}`. + When building your application, you have to list these requirements somewhere, and the standard way to do that is by using the `DESCRIPTION` file.[^step-by-step-build-3] + +- The `NAMESPACE` file describes how your app interacts with the R session at run time, i.e. **when your application is launched**. + With this `NAMESPACE` file, you can specify only a subset of functions to import from other packages: for example, you can choose to import only `renderDT()` and `DTOutput()` from `{DT}`, instead of importing all the functions. + This selective import mechanism allows you to avoid namespace conflicts: for example, between `jsonlite::flatten()` and `purrr::flatten()`.[^step-by-step-build-4] + To do so, we will need to go to every script that defines one or several function/s, and add a `{roxygen2}` [@R-roxygen2] tag, in the following form : `#' @importFrom jsonlite fromJSON` and `#' @importFrom purrr flatten`: that way, you are only importing `fromJSON()` from `{jsonlite}`. + +[^step-by-step-build-3]: Note that most of the time, you will not be filling this by hand, but by using `usethis::use_package()`. + +[^step-by-step-build-4]: This can be pretty common as `{jsonlite}` might import `JSON` files as list, and `{purrr}` has pretty powerful tools for manipulating lists. + +Note that you can also use explicit namespacing, i.e. the `pkg::function()` notation inside your code. +And if you need a little help to identify dependencies, all the explicitly namespaced calls (`pkg::function()`) can be scraped using the `{attachment}` [@R-attachment] package: + +```{r 10-step-by-step-build-2, eval = FALSE} +# This function will read all the scripts in the R/ folder and +# try to guess required dependencies +attachment::att_from_rscripts() +``` + +If you are using a development package (for example, one installed from GitHub), you can add it to the `DESCRIPTION` using the `use_dev_package()` function from `{usethis}`. +This will add another field to the `DESCRIPTION` file, `Remotes`, with the location where the package is available. + +All of this can seem a little bit daunting at first, but that is for the best: + +> Having a high quality namespace helps encapsulate your package and makes it self-contained. +> This ensures that other packages won't interfere with your code, that your code won't interfere with other packages, and that your package works regardless of the environment in which it's run. +> +> _R Packages_ [@rpkg] + +To learn more about the details of how to manage dependencies, and about the `DESCRIPTION` and `NAMESPACE` files, here are some resources: + +- [_Writing R Extensions_](https://cran.r-project.org/manuals.html), the official manual from the R-Core team +- [R Packages](https://r-pkgs.org/), especially the *Package metadata* and *Namespace* chapters + +## Submodules and utility functions + +When building a large application, you **will be splitting your codebase into smaller pieces**. +In Chapter \@ref(structuring-project), "Structuring Your Project", that these utilitarian functions should be defined in files that are prefixed with a specific term. +In the `{golem}` world, these are `utils_*` and `fct_*` files: + +- `utils_*` files contain small functions that might be used several times in the application. +- `fct_*` files contain larger functions that are more central to the application. + +Two functions can be called to create these files: + +```{r 10-step-by-step-build-3, eval = FALSE} +# Adding fct_ and utils_ files to the project +golem::add_fct( "helpers" ) +golem::add_utils( "helpers" ) +``` + +- The first will create a `R/fct_helpers.R` file. +- The second will create a `R/utils_helpers.R` file. + +The idea, as explained before, is that as soon as you open a `{golem}`-based project, you are able to identify what the files contain, without having to open them.[^step-by-step-build-5] + +[^step-by-step-build-5]: The `utils_*` convention is a pretty common one: a lot of R packages contain a file called `utils.R` that bundles a series of small functions that are used throughout the package. + +For example, the `{hexmake}` app has two of these files, [`R/utils_ui.R`](https://github.com/ColinFay/hexmake/blob/master/R/utils_ui.R) and [`R/utils_server.R`](https://github.com/ColinFay/hexmake/blob/master/R/utils_server.R), in which you will find small functions that are reused throughout the app. + +The `fct_*` files are to be used with larger functions, which are more central to the application, but that might not fit into a specific module. +For example, in `{hexmake}`, you will find [`R/fct_mongo.R`](https://github.com/ColinFay/hexmake/blob/master/R/fct_mongo.R), which is used to handle all the things related to connecting and interacting with the Mongodb database. + +As you can see, the difference is that `fct_*` file are more "topic centered", in the sense that they gather functions that relate to a specific feature of the application (here, the database), while `utils_*` files are more used as a place to put miscellaneous functions. + +Note that when building a module with `golem::add_module()`, you can add a module-specific `fct_*` or `utils_*` file: + +```{r 10-step-by-step-build-4, eval = FALSE} +# Creating the fct_ and utils_ file along the module creation +golem::add_module( + name = "rendering", + fct = "connect", + utils = "wrapper" +) +``` + +Will create: + +- `R/mod_rendering.R` +- `R/mod_rendering_fct_connect.R` +- `R/mod_rendering_utils_wrapper.R` + +And this can also be done the other way around, by specifying the module you want to link your file to: + +```{r 10-step-by-step-build-5, eval = FALSE} +# Linking the utils_wrapper file to the rendering module +golem::add_utils("wrapper", module = "rendering") +``` + +## Add tests + +No piece of software should go into production if it has not been sufficiently tested. +In this part of the building process, you will be setting tests for the application you are building. +We will get back to the how, why and what of testing in an upcoming chapter, but as we are currently going through the `02_dev.R` script, we mention here the line that allows you to add a test skeleton to your app. + +If you have followed every step from the `01_start.R` file, you already have a full testing infrastructure ready, with a set of recommended tests inserted by `{golem}`. +But as it is hard to find tests that are relevant to all applications (as every application is unique), you will have to add and manually fill the tests that will check your app. +And right now, to add a new testing file, you can call: + +```{r 10-step-by-step-build-6, eval = FALSE} +# Generate the testing infrastructure +usethis::use_test("app") +``` + +More on testing in Chapter \@ref(build-yourself-safety-net). + +## Documentation and code coverage + +### Vignette + +Vignettes are the long-format documentation for your application: users see this documentation when they are running `browseVignettes()`, when they look at the documentation in the `Help` pane from RStudio, or when they are browsing a web page on CRAN, and they are also the files that are used when the `{pkgdown}` websites are built. +The good news is that if you have been using our "Rmd first" method, you already have most of the Vignettes built: they are the Markdown files describing how the back-end of your application works. +Depending on how you applied this principle, these Rmd files might live inside the `inst/` folder, or already as package Vignettes. +If you need to add a new Vignette, be it for adding an Rmd describing the back-end or a global documentation about the application, you can call the `use_vignette()` function from `{usethis}`. + +```{r 10-step-by-step-build-7, eval = FALSE} +# Adding a new Vignette named "shinyexample" +usethis::use_vignette("shinyexample") +``` + +Then, you can build all the Vignettes with: + +```{r 10-step-by-step-build-8, eval = FALSE} +# Compiling the Vignettes +devtools::build_vignettes() +``` + +### Code coverage and continuous integration + +#### A. Code coverage {.unnumbered} + +Code coverage is a way to detect the volume of code that is covered by unit testing. +You can do this locally, or you can use online services like Appveyor, an online platform that computes and tracks the code coverage of your repository. + +To add it to your application, call the `use_coverage()` function from the `{usethis}` package: + +```{r 10-step-by-step-build-9, eval = FALSE} +# Adding the correct code coverage +# infrastructure in your application +usethis::use_coverage() +``` + +At the time of writing these lines, this function supports two services: [CodeCov](https://codecov.io/) and [coveralls](https://coveralls.io/). + +Note that you can also perform code coverage locally, using the `{covr}` [@R-covr] package, and the `package_coverage()` function. + +```{r 10-step-by-step-build-10, eval = FALSE} +# Compute the code coverage of your application +code_coverage <- covr::package_coverage() +``` + +For example, Figure \@ref(fig:10-step-by-step-build-11) is the output of running the `package_coverage()` function on the `{golem}` package on the 2020-04-29 on the `dev` branch: + +(ref:golemcodecoverageresults) {golem} code coverage results. + +```{r 10-step-by-step-build-11, echo=FALSE, fig.cap="(ref:golemcodecoverageresults)", out.width="100%"} +knitr::include_graphics("img/golemcov.png") +``` + +As you can see, we reach a code coverage of almost 70%. +**Deciding what the perfect percentage of coverage should be is not an easy task, and setting for an arbitrary coverage is not a smart move either, as it very much depends on the type of project you are working on**. +For example, in `{golem}`, the `addins.R` file is not tested (0% code coverage), and that is for a good reason: these addins are linked to RStudio and are not meant to be tested/used in a non-interactive environment, and (at least at the time of writing these lines) there is no automated way to test for RStudio addins. +Another thing to keep in mind while computing code coverage is that it counts the number of lines that are run when the tests are run, which means that if you write your whole function on one single line, you will have 100% code coverage. +Another example is writing your `if/else` statement on one line `if (this) that else that`: your code coverage will count this line as covered, even if your test suite only runs the `if(this)` and not the `else`; in other words, even if your code coverage is good here, you are still not testing this algorithm extensively. + +Note that you can also identify files with zero code coverage using the `covr::zero_coverage(covr::package_coverage())` function, which, instead of printing back a metric of coverage for each file, will point to all the lines that are not covered by tests inside your package, as shown in Figure \@ref(fig:10-step-by-step-build-12). + +(ref:golemzerocoverage) {golem} files with zero code coverage. + +```{r 10-step-by-step-build-12, echo=FALSE, fig.cap="(ref:golemzerocoverage)", out.width="100%"} +knitr::include_graphics("img/zerocov.png") +``` + +To sum up: do not set an arbitrary code coverage percentage goal, but rather use it as a general metric throughout your project. +With CodeCov, you can get a timeline of the evolution of code coverage: a good tool for judging when you need to write more tests. +For example, Figure \@ref(fig:10-step-by-step-build-13) is the general tendency for the code coverage of the `{tibble}` package over the last 6 months (November 2019 to April 2020): + +(ref:codecovtibble) CodeCov.io results for the {tibble} package. + +```{r 10-step-by-step-build-13, echo=FALSE, fig.cap="(ref:codecovtibble)", out.width="100%"} +knitr::include_graphics("img/codecov-tibble.png") +``` + +Perfect for getting a general feeling about the code coverage during the life of the project! + +Note also that if you want to add the code coverage of your application inside a Vignette, you can use the `{covrpage}` [@R-covrpage] package, which bundles the results of the `{covr}` coverage report into an interactive, human-readable Vignette that you can use later on as package documentation, or as an article inside your package website. +`{covrpage}` can be installed from GitHub with `remotes::install_github('yonicd/covrpage')`. + +#### B. Continuous Integration {.unnumbered} + +Continuous integration, on the other hand, is ensuring the software is still working whenever a change is made by one of the developers. +The idea is to add to the centralized version control system (for example, Git)[^step-by-step-build-6] a service like Travis CI, GitHub Action (if you are on GitHub), or GitLab CI (for GitLab) that runs a series of commands whenever something is integrated in the repository, i.e. every time a change to the codebase is made. +In other words, every time a new piece of code is sent to the central repository, a service runs regression tests that check that the software is still in a valid, working state. + +[^step-by-step-build-6]: We will get back to version control in the Chapter \@ref(version-control), "Version Control".. + +You can set up various continuous integration services automatically by using functions from the `{usethis}` package: + +- Travis CI is set up with `usethis::use_travis()`. +- AppVeyor with `usethis::use_appveyor()`. +- GitLab CI with `use_gitlab_ci()`. +- Circle CI with `use_circleci()`. +- GitHub Actions with `use_github_actions()`. +- Jenkins with `use_jenkins()`. + +If ever you want to add badges to your `README` files for these services, `{usethis}` also comes with a series of functions to do just that: `use_travis_badge()`, `use_appveyor_badge()`, `use_circleci_badge()` and `use_github_actions_badge()`. + +CI services can do a lot more, like for example, deploy the application, build a container and send it to a container registry, compile RMarkdown files, etc.[^step-by-step-build-7] +The possibilities are almost limitless! + +[^step-by-step-build-7]: For example, the online version of this book is compiled to HTML every time something is merged into the `master` branch on GitHub. + +## Using `{golem}` dev functions + +When building an application, you will want it to behave differently depending on where it is run, and notably, if it is run in development or in production. +We have seen in previous chapters that you can use the `golem-config.yml` file, or pass arguments to `run_app()`. +A third option is to use the `dev` functions from `{golem}`. + +There is a series of tools to make your app behave differently whether it is in "dev" or "prod" mode. +Notably, the `app_prod()` and `app_dev()` functions look for the value of `options( "golem.app.prod" )`, or return `TRUE` if this option does not exist. +In other words, by setting `options( "golem.app.prod" )` to `TRUE`, you will make the functions that depend on this option behave in a specific way. + +Some functions pre-exist in `{golem}`, for example if you need to print a message to the console only during dev, you can do it with `cat_dev()`. + +```{r 10-step-by-step-build-14, eval = TRUE} +# Setting the option to FALSE +options( "golem.app.prod" = FALSE) +# Function runs as expected +golem::cat_dev("In dev\n") +``` + +```{r 10-step-by-step-build-15 } +# Switching the option to TRUE +options( "golem.app.prod" = TRUE) +# Nothing is printing +golem::cat_dev("In dev\n") +``` + +Of course, chances are you do not only need to print things, you might want to use other functions. +Good news! +You can make any function being "dev-dependent" with the `make_dev()` function: + +```{r 10-step-by-step-build-16, eval = TRUE} +# Same mechanism as cat_dev, but with other functions +log_dev <- golem::make_dev(log) +options( "golem.app.prod" = FALSE) +log_dev(10) +options( "golem.app.prod" = TRUE) +log_dev(10) +``` + +That way, you can use functions in your back-end for development purposes, that will be ignored in production. diff --git a/11-step-by-step-secure.Rmd b/11-step-by-step-secure.Rmd new file mode 100644 index 00000000..48635620 --- /dev/null +++ b/11-step-by-step-secure.Rmd @@ -0,0 +1,1099 @@ +# (PART) Step 4: Strengthen {.unnumbered} + +```{r 11-step-by-step-secure-1, include = FALSE} +try({ + system("docker kill hexmake") + system("docker kill xmk2") +}) +``` + +# Build Yourself a Safety Net {#build-yourself-safety-net} + +> Don't fuck over Future You +> +> _JD_ (<https://twitter.com/CMastication>) + +Strengthening your app means two things: testing and locking the application environment. + +## Testing your app + +The process of getting your application production-ready implies that the application is tested. +With a robust testing suite, you will develop, maintain, and improve in a safe environment and ensure your project sustainability. +What will you be testing? +Both sides of the application: the business logic and the user interface. +And also, the application load, i.e. how much time and memory are required when your application starts being used by a significant number of users, be it from the user perspective (how long does it take to complete a full scenario) and from the server perspective (how much memory is needed for my app to run). + +### Testing the business logic + +If you have been following the good practices we have listed in previous chapters, your current application has at least these two properties: + +- The business-logic functions are separated from your interactive-logic functions. +- Your application is inside a package. + +On top of being a sane organization approach, **using this separation inside a package structure allows you to leverage all the tooling that has been built for testing "standard" packages**. + +R developers have been developing packages for a long time, and at the time of writing these lines (April 2020), more than 15,000 packages are available on CRAN. +To sustain these developments, a lot of tools have been created to secure the development process, and especially tools for creating unit tests for your package. + +Unit tests are a general concept in software engineering that describes the process of writing a form of assessment to check the validity of your code. +A simplified explanation is that if you write a function called `meaning_of_life` that returns `42`, you will expect this function to always return `42`, and to be alerted if ever this value changes. +Using unit tests is a way to secure your work in the future, be it for future you, for your collaborator, or for anybody wanting to collaborate on the project: if anyone comes and change the code behind the `meaning_of_life()` function, and the result is no longer `42`, the developer working on this piece of code will be able to catch it. +The general idea is to detect bugs and breaking changes at the moment they are happening, not once it is too late. + +There are several packages in R that can be used to implement unit testing, and you can even implement your own tests. +One of the most popular right now [^step-by-step-secure-1] is `{testthat}` [@R-testthat]. +This testing framework lets you write a series of tests and expectations, which are then launched when calling `test()` from `{devtools}` [@R-devtools], either locally or in your CI system. + +[^step-by-step-secure-1]: Popularity based on the number of reverse dependencies and suggests, as shown in <https://cran.r-project.org/web/packages/testthat/index.html>. + +Here is an example of testing that the `meaning_of_life` will always be `42`. + +``` {.r} +# Creating a testing context, with one expectation +test_that("The meaning of life is 42", { + expect_equal( + meaning_of_life(), + 42 + ) +}) +``` + +Once you have this test skeleton set, you will be able to detect any change to this function. + +If you want to learn more about how to use `{testthat}`, you can refer to the following resources: + +- [`{testthat}` online documentation](https://testthat.r-lib.org/) + +- [R Packages - Chapter 10 Testing](https://r-pkgs.org/tests.html) + +- [Building a package that lasts, eRum 2018 workshop - Part 5: Test and Code Coverage](https://speakerdeck.com/colinfay/building-a-package-that-lasts-erum-2018-workshop?slide=107) + +### `shiny::testServer()` + +At the time of writing these lines, the `{shiny}` team is actively working on a new way to test `{shiny}` server functions. +These features are still a work in progress, and are not available in the stable version of `{shiny}` we have used in this book (`r packageVersion("shiny")`). +Given that these features are still subject to change, we will not go into detail about these new features, but here is a preview of what it will look like: + +```{r 11-step-by-step-secure-2, eval = FALSE} +# Given the following module +computation_module_server <- function(input, output, session){ + ns <- session$ns + r <- reactiveValues( + value = NULL + ) + observeEvent( input$selector , { + r$value <- input$selector * 10 + }) + +} + +# We can test it that way +library(shiny) +library(testthat) +testServer(computation_module_server, { + + # Give input$selector a value + session$setInputs(selector = 1) + # Call {testthat} functions + expect_equal(r$value, 10) + + # Give input$selector a value + session$setInputs(selector = 2) + # Call {testthat} functions + expect_equal(r$value, 20) + + +}) +``` + +This methodology is still under development, so we won't go deeper into this subject, but if you want to follow the update on this topic, please refer to the [Server function testing with Shiny](https://shiny.rstudio.com/articles/integration-testing.html) article on the `{shiny}` website. + +### Testing the interactive logic + +Once you have built a solid test suite for your business logic, another side of your app you might want to check is the interactive logic, i.e. the user interface. + +There are several tools from the web development world that can be used to do exactly that: mimicking an interactive session where instead of deliberately clicking on the application interface, you let a program do it for you. + +#### A. `puppeteer` {.unnumbered} + +`puppeteer` is a NodeJS module that drives a Google Chrome headless session and mimics a session on the app. + +And good news, there is a Google Chrome extension, called [Puppeteer Recorder](https://chrome.google.com/webstore/detail/puppeteer-recorder/djeegiggegleadkkbgopoonhjimgehda), that allows you to create, while visiting a web page, the `pupepeteer` script to reproduce your visit. +Here is, for example, a very small JavaScript script for testing `{hexmake}` [@R-hexmake], generated by this extension. + +``` {.javascript} +// Require the node module +const puppeteer = require('puppeteer'); +(async () => { +// launch puppeteer and connect to the page +const browser = await puppeteer.launch() +const page = await browser.newPage() +await page.goto('http://localhost:2811/') + +// We're waiting for a DOM element to be ready +await page.waitForSelector('.row > .col > \ + .rounded > details:nth-child(3) > summary') + // Now it's ready, we can click on it + await page.click('.row > .col > .rounded > \ + details:nth-child(3) > summary') + + // Now our test is over, we can close the connection + await browser.close() +})() +``` + +Be aware, though, that this extension does not record everything, at least with the version used while writing this book (`0.7.1`). +For example, typing inside a text input is not recorded: that is completely doable inside `puppeteer`, yet not recorded by this extension.[^step-by-step-secure-2] + +[^step-by-step-secure-2]: See <https://github.com/puppeteer/puppeteer/issues/441> for the code to set the text input values. + +Once you have this piece of code, put it into a NodeJS script, and replay the session as many time as you need. +If ever one of the steps cannot be replayed as recorded, the script will fail, notifying you of a regression. + +Several packages in R mimic what `puppeteer` does (Google Chrome headless orchestration), with notably `{crrri}` [@R-crrri] and `{chromote}` [@R-chromote]. +These packages can be used to launch and manipulate a Google Chrome headless session, meaning that you can programmatically navigate and interact with a web page from R. +And to do the tests in a `puppeteer` spirit, you can refer to the `{crrry}` package [@R-crrry], which contains a series of wrapper functions around `{crrri}`, specifically designed for `{shiny}`. + +Here is an example: + +```{r 11-step-by-step-secure-3, eval = FALSE} +# Creating a new test instance +test <- crrry::CrrryOnPage$new( + # Using the `find_chrome()` function to guess where the + # Chrome bin is on our machine + chrome_bin = pagedown::find_chrome(), + # Launching Chrome on a random available port on our machine + # Note that you will need httpuv >= 1.5.2 if you want to use + # this function + chrome_port = httpuv::randomPort(), + # Specifying the page we want to connect to + url = "https://connect.thinkr.fr/hexmake/", + # Do everything on the terminal, with no window open + headless = TRUE +) +``` + + Running \ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + --no-first-run --headless + '--user-data-dir=/Users/colin/Library/Application Support/ + r-crrri/chrome-data-dir-dhutmfux' + '--remote-debugging-port=40698' + +```{r 11-step-by-step-secure-4, eval = FALSE} +# We'll wait for the application to be ready to accept inputs +test$wait_for_shiny_ready() +``` + + Shiny is computing + ✓ Shiny is still running + +You can then call one of the `test` object methods: + +- `call_js()`, that allows you to run JavaScript code +- `shiny_set_input()` changes the value of a `{shiny}` Input +- `wait_for()` waits for a JavaScript condition to be TRUE +- `click_on_id` clicks on a given id + +Of course, the interesting part is doing "bulk testing" of your application, for example, by setting a series of values to an input: + +```{r 11-step-by-step-secure-5, eval = FALSE} +for (i in letters[1:5]){ + # We'll be setting a series of letters, one by one + # for the package name input + test$shiny_set_input( + "main_ui_1-left_ui_1-pkg_name_ui_1-package", + i + ) +} +``` + + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package-- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + +And once your test is done, do not forget to close the connection! + +```{r 11-step-by-step-secure-6, eval = FALSE} +# Closing the connection +test$stop() +``` + +#### B. Monkey test {.unnumbered} + +If you are working on a user-facing software (i.e. a software used by external users), there is one rule to live by: every unexpected behavior that can happen, will happen. +In other words, if you develop and think "a user will never do that", just expect a user to eventually do "that". + +But how can we get prepared for the unexpected? +How can we test the "crazy behavior" that user will adopt? +In web development, there exists a methodology called "Monkey testing", which consists of **launching a series of random event on a web page: random text in input, scrolling, clicking, zooming... and see if the application crashes or not**. +This software testing method allows you to test the robustness of the application, by seeing how well it can handle unexpected behaviors. + +Several JavaScript libraries exist when it comes to monkey testing, one of the most popular (and easy to use) libraries is called [`gremlin.js`](https://github.com/marmelab/gremlins.js). +This library is particularly interesting when it comes to `{shiny}` as it does not need external installation: you can add the library as a bookmark on your browser, navigate to the application, and launch the testing (click on the "Generate Bookmarklet" link on the [top of the README]((https://github.com/marmelab/gremlins.js))). +Figure \@ref(fig:11-step-by-step-secure-7) show an example of running gremlins on the prenoms application. + +(ref:gremlinscap) Example of using `gremlins.js` on the "prenoms" `{shiny}` application. + +```{r 11-step-by-step-secure-7, echo=FALSE, fig.cap="(ref:gremlinscap)", out.width='100%'} +knitr::include_graphics("img/gremlins.png") +``` + +And if you want to scale this, you can also combine it with `{shinyloadtest}` [@R-shinyloadtest]: launch a session recording, run `gremlins` one or several time inside the recording, then replay it with multiple sessions. + +With `{crrry}`, this `gremlins` test comes for free: + +```{r 11-step-by-step-secure-8, eval = FALSE} +# Creating a new test instance +test <- crrry::CrrryOnPage$new( + # Using the `find_chrome()` function to guess where the + # Chrome bin is on our machine + chrome_bin = pagedown::find_chrome(), + # Launching Chrome on a random available port on our machine + # Note that you will need httpuv >= 1.5.2 if you want to use + # this function + chrome_port = httpuv::randomPort(), + # Specifying the page we want to connect to + url = "https://connect.thinkr.fr/hexmake/", + # Do everything on the terminal, with no window open + headless = TRUE +) +# We'll wait for the application to be ready to accept inputs +test$wait_for_shiny_ready() +# We launch the horde of gremlins +test$gremlins_horde() +# Sleep, let the gremlins do their job +Sys.sleep(10) +# Check that the app is still working +test$wait_for_shiny_ready() +# Stop the connection +test$stop() +``` + +#### C. `{shinytest}` {.unnumbered} + +Finally, if you prefer a `{shiny}` specific package, you can go for `{shinytest}` [@R-shinytest]. +This package, created and maintained by RStudio, **allows you to do a series of screenshots of your application, and then replays your app and compares the previously taken screenshots to the current state of your application**, allowing you to detect any changes in the interface. + +If you are building your application with `{golem}` [@R-golem], you will need to add an `app.R` file at the root of your package, then run `shinytest::recordTest()`: + +```{r 11-step-by-step-secure-9, eval = FALSE} +# Create an app.R file at the root of the package +golem::add_rstudioconnect_file() +# Launch a test, and record a series of +# snapshots of your application +shinytest::recordTest() +``` + +Once this function is run, a new window opens: it contains your app, and a "Screenshot" button on the right. +Using this button, you can take various recordings of your shiny application at different states, as shown in Figure \@ref(fig:11-step-by-step-secure-10). + +(ref:shinytestcap) General view of a `{shinytest}` window. + +```{r 11-step-by-step-secure-10, echo=FALSE, fig.cap="(ref:shinytestcap)", out.width='100%'} +knitr::include_graphics("img/shinytest.png") +``` + +Then, you can do some changes in your app, and run: + +```{r 11-step-by-step-secure-11, eval = FALSE} +shinytest::testApp() +``` + +If the `{shinytest}` package detects a visual change in the application, you will be immediately alerted, with a report of the difference from the snapshots you took and the current state of the application. + +### Testing the app load + +```{r 11-step-by-step-secure-12, include=FALSE, error=TRUE, eval = TRUE} +try({system("docker rm hexmake")}) +``` + +#### A. `{shinyloadtest}` {.unnumbered} + +`{shinyloadtest}` [@R-shinyloadtest] **tests how an application behaves when one, two, three, twenty, one hundred users connect to the app and use it**, and gives you a visual report about the connection and response time of each session. +The idea with `{shinyloadtest}` is to first record a session where you mimic a user behavior, then `shinycannon`, a command line tool that comes with `{shinyloadtest}`, replays the recording several times. +Once the session has been replayed several times mimicking the session you have recorded, you have access to a report of the behavior of your app. + +```{r 11-step-by-step-secure-13 } +library(shinyloadtest) +``` + +```{r 11-step-by-step-secure-14, eval = FALSE} +# Starting your app in another process +p <- processx::process$new( + "Rscript", + c( "-e", "options('shiny.port'= 2811);hexmake::run_app()" ) +) +# We wait for the app to be ready +Sys.sleep(5) +# Check that the process is alive +p$is_alive() +# Open the app in our browser just to be sure +browseURL("http:://localhost:2811") +``` + +Record the tests, potentially in a new dir: + +```{r 11-step-by-step-secure-15, eval = FALSE} +# Creating a directory to receive the logs +fs::dir_create("shinylogs") +# Performing the session recording inside this new folder +withr::with_dir( + "shinylogs", { + # Launch the recording of an app session, using port 1234 + shinyloadtest::record_session( + "http://localhost:2811", + port = 1234 + ) + } +) +``` + +We now have a series of one or more recording/s inside the `shinylogs/` folder: + +Then, let's switch to our command line, and rerun the session with `shinycannon`. +The `shinycannon` command line tools take several arguments: the path the `.log` file, the URL of the app, `--workers` specify the number of concurrent connections to run, and the `--output-dir` argument specifies where the report should be written. + +Then, go to your terminal and run: + +```{bash 11-step-by-step-secure-16, eval = FALSE} +shinycannon shinylogs/recording.log \ + http://localhost:2811 --workers 10 \ + --output-dir shinylogs/run1 +``` + +And now, we have new files inside the folder, corresponding to the session recordings. + +```{r 11-step-by-step-secure-17, eval = FALSE} +# printing the structure of shinylogs +fs::dir_tree("shinylogs", recurse = FALSE) +``` + +```{r 11-step-by-step-secure-18, echo = FALSE} +fs::dir_tree("shinylogs", recurse = FALSE, regexp = "csv$", invert = TRUE) +``` + +Good news: we do not have to manually analyze these files—`{shinyloadtest}` offers a series of wrapper functions to do that. + +```{r 11-step-by-step-secure-19, message = FALSE, warning = FALSE} +# Bringing the runs in the R session +shinyload_runs <- shinyloadtest::load_runs( + "5 workers" = "shinylogs/run1" +) +``` + +We now have a data.frame that looks like this: + +```{r 11-step-by-step-secure-20 } +dplyr::glimpse(head(shinyload_runs)) +``` + +Then, `{shinyloadtest}` comes with a series of plotting functions that can be used to analyze your recording. +For example: + +- `slt_session_duration()` plots the session duration, with the various types of events that take computation time: JS and CSS load, R computation, etc. +The output is available in Figure \@ref(fig:11-step-by-step-secure-21). + +(ref:sessionduration) Session duration. + +```{r 11-step-by-step-secure-21, fig.cap="(ref:sessionduration)", out.width="100%"} +slt_session_duration(shinyload_runs) +``` + +<!-- + `slt_waterfall()` plots the waterfall graph of session durations, ordered by events. --> + +<!-- (ref:waterfall) Waterfall graph of session durations --> + +<!-- ```{r 12-step-by-step-secure-20, fig.cap="(ref:waterfall)", out.width="100%", out.height="100%"} --> + +<!-- slt_waterfall(shinyload_runs) --> + +<!-- ``` --> + +And if you need to bundle everything into an HTML reports, `shinyloadtest_report()` is what you are looking for. + +```{r 11-step-by-step-secure-22, eval = FALSE} +# Generating the report +shinyloadtest_report(shinyload_runs) +``` + +This function will generate an HTML report of all the things computed by `{shinyloadtest}`, as shown in Figure \@ref(fig:11-step-by-step-secure-23). + +(ref:shinyloadtestreport) Webpage generated by `shinyloadtest_report()`. + +```{r 11-step-by-step-secure-23, echo=FALSE, fig.cap="(ref:shinyloadtestreport)", out.width="100%"} +knitr::include_graphics("img/shinyloadtestreport.png") +``` + +To sum up with a step-by-step guide: + +- If the shiny app is only available locally, on your machine, then launch a process with `{processx}` [@R-processx], or in another R session, that launches the application. + You can either set the port with `options('shiny.port'= 2811)`, or let shiny decide for you. + Be sure that the process is running. + If the app is online, use the online URL (and make sure you have access to the app). + +- Run `shinyloadtest::record_session(url)`. + You should probably set a different port for `{shinyloadtest}`, so that it does not try to connect on port 80. + +- Play around with your app; record a scenario of usage. + +- Close the tab where the app is running. + +- Return to your terminal, and run the `shinycannon` command line tool. + +- Wait for the process to be finished. + +- Go back to R, and then you can analyze the data from the recordings, either manually or by generating the HTML report. + +#### B. `{shinyloadtest}`, `{crrry}`, and `{dockerstats}` {.unnumbered} + +Another thing you might want to monitor is the memory/CPU usage of your application, which `{shinyloadtest}` does not natively provide: the package records the load from the browser point of view, not from the server one. +That's where `{dockerstats}` [@R-dockerstats] can come into play: this package is a wrapper around the command line `docker stats`, and returns an R data.frame with the stats. + +You can get the `{dockerstats}` package from GitHub with: + +```{r 11-step-by-step-secure-24, eval = FALSE} +remotes::install_github("ColinFay/dockerstats") +``` + +Or from NPM via: + +``` {.bash} +npm install -g r-dockerstats +``` + +```{r 11-step-by-step-secure-25 } +library(dockerstats) +``` + +With these stats, we can monitor the load on the app when it is run in a Docker container. + +We will start by launching the container using a `system()` call: here, we are running the `{hexmake}` application, bundled in the `colinfay/hexmake` Docker image, on port 2811. +We also make sure we give it a name with `--name`, so that we can call it in our `dockerstats()` call later on. + +```{r 11-step-by-step-secure-26, echo = FALSE, error = TRUE, eval = TRUE, cache=TRUE} +try({system("docker rm hexmake")}) +system("docker run --name hexmake --rm -p 2811:80 colinfay/hexmake", wait = FALSE) +Sys.sleep(5) +``` + +```{r 11-step-by-step-secure-27, eval = FALSE} +# We are launching the docker container +# using R system() command. Here, we are +# running the container image called +# colinfay/hexmake. We are naming the +# container hexmake using the --name flag, +# --rm means the container will be removed +# when stopped, and finally the -p flag defines +# how to bind the ports of the container +# with the ports of the host (left is the host, +# right is the container): in other word, here, +# we bind port 80 of our container to the port 2811 +# of our machine. +system( + "docker run --name hexmake --rm -p 2811:80 colinfay/hexmake", + wait = FALSE +) +``` + +Let's say now we want the stats for the hexmake container: + +```{r 11-step-by-step-secure-28, eval = FALSE, cache=TRUE} +# Waiting for the container to be ready +Sys.sleep(30) +# Showing the docker stats for hexmake +tibble::as_tibble( + dockerstats("hexmake") +) +``` + +```{r echo = FALSE} +structure(list(Container = "hexmake", Name = "hexmake", ID = "34cc1544556a", + CPUPerc = 0.37, MemUsage = "108.5MiB", MemLimit = "5.807GiB", + MemPerc = 1.82, NetI = "726B", NetO = "0B", BlockI = "0B", + BlockO = "0B", PIDs = 4L, record_time = "2021-07-16 12:19:52", + extra = ""), row.names = c(NA, -1L), class = c("tbl_df", +"tbl", "data.frame")) +``` + + +Of course, right now nobody is using the app, so the usage can be pretty small. +But let's push it a little bit by mimicking a lot of connections. + +To do that, we can replay our `shinycannon` call, and at the same time use the `dockerstats_recurse()` function, which will recursively call `dockerstats()` on a regular interval. + +```{bash 11-step-by-step-secure-29, eval = FALSE} +# Replaying the recording +shinycannon shinylogs/recording.log \ +# Specificying the host url and the number of "visitors" + http://localhost:2811 --workers 10 \ +# Define where the recording will be outputed + --output-dir shinylogs/run3 +``` + +Let's launch at the same time a `dockerstats_recurse()`. +For example, here, we will print, on each loop, the `MemUsage` of the container, then save the data inside a `dockerstats.csv` file. + +```{r 11-step-by-step-secure-30, eval = FALSE} +# Calling recursive the dockerstats function. +# The callback function takes a function, and define +# what to do with the data.frame each time the +# dockerstats results are computed. +dockerstats_recurse( + "hexmake", + # append_csv is a {dockerstats} function that will + # apped the output to a given csv + callback = append_csv( + file = "shinylogs/dockerstats.csv", + print = TRUE + ) +) +``` + +Figure \@ref(fig:11-step-by-step-secure-31) shows these processes side to side. + +(ref:dockerstatscap) `{dockerstats}` and `shinycannon` running side-by-side at the same time. + +```{r 11-step-by-step-secure-31, echo=FALSE, fig.cap='(ref:dockerstatscap)', out.width='100%'} +knitr::include_graphics("img/hexmake-dockerstats.png") +``` + +As you can see, as the number of connections grow, the memory usage grows. +And we now have a csv with the evolution of the `docker stats` records over time! + +```{r 11-step-by-step-secure-32 } +# read_appended_csv() allows to read a csv that has been +# constructed with the append_csv() function +docker_stats <- read_appended_csv( + "shinylogs/dockerstats.csv" +) +``` + +```{r 11-step-by-step-secure-33 } +dplyr::glimpse(head(docker_stats)) +``` + +```{r 11-step-by-step-secure-34, include=FALSE, eval = TRUE, cache=TRUE} +try({system("docker kill hexmake")}) +``` + +If you need a deeper look into the connection between application actions and the Docker stats, you can also combine `{dockerstats}` with `{crrry}`, the idea being that you can record the CPU usage at the exact moment the application performs a specific computation. + +Let's record the computation of the `hexmake` container containing the same app as before. + +First, launch the container: + +```{r 11-step-by-step-secure-35 } +# Launching the container a second time, +# but name it xmk2 and serve it on port 2708 +system( + "docker run -p 2708:80 --rm --name xmk2 -d colinfay/hexmake", + wait = FALSE +) +Sys.sleep(60) # Let the container launch +``` + +Then, a `{crrry}` job: + +```{r 11-step-by-step-secure-36, eval = FALSE, echo = TRUE} +# See previous version of this code for a commented explanation +test <- crrry::CrrryOnPage$new( + chrome_bin = pagedown::find_chrome(), + chrome_port = httpuv::randomPort(), + url ="http://localhost:2708", + headless = TRUE +) +``` + + Running '/Applications/Google + Chrome.app/Contents/MacOS/Google Chrome' + --no-first-run --headless \ + '--user-data-dir=/Users/colin/Library/Application + Support/r-crrri/chrome-data-dir-thyhpptv' \ + '--remote-debugging-port=48938' + +```{r 11-step-by-step-secure-37, eval = FALSE} +test$wait_for_shiny_ready() +``` + + Shiny is computing + ✓ Shiny is still running + +```{r 11-step-by-step-secure-38, eval = FALSE, include = FALSE, results = 'hide'} +# See previous version of this code for a commented explanation +test <- crrry::CrrryOnPage$new( + chrome_bin = pagedown::find_chrome(), + chrome_port = httpuv::randomPort(), + url ="http://localhost:2708", + headless = TRUE +) +test$wait_for_shiny_ready() +``` + +```{r 11-step-by-step-secure-39, eval = FALSE, include=FALSE} +# We are creating a first data.frame that records the launch +# of the container. +results <- dockerstats::dockerstats("xmk2", extra = "launch") + +for (i in letters[1:10]){ + # We will be setting a letter for the package name input + test$shiny_set_input( + "main_ui_1-left_ui_1-pkg_name_ui_1-package", + i + ) + # Once the input is set, we call dockerstats() for this container + # and bind the results to the previously created data.frame + results <- rbind( + results, + dockerstats::dockerstats("xmk2", extra = i) + ) +} + +# Stopping the docker container +system("docker kill xmk2") + +# Stopping the tests +test$stop() +``` + +```{r 11-step-by-step-secure-40, eval = FALSE} +# We are creating a first data.frame that records the launch +# of the container. +results <- dockerstats::dockerstats("xmk2", extra = "launch") + +for (i in letters[1:10]){ + # We will be setting a letter for the package name input + test$shiny_set_input( + "main_ui_1-left_ui_1-pkg_name_ui_1-package", + i + ) + # Once the input is set, we call dockerstats() + # for this container and bind the results to + # the previously created data.frame + results <- rbind( + results, + dockerstats::dockerstats("xmk2", extra = i) + ) +} +``` + + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + -- Setting id main_ui_1-left_ui_1-pkg_name_ui_1-package -- + Shiny is computing + ✓ Shiny is still running + +```{r 11-step-by-step-secure-41, eval = TRUE, include=FALSE} +try({ + # Stopping the docker container + system("docker kill xmk2") + + # Stopping the tests + test$stop() +}) + +``` + +And draw a small graph of this evolution, shown in Figure \@ref(fig:11-step-by-step-secure-43): + +```{r 11-step-by-step-secure-42, echo = FALSE} +results <- readRDS("dataset/results.RDS") +``` + +(ref:dockerstatsusage) Plot of the `{dockerstats}` evolution. + +```{r 11-step-by-step-secure-43, fig.cap="(ref:dockerstatsusage)", eval = TRUE, cache=TRUE} +library(dplyr, warn.conflicts = FALSE) +# We are converting the MemUsage and record_time columns +# to a format that can be used in {ggplot2} +results <- results %>% + mutate( + MemUsage = to_mib(MemUsage), + record_time = as.POSIXct(record_time) + ) +library(ggplot2) +# Using the record time as an x axis, +# then adding the MemUsage as a line (to watch the +# evolution over time), then we add the 'extra' column, +# that contains the letters, as vertical lines + as +# label +ggplot( + data = results, + aes(x = record_time) +) + + geom_line( + aes(y = MemUsage) + ) + + geom_vline( + aes(xintercept = record_time) + ) + + geom_label( + aes( + y = max(MemUsage), + label = extra + ) + ) + + labs( + title = "MemUsage of 10 inputs for package name" + ) +``` + +## A reproducible environment + +One of the challenges of building an app that needs to be sent to production is that you will need to work in a reproducible environment. +What does this mean? +**When building a production application, you are building a piece of software that will be launched on another computer, be it a server in the cloud or someone else's computer**. +Once your app is built, there are few chances that you will launch it on your own computer and that external users will connect to your computer. +You will either give your users a package (which will be the simplest way to share it: bundle the packaged app to a `tar.gz`, then let people install it either manually or from a package repository), or a URL where they can connect and use your app. + +If you follow the `{golem}` workflow and all the good practices for a solid package, the application you have built should be deployable on another computer that has R. +In that second case, **you will have to think about how you can create your app in a reproducible environment**: in other words, be sure that the app is deployed under the same configuration as your local application—R version, package versions, system requirements, environment variables, etc. + +To help you achieve that, we will introduce two tools in the next section: `{renv}` [@R-renv] and [Docker](https://www.docker.com/). + +### `{renv}` + +#### A. About `{renv}` {.unnumbered} + +How do we make sure the package versions we have installed on our machine stay the same in the production environment? +And also, how can we be sure that, working as a team, we will be able to work together using the same package versions? + +From one package version to another, functions and behaviors change. +Most of the time, a new version means new functions and new features. +But from time to time, a new version means breaking change**s. Monitoring these changes and how they potentially break our code is a hard task: because checking versions of packages on various machines can take time**, or because debugging these bugs in your application logs is not straightforward. +And of course, the moment when we discover the error might not be the perfect time for us, as we might not have enough free time on our calendar to debug the application that has stopped running. + +Let's take, for example, this traceback from the logs of an application we sent one day on a `Shiny Server`: + +``` {.bash} +root@westeros-vm:/var/log/shiny-server# cat thewall(...).log +*** caught segfault *** +[...] +address 0x5100004d, cause 'memory not mapped' + +Traceback: +1: rcpp_sf_to_geojson(sf, digits, factors_as_string) +2: sf_geojson.sf(data) +3: geojsonsf::sf_geojson(data) +4: addGlifyPolygons(., data = pol_V1, color = les_couleurs, +popup = "val", opacity = 1) +5: function_list[[i]](value) +6: freduce(value, `_function_list`) +7: `_fseq`(`_lhs`) +8: eval(quote(`_fseq`(`_lhs`)), env, env) +[...] +105: captureStackTraces({ +while (!.globals$stopped) { +..stacktracefloor..(serviceApp()) +Sys.sleep(0.001) }}) +106: ..stacktraceoff..(captureStackTraces({ +while (!.globals$stopped) { +..stacktracefloor..(serviceApp()) +Sys.sleep(0.001) }})) +107: runApp(Sys.getenv("SHINY_APP"), +port = port, +launch.browser = FALSE) +An irrecoverable exception occurred. R is aborting now ... +``` + +Pretty hard to debug, isn't it? +What has actually happened? +On that specific case, it turned out that the package version from `{geojsonsf}` [@R-geojsonsf] was `1.2.1` on our development machine, and the one on the `{shiny}` server was updated to `1.3.0`, and there was a breaking change in the package, as shown in Figure \@ref(fig:11-step-by-step-secure-44). +This bug was hard to detect as `{geojsonsf}` was not a direct dependency of our app, but a dependency of one of our dependencies, making it slightly more complex to identify. + +(ref:geojsoncap) Breaking changes in `{geojsonsf}`, a dependency of a dependency of our `{shiny}` application. + +```{r 11-step-by-step-secure-44, echo=FALSE, fig.cap='(ref:geojsoncap)', out.width='100%'} +knitr::include_graphics("img/geojson.png") +``` + +The same thing could have happened if working as a team: one of the computers has an old version, when another one has updated to a more recent one. +How do we prevent that? +This is where the `{renv}` package comes into play: this package allows you to have a project-based library, instead of a global one. +In other words, instead of having a library that is global to your machine, `{renv}` allows you to specify packages with fixed versions for a project. +That means that you can have `{geojsonsf}` version `1.2.1` in one of your projects, and version `1.3.0` in another, with the two not conflicting with each other. + +#### B. Using `{renv}` {.unnumbered} + +> Underlying the philosophy of renv is that any of your existing workflows should just work as they did before. +> +> _Introduction to renv_ (<https://rstudio.github.io/renv/articles/renv.html>) + +The first thing to do with `{renv}` is initiate it with the `init()` function. + +```{r 11-step-by-step-secure-45, eval = FALSE} +# Loading and initiaing {renv} +library(renv) +init() +``` + +This function does several things: + +- Create/modify the `.Rprofile` file at the root of your project. Here is an example of what this file may look like inside an empty project: + +```{r 11-step-by-step-secure-46, comment="", echo = FALSE} +readLines("data-raw/.Rprofile") %>% + glue::as_glue() +``` + +In this example, there is just one call to a script, one located at `renv/activate.R`. + +- It creates a `renv.lock` file, which will list all the package dependencies + +As we have initiated an empty project, we do not have any dependencies here. +If you run this command in a project that already has scripts and dependencies, `{renv}` will try to locate them all, and add them to this file. +Note that these packages may come from CRAN, Bioconductor, GitHub, GitLab, Bitbucket, and even local repositories. + +The `renv/` folder contains a series of files that store your settings and the necessary packages, using a structure that mimics a local repository. + +```{r 11-step-by-step-secure-47 } +# Displaying the structure of the folder +fs::dir_tree("data-raw/renvinit/", recurse = 5) +``` + +We will not go into details on this folder, as it is a rather complex structure and chances are that you will never have to update it by hand. + +With `{renv}`, you can choose to link this "local repository" to a local cache, i.e. a folder which is common to all your projects and stores packages and the different versions you already installed (this is the default behavior) or to store the complete packages inside the project, making it portable. + +When you need a new package, you will have to install it in your local library. +The fastest way to install new packages in your `{renv}`-powered project is by using the `install.packages` function, which is shimmed by `{renv}`. +This shim will search the local cache to see if the package has already been cached, and if it is not, it will install and link it. + +Now, we need to install a new package, for example `{attempt}` [@R-attempt]: + +```{r 11-step-by-step-secure-48, eval = FALSE} +# Installing attempt +install.packages("attempt") +``` + +We will now add a little call to this library: + +```{r 11-step-by-step-secure-49 } +# Create a fake script that launches {attempt} +write("library(attempt)", "script.R") +``` + +Once you want to update your `{renv}` `Lockfile`, call `snapshot()`. + +```{r 11-step-by-step-secure-50, eval = FALSE} +# Snapshoting the current status of the environment +renv::snapshot(confirm = FALSE) +``` + +Note that if you are building an application as a package, use `renv::snapshot(type = "explicit")` (need version \> `0.9.3-99`): this will only capture the dependencies listed in the `DESCRIPTION` file. +If you don't specify this `type = "explicit"`, `{renv}` will go and look for all the packages it can find in your file, and notably in your `.Rhistory`, meaning that if you one day used a function from `{dplyr}` but your current package doesn't use it anymore, `{renv}` will include it. + +```{r 11-step-by-step-secure-51, comment="", echo = FALSE} +readLines("data-raw/renv.lock") %>% + glue::as_glue() +``` + +And now that you have a reproducible `{renv}` library, what is next? +Of course, if you are either working as a team or deploying to a server, you will have to restore the state of your project, which is now living somewhere else, inside your current project/deployment. +And to do that, the function to call is `renv::restore()`, which will update your local project with the dependencies listed inside your `Lockfile`. + +To sum up, here are the steps to follow: + +- Initiate the project with `renv::init()`. +- Install/remove packages. +- Take a `snapshot()` of the state of your project. +- `renv::restore()` the state of your project using `renv.lock`. +- Share `.Rprofile`, `renv.lock`, `renv/activate.R` and `renv/settings.dcf` files for reproducibility. + +Of course, `renv::restore()` comes with another superpower: time traveling! +If you decide to update a package in your project, and realize that this package makes the application crash (e.g., an update to `{geojsonsf}`), you can go back in time to a previous version of your library by calling the `restore()` function. + +There are more things you can do with `{renv}`. +If you want to know more, we invite you to refer to the [official documentation](https://rstudio.github.io/renv). + +### Docker + +#### A. R, Docker, `{shiny}` {.unnumbered} + +Docker is a program that allows to download, install, create, launch and stop multiple operating systems, called containers, on a machine, which will be called the host. +This host can be your local computer, or the server where you deploy your application/s. + +Docker was designed for **enclosing software environments inside an image that can later be launched**. +The general idea is that with Docker, you are defining in a `Dockerfile` all the "rules" that are used to create a given environment, and then you can use this file (and the linked files, for example the R package containing your app) to **deploy your application on any given server that can run Docker**. +That way, if the `Dockerfile` can compile on your machine and if you can run it, it should work everywhere (of course, it is a little bit more complex than that, but you get the idea). + +Why Docker in the context of `{shiny}` apps? +Because Docker allows you to abstract away the complexity of managing multiple versions of R and multiple versions of the same package, or even different versions of the same system requirement. +For example, let's take our example with the breaking change in `{geojsonsf}` that we used in the previous section. +With Docker, we can safely specify a `1.2.1` version in our image, and changing versions on the server would not have broken our code. + +By using Docker for your deployment, you can build and deploy an application with the very same version of packages and R as the one on your computer. +And of course, you can change them without breaking the rest of the machine: everything that happens in a container stays in a container. +That way, if you are building your application with an older version of `{shiny}`, **you are sure that sending it to production will not break everything: the version inside the Docker image is the same as the one from your machine**. +And later, if you update `{shiny}` and start a new project, you can deploy your app with another version of the package. +Same goes for your version of R. + +#### B. Building a Dockerfile for your app {.unnumbered} + +Good news! +If you are building your app with `{golem}`, the creation of the `Dockerfile` is just one function away! +If you have a look at the `03_deploy.R` file in the `dev` folder, you will find a series of functions that can create the `Dockerfile` for your project: either as a generic Docker image, or for `{shiny}`Proxy or Heroku. + +For example, to create a `Dockerfile` for a `{golem}` project, you can run the following, from the root of your package: + +```{r 11-step-by-step-secure-52, eval=FALSE} +golem::add_dockerfile() +``` + +Let's take some time to understand this file, and detail how we could be building it from scratch. + +1. `FROM` + +```{r 11-step-by-step-secure-53, echo = FALSE, comment=""} +# readLines("data-raw/Dockerfile")[1] %>% +# glue::as_glue() +cat("FROM", paste0( + "rocker/r-ver:", + R.Version()$major,".", + R.Version()$minor + )) +``` + +This line defines what version of R to use for deploying your application. +This `FROM` line is the one that sets an image to start from: you rarely (if ever) build a Docker image from nothing, but instead you use an existing image on top of which you build your own image. +Here, we choose one of the [r-ver](https://hub.docker.com/r/rocker/r-ver/) Docker images, based on the output of: + +```{r 11-step-by-step-secure-54, comment=""} +R.Version()$version.string +``` + +2. `RUN` + +The `RUN` call in the file refers to bash calls that are used to build the image. +For example, the second line of the `Dockerfile` installs all the system requirements needed by our application. + + RUN apt-get update && \ + apt-get install -y git-core \ + libcurl4-openssl-dev libssh2-1-dev \ + libssl-dev libxml2-dev make \ + zlib1g-dev && rm -rf /var/lib/apt/lists/* + +In the subsequent `RUN` calls, `{golem}` chooses to call `remotes::install_version()` to be sure we install the version of the package that matches the one from your computer. + + RUN Rscript -e \ + 'remotes::install_version("xfun",upgrade="never",version="0.19")' + +As you can see, it matches the local version: + +```{r 11-step-by-step-secure-55, comment="", eval = FALSE} +packageVersion("xfun") +``` + + [1] ‘0.19’ + +3. `ADD` + +This Docker entry takes a folder or a file, and copies it inside the image. +With `{golem}`, we are adding the current project, containing the app, to a folder called `/build_zone`. + +```{r 11-step-by-step-secure-56, echo = FALSE, comment=""} +readLines("data-raw/Dockerfile")[13] %>% + glue::as_glue() +``` + +4. `EXPOSE` + +This command defines which port of the container will be available from the outside of the container. + +```{r 11-step-by-step-secure-57, echo = FALSE, comment=""} +readLines("data-raw/Dockerfile")[16] %>% + glue::as_glue() +``` + +5. `CMD` + +This final command is the one that is launched when you run a container. +With a `{shiny}` app, this command is the one that launches the application. + + CMD R -e \ + "options('shiny.port'=80,shiny.host='0.0.0.0');golex::run_app()" + +#### C. `{dockerfiler}` {.unnumbered} + +If you want to do everything from the R command line, the `{dockerfiler}` [@R-dockerfiler] package is here for you! +This package allows you to generate a `Dockerfile` straight from R: + +```{r 11-step-by-step-secure-58 } +library(dockerfiler) +# Creating a new Dockerfile object +my_dock <- Dockerfile$new() +# Adding RUN, ADD, WORKDIR and EXPOSE commands +my_dock$RUN("apt-get update && apt-get install -y git-core") +my_dock$ADD(".", "/") +my_dock$RUN("mkdir /build_zone") +my_dock$ADD(".", "/build_zone") +my_dock$WORKDIR("/build_zone") +my_dock$RUN(r(remotes::install_local(upgrade="never"))) +my_dock$EXPOSE(80) +# Viewing the Dockerfile +my_dock +``` + +#### D. Docker and `{renv}` {.unnumbered} + +If you use `{renv}` to build your `{shiny}` application, it can also be used inside your Docker container. +To make those two tools work together, you will have to copy the files produced by `{renv}` inside the container: `.Rprofile`, `renv.lock`, `renv/activate.R` and `renv/settings.dcf` files. + +Then run `renv::restore()` inside your application, instead of using the calls to `remotes::install_version()` as they are currently implemented when doing it with `{golem}`. + +At the time of writing these lines, there is no native support of `{renv}` (with or without Docker) in `{golem}`, but that is something we can expect to happen in future versions of `{golem}`. + +#### E. Develop inside a Docker container {.unnumbered} + +Developers have their own R versions and operating systems, which generally differ from the one used on the production server, leading to issues when it comes to deploying the application. + +If you plan on using Docker as a deployment mechanism, you can also use Docker as a local developer environment. +Thanks to the containers maintained by the [The Rocker Project](https://www.rocker-project.org/), it's possible to have a local environment that comes close to what you will find on the production server. +What's even more interesting is that this project offers images that can contain RStudio server: that means that the application that you will deploy in production can have the very same configuration as the one developers are using on their local machine: thanks to these containers, developers can work on a version of R that matches the one from the production server, using packages that will exactly match the one used in production. + +Even more interesting is using RStudio inside Docker in combination with `{renv}`: the developers work on their machines, inside an IDE they know, and with system requirements (R versions, packages, etc.) that can be reproduced on the production server! + +#### F. Read more about Docker {.unnumbered} + +- [An Introduction to Docker for R Users](https://colinfay.me/docker-r-reproducibility/) + +- An Introduction to Rocker: Docker Containers for R [@RJ-2017-065] + +- The Rockerverse: Packages and Applications for Containerization with R [@rockerverse] diff --git a/12-secure.Rmd b/12-secure.Rmd new file mode 100644 index 00000000..3106f532 --- /dev/null +++ b/12-secure.Rmd @@ -0,0 +1,274 @@ +# Version Control {#version-control} + +## Using version control with `Git` {#version-control-git} + +"Friends do not let friends work on a coding project without version control." You might have heard this before, without really considering what this means. +Or maybe you are convinced about this saying, but have not had the opportunity to use `Git`, GitHub or GitLab for versioning your applications. +If so, now is the time to update your workflow! + +### Why version control? + +Have you ever experienced a piece of code disappearing? +Or the unsolvable problem of integrating changes when several people have been working on the same piece of code? +Or the inability to find something you have written a while back? + +If so, you might have been missing version control (also shortened as VC). +In this chapter, we'll be focusing on `Git`, but you should be aware that other VC systems exist. +As they are less popular than `Git`, we will not cover them here. +`Git` was designed to handle collaboration on code projects [^secure-1] where potentially a lot of people have to interact and make changes to the codebase. +`Git` might feel a little bit daunting at first, and even seasoned developers still misuse it, or do not understand it completely, but getting at ease with the basics will significantly improve the way you build software, so do not give up: the benefits from learning it really outweigh the (apparent) complexity. + +[^secure-1]: It was first developed by Linus Torvalds, the very same man behind Linux. + +There are many advantages to VC, including: + +- **You can go back in time**. + With a VC system like `Git`, every change is recorded (well, every **committed** change), meaning that you can potentially go back in time to a previous version of a project, and see the complete history of a file. + This feature is very important: if you accidentally made changes that break your application, or if you deleted a feature you thought you would never need, you can go back to where you were a few hours, a few days, a few months back. + +- **Several people can work on the same file**. + `Git` relies on a system of branches. + Within this branch pattern, there is one main branch, called "main", which contains the stable, main version of the code-base. + By "forking" this branch (or any other branch), developers will have a copy of the base branch, where they can safely work on changing (and breaking) things, without impacting the origin branch. + This allows you to try things in a safe environment, without touching what works. + Note that simultaneously working on the same file at the same time might not be the perfect practice, it's better, if possible, to split the code into smaller files. + +- **You can safely track changes**. + Every time a developer records something to `Git`, changes are listed. + In other words, you can see what changes are made to a specific file in your codebase. + +- **It centralizes the codebase**. + You can use `Git` locally, but its strength also relies on the ability to synchronize your local project with a distant server. + This also means that *several* people can synchronize with this server and collaborate on a project. + That way, changes on a branch on a server can be downloaded (it is called `pull` in `Git` terminology) by all the members of the team, and synchronized locally, i.e. if someone makes changes to a branch and sends them to the main server, all the other developers can retrieve these changes on their machine. + +### `Git` basics: `add` - `commit` - `push` - `pull` + +These are the four main actions you will be performing in `Git`: if you just need to learn the minimum to get started, they are the four essential ones. + +#### `add` {.unnumbered} + +When using `add`, you are choosing which elements of your project you want to track, be it new files or modifications of an already versioned file. +This action does not save the file in the `Git` repository, but flags the changes to be added to the next commit. + +#### `commit` {.unnumbered} + +A `commit` is a snapshot of a codebase at a given moment in time. +Each commit is associated with two things: a `sha1`, which is a unique reference in the history of the project, allowing you to identify this precise state when you need to get back in time, and a `message`, which is a piece of text that describes the commit.[^secure-2] +Note that messages are mandatory, you cannot commit without them, and that the `sha1` references are automatically generated by `Git`. Do not overlook these messages: they might seem like a constraint at first but they are a life saver when you need to understand the history of a project. + +[^secure-2]: For example: "Added a graph in the analysis tab" or "Fixed the docx export bug". + +There is no strict rule about what and when to commit. +Keep in mind that commits are what allow you to go back in time, so a commit is a complete state of your codebase to which it would make sense to return. +A good practice is to state in the commit message which choices you made and why (but not how you implemented these changes), so that other developers (and you in the future) will be able to understand changes. +Commit messages are also where you might specify the breaking changes, so that other developers can immediately see these when they are merging your code. + +#### `push` {.unnumbered} + +Once you have a set of commits ready, you are ready to `push` it to the server. +In other words, you will permanently record these commits (hence the series of changes) to the server. + +Making a push implies three things: + +- Other people in the team will be able to retrieve the changes you have made. + +- These changes will be recorded permanently in the project history. + +- You cannot modify commits once they were sent to the server.[^secure-3] + +[^secure-3]: If you want to modify some code or have to go back in time, the best way to do it is to create a new commit with these changes or use adequate `Git` commands. + +#### `pull` {.unnumbered} + +Once changes have been recorded in the main server, everybody synchronized with the project can `pull` the commits to their local project. + +### About branches + +Branches are the `Git` way to organize work and ideas, notably when several people are collaborating on the same project (which might be the case when building large web applications with R). + +How does it work? +When you start a project, you are in the main branch, which is called the "main". +In a perfect world, you never work directly on this branch: it should always contain a working, deployable version of the application. + +Other branches are to be thought of as work areas, where developers fix bugs or add features. +The modifications made in these development branches will then be transferred (directly or indirectly) to the main branch. +This principle is shown in Figure \@ref(fig:12-secure-1). + +(ref:gitbranchcap) Branches in `Git`. + +```{r 12-secure-1, echo=FALSE, fig.cap="(ref:gitbranchcap)", out.width='100%'} +knitr::include_graphics("img/Git_branches_fork.png") +``` + +In practice, you might want to use a workflow where each branch is designed to fix a small issue or implement a feature, so that it is easier to separate each small part of the work. +Even when working alone. + +### Issues + +If you are working with a remote tool with a graphical interface like GitLab, GitHub or Bitbucket, there is a good chance you will be using issues. +Issues are "notes" or "tickets" that can be used to track a bug or to suggest a feature. +This tool is crucial when it comes to project management: issues are the perfect spot for organizing and discussing ideas, but also to have an overview of what has been done, what is currently being done, and what is left to be done. +Issue may also be used as a discussion medium with beta testers, clients or sponsors. + +One other valuable feature of issues is that they can be referenced inside commits using a hashtag and its number: `#123`. +In other words, when you send code to the centralized server, you can link this code to one or more issues and corresponding commits appear in the issue discussions. + +## `Git` integration + +### With RStudio + +`Git` is very well integrated in the RStudio IDE, and using `Git` can be as simple as clicking on a button from time to time. +If you are using RStudio, you will find a pull/push button, a stage and commit interface, and a tool for visualizing differences in files. +Everything you need to get started is there. + +Note that of course, it will be better in the long run to get a more complete understanding of how `Git` works, so that when things get more complexe, you will be able to handle them. + +### As part of a larger world + +`Git` is not reserved for team work: even if you are working alone on a project, using `Git` is definitely worth the effort. +Using `Git`, and particularly issues, helps you organize your train of thought, especially upfront when you need to plan what you will be doing. + +And of course, remember that `Git` is not limited to `{shiny}` applications: it can be used for any other R-related projects, and at the end of the day for any code related projects, making it a valuable skill to have in your toolbox, whatever language you will be working with in 10 years! + +### About `git-flow` + +There are a lot of different ways and methodologies to organize your `Git` workflow. +One of the most popular ones is called `git flow`, and we will give you here a quick introduction on how you can manage your work using this approach. +Please note that this is a quick introduction, not a complete guide: we will link to some further reading just at the end of this section. + +So, here are the key concepts of `git flow`: + +- The `main` branch only contains stable code: most of the time it matches a tagged, fixed version (v0.0.1, 0.1.0, v1.0.0, etc.). + A very small subset of developers involved in the project have writing access to the `main` branch, and no developer should ever push code straight to this branch: new code to `main` only comes either from the `dev` branch, or from a `hotfix` branch. + For an app in production, the last commit of this branch should be the version that is currently in production. + +- The `dev` branch, on the other hand, is the "Work in progress" branch: the one that contains the latest changes before they are merged into main. + This is the common working branch for every developer. + Most of the time, developers do not push code into these branches either: they make merge/pull requests (MR/PR) to `dev` from a `feature branch`. + +- A `feature branch` is one branch, forked from `dev`, that implements one of the features of the application. + To keep a clean track of what each branch is doing, a good practice is to use `issue-XXX`, where `XXX` is the corresponding issue you plan to solve in this branch. + +- A `hot fix` branch is a branch to correct a critical issue in `main`. + It is forked from `main`, and is merged straight into `main` using an MR. + +A summary of this process is available in Figure \@ref(fig:12-secure-2). + +(ref:gitflowcap) Presentation of a `git flow` (Vincent Driessen, <http://nvie.com>). + +```{r 12-secure-2, echo=FALSE, fig.cap="(ref:gitflowcap)", out.width='100%'} +knitr::include_graphics("img/GitFlowHotfixBranch.png") +``` + +From a software engineer point of view, here is how daily work goes: + +- Identify an issue to work on. + +- Fork dev into `issue-XXX`. + +- Develop a feature inside the branch. + +- Regularly run `git stash`, `git rebase dev`, and `git stash apply` to include the latest changes from `dev` to stay synchronized with `dev`.[^secure-4] + +- Make a pull request to `dev` so that the feature is included. + +- Once the PR is accepted by the project manager, notify the rest of the team that there have been changes to `dev`, so they can rebase it to the branch they are working on. + +- Start working on a new feature. + +[^secure-4]: There are two strategies for merging `dev`: either a "merge strategy" or a "rebase strategy". + Both strategies have pros and cons. + We work with the "rebase strategy" to force ourselves to stay updated. + We also notice that this strategy lowers the risk of bad merging, that can cause code loss. + However, this requires a lot of communication between developers and a good knowledge of `Git`. + +Of course, there are way more subtleties to this flow of work, but this gives you a good starting point. +Generally speaking, good communication between developers is essential for a successful collaborative development project. + +### Further readings on `Git` + +If you want to learn more about `Git`, here are some resources that have helped us in the past: + +- <https://happygitwithr.com/> +- <https://git-scm.com/book> +- <https://www.git-tower.com/blog/git-cheat-sheet/> + +## Automated testing + +We have seen in Chapter \@ref(build-yourself-safety-net) how to build a testing infrastructure for your app, notably using the `{testthat}` [@R-testthat] package. +What we have described is a way to build it locally, before running your test on your own machine. +But there is a big flaw to this approach: you have to remember to run the tests, be it regularly or before making a pull request/pushing to the server. +To do this kind of job, you will be looking for **a tool to do automated testing at the repository level: in other words, a software that can test your application whenever a piece of code is pushed/moved on the repository**. + +To do this, various tools are available, each with their own features. +Here is a non-exhaustive list of the ones you can choose: + +[Travis CI](https://travis-ci.org/) is a software that can be synced with your `Git` repositories (GitHub or Bitbucket), and whenever something happens on the repo, the events described in the travis configuration file (`.travis.yml`) are executed. +If they exit with a code 0, the test passes. +If they do not, the integrated tests have failed. +Travis CI integration may be used internally and externally: internally, in the sense that before merging any pull request, the project manager has access to a series of tests that are automatically launched. +Externally, as a "health check" before installing software: if you visit a GitHub repository that has Travis badges included, you can check if the current state of the package/software is stable, *i.e.* if it passes the automated tests. + +Travis CI can do a lot more than just testing your app: it can be used to build documentation, deploy to production, or to run any other scripts you want to be run before/after the tests have passed. +And the nice thing is that you can test for various versions of R, so that you are sure that you are supporting current, future and previous versions of R. + +All of this is defined in the `.travis.yml` file, which is to be put at the root of your source directory, a file that is automatically generated when calling `usethis::use_travis()`. + +Note that Travis CI can run tests on GNU/Linux or MacOS operating systems. + +[Appveyor](https://www.appveyor.com/) has the same functionalities as Travis CI. This service can integrate with GitHub, GitHub Enterprise, Bitbucket, GitLab, Azure Repos, Kiln, Gitea. +It supports Windows, Linux and macOS. + +[GitHub actions](https://github.com/features/actions) serve a related purpose: defining actions to be performed as responses to events on the GitHub repository. +Testing, building documentation, push to another repository, deploy on the server—all these actions can be automatically performed. +As with Travis CI, these actions are defined in a `yaml` file. +Examples of these configurations can be found at [r-lib/actions](https://github.com/r-lib/actions), and some can be automatically linked to your project using functions from `{usethis}`: `use_github_action_check_release()`, `use_github_action_check_standard()`, `use_github_action_check_full()` and `use_github_action_pr_commands()`. +The first three perform a standard `R CMD check`, under various conditions: + +- The `release` tests on MacOS, with the latest version of R, and runs the check via the `{rcmdcheck}` [@R-rcmdcheck] package. +- `standard` does the check for 3 operating systems (Windows, Mac and Linux), and for R and R-devel. +- `full` does `standard` but for the last 5 minor versions of R. + +Finally, `use_github_action_pr_commands()` sets checks to be performed when a pull request is made to the repository. + +If you are working with GitLab, you can use the integrated `GitLab CI` service: it serves the same purpose, with the little difference that it is completely Docker-based: you define a `yaml` with a series of stages that are performed (concurrently or sequentially), and they are all launched inside a Docker container. +To help you with this, the [`colinfay/r-ci-tidyverse`](https://hub.docker.com/r/colinfay/r-ci-tidyverse) Docker image comes with pre-installed packages for testing: `{remotes}` [@R-remotes], `{testthat}` [@R-testthat], `{config}` [@R-config] and is available for several R versions. +This Docker image can be used as the source image for your `GitLab CI` yaml file. + +Here is an example of one of these files: + +``` {.yaml} +image: colinfay/r-ci-tidyverse:3.6.0 + +cache: + paths: + - ci/ + +stages: + - test + - document + +building: + stage: test + script: + - R -e "remotes::install_deps(dependencies = TRUE)" + - R -e 'devtools::check()' + +documenting: + stage: document + allow_failure: true + when: on_success + only: + - main + script: + - Rscript -e 'install.packages("DT")' + - Rscript -e 'covr::gitlab(quiet = FALSE)' + artifacts: + paths: + - public +``` + +Automated testing, continuous integration, and continuous deployment are vast topics that cannot be covered in a few pages inside this book, but spending some time learning about these methodologies is definitely worth the time spent: the more you can automate these processes, and the more you test, the more your application will be resilient, easy to maintain, and easy to enhance: the more you check, the quicker you will discover bugs. + +And the quicker you detect bugs, the easier it is to correct them! diff --git a/13-deploy.Rmd b/13-deploy.Rmd new file mode 100644 index 00000000..5049d04b --- /dev/null +++ b/13-deploy.Rmd @@ -0,0 +1,156 @@ +# (PART) Step 5: Deploy {.unnumbered} + +# Deploy Your Application {#deploy} + +> Your deploys should be as boring, straightforward, and stress-free as possible. +> +> _How to Deploy Software - Zach Holman_ (<https://zachholman.com/posts/deploying-software>) + +Once your app is built, you are ready to deploy it! +In other words, your software is now ready to be used by other users. +There are two main ways to share your application and make it available to others: by creating a package and making it installable, or by sending it to a remote server. +We will see in this part how you can do that using `{golem}` [@R-golem]. + +## Before deployment checklist + +Here is a quick checklist of things to think about once your application is ready, and before sending it to production: + +- [ ] `devtools::check()`, run from the command line, returns 0 errors, 0 warnings, and 0 notes. + +- [ ] The current version number is valid, i.e. if the current app is an update, the version number has been bumped. + +- [ ] Everything is fully documented. + +- [ ] Test coverage is good, i.e. you cover a sufficient amount of the codebase, and these tests cover the core/strategic algorithms + +- [ ] Everyone in the project knows the person to call if something goes wrong. + +- [ ] The following things are clear to everyone involved in the project: the debugging process, how to communicate bugs to the developer team, and how long it will take to get changes implemented. + +- [ ] (If relevant) The server it is deployed on has all the necessary software installed (Docker, Connect, `Shiny Server`, etc.) to make the application run. + +- [ ] The server has all the system requirements needed (i.e. the system libraries), and if not, they are installed with the application (if it's dockerized). + +- [ ] The application, if deployed on a server, will be deployed on a port which will be accessible by the users. + +- [ ] (If relevant) The environment variables from the production server are managed inside the application. + +- [ ] (If relevant) The app is launched on the correct port, or at least this port can be configured via an environment variable. + +- [ ] (If relevant) The server where the app is deployed has access to the data sources (database, API, etc.). + +- [ ] If the app records data, there are backups for these data. + +## Sharing your app as a package + +### Install on your machine + +A `{shiny}` application built with `{golem}` [@R-golem] is **by definition** an R package. +This `{shiny}` app as a package is also helpful when it comes to deploying your application: packages are designed to be shareable pieces of R code. + +Before sending it to a remote server or sharing it with the world, **the first step is testing if the package can be installed on your own computer**. +To do that, when you are in the project corresponding to the golem you built, you can call `remotes::install_local()` to install the application on your computer. +Of course, if you are somewhere else on your machine, you can call `remotes::install_local("path/to/app")`. +If you are using the RStudio IDE, you can also click on the `Build` tab, then click on the `Install and Restart` button. + +This should restart your R session, and call `library(yourpackagename)`. +Then, try the `run_app()` function to check that the app can be launched. + +### Share as a built package + +#### A. Local build {.unnumbered} + +Building an app as a package also means that this app can be bundled into an archive, and then shared, either as is or using a package repository like the CRAN. + +To do that, you first need an bundled version of your app, which can be created using the `build()` function from `{pkgbuild}` [@R-pkgbuild] in the same working directory as your application. +Calling this function will create a .tar.gz file that is called `mygolem_0.0.1.tar.gz` (of course with the name of your package). +Once you have this `tar.gz`, you can send it to your favorite package repository. + +You can also share the file as is with others. +If you do so, they will have to install the app with `remotes::install_local("path/to/tar.gz")`, that will take care of doing a full installation of the app, including installing the required dependencies. +Then, they can do `library(yourpackagename)` and `run_app()` on their machine. + +#### B. Send to a package repository {.unnumbered} + +The upside of building the application `{golem}`, i.e. as a package, is that you can share your application on a remote package manager, the more widely used, for example, on the CRAN like `{dccvalidator}` [@R-dccvalidator], or on BioConductor like `{spatialLIBD}` [@R-spatialLIBD]. +But any other package manager will work: for example, if the company uses RStudio Package Manager, your application can be installed here in the same way as any other package. +If your application is open source, the package structure also allows you to install from GitHub, by using the `remotes::install_github()` function.[^deploy-with-golem-1] +For example, this is what you can do with `{hexmake}` or `{tidytuesday}`: as they are open-source packages, they can be installed from GitHub. +Then, once your application is installed as a package on the users' machines, they can do `library(yourpackagename)` and `run_app()`. + +[^deploy-with-golem-1]: This is also true for other version control systems. + +The advantage of this solution is that R users are familiar with package installation, so it makes using your application easier for them. +Also, and we will see it in the next section, making your application available as a standard R package makes it easier to deploy it: for example, if your RStudio Connect is coupled with your RStudio Package Manager, the deployment file just has to contain one line launching the application. + +Note that releasing to CRAN or BioConductor requires extra effort: you have to comply with a series of rules. +But good news: as you have been following the best practices from this book, you should not have to put in that much extra effort! + +Know more about releasing on CRAN: + +- [Checklist for CRAN submissions](https://cran.r-project.org/web/packages/submission_checklist.html) +- [CRAN Repository Policy](https://cran.r-project.org/web/packages/policies.html) +- [R packages - Chapter 18, Releasing a package](https://r-pkgs.org/release.html) +- [Getting your R package on CRAN](https://kbroman.org/pkg_primer/pages/cran.html) +- [prepare-for-cran - A collaborative list of things to know before submitting to CRAN](https://github.com/ThinkR-open/prepare-for-cran) + +## Deploying apps with `{golem}` + +The other way to make your application available to others is by sending it to a remote server that can serve `{shiny}` applications. +In other words, instead of having to install the application on their machines, **they can crack open a web browser and navigate to the URL where the application is deployed**. +Deploying to a server is the solution of choice when you want to make your application available to a wide public: on a server, visitors do not have to have R installed on their computer, they do not have to install a package or launch it; they can just browse the application like any other web application. +This solution is also a common choice in companies that have strict security requirements: the IT team might not be willing to let everyone install software on their machine, and sharing an application on a server allows them more control over who can access the application. +For example, deploying on a server allows you to use a proxy, and to filter by IP: then, only a subset of people can have access to the application. + +When using `{golem}`, you can open the `dev/03_deploy.R` and find the functions for server deployment. +At the time of writing this book, there are two main ways to deploy a shiny app on a server: + +- RStudio's solutions +- A Docker-based solution + +### RStudio environments + +RStudio proposes three services to deploy `{shiny}` application: + +- `shinyapps.io`, an on-premises solution, can serve `{shiny}` application (freemium). + +- `Shiny Server` is a software you have to install on your own server, and can be used to deploy multiple applications (you can find either an open source or a professional edition). + +- `RStudio Connect` is a server-based solution that can deploy `{shiny}` applications and `Markdown` documents (and other kinds of content), and serves them as ordinary websites. + +Each of these platforms has its own function to create an `app.R` file that is to be used as a launch script of each platform. + +- `golem::add_rstudioconnect_file()` + +- `golem::add_shinyappsio_file()` + +- `golem::add_shinyserver_file()` + +These `app.R` files call a `pkgload::load_all()` function, that will mimic the launch of your package, and then call the `run_app()` function from your packaged app. +Note that if you need to configure the way your app is launched on these platforms (for example, if you need to pass arguments to the `run_app()` function), you will have to edit this file. + +Note that when using these functions, you will be able to use the "One click deploy" for these platforms: on the top right of these `app.R`, use the Blue Button to deploy to a server. + +Another way to deploy your `{golem}`-based app to `{shiny}` server and to Connect is to link these two software to a local repository (for example, an RStudio Package Manager), and then to only use `mypackage::run_app()` to the `app.R`. + +### Docker + +Docker is an open source software used to build and deploy applications in containers. +Docker has become a core solution in the DevOps world and a lot of server solutions are based on it. +See Part 5, "Strengthen", for a more complete introduction to Docker. + +You will find the function for creating a `Dockerfile` for your `{golem}` app inside the `03_deploy.R` file, which contains a series of 3 functions: + +- `golem::add_dockerfile()` +- `golem::add_dockerfile_shinyproxy()` +- `golem::add_dockerfile_heroku()` + +The first function creates a "generic" `Dockerfile`, in the sense that it is not specific to any platform, and would work out of the box for your local machine. +The second one is meant for [`{shiny}`Proxy](https://www.shinyproxy.io/), an open source solution for deploying containerized `{shiny}` applications, and the third is for [Heroku](https://www.heroku.com/), an online service that can serve containerized applications (not specific to `{shiny}`). + +Other platforms can run Docker containers, notably AWS and Google Cloud Engine. +At the time of writing these lines, `{golem}` does not provide support for these environments, but that is on the to-do list! + +Note that the `Dockerfile` creation in `{golem}` tries to replicate your local environment as precisely as possible, notably by matching your R version, and the version of the packages you have installed on your machine. +System requirements are also added when they are found on [the sysreqs service from r-hub](https://sysreqs.r-hub.io/). +Otherwise you might have to add them manually. diff --git a/14-when_optimize.Rmd b/14-when_optimize.Rmd new file mode 100644 index 00000000..c44f6170 --- /dev/null +++ b/14-when_optimize.Rmd @@ -0,0 +1,614 @@ +# (PART) Optimizing {.unnumbered} + +# The Need for Optimization {#need-for-optimization} + +> Only once we have a solid characterization of the surface area we want to improve can we begin to identify the best way to improve it. +> +> _Refactoring at Scale_ [@lemaire2020] + +## Build first, then optimize + +### Identifying bottlenecks + +Refactoring existing code for speed sounds like an appealing activity for a lot of us: it is always satisfying to watch our functions get faster, or finding a more elegant way to solve a problem that also results in making your code a little bit faster. +Or as Maude Lemaire writes in _Refactoring at Scale_ [@lemaire2020], "Refactoring can be a little bit like eating brownies: the first few bites are delicious, making it easy to get carried away and accidentally eat an entire dozen. When you've taken your last bite, a bit of regret and perhaps a twinge of nausea kick in." + +But beware! +As Donald Knuth puts it "Premature optimization is the root of all evil". +What does that mean? +That **focusing on optimizing small portions of your app before making it work fully is the best way to lose time along the way, even more in the context of a production application, where there are deadlines and a limited amount of time to build the application**. +Why? +Here is the general idea: let's say the schema in Figure \@ref(fig:14-when-optimize-1) represents your software, and its goal is to make things travel from *X1* to *X2*, but you have a bottleneck at *U*. +You are building elements piece by piece: first, the portion `X1.1` of the "road", then `X1.2`, etc. +Only when you have your application ready can you really appreciate where your bottleneck is, and you can focus on making things go fast from `X1.1` to `X.1.2`, these performance gains won't make your application go faster: you will only make the elements move faster to the bottleneck. + +When? +Once the application is ready: here in our example, we can only detect the bottleneck once the full road is actually built, not while we are building the circle. + +(ref:bottleneck) Road bottleneck, from WikiMedia <https://commons.wikimedia.org/wiki/File:Roadway_section_with_bottleneck.png>. + +```{r 14-when-optimize-1, echo=FALSE, fig.cap="(ref:bottleneck)", out.width="100%"} +knitr::include_graphics("img/bottleneck.png") +``` + +This bottleneck is the very thing you should be optimizing: **having faster code anywhere else except this bottleneck will not make your app faster**: you will just make your app reach the bottleneck faster, but there will still be this part of your app that slows everything down. +But this is something you might only realize when the app is fully built: pieces might be fast individually, but slow when put together. +It is also possible that the test dataset you have been using from the start works just fine, but when you try your app with a bigger, more realistic dataset, the application is actually way slower than it should be. +And, maybe you have been using an example dataset so that you do not have to query the database every time you implement a new feature, but the SQL query to the database is actually very slow. +This is something you will discover only when the application is fully functional, not when building the parts, and realizing that when you only have 5% of the allocated time for this project left on your calendar is not a good surprise. + +Or to sum up: + +\newpage + +> Get your design right with an un-optimized, slow, memory-intensive implementation before you try to tune. +> Then, tune systematically, looking for the places where you can buy big performance wins with the smallest possible increases in local complexity. +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +### Do you need faster functions? + +Optimizing an app is a matter of trade-offs: of course, in a perfect world, every piece of the app would be tailored to be fast, easy to maintain, and elegant. +But in the real world, you have deadlines, limited time and resources, and we are all but humans. +That means that at the end of the day, your app will not be completely perfect: software can **always** be made better. +No piece of code has ever reached complete perfection. + +Given that, **do you want to spend 5 days out of the 30 you have planned optimizing a function so that it runs in a quarter of a second instead of half a second**, then realize the critical bottleneck of your app is actually the SQL query and not the data manipulation? +Of course a function running twice as fast is a good thing, but think about it in context: for example, how many times is this function called? +We can safely bet that if your function is only called once, working on making it twice as fast might not be the one function you would want to focus on (well, unless you have unlimited time to work on your project, and in that case lucky you; you can spend a massive amount of time building the perfect software). +On the other hand, the function which is called thousands of times in your application might benefit from being optimized. + +And all of this is basic maths. +Let's assume the following: + +- A current scenario takes 300 seconds to be accomplished on your application. +- One function `A()` takes 30 seconds, and it's called once. +- One function `B()` takes 1 second, and it's called 50 times. + +If you divide the execution time of `A()` by two, you would be performing a local optimization of 15 seconds, and a global optimization of 15 seconds. +On the other hand, if you divide the execution time of `B()` by two, you would be performing a local optimization of 0.5 seconds, but a global optimization of 25 seconds. + +Again, this kind of optimization is hard to detect until the app is functional. +An optimization of 15 seconds is way greater than an optimization of 0.5 seconds. +Yet you will only realize that once the application is up and running! + +### Don't sacrifice readability + +As said in the last section, every piece of code can be rewritten to be faster, either from R to R or using a lower-level language: for example C or C++. +You can also rebuild data manipulation code switching from one package to another, or use a complex data structures to optimize memory usage, etc. + +But that comes with a price: **not keeping things simple for the sake of local optimization makes maintenance harder, even more if you are using a lesser-known language/package**. +Refactoring a piece of code is better done when you keep in mind that "the primary goal should be to produce human-friendly code, even at the cost of your original design. If the laser focus is on the solution rather than the process, there's a greater chance your application will end up more contrived and complicated than it was in the first place" [@lemaire2020]. + +For example, switching some portions of your code to C++ implies that you might be the only person who can maintain that specific portion of code, or that your colleague taking over the project will have to spend hours learning the tools you have been building, or the language you have chosen to write your functions with. + +Again, **optimization is always a matter of trade-off**: is the half-second local optimization worth the extra hours you will have to spend correcting bugs when the app will crash and when you will be the only one able to correct it? +Also, are the extra hours/days spent rewriting a working code-base worth the speed gain of 0.5 seconds on one function? + +For example, let's compare both these implementations of the same function, one in R, and one in C++ via `{Rcpp}` [@R-Rcpp]. +Of course, the C++ function is faster than the R one—this is the very reason for using C++ with R. + +```{r 14-when-optimize-2 } +library("Rcpp") +# A C++ function to compute the mean +cppFunction(" +double mean_cpp(NumericVector x) { + int j; + int size = x.size(); + double res = 0; + for (j = 0; j < size; j++){ + res = res + x[j]; + } + return res / size; +}") + +# Computing the mean using base R and C++, +# and comparing the time spent on each +benched <- bench::mark( + cpp = mean_cpp(1:100000), + native = mean(1:100000), + iterations = 1000 +) +benched +``` + +(Note: we will come back to `bench::mark()` later.) + +However, how much is a time gain worth if you are not sure you can get someone on your team to take over the maintenance if needed? +In other words, given that (in our example) we are gaining around `r benched$median[1] - benched$median[2]` on the execution time of our function, is it worth switching to C++? +Using external languages or complex data structures implies that from the start, you will need to think about who and how your codebase will be maintained over the years. + +Chances are that if you plan on using a `{shiny}` application during a span of several years, various R developers will be working on the project, and including C++ code inside your application means that these future developers will either be required to know C++, or they will not be able to maintain this piece of code. + +So, to sum up, there are three ways to optimize your application and R code, and the bad news is that you cannot optimize for all of them: + +- Optimizing for speed +- Optimizing for memory +- Optimizing for readability/maintainability + +Leading a successful project means that you should, as much as possible, find the perfect balance between these three. + +\newpage + +## Tools for profiling + +### Profiling R code + +#### A. Identifying bottlenecks {.unnumbered} + +The best way to profile R code is by using the `{profvis}` [@R-profvis] package,[^when_optimize-1] a package designed to evaluate how much time each part of a function call takes. +With `{profvis}`, you can spot the bottleneck in your function. +Without an automated tool to do the profiling, the developers would have to profile by guessing, which will, most of the time, come with bad results: + +[^when_optimize-1]: `{utils}` also comes with a function call `Rprof()`, but we will not be examining this one here, as `{profvis}` provides a more user-friendly and enhanced interface to this profiling function. + +> One of the lessons that the original Unix programmers learned early is that intuition is a poor guide to where the bottlenecks are, even for one who knows the code in question intimately. +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +Instead of guessing, it is a safe bet to go for a tool like `{profvis}`, which allows you to have a detailed view of what takes a long time to run in your R code. + +Using this package is quite straightforward: put the code you want to benchmark inside the `profvis()` function,[^when_optimize-2] wait for the code to run, and that is it; you now have an analysis of your code running time. + +[^when_optimize-2]: Do not forget to add `{}` inside `profvis({})` if you want to write several lines of code. + +Here is an example with 3 nested functions, `top()`, `middle()` and `bottom()`, where `top()` calls `middle()` which calls `bottom()`: + +```{r 14-when-optimize-3, eval = FALSE} +library(profvis) +top <- function(){ + # We use profvis::pause() because Sys.sleep() doesn't + # show in the flame graph + pause(0.1) + # Running a series of function with lapply() + lapply(1:10, function(x){ + x * 10 + }) + # Calling a lower level function + middle() +} + +middle <- function(){ + # Pausing before computing, and calling other functions + pause(0.2) + 1e4 * 9 + bottom_a() + bottom_b() +} + +# Both will pause and print, _a for 0.5 seconds, +# _b for 2 seconds +bottom_a <- function(){ + pause(0.5) + print("hey") +} +bottom_b <- function(){ + pause(2) + print("hey") +} +profvis({ + top() +}) +``` + +What you see now is called a `flame graph`: it is a detailed timing of how your function has run, with a clear decomposition of the call stack. +What you see in the top window is the expression evaluated, and on the bottom the details of the call stack, with what looks a little bit like a Gantt diagram. +This result reads as follow: the wider the function call, the more time it has taken R to compute this piece of code. +On the very bottom, the "top" function (i.e. the function which is directly called in the console), and the higher you go, the more you enter the nested function calls. + +Here is how to read the graph in \@ref(fig:14-when-optimize-4): + +- On the x axis is the time spent computing the whole function. + Our `top()` function being the only one executed, it takes the whole record time. + +- Then, the second line shows the functions which are called inside `top()`. + First, R pauses, then does a series of calls to `FUN` (which is the internal anonymous function from `lapply()`), and then calls the `middle()` function. + +- Then, the third line details the calls made by `middle()`, which pauses, then calls `bottom_a()` and `bottom_b()`, which each `pause()` for a given amount of time. + +(ref:profvizflame) `{profvis}` flame graph. + +```{r 14-when-optimize-4, echo=FALSE, fig.cap="(ref:profvizflame)", out.width="100%"} +knitr::include_graphics("img/profviz_flame.png") +``` + +If you click on the "Data" tab, you will also find another view of the `flame graph`, shown in \@ref(fig:14-when-optimize-5), where you can read the hierarchy of calls and the time and memory spent on each function call: + +(ref:profvizdata) `{profvis}` data tab. + +```{r 14-when-optimize-5, echo=FALSE, fig.cap="(ref:profvizdata)", out.width="100%"} +knitr::include_graphics("img/profviz_data.png") +``` + +If you are working on profiling the memory usage, you can also use the `{profmem}` [@R-profmem] package which, instead of focusing on execution time, will record the memory usage of calls. + +```{r 14-when-optimize-6 } +library(profmem) +# Computing the memory used by each c +p <- profmem({ + x <- raw(1000) + A <- matrix(rnorm(100), ncol = 10) +}) +p +``` + +You can also get the total allocated memory with: + +```{r 14-when-optimize-7 } +total(p) +``` + +And extract specific values based on the memory allocation: + +```{r 14-when-optimize-8 } +p2 <- subset(p, bytes > 1000) +print(p2) +``` + +(Example extracted from `{profmem}` help page). + +Here it is; now you have a tool to identify bottlenecks! + +#### B. Benchmarking R code {.unnumbered} + +Identifying bottlenecks is a start, but what to do now? +In the next chapter about optimization, we will dive deeper into common strategies for optimizing R and `{shiny}` code. +But before that, remember this rule: **never start optimizing if you cannot benchmark this optimization**. +Why? +Because developers are not perfect at identifying bottlenecks and estimating if something is faster or not, and some optimization methods might lead to slower code. +Of course, most of the time they will not, but in some cases adopting optimization methods leads to writing slower code, because we have missed a bottleneck in our new code. +And of course, without a clear documentation of what we are doing, we will be missing it, relying only on our intuition as a rough guess of speed gain. + +In other words, if you want to be sure that you are actually optimizing, be sure that you have a basis for comparison. + +How to do that? +One thing that can be done is to keep an RMarkdown file with your starting point: use this notebook to keep track of what you are doing, by noting where you are starting from (i.e, what's the original function you want to optimize), and compare it with the new one. +By using an Rmd, you can document the strategies you have been using to optimize the code, e.ga: "switched from for loop to vectorize function", "changed from x to y", etc. +This will also be helpful for the future: either for you in other projects (you can get back to this document), or for other developers, as it will explain why specific decisions have been made. + +To do the timing computation, you can use the `{bench}` [@R-bench] package, which compares the execution time (and other metrics) of two functions. +This function takes a series of named elements, each containing an R expression that will be timed. +Note that by default, the `mark()` function compares the output of each function, + +Once the timing is done, you will get a data.frame with various metrics about the benchmark. + +```{r 14-when-optimize-9 } +# Multiplying each element of a vector going from 1 to size +# with a for loop +for_loop <- function(size){ + res <- numeric(size) + for (i in 1:size){ + res[i] <- i * 10 + } + return(res) +} +# Doing the same thing using a vectorized function +vectorized <- function(size){ + (1:size) * 10 +} +res <- bench::mark( + for_loop = for_loop(1000), + vectorized = vectorized(1000), + iterations = 1000 +) +res +``` + +Here, we have an empirical evidence that one code is faster than the other: by benchmarking the speed of our code, we are able to determine which function is the fastest. + +If you want a graphical analysis, `{bench}` comes with an `autoplot` method for `{ggplot2}` [@R-ggplot2], as shown in Figure \@ref(fig:14-when-optimize-10): + +(ref:benchautoplot) `{bench}` autoplot. + +```{r 14-when-optimize-10, fig.cap="(ref:benchautoplot)", out.width="100%", warning = FALSE} +ggplot2::autoplot(res) +``` + +And, bonus point, `{bench}` takes time to check that the two outputs are the same, so that you are sure you are comparing the very same thing, which is another crucial aspect of benchmarking: be sure you are not comparing apples with oranges! + +### Profiling `{shiny}` + +#### A. `{shiny}` back-end {.unnumbered} + +You can profile `{shiny}` applications using the `{profvis}` package, just as any other piece of R code. +The only thing to note is that if you want to use this function with an app built with `{golem}` [@R-golem], you will have to wrap the `run_app()` function in a `print()` function. +Long story short, what makes the app run is not the function itself, but the printing of the function, so the object returned by `run_app()` itself cannot be profiled. +See the discussion of this [issue on the `{golem}` repository](https://github.com/ThinkR-open/golem/issues/146) to learn more about this. + +#### B. `{shiny}` front-end {.unnumbered} + +##### Google Lighthouse {.unnumbered} + +One other thing that can be optimized when it comes to the user interface is the web page rendering performance. +To do that, we can use standard web development tools: as said several times, a `{shiny}` application IS a web application, so tools that are language agnostic will work with `{shiny}`. +There are thousands of tools available to do exactly that, and going through all of them would probably not make a lot of sense. + +Let's focus on getting started with a basic but powerful tool, that comes for free inside your browser: [Google Lighthouse](https://developers.google.com/web/tools/lighthouse), one of the famous tools for profiling web pages, is bundled into recent versions of Google Chrome. +The nice thing is that this tool not only covers what you see (i.e. not only what you are actually rendering on your personal computer), but can also audit your app with various configurations, notably on mobile, with low bandwidth and/or mimicking a 3G connection. +**Being able to perform an audit of our application as seen on a mobile device is a real strength: we are developing an application on our computer, and might not be regularly checking how our application is performing on a mobile. Yet a large portion of web navigation is performed on a mobile or tablet**. + +Already in 2016, Google [wrote](https://www.thinkwithgoogle.com/data/web-traffic-from-smartphones-and-tablets/) that "*More than half of all web traffic now comes from smartphones and tablets*". +Knowing the exact number of visitors that browse through mobile is hard: the web is vast, and not all websites record the traffic they receive. +Yet many, if not all, studies of how the web is browsed report the same results: more traffic is created via mobile than via computer.[^when_optimize-3] + +[^when_optimize-3]: broadbandsearch <https://www.broadbandsearch.net/blog/mobile-desktop-internet-usage-statistics> for example, reports a 53.3% share for mobile browsing. + +And, the advantages of running it in your browser is that it can perform the analysis on locally deployed applications: in other words, you can launch your `{shiny}` application in your R console, open the app in Google Chrome, and run the audit. +A lot of online services need a URL to do the audit! + +Each result from the audit comes with advice and changes you can make to your application to make it better, with links to know more about the specific issue. + +And of course, last but not least, you also get the results of the metrics you have "passed". +It is always a good mood booster to see our app passing some audited points! + +Here is a quick introduction to this tool: + +- Open Chrome in incognito mode (File \> New Icognito Window),[^when_optimize-4] so that the page performance is not influenced by any of the installed extensions in your Google Chrome. +- Open your developer console, either by going to View \> Developer \> Developer tools, by right-clicking \> Inspect, or with the keyboard shortcut ctrl/cmd + alt + I, as shown in Figure \@ref(fig:14-when-optimize-11). +- Go to the "Audit" tab. +- Configure your report (or leave the default). +- Click on "Generate Report". + +[^when_optimize-4]: This mode opens an "anonymous" session, in the sense that you don't have access to your account and extensions, and that the visits will not be recorded in your history. + +Note that you can also install a command-line tool with `npm install -g lighthouse`,[^when_optimize-5] then run `lighthouse http://urlto.audit`: it will produce either a JSON (if asked) or an HTML report (the default). + +[^when_optimize-5]: Being a NodeJS application, you will need to have NodeJS installed on your machine. + +(ref:lighthouseaudit) Launching Lighthouse audit from Google Chrome. + +```{r 14-when-optimize-11, echo=FALSE, fig.cap="(ref:lighthouseaudit)", out.width="100%"} +knitr::include_graphics("img/lighthouse-audit.png") +``` + +See Figure \@ref(fig:14-when-optimize-12) for a screenshot of the results computed by Google Lighthouse. + +(ref:lighthouseres) Lighthouse audit results. + +```{r 14-when-optimize-12, echo=FALSE, fig.cap="(ref:lighthouseres)", out.width="100%"} +knitr::include_graphics("img/lighthouse-audit-results.png") +``` + +Once the audit is finished, you have some basic but useful indications about your application: + +- Performance. + This metric mostly analyzes the rendering time of the page: for example, how much time does it take to load the app in full, that is to say how much time it takes from the first byte received to the app being fully ready to be used, the time between the very first call to the server and the very first response, etc. + With `{shiny}` [@R-shiny], you should get low performance here, notably due to the fact that it is serving external dependencies that you might not be able to control. + For example, the report from `{hexmake}` [@R-hexmake] suggests to "Eliminate render-blocking resources", and most of them are not controlled by the shiny developer: they come bundled with `shiny::fluidPage()` itself. + +- Accessibility. + Google Lighthouse performs a series of accessibility tests (see our chapter about accessibility for more information). + +- Best practices bundles a list of "misc" best practices around web applications. + +- SEO, search engine optimization, or how your app will perform when it comes to search engine indexation.[^when_optimize-6] + +- Progressive Web App (PWA): A PWA is an app that can run on any device, *"reaching anyone, anywhere, on any device with a single codebase"*. + Google audit your application to see if your application fits with this idea. + +[^when_optimize-6]: Search engine indexation refers to how Google ranks your website in the search results for a given query. + +Profiling web page is a wide topic and a lot of things can be done to enhance the global page performance. +That being said, if you have a limited time to invest in optimizing the front-end performance of the application, Google Lighthouse is a perfect tool, and can be your go-to audit tool for your application. + +And if you want to do it from R, the npm lighthouse module allows you to output the audit in JSON, which can then be brought back to R! + +``` {.bash} +lighthouse --output json \ + --output-path data-raw/output.json \ + http://localhost:2811 +``` + +Then, being a JSON file, you can call if from R: + +```{r 14-when-optimize-13 } +# Reading the JSON output of your lighthouse audit, +# and displaying the Speed Index value +lighthouse_report <- jsonlite::read_json("data-raw/output.json") +lighthouse_report$audits$`speed-index`$displayValue +``` + +The results are contained in the `audits` sections of this object, and each of these sub-elements contains a `description` field, detailing what the metric means. + +Here are, for example, some of the results, focused on performance, with their respective descriptions: + +##### "First Meaningful Paint" {.unnumbered} + +```{r 14-when-optimize-14, eval = FALSE } +# Each audit point contains a description, +# that explains what this value stands for +lighthouse_report$audits$`first-meaningful-paint`$description +``` + +```{r 14-when-optimize-1-bis, echo = FALSE} +# Each audit point contains a description, that explains what this +# value stands for +lighthouse_report$audits$`first-meaningful-paint`$description %>% + strwrap(width = 55) +``` + +```{r 14-when-optimize-15 } +# We can turn the results into a data frame +lighthouse_report$audits$`first-meaningful-paint` %>% + tibble::as_tibble() %>% + dplyr::select(title, score, displayValue) +``` + +##### "Speed Index" {.unnumbered} + +```{r 14-when-optimize-16, eval = FALSE} +lighthouse_report$audits$`speed-index`$description +``` + +```{r 14-when-optimize-2-bis, echo = FALSE} +lighthouse_report$audits$`speed-index`$description %>% + strwrap(width = 55) +``` + +```{r 14-when-optimize-17 } +lighthouse_report$audits$`speed-index` %>% + tibble::as_tibble() %>% + dplyr::select(title, score, displayValue) +``` + +##### "Estimated Input Latency" {.unnumbered} + +```{r 14-when-optimize-18, eval = FALSE } +lighthouse_report$audits$`estimated-input-latency`$description +``` + +```{r 14-when-optimize-3-bis, echo = FALSE} +lighthouse_report$audits$`estimated-input-latency`$description %>% + strwrap(width = 55) +``` + +```{r 14-when-optimize-19 } +lighthouse_report$audits$`estimated-input-latency` %>% + tibble::as_tibble() %>% + dplyr::select(title, score, displayValue) +``` + +##### "Total Blocking Time" {.unnumbered} + +```{r 14-when-optimize-20, eval = FALSE} +lighthouse_report$audits$`total-blocking-time`$description +``` + +```{r 14-when-optimize-4-bis, echo = FALSE} +lighthouse_report$audits$`total-blocking-time`$description %>% + strwrap(width = 55) +``` + +```{r 14-when-optimize-21 } +lighthouse_report$audits$`total-blocking-time` %>% + tibble::as_tibble() %>% + dplyr::select(title, score, displayValue) + +``` + +##### "Time to first Byte" {.unnumbered} + +```{r 14-when-optimize-22, eval = FALSE} +lighthouse_report$audits$`time-to-first-byte`$description +``` + +```{r 14-when-optimize-5-bis, echo = FALSE} +lighthouse_report$audits$`time-to-first-byte`$description %>% + strwrap(width = 55) +``` + +```{r 14-when-optimize-23 } +lighthouse_report$audits$`time-to-first-byte` %>% + .[c("title", "score", "displayValue")] %>% + tibble::as_tibble() +``` + +Google Lighthouse also comes with a continuous integration tool, so that you can use it as a regression testing tool for your application. +To know more, feel free to read the [documentation](https://github.com/GoogleChrome/lighthouse-ci/blob/master/docs/getting-started.md)! + +##### Side note on minification {.unnumbered} + +Chances are that right now you are not using *minification* in your `{shiny}` application. +Minification is the process of removing unnecessary characters from files, without changing the way the code works, to make the file size smaller. +The general idea being that line breaks, spaces, and a specific set of characters are used inside scripts for human readability, and are not useful when it comes to the way a computer reads a piece of code. +Why not remove them when they are served in a larger software? +This is what *minification* does. + +Here is an example of how minification works, taken from *Empirical Study on Effects of Script Minification and HTTP Compression for Traffic Reduction* [@Sakamoto2015]: + +``` {.javascript} +var sum = 0; +for ( var i = 0; i <=10; i ++ ) { + sum += i ; +} +alert( sum ) ; +``` + +is minified into: + +``` {.javascript} +var sum=0;for(var i=0;i<=10;i++){sum+=i};alert(sum); +``` + +Both these code blocks behave the same way, but the second one will be smaller when saved to a file: this is the very core principle of minification of files. +It is something pretty common to do when building web applications: on the web, every byte counts, so the smaller your external resources the better. +Minification is important as the larger your resources, the longer your application will take to launch, and: + +- Page launch time is crucial when it comes to ranking the pages on the web. + +- The larger the resources, the longer it will take to launch the application on a mobile, notably if users are visiting your application from a 3G/4G network. + +And do not forget the following: + +> Extremely high-speed network infrastructures are becoming more and more popular in developed countries. +> However, we still face crowded and low-speed Wi-Fi environments on airport, cafe, international conference, etc. +> Especially, a network environment of mobile devices requires efficient usage of network bandwidth. +> +> _Empirical study on effects of script minification and HTTP compression for traffic reduction_ [@Sakamoto2015] + +To minify JavaScript, HTML and CSS files from R, you can use the `{minifyr}` [@R-minifyr] package, which wraps the `node-minify` NodeJS library. +For example, compare the size of this file from `{shiny}`: + +```{r 14-when-optimize-24, eval = FALSE} +# Displaying the file size of a CSS file from {shiny} +fs::file_size( + system.file("www/shared/shiny.js", package = "shiny") +) +``` + +```{r 14-when-optimize-25, echo = FALSE} +cat("239K") +``` + +To its minified version: + +```{r 14-when-optimize-26, eval = FALSE} +# Using the {minifyr} package to minify the CSS file +minified <- minifyr::minifyr_js_gcc( + system.file("www/shared/shiny.js", package = "shiny"), + "shinymini.js" +) +``` + +```{r 14-when-optimize-27, eval = FALSE} +# Minifying can help you gain kilobytes +fs::file_size(minified) +``` + +```{r 14-when-optimize-28, echo = FALSE } +cat("87.5K") +``` + +That might not seem like much (a couple of KB) on a small scale, but as it can be done automatically, why not leverage these small performance gains when building larger applications? +Of course, minification will not suddenly make your application blazing fast, but that's something you should consider when deploying an application to production, notably if you use a lot of packages with interactive widgets: they might contain CSS and JavaScript files that are not minified. + +Minification can be important notably if you expect your audience to be connecting to your app with a low bandwidth: whenever your application starts, the browser has to download the source files from the server, meaning that the larger these files, the longer it will take to render. + +Note that `{shiny}` files are minified by default, so you will not have to re-minify them. +But most packages that extend `{shiny}` are not, so minifying the CSS and JavaScript files from these packages might help you win some points on you Google Lighthouse report! + +To do this automatically, you can add the `{minifyr}` commands to your deployment, be it on your CD/CI platform, or as a Dockerfile step. +`{minifyr}` comes with a series of functions to do that: + +- `minify_folder_css()`, `minify_folder_js()`, `minify_folder_html()` and `minify_folder_json()` do a bulk minification of the files found in a folder that matches the extension. +- `minify_package_js()`, `minify_package_css()`, `minify_package_html()` and `minify_package_json()` will minify the CSS and JavaScript files contained inside a package installed on the machine. + +Here is what it can look like inside a `Dockerfile` (Note that you will need to install NodeJS inside the container): + + FROM rocker/shiny-verse:3.6.3 + + RUN apt-get -y install curl RUN curl -sL \ + <https://deb.nodesource.com/setup_14.x> \ + | bash - RUN apt-get install -y nodejs + + RUN Rscript -e 'remotes::install_github("colinfay/minifyr")' + RUN Rscript -e 'remotes::install_cran("cicerone")' + RUN Rscript -e 'library(minifyr);\ + minifyr_npm_install(TRUE);\ + minify_package_js("cicerone", minifyr_js_uglify)' + +### More resources about web-page performance + +- [Why Performance Matters - Google Web Fundamentals](https://developers.google.com/web/fundamentals/performance/why-performance-matters) + +- [Web Performance - Mozilla Web Docs](https://developer.mozilla.org/en-US/docs/Web/Performance) diff --git a/15-common-app-caveats.Rmd b/15-common-app-caveats.Rmd new file mode 100644 index 00000000..a15cc636 --- /dev/null +++ b/15-common-app-caveats.Rmd @@ -0,0 +1,1000 @@ +# Common Application Caveats {#common-app-caveats} + +## Reactivity anti-patterns + +### Reactivity is awesome... until it is not + +Let's face it, reactivity is awesome... until it is not. +Reactivity is a common source of confusion for beginners, and a common source of bugs and bottlenecks, even for seasoned `{shiny}` developers. +Most of the time, issues come from the fact that **there is too much reactivity**, *i.e.* we build apps where too many things happen, and some things are updated way more often than they should be, and computations are performed when they should not be, and in the end we have a hard time understanding what is really happening inside our application. + +Of course, it is a nice feature to make everything react instantly to changes, but when building larger apps it is easy to create monsters, i.e. complicated, messy, reactive graphs where everything is updated too much and too often. +Or worse, we generate endless reactive loops, aka "the reactive inferno" where A invalidates B which invalidates C which invalidates A which invalidates B which invalidates C, and so on. + +Let's take a small example of a reactive inferno: + +```{r 15-common-app-caveats-1, eval = FALSE} +library(shiny) +library(lubridate) +ui <- function(){ + tagList( + # Adding a first input which allow + # to select a specific date + dateInput( + "date", + "choose a date" + ), + # Adding a second input allowing + # to specify a year + selectInput( + "year", + "Choose a year", + choices = 2010:2030 + ) + ) +} + +server <- function( + input, + output, + session +){ + # We want the year to be update whenever + # the dateInput is updated + observeEvent( input$date , { + updateSelectInput( + session, + "year", + selected = year(input$date) + ) + }) + + # We want the date to be update whenever + # the selectInput is updated + observeEvent( input$year , { + updateDateInput( + session, + "date", + value = lubridate::as_date( + sprintf("%s-01-01", input$year) + ) + ) + }) + +} + +shinyApp(ui, server) +``` + +Here, we want to handle something pretty common: + +- The user can pick a `date` and the `year` input is updated. +- And the other way round: when the `year` input changes, the `date` is updated too. + +But if you try to run this in your console, it will end as a reactive inferno: date updates year that updates date that updates year, and so on. + +And the more you work on your app, the more complex it gets, and the more you will be likely to end up in a reactive inferno. +In this section, we will deal with reactivity, how to have more control over it, and how to share data across modules without relying on passing along reactive objects. + +This application is in this state of infinite loop because it starts in a mutually inconsistent state: the `dateInput()` year value is the current year, while the `selectInput()` value is `2010`. +One way to solve this is to add some extra logic to the app by selecting the current year for `selectInput()`, and adding an `if` statement in the `observeEvent(input$year, {})`, as shown below.[^common_app_caveats-276] + +[^common_app_caveats-276]: We want to thank Hadley for his help simplifying this solution <https://github.com/ThinkR-open/engineering-shiny-book/issues/276>. + +```{r 15-common-app-caveats-2, eval = FALSE} +library(shiny) +ui <- fluidPage( + dateInput( + "date", + "choose a date" + ), + selectInput( + "year", + "Choose a year", + choices = 2010:2030, + # Setting a state for the year + selected = format( + Sys.Date(), + "%Y" + ) + ) +) + +server <- function(input, output, session) { + observeEvent(input$date, { + year <- format(input$date, "%Y") + message("Changing year to ", year) + updateSelectInput(inputId = "year", selected = year) + }) + + observeEvent(input$year, { + # Preventing this update to be sent at application launch + if (input$year != format(input$date, "%Y")) { + date <- as.Date(ISOdate(input$year, 1, 1)) + message("Changing date to ", date) + updateDateInput(inputId = "date", value = date) + } + }) +} + +shinyApp(ui, server) +``` + +### `observe` vs `observeEvent` + +One of the most common features of reactive inferno is the use of `observe()` in cases where you should use `observeEvent`. +Spoiler: you should try to use `observeEvent()` as much as possible, and avoid `observe()`as much as possible. + +At first, `observe()` seems easier to implement, and feels like a shortcut as you do not have to think about what to react to: everything gets updated without you thinking about it. +But the truth is, this stairway does not lead to heaven. + +Let's stop and think about `observe()` for a minute. +This function updates **every time a reactive object it contains is invalidated**. +Yes, this works well if you have a small number of reactive objects in the observer, but that gets tricky when you start adding a long list of things inside your `observe()`, as you might be launching a computation 10 times if your reactive scope contains 10 reactive objects that are somehow invalidated in chain. +And believe us, we have seen pieces of code where the `observe()` contains hundreds of lines of code, with reactive objects all over the place, with one `observe()` context being invalidated dozens of times when one input changes in the application. + +For example, let's start with that: + +```{r 15-common-app-caveats-3, eval = FALSE} +## DO NOT DO GLOBAL VARIABLES, IT'S JUST TO SIMPLIFY THE EXAMPLE +# We initiate a counter that will help to track how many times +# some pieces of the code are called +i <- 0 +library(shiny) +library(cli) +ui <- function(){ + tagList( + # We are adding a simple text input + # that will be printed to the console + textInput("txt", "Text") + ) +} + +server <- function(input, output, session){ + observe({ + # Every time this reactive context is invalidated, + # we add 1 to the i value + i <<- i + 1 + # We print the i value to the console, + # and the value of input$txt + cat_rule(as.character(i)) + print(input$txt) + }) +} + +shinyApp(ui, server) +``` + +Oh, and then, let's add a small `selectInput()`: + +```{r 15-common-app-caveats-4, eval = FALSE} +i <- 0 +library(shiny) +library(cli) +ui <- function(){ + tagList( + # We are adding a simple text input + # that will be printed to the console + textInput("txt", "Text"), + # We add a selectInput() to allow text transformation + selectInput( + "casefolding", + "Casefolding", + c("lower", "upper") + ) + ) +} + +server <- function(input, output, session){ + observe({ + # Every time this reactive context + # is invalidated, we add 1 to the i value + i <<- i + 1 + # We print the i value to the console + cat_rule(as.character(i)) + # If the user select lower, then the text is + # passed through tolower, otherwise it's passed + # through toupper + if (input$casefolding == "lower") { + print(tolower(input$txt)) + } else { + print(toupper(input$txt)) + } + }) +} + +shinyApp(ui, server) +``` + +And, as time goes by, we add another control flow to our `observe()`: + +```{r 15-common-app-caveats-5, eval = FALSE} +i <- 0 +library(shiny) +library(cli) +library(stringi) +ui <- function(){ + tagList( + # We are adding a simple text input + # that will be printed to the console + textInput("txt", "Text"), + # We add a selectInput() to allow text transformation + selectInput( + "casefolding", + "Casefolding", + c("lower", "upper") + ), + # A new checkbox to reverse (or not) the input text + checkboxInput("rev", "reverse") + ) +} + +server <- function(input, output, session){ + observe({ + # Every time this reactive context + # is invalidated, we add 1 to the i value + i <<- i + 1 + # We print the i value to the console + cat_rule(as.character(i)) + # Use input_txt as a container for our input + input_txt <- input$txt + if (input$rev){ + # If the input$rev is select, we reverse the text + input_txt <- stri_reverse(input_txt) + } + # If the user select lower, then the text is + # passed through tolower, otherwise it's passed + # through toupper + if (input$casefolding == "lower") { + print(tolower(input_txt)) + } else { + print(toupper(input_txt)) + } + }) +} + +shinyApp(ui, server) +``` + +And it would be nice to keep the selected values in a reactive list, so that we can reuse it elsewhere. +And maybe you would like to add a checkbox so that the logs are printed to the console only if checked. + +```{r 15-common-app-caveats-6, eval = FALSE} +i <- 0 +library(shiny) +library(cli) +library(stringi) +ui <- function(){ + tagList( + # We are adding a simple text input + # that will be printed to the console + textInput("txt", "Text"), + # We add a selectInput() to allow text transformation + selectInput( + "casefolding", + "Casefolding", + c("lower", "upper") + ), + # A new checkbox to reverse (or not) the input text + checkboxInput("rev", "reverse") + ) +} + +server <- function(input, output, session){ + # We are using a reactiveValues to keep this input value + r <- reactiveValues() + observe({ + # Every time this reactive context + # is invalidated, we add 1 to the i value + i <<- i + 1 + # We print the i value to the console + cat_rule(as.character(i)) + if (input$rev){ + # If the input$rev is select, we reverse the text + r$input_txt <- stri_reverse(r$input_txt) + } else { + # Otherwise, we leave it as it is + r$input_txt <- input$txt + } + # If the user select lower, then the text is + # passed through tolower, otherwise it's passed + # through toupper + if (input$casefolding == "lower") { + print(tolower(r$input_txt)) + } else { + print(toupper(r$input_txt)) + } + }) +} + +shinyApp(ui, server) +``` + +Ok, now can you tell how many potential invalidation points we have here? +Three: whenever `input$txt`, `input$rev` or `input$casefolding` change. +Of course, three is not that much, but you get the idea. + +Let's pause a minute and think about why we use `observe()` here. +To update the values inside `r$input_txt`, yes. +But do we need to use `observe()` for, say, updating `r$input_txt` under dozens of conditions, each time the user types a letter? +Possibly not. + +We generally want our observer to update its content under a small, controlled number of inputs, i.e. with a controlled number of invalidation points. +And, what we often forget is that users do not type/select correctly on the first try. +No, they usually try and miss, restart, change things, amplifying the reactivity "over-happening". + +Moreover, long `observe()` statements are hard to debug, and they make collaboration harder when the trigger to the observe logic can potentially live anywhere between line one and line 257 of your `observe()`. +That's why (well, in 99% of cases), it is safer to go with `observeEvent`, as it allows you to see at a glance the condition under which the content is invalidated and re-evaluated. +Then, if a reactive context is invalidated, **you know why**. +For example, here is where the reactive invalidation can happen (lines with a `*`)[^common-app-caveats-1]: + +[^common-app-caveats-1]: Of course it's an over-simplification: the reactive context will not be invalidated in all of these contexts. The idea is to illustrate how `observe()` can lead to invalidation points that are spread all across the code bloc. + +``` {.r} +observe({ + i <<- i + 1 + cat_rule(as.character(i)) +* if (input$rev){ +* r$input_txt <- stri_reverse(r$input_txt) + } else { +* r$input_txt <- input$txt + } +* if (input$casefolding == "lower") { +* print(tolower(r$input_txt)) + } else { +* print(toupper(r$input_txt)) + } +}) +``` + +Whereas in this refactored code using `observeEvent()`, it is easier to identify where the invalidation can happen: + +``` {.r} +observeEvent( c( +* input$rev, +* input$txt +),{ + i <<- i + 1 + cat_rule(as.character(i)) + if (input$rev){ + r$input_txt <- stri_reverse(r$input_txt) + } else { + r$input_txt <- input$txt + } + if (input$casefolding == "lower") { + print(tolower(r$input_txt)) + } else { + print(toupper(r$input_txt)) + } +}) +``` + +### Building triggers and watchers + +To prevent this, one way to go is to create "flag" objects, which can be thought of as internal buttons to control what you want to invalidate: you create the button, set some places where you want these buttons to invalidate the context, and finally press these buttons. + +These objects are launched with an `init` function, then these flags are triggered with `trigger()`, and wherever we want these flags to invalidate a reactive context, we `watch()` these flags. + +The idea here is to get full control over the reactive flow: we only invalidate contexts when we want, making the general flow of the app more predictable. +These flags are available using the `{gargoyle}` [@R-gargoyle] package, that can be installed from GitHub with: + +```{r 15-common-app-caveats-7, eval = FALSE} +# CRAN version +install.pacakges("gargoyle") +# Dev version +remotes::install_github("ColinFay/gargoyle") +``` + +- `gargoyle::init("this")` initiates a `"this"` flag: most of the time you will be generating them at the `app_server()` level. + +- `gargoyle::watch("this")` sets the flag inside a reactive context, so that it will be invalidated every time you `trigger("this")` this flag. + +- `gargoyle::trigger("this")` triggers the flags. + +And, bonus, as these functions use the `session` object, they are available across all modules. +That also means that you can easily trigger an event inside a module from another one. + +This pattern is, for example, implemented in `{hexmake}` [@R-hexmake] (though not with `{gargoyle}`), where the rendering of the image on the right is fully controlled by the [`"render"` flag](https://github.com/ColinFay/hexmake/blob/master/R/mod_right.R#L40). +The idea here is to allow complete control over when the image is recomputed: only when `trigger("render")` is called does the app regenerate the image, helping us lower the reactivity of the application. +That might seem like a lot of extra work, but that is definitely worth considering in the long run, as it will help in optimizing the rendering (fewer computations), and lowering the number of errors that can result from too much reactivity inside an application. + +Here is a small example of this implementation, using an environment to store the value. +When using this pattern, we do not rely on any reactive value invalidating the reactive context: the second result is only displayed when the `"render2"` flag is triggered, giving us a full control on how the reactivity is propagated. + +```{r 15-common-app-caveats-8, eval = FALSE} +library(shiny) +library(gargoyle) +ui <- function(){ + fluidPage( + tagList( + # Creating an action button to launch the computation + actionButton("compute", "Compute"), + # Output for all runif() + verbatimTextOutput("result"), + # This output will change only if runif() > 0.5 + verbatimTextOutput("result2"), + # This button will reset x$results to 0, we use it + # to show that it won't launch a series of reactivity + # invalidation + actionButton("reset", "Reset x") + ) + ) +} + +server <- function( + input, + output, + session +){ + + # Mimic an R6 class, i.e. a non-reactive object + x <- environment() + + # Creating two watchers + init("render_result", "render_result2") + + observeEvent( input$compute , { + # When the user presses compute, we launch runif() + x$results <- runif(1) + # Every time a new value is stored, we render result + trigger("render_result") + # Only render the second result if x$results is over 0.5 + if (x$results > 0.5){ + trigger("render_result2") + } + }) + + output$result <- renderPrint({ + # Will be rendered every time + watch("render_result") + # require x$results before rendering the output + req(x$results) + x$results + }) + + + output$result2 <- renderPrint({ + # This will only be rendered if trigger("render_result2") + # is called + watch("render_result2") + req(x$results) + x$results + }) + + observeEvent( input$reset , { + # This resets x$results. This code block is here + # to show that reactivity is not triggered in this app + # unless a trigger() is called + x$results <- 0 + print(x$results) + }) + +} + +shinyApp(ui, server) + +``` + +### Using R6 as data storage + +One pattern we have also been playing with is storing the app business logic inside one or more R6 objects. +Why would we want to do that? + +#### A. Sharing data across modules {.unnumbered} + +Sharing an R6 object makes it simpler to create data that are shared across modules, but without the complexity generated by reactive objects, and the instability of using global variables. + +Basically, the idea is to hold the whole logic of your **data** **reading/cleaning/processing/outputting inside an R6 class**. +An object of this class is then initiated at the top level of your application, and you can pass this object to the sub-modules. +Of course, this makes even more sense if you are combining it with the trigger/watch pattern from before! + +```{r 15-common-app-caveats-9, eval = FALSE} +library(shiny) +data_cleaning_ui <- function(id){ + ns <- NS(id) + tagList( + # Defining the UI for your first module + # [...] + ) +} + +mod_data_cleaning_server <- function(id, r6){ + moduleServer( id, function(input, output, session){ + ns <- session$ns + observeEvent( input$launch_cleaning , { + # Once the launch_cleaning input is triggered, we + # use the internal method from our r6 object + r6$clean(arg1 = input$a, arg2 = input$b) + # Triggering the plot + trigger("plot") + }) + }) +} + +plotting_ui <- function(id){ + ns <- NS(id) + tagList( + # Defining the UI for your second module + # [...] + ) +} + +mod_plotting_server <- function(id, r6){ + moduleServer( id, function(input, output, session){ + ns <- session$ns + # Rendering, inside this second module, the plot based on the + # cleaning done in the other module + output$plot <- renderPlot({ + # We use the trigger/watch pattern from before + watch("plot") + # Calling the plot() method from our R6 object + r6$plot() + }) + }) +} + + +ui <- function(){ + tagList( + # Putting our two module UIs here + data_cleaning_ui("data_cleaning_ui"), + plotting_ui("plotting_ui") + ) +} + +server <- function( + input, + output, + session +){ + # We start by creating a new instance of th + r6 <- MyDataProcessing$new() + # Passing this object to the two server functions + mod_data_cleaning_server("data_cleaning_ui_1", r6) + mod_plotting_server("plotting_ui_1", r6) + +} + +shinyApp(ui, server) + +``` + +#### B. Be sure it is tested {.unnumbered} + +During the process of building a robust `{shiny}` app, we strongly suggest that you test as many things as you can. +This is where using an R6 for the business logic of your app makes sense: this allows you to build the whole testing of your application data logic outside of any reactive context: you simply build unit tests just as any other function. + +For example, let's say we have the following R6 generator: + +```{r 15-common-app-caveats-10} +MyData <- R6::R6Class( + "MyData", + # Defining our public methods, that will be + # the dataset container, and a summary function + public = list( + data = NULL, + initialize = function(data){ + self$data <- data + }, + summarize = function(){ + summary(self$data) + } + ) +) +``` + +We can then build a test for this class using `{testthat}`: + +```{r 15-common-app-caveats-11} +library(testthat, warn.conflicts = FALSE) +test_that("R6 Class works", { + # We define a new instance of this class, that will contain + # the mtcars data.frame + my_data <- MyData$new(mtcars) + # We will expect my_data to have two classes: + # "MyData" and "R6" + expect_is(my_data, "MyData") + expect_is(my_data, "R6") + # And the summarize method to return a table + expect_is(my_data$summarize(), "table") + # We would expect the data contained in the object + # to match the one taken as input to new() + expect_equal(my_data$data, mtcars) + # And the summarize method to be equal to the summary() + # on the input object + expect_equal(my_data$summarize(), summary(mtcars)) +}) +``` + +Using R6 allows to rely on these battle-tested tools when it comes to testing functions, something which is made more complex when using other patterns like `reactiveValues()`. + +### Logging reactivity with `{whereami}` + +Getting a good sense of how reactivity is actually working in your app is not an easy task: the reactivity logic is a graph, and it happens very quickly when you run the app, so it's very hard to follow everything. + +`whereami::whereami()`'s [@R-whereami] goal is simple: informing you about where it is called, i.e. from what file and at which line, and how many times. +For example, if you add the following piece of code to your `app_server()`, the location of the function call will be printed to the logs. + +```{r 15-common-app-caveats-12, eval = FALSE} +whereami::cat_where( whereami::whereami() ) +``` + + ── Running server(...) at app_server.R#9 (2) ─────────────── + +Combining `cat_where()` will implement a reactive logging to your console while developing: that way, you can instantaneously know what reactive contexts are invalidated while using the application. +Of course, you still have to implement it by hand, but that is definitely worth the effort: seeing in real time, in your console, which line is run allows you to detect unexpected behavior. +For example, you will be able to see that the `observeEvent()` from `mod_main.R#79` has been called 17 times when launching the app, which might be an unexpected behavior. + +The screenshot in Figure \@ref(fig:15-common-app-caveats-13) shows what a `{whereami}` log might look like, here, for the `{hexmake}` application. + +(ref:whereami) `{whereami}` output for `{hexmake}`. + +```{r 15-common-app-caveats-13, echo=FALSE, fig.cap="(ref:whereami)", out.width="100%"} +knitr::include_graphics("img/whereami.png") +``` + +And bonus, once the app is closed, you can get a list of all the "counters" with `whereami::counter_get()`, and how many times they each have been called, and `plot(whereami::counter_get())` will draw a raw plot of the various counters, as shown in Figure \@ref(fig:15-common-app-caveats-14). + +(ref:whereamiplot) plot of `{whereami}` counters. + +```{r 15-common-app-caveats-14, echo=FALSE, fig.cap="(ref:whereamiplot)", out.width="100%"} +knitr::include_graphics("img/plot_whereami.png") +``` + +\newpage + +## R does too much + +### Rendering the UI from the server side + +There are many reasons we would want to change things on the UI based on what happens in the server: changing the choices of a `selectInput()` based on the columns of a table which is uploaded by the user, showing and hiding pieces of the app according to an environment variable, allowing the user to create an indeterminate number of inputs, etc. + +Chances are that to do that, you have been using the `uiOutput()` and `renderUI()` functions from `{shiny}` [@R-shiny]. +Even if convenient, and the functions of choice in some specific context, this pair of functions makes R do a little bit too much: you are making R regenerate the whole UI component instead of changing only what you need, which can be a suboptimal, be it from the user point of view, or from a developer perspective. + +**One of the instance in which this pattern might not be optimal is in the case where your visitors do not have an high-speed internet or when visiting and using a smartphone, contexts where every byte counts**. +Rendering large elements from the server side in your `{shiny}` app means that these elements will have to transit through the socket, i.e. they need to be sent by the server, and downloaded by the browser. +In this case, the smaller the message size the better! + +From the developer perspective, you will create code that is harder to reason about, as we are used to having the UI parts in the UI functions (but that is not related to performance). + +Here are three strategies to code without `uiOutput()` and `renderUI()`. + +#### A. Implement UI events in JavaScript {.unnumbered} + +> Mixing languages is better than writing everything in one, if and only if using only that one is likely to overcomplicate the program.\ +> +> _The Art of UNIX Programming_ [@ericraymond2003] + +We will see in the last chapter of this book how you can integrate JS inside your `{shiny}` app, and how even basic functions can be useful for making your app server smaller. +For example, compare: + +```{r 15-common-app-caveats-15, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # Adding a button with an onclick event, + # that will show or hide the plot + actionButton( + "change", + "show/hide graph", + # The toggle() function hide or show the queried element + onclick = "$('#plot').toggle()" + ), + plotOutput("plot") + ) +} + +server <- function( + input, + output, + session +){ + output$plot <- renderPlot({ + # This renderPlot will only be called once + cli::cat_rule("Rendering plot") + plot(iris) + }) +} + +shinyApp(ui, server) +``` + +to + +```{r 15-common-app-caveats-16, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # We use a pattern without JavaScript + actionButton("change", "show/hide graph"), + plotOutput("plot") + ) +} + +server <- function( + input, + output, + session +){ + + output$plot <- renderPlot({ + # Here, every time the button is clicked, this reactive + # context will be invalidated, and the code re-evaluated + cli::cat_rule("Rendering plot") + # Simulate a show and hide pattern + req(input$change %% 2 == 0) + plot(iris) + }) + +} + +shinyApp(ui, server) +``` + +The result is the same, but the first version is shorter and easier to understand: we have one button, and the behavior of the button is self-contained. +The second solution redraws the plot every time the `reactiveValues` is updated, making R compute way more than it should, whereas with the JavaScript-only solution, the plot is not recomputed every time you need to show it: the plot is drawn by R only once. + +At a local level, the improvements described in this section will not make your application way faster: for example, rendering UI elements (let's say rendering a simple title) will not be computationally heavy. +But at a global level, less UI computation from the server side helps the general rendering of the app: let's say you have an output that takes 3 seconds to run, then if the whole UI + output is to be rendered on the server side, the whole UI stays blank until everything is computed. + +Compare: + +```{r 15-common-app-caveats-17, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # We make the whole UI be generated by R + uiOutput("caption") + ) +} + +server <- function( + input, + output, + session +){ + output$caption <- renderUI({ + # Simulate something that takes 3 seconds to run + Sys.sleep(3) + # Returning the UI + tagList( + h3("test"), + shinipsum::random_text(10) + ) + + }) +} + +shinyApp(ui, server) +``` + +to + +```{r 15-common-app-caveats-18, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # Only the text input will be rendered by R + h3("test"), + textOutput("caption") + ) +} + +server <- function( + input, + output, + session +){ + output$caption <- renderText({ + # Here, we only render the text, not the whole UI + Sys.sleep(3) + shinipsum::random_text(10) + }) +} + +shinyApp(ui, server) +``` + +In the first example, the UI will wait for the server to have rendered, while in the second we will first see the title, then the rendered text after a few seconds. +That approach makes the user experience better: they know that something is happening, while a completely blank page is confusing. + +Also, because R is single threaded, manipulating DOM elements from the server side causes R to be busy doing these DOM manipulations while it could be computing something else. +And let's imagine it takes a quarter of a second to render the DOM element. +That is a full second for rendering four of them, while R should be busy doing something else! + +#### B. `update*` inputs {.unnumbered} + +Almost every `{shiny}` input, even the custom ones from packages, come with an `update_` function that allows us to change the input values from the server side, instead of re-creating the UI entirely. +For example, here is a way to update the content of a `selectInput` from the server side: + +```{r 15-common-app-caveats-19, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # We start the selectInput empty + selectInput("species", "Species", choices = NULL), + # The selectInput will be populate + # when the update button is pressed + actionButton("update", "Update") + ) +} + +server <- function( + input, + output, + session +){ + observeEvent( input$update , { + # Update the selectInput with the species from iris + spc <- unique(iris$Species) + updateSelectInput( + session, + "species", + choices = spc, + selected = spc[1] + ) + }) + +} + +shinyApp(ui, server) +``` + +This switch to `updateSelectInput` makes the code easier to reason about as the `selectInput` is where it should be: inside the UI, instead of another pattern where we would use `renderUI()` and `uiOutput()`. +Plus, with the `update` method, we are only changing what is needed, not re-generating the whole input. + +#### C. `insertUI` and `removeUI` {.unnumbered} + +Another way to dynamically change what is in the UI is with `insertUI()` and `removeUI()`. +It is more global than the solution we have seen before with setting the `reactiveValue` to `NULL` or to a value, as it allows us to target a larger UI element: we can insert or remove the whole input, instead of having the DOM element inserted but empty. +This method allows us to have a smaller DOM: `<div>` that are not rendered are not generated empty, they are simply not there. + +Two things to note concerning this method, though: + +- Removing an element from the app will not delete the input from the input list. In other words, if you have `selectInput("x", "x")`, and you remove this input using `removeUI()`, you will still have `input$x` in the server. + +For example, in the following example, the `input$val` value will not be removed once you have called `removeUI(selector = "#val")`. + +```{r 15-common-app-caveats-20, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # Creating a text input that will be removed + # from the UI whenever the remove button is pressed + textInput("value", "Value", "place"), + actionButton("remove", "Remove UI") + ) +} + +server <- function( + input, + output, + session +){ + + observeEvent( input$remove , { + # When the button is pressed, + # the textInput will be removed from the UI + removeUI(selector = "#value") + }) + + observe({ + # We observe input$value every second. + # You'll realize that even after the UI + # is removed, input$value is still available. + invalidateLater(1000) + print(input$value) + }) + +} + +shinyApp(ui, server) +``` + +- Both these functions take a `jQuery` selector to select the element in the UI. We will introduce these selectors in Chapter \@ref(using-javascript). + +### Too much data in memory + +If you are building a `{shiny}` application, there is a great chance you are building it to analyze data. +If you are dealing with large datasets, **you should consider deporting the data handling and computation to an external database system: for example, to an SQL database**. +Why? +Because these systems have been created to handle and manipulate data on disk: in other words, it will allow you to perform operations on your data without having to clutter R memory with a large dataset. + +For example, if you have a `selectInput()` that is used to perform a filter on a dataset, you can do that filter straight inside SQL, instead of bringing all the data to R and then doing the filter. +That is even more necessary if you are building the app for a large number of users: for example if one `{shiny}` session takes up to 300MB, multiply that by the number of users that will need one session, and you will have a rough estimate of how much RAM you will need. +On the contrary, if you reduce the data manipulation so that it is done by the back-end, you will have, let's say, one database with 300MB of data, so the database size will remain (more or less constant), and the only RAM used by `{shiny}` will be the data manipulation, not the data storage. +That's even more true now that almost any operation you can do today in `{dplyr}` [@R-dplyr] would be doable with an SQL back-end, and that is the purpose of the `{dbplyr}` [@R-dbplyr] package: translates `{dplyr}` code into SQL. + +If using a database as a back-end seems a little bit far-fetched right now, that is how it is done in most programming languages: if you are building a web app with NodeJS or Python for example, and need to interact with data, nothing will be stored in RAM: you will be relying on an external database to store your data. +Then your application will be used to make queries to this database back-end. + +## Reading data + +`{shiny}` applications are a tool of choice when it comes to analyzing data. +But that also means that these data have to be imported/read at some point in time, and reading data can be time consuming. +How can we optimize that? +In this section, we will take a look at three strategies: including datasets inside your application, using R packages for fast data reading, and when and why you should move to an external database system. + +### Including data in your application + +If you are building your application using the `{golem}` [@R-golem] framework, you are building your application as a package. +R packages provide a way to include internal datasets, which can then be used as objects inside your app. +This is the solution you should go for if your data are never to rarely updated: the datasets are created during package development, then included inside the build of your package. +The plus side of this approach is that it makes the data fast to read, as they are serialized as R native objects. + +To include data inside your application, you can use the `usethis::use_data_raw( name = "my_dataset", open = FALSE )` command, which is inside the `02_dev.R` script inside the `dev/` folder of your source application (if you are building the app with `{golem}`). +This will create a folder called `data-raw` at the root of your application folder, with a script to prepare your dataset. +Here, you can read the data, modify it if necessary, and then save it with `usethis::use_data(my_dataset)`. +Once this is done, you will have access to the `my_dataset` object inside your application. + +This is, for example, what is done in the `{tidytuesday201942}` [@R-tidytuesday201942] application, in [data-raw/big\_epa\_cars.R](https://github.com/ColinFay/tidytuesday201942/blob/master/data-raw/big_epa_cars.R): the CSV data are read there, and then used as an internal dataset inside the application. + +### Reading external datasets + +Other applications use data that are not available at build time: they are created to analyze data that are uploaded by users, or maybe they are fetched from an external service while using the app (for example, by calling an API). +When you are building an application for the "user data" use case, the first thing you will need is to provide users a way to upload their dataset: `shiny::fileInput()`. + +One crucial thing to keep in mind when it comes to using user-uploaded files is that you have to be (very) strict with the way you handle files: + +- Always specify what type of file you want: `shiny::fileInput()` has an `accept` parameter that allows you to set one or more [MIME types](https://en.wikipedia.org/wiki/Media_type) or extensions. When using this argument (for example, with `text/csv`, `.csv`, or `.xslx`), the user will only be able to select a subset of files from their computer: the ones that match the type. +- Always perform checks once the file is uploaded, even more if it is tabular data: column type, naming, empty rows, etc. The more you check the file for potential errors, the less your application is likely to fail to analyze this uploaded dataset. +- If the data reading takes a while, do not forget to add a visual progression cue: a `shiny::withProgress()` or tools from the [`{waiter}`](https://github.com/JohnCoene/waiter) package. + +Whenever you offer a user the possibility to upload anything, you can be sure that at some point, they will upload a file that will make the app crash. +By setting a specific MIME type and by doing a series of checks once the file is uploaded, you will make your application more stable. +Finally, having a visual cue that "something is happening" is very important for the user experience, because "something is happening" is better than not knowing what is happening, and it may also prevent the user from clicking again and again on the upload button, or worse, they will stop using the app. + +Now that we have our `fileInput()` set, how do we read these data as fast as possible? +There are several options depending on the type of data you are reading. +Here are some packages that can make the file reading faster: + +- For a tabular, flat dataset (typically csv, tsv, or text), `{vroom}` [@R-vroom] can read data at a 1.40 GB/sec/sec speed. The `fread()` function from `{data.table}` [@R-data.table] is also fast at reading delimited files. +- For JSON files, `{jsonlite}` [@jsonlite2014]. Or more recently, `{RcppSimdJSON}` [@R-RcppSimdJson], which is a binding to the `simdjson` C++ library. +- If you need to read Excel files inside your app, `{readxl}` [@R-readxl] offers a binding to the [`RapidXML`](http://rapidxml.sourceforge.net/) C++ library, which reads Excel files fast. +- Most files exported from statistical software (SAS, SPSS, etc.) can be read using either the `{foreign}` [@R-foreign] or `{haven}` [@R-haven] packages. + +### Using external databases + +Another type of data analyzed in a shiny application is data that is contained inside an external database. +Databases are heavily used in the data science world and in software engineering as a whole. +Databases come with APIs and drivers that help retrieve and transfer data: be it SQL, NoSQL, or even a graph. + +Using a database is one of the solutions for making your app smaller and more efficient in the long run, especially if you need to scale your app to thousands of visitors. +Indeed, **if you plan on having your app scale to numerous people, that will mean that a lot of R processes will be triggered. And if your data is contained in your app, this will mean that each R process will take a significant amount of RAM if the dataset is large**. +For example, if your dataset alone takes \~300 MB of RAM, that means that if you want to launch the app 10 times, you will need \~3GB of RAM. +On the other hand, if you decide to switch these data to an external database, it will lower the global RAM need: the DB will take these 300MB of data, and each shiny application will make a request to the database. +For instance, if the database needs 300MB, and one shiny app 50MB, then 10 apps will be 300MB (for the DB) + 50MB \* 10 (for the 10 apps). +In practice, other things are to be considered: making database requests can be computationally expensive, and might need some network adjustments, but you get the idea. + +How does one choose between database back-end? +Well, first of all you need to see what is available in the environment the application will be deployed: maybe the company you are building the application for already has database servers deployed. +If ever you are free to choose any database as a back-end, your choice should be driven by what kind of operations you want to make on these databases. +**For example, SQL databases are designed to store tabular data, and they tend to be very fast when it comes to reading data: so if you have one or more large data.frames you want to use inside your application, and with no specific update of these data, an SQL back-end can be the perfect choice**. +On the other hand, a NoSQL database like MongoDB will be faster when it comes to doing write operations, and can store any kind of object: for example, `{hexmake}` can use a MongoDB back-end to store RDS files. +But that comes with a price: read calls are a little bit slower, and you might have to work a little bit more on handling the JSON results that come out of MongoDB. +Another example of an app that uses on an external database is `{databasedemo}`, available at [engineering-shiny.org/databasedemo/](https://engineering-shiny.org/databasedemo/). +Feel free to follow this link for more information about this application! + +Covering all the available types of databases and the packages associated with each is a very, very large topic: there are dozens of database systems, and as many (if not more) packages to interact with them. +For more extensive coverage of using databases in R, please follow these resources: + +- [Databases using R](https://db.rstudio.com/), the official RStudio documentation around databases and R. + +- [colinfay/r-db](https://colinfay.me/r-db/), a Docker image that bundles the toolchain for a lot of database systems for R. + +- [CRAN Task View: Databases with R](https://cran.r-project.org/web/views/Databases.html): the official task view from CRAN with a series of packages for database manipulation + +### Data-source checklist + +How to choose between these three methodologies: + +```{r 15-common-app-caveats-21, echo= FALSE} +knitr::kable( + data.frame( + Choice = c("Package data", "Reading files", "External DataBase"), + Update = c("Never to very rare", "Uploaded by Users", "Never to Streaming"), + Size = c("Low to medium", "Preferably low", "Low to Big") + ) +) +``` diff --git a/16-optimizing-shiny-code.Rmd b/16-optimizing-shiny-code.Rmd new file mode 100644 index 00000000..bc477dd9 --- /dev/null +++ b/16-optimizing-shiny-code.Rmd @@ -0,0 +1,814 @@ +# Optimizing `{shiny}` Code + +## Optimizing R code + +In its core, `{shiny}` runs R code on the server side. +To be efficient, the R code computing your values and returning results also has to be optimized. + +Optimizing R code is a very broad topic, and it would be possible to write a full book about it. +In fact, a lot of books and blog posts already cover this topic. +Instead of re-writing these books, we will try to point to some crucial resources you can refer to if you want to get started optimizing your R code. + +- Efficient R programming [@colingillespie2017], has a series of methods you can quickly put into practice for more efficient R code. + +- Advanced R [@hadleywickham2019] has a chapter about optimizing R code (number 24). + In the rest of this chapter, we will be focusing on how to optimize `{shiny}` specifically. + +## Caching elements + +### What is caching? + +Caching is the process of storing resources-intensive results so that when they are needed again, your program can reuse the result another time without having to redo the computation again. +This is particularly useful for computation that will always return the same result, and should never be used if you expect the result could vary from one function call to the other. + +How does it work? +Let's make a brief parallel with the human brain, and imagine that you know that you will need to use a phone number many times during the day, and for the purpose of this thought experiment, you are completely unable to remember it.[^optimizing-shiny-code-1] +What are you going to do? +There are two solutions here: either you look in the phone book or in your phone contact list every time you need it, which takes a couple of seconds every time, or you use a post-it that you put on your computer screen with the number on it, so that you have direct access to it when you need it. +It takes a couple of seconds the first time you look for the number, but it is almost instantaneous the next times you need it. + +[^optimizing-shiny-code-1]: Anyway, now that we all have smartphones, who still remembers phone numbers? + +This is what caching does: **it stores the result of an expensive computation, so that the next time you need the very same information again, you can read the result instead of redoing the full computation**. +The downside is that you only have limited space on your screen: when your screen is covered by sticky notes, you cannot store any more notes.[^optimizing-shiny-code-2] + +[^optimizing-shiny-code-2]: In that case, you can either hide pre-existing sticky notes, or buy a bigger screen. + But we are not here to talk about cache management theory. + If you are interested in reading more about caching theory, we suggest the excellent *Algorithms to Live By*, by Brian Christian and Tom Griffiths [@brianchristian2016]. + +In the context of an interactive application built with `{shiny}`, it makes sense to cache data structures: users tend to repeat what they do, or go back and forth between parameters. +For example, if you have a graph which is taking 2 seconds to render (which is quite common in `{shiny}`, notably when relying on `{ggplot2}` [@R-ggplot2]), you do not want these 2 seconds to be repeated over and over again when users switch from one parameter to another. +In that case, it does make sense to cache the result: if you call `ploting_function(input$selection)` twice with the same value for `input$selection`, and you are sure that this plot will be the same every time, you can cache it. +In other words, instead of recomputing the graph on each `input$selection` change, you can cache the plot the first time it is generated, and then the application will read the cache instead of re-doing the computation. + +Same goes for queries to a database: if a query is done with the same parameters, and you know that they will return the same result, there is no need to ask the database again and again—ask the cache to retrieve the data. + +Keep in mind that this caching mechanism is only to be used when **the data don't change**. +For example, if you are calling a database which is updated on a regular basis, you might not want to cache the results of a function. +In that specific case, you will want the query to be performed every time the function is called, so that you get fresh data. + +### Native caching in R + +At least two packages in R implement caching of functions (also called memoization): `{R.cache}` [@R-R.cache], and `{memoise}` [@R-memoise]. +They both more or less work the same way: you will call a memoization function on another function, and cache is created for this function output, based on the arguments value. +Then every time you call this function again with the same parameters, the cache is returned instead of computing the function another time. +For example, if computing your data once takes 5 seconds with the parameter `n = 50`, the next time you will be calling this function with `n = 50`, instead of recomputing, R will go and fetch the value stored in cache. + +Here is a simple example with `{memoise}`: + +```{r 16-optimizing-shiny-code-1 } +library(memoise) +library(tictoc) +# We define a function that sleeps for a given number of seconds, +# then return the time +sleep_and_return_time <- function(seconds = 1){ + Sys.sleep(seconds) + return(Sys.time()) +} +# "Memoising" this function +msleep_and_return_time <- memoise(sleep_and_return_time) +# We use the {tictoc} package to count the time to run the code +tic() +# This will sleeep for 2 seconds and return the time +msleep_and_return_time(2) +# The code should have taken around 2 seconds to run +toc() +# We launch a new recording +tic() +# This memoised function will return immediately, +# without sleeping +msleep_and_return_time(2) +toc() +``` + +Let's try with another example that might look more like what we can find in a `{shiny}` app: connecting to a database, using the `{DBI}` [@R-DBI] and `{RSQLite}` [@R-RSQLite] packages: + +```{r 16-optimizing-shiny-code-2, eval = FALSE} +# We create an in-memory database using SQLite +con <- DBI::dbConnect( + RSQLite::SQLite(), + dbname = ":memory:" +) + +# Writing a large dataset to the db +DBI::dbWriteTable( + con, + "diams", + # This table will have 539400 rows + dplyr::bind_rows( + purrr::rerun(10, ggplot2::diamonds) + ) +) + +# We memoise the dbGetQuery, +# so that every time this function is called with +# the same parameters, +# the SQL query is not actually run, +# but the results are fetched from the cache +m_get_query <- memoise(DBI::dbGetQuery) +# We call a function the first time, +# with the connection object and an SQL query +tic() +res_a <- m_get_query( + con, + "SELECT * FROM diams WHERE cut = 'Ideal'" +) +toc() + +``` + +``` +1.251 sec elapsed +``` + +```{r eval = FALSE} +# We call this function a second time, +# with the same parameters +tic() +res_b <- m_get_query( + con, + "SELECT * FROM diams WHERE cut = 'Ideal'" +) +toc() +``` + +``` +0.005 sec elapsed +``` + +```{r eval = FALSE} +# Let's check that the two are equal +setequal(res_a, res_b) +``` + +``` +[1] TRUE +``` + +```{r eval = FALSE} +# We now try with a new SQL code (cut = 'Good') +tic() +res_c <- m_get_query( + con, + "SELECT * FROM diams WHERE cut = 'Good'" +) +toc() +``` + +``` +0.384 sec elapsed +``` + +```{r eval = FALSE} +# The function has effectively returned a different result +setequal(res_a, res_c) +``` + +``` +[1] FALSE +``` + +Note that you can change where the cache is stored by `{memoise}`. +Here, we will save it in a random directory (do not do this in production). + +```{r 16-optimizing-shiny-code-3, error = TRUE, eval = FALSE} +random_dir <- fs::path( + paste( + sample( + letters, + 10 + ), + collapse = "" + ) +) +random_dir +``` + +``` +xawcubtjzp +``` + +```{r 16-optimizing-shiny-code-4, include = FALSE, error = TRUE} +try(fs::dir_delete(random_dir)) +``` + +```{r 16-optimizing-shiny-code-5, error = TRUE, eval = FALSE, eval = FALSE} +# We create a directory in the current working directory +fs::dir_create(random_dir) +# We use this directory as the cache_filesystem for {memoise} +local_cache_folder <- cache_filesystem(random_dir) +# The memoised function will use this directory for cache +m_get_query <- memoise( + DBI::dbGetQuery, + cache = local_cache_folder +) +# Run the function twice +res_a <- m_get_query( + con, + "SELECT * FROM diams WHERE cut = 'Ideal'" +) +res_b <- m_get_query( + con, + "SELECT * FROM diams WHERE cut = 'Good'" +) +res_c <- m_get_query( + con, + "SELECT * FROM diams WHERE cut = 'Good'" +) +# The random directory now contains two objects, +# one for each memoized call +fs::dir_tree(random_dir) +``` + + +```{r echo = FALSE} +dir.create("xawcubtjzp") +fs::file_create("xawcubtjzp/3764a0a4950cb30b") +fs::file_create("xawcubtjzp/c295e060ea7d77c1") +fs::dir_tree("xawcubtjzp") +fs::dir_delete("xawcubtjzp") +``` + + +As you can see, we now have two cache objects inside the directory we have specified as a `cache_filesystem`. + +```{r 16-optimizing-shiny-code-6, include = FALSE, error = TRUE} +try(fs::dir_delete(random_dir)) +#try(fs::dir_delete("cache")) +``` + +### Caching in `{shiny}` + +We can apply what we have just seen with `{memoise}`, for example, to render a table: + +```{r 16-optimizing-shiny-code-8, eval=TRUE} +library(memoise) +# We create an in-memory database using SQLite +con <- DBI::dbConnect( + RSQLite::SQLite(), + dbname = ":memory:" +) + +# Writing a large dataset to the db +DBI::dbWriteTable( + con, + "diams", + # This table will have 539400 rows + dplyr::bind_rows( + purrr::rerun(10, ggplot2::diamonds) + ) +) + + +fct_sql <- function(cut, con){ + # NEVER EVER SPRINTF AN SQL CODE LIKE THAT + # IT'S SENSITIVE TO SQL INJECTIONS, WE'RE + # DOING IT FOR THE EXAMPLE + cli::cat_rule("Calling the SQL db") + results <- DBI::dbGetQuery( + con, sprintf( + "SELECT * FROM diams WHERE cut = '%s'", + cut + ) + ) + head(results) +} + +# Using a local cache +cache_dir <- cache_filesystem("cache") +memoised_fct_sql <- memoise(fct_sql, cache = cache_dir) +``` + +Then, it can be used in an app: + +```{r 16-optimizing-shiny-code-8-bis, eval=FALSE} +library(shiny) +ui <- function(){ + tagList( + # The user can select one of the cut from ggplot2::diamonds, + # {shiny} will then query the SQL database to retrieve the + # first rows of the result + selectInput("cut", "cut", unique(ggplot2::diamonds$cut)), + tableOutput("tbl") + ) +} + +server <- function( + input, + output, + session +){ + + # Rendering the table of the SQL call + output$tbl <- renderTable({ + # Using a memoised function allows to prevent from + # calling the SQL database every time the user inputs + # a change + memoised_fct_sql(input$cut, con) + }) + +} + +shinyApp(ui, server) +``` + +You will see that the first time you run this piece of code, it will take a couple of seconds to render the table for a new `input$cut` value. +But if you re-select this input a second time, the output will show instantaneously. + +Since version `1.6.0`, `{shiny}` [@R-shiny] has two caching functions: `renderCachedPlot()` and `bindCache()` +(note that `renderCachedPlot()` is in `{shiny}` since version `1.2.0`). + +`renderCachedPlot()` behaves more or less like the `renderPlot()` function, except that it is tailored for caching. +The extra arguments you will find are `cacheKeyExpr` and `sizePolicy`: the former is the list of inputs and values that allow you to cache the plot—every time these values and inputs are the same, they produce the same graph, so `{shiny}` will be fetching inside the cache instead of computing the value another time. +`sizePolicy` is a function that returns a `width` and a `height`, which are used to round the plot dimension in pixels, so that not every pixel combination is generated in the cache. + +The good news is that converting existing `renderPlot()` functions to `renderCachedPlot()` is pretty straightforward in most cases: take your current `renderPlot()`, and add the cache keys.[^optimizing-shiny-code-3] + +[^optimizing-shiny-code-3]: In some cases you will have to configure the size policy, but in most cases the default values work just well. + +Here is an example: + +```{r 16-optimizing-shiny-code-7, eval=FALSE} +library(shiny) +ui <- function(){ + tagList( + # We select a data.frame to plot + selectInput( + "tbl", + "Table", + c("iris", "mtcars", "airquality") + ), + # This plotOutput will be cached + plotOutput("plot") + ) +} + +server <- function( + input, + output, + session +){ + + # The cache mechanism is made available by renderCachedPlot + output$plot <- renderCachedPlot({ + # Plotting the selected data.frame + plot( get(input$tbl) ) + }, cacheKeyExpr = { + # List here all the reactive expression that will + # be used as cache key when running the app, + # you will see that the first time you plot one + # graph, it takes a couple of seconds, + # but the second time, it's almost + # instantaneous + input$tbl + }) + +} + +shinyApp(ui, server) +``` + +If you try this app, the first rendering of the three plots will take a little bit of time, but every subsequent rendering of the plot is almost instantaneous. + +`bindCache()`, a new function from version `1.6.0`, offers a more general approach, as it can cache any reactive expression. + +```{r 16-optimizing-shiny-code-7bis, eval=FALSE} +library(shiny) +ui <- function(){ + tagList( + # Select a number of row to sample from mtcars + sliderInput( + "nrows", + "Number of rows", + 1, + nrow(mtcars), + 10 + ), + tableOutput("tbl") + ) +} + +server <- function( + input, + output, + session +){ + + # The random sample will always be the same + # Whenever input$nrows is the same + output$tbl <- renderTable({ + dplyr::sample_n(mtcars, input$nrows) + }) %>% + bindCache({ + input$nrows + }) +} + +shinyApp(ui, server) +``` + +**Caching is a nice way to make your app faster, even more if you expect your output to be stable over time: if the plot created by a series of inputs stays the same throughout your app lifecycle, it is worth thinking about implementing on-disk caching**. +With `{memoise}`, you can also use remote caching, in the form of Amazon S3 storage or with Google Cloud Storage. +See also the [{bank}](https://github.com/ThinkR-open/bank) package for database caching of `{shiny}` expressions. + +If your application needs "fresh" data every time it is used, for example because data in the SQL database are updated every hour, cache will not be of much help here. +On the contrary, the same function inputs will render different output depending on when they are called. + +One other thing to remember is that, just like our computer screen from our phone number example from before, you do not have unlimited space when it comes to cache storage: storing a large amount of cache will take space on your disk. + +For example, from our stored cache from before: + +```{r 16-optimizing-shiny-code-9 } +dir_i <- fs::dir_info("cache")[, "size", drop = FALSE] +head(dir_i) +``` + +Managing cache at a system level is a very vast, fascinating topic that we cannot cover here, but note that the most commonly accepted rule for deleting cache is called **LRU**, for **Least Recently Used**. +The underlying principle of this approach is that users tend to need what they have needed recently: hence the more a piece of data has been used recently, the more likely it is that it will be needed soon. +And this can be retrieved with: + +```{r 16-optimizing-shiny-code-10 } +dir_at <- fs::dir_info("cache")[, "access_time", drop = FALSE] +head(dir_at) +``` + +Hence, when using cache, it might be interesting to periodically remove the oldest used cache, so that you can regain some space on the server running the application. + +## Asynchronous in `{shiny}` + +One of the drawbacks of `{shiny}` is that as it is running on top of R, it is single threaded, meaning that each computation is run in sequence, one after the other. +Well, at least natively, as methods have emerged to run pieces of code in parallel. + +### How to + +To launch code blocks in parallel, we will use a combination of two packages, `{future}` [@R-future] and `{promises}` [@R-promises], and a `reactiveValue()`. +`{future}` is an R package whose main purpose is to allow users to send code to be run elsewhere, i.e. in another session, thread, or even on another machine. +`{promises}`, on the other hand, is a package providing structure for handling asynchronous programming in R.[^optimizing-shiny-code-4] + +[^optimizing-shiny-code-4]: If you are familiar with promises in JavaScript, `{promises}` is an implementation of this structure into R. + +#### A. Asynchronous for cross-sessions availability {.unnumbered} + +The first type of asynchronous programming in `{shiny}` **allows non-blocking programming in a cross-session context**. +In other words, it is a programming method which is useful in the context of running one `{shiny}` session that is accessed by multiple users. +Natively, in `{shiny}`, if *user1* launches a 15-seconds computation, then *user2* has to wait for this computation to finish before launching their own 15-seconds computation, and *user3* has to wait the 15 seconds of *user1* plus the 15 seconds for user, etc. + +With `{future}` and `{promises}`, each long computation is sent to be run somewhere else, so when *user1* launches their 15-seconds computation, they are not blocking the R process for *user2* and *user3*. + +How does it work?[^optimizing-shiny-code-5] +`{promises}` comes with two operators which will be useful in our case, `%...>%` and `%...!%`: the first being "what happens when the `future()` is solved?" (i.e. when the computation from the `future()` is completed), and the second is "what happens if the `future()` fails?" (i.e. what to do when the `future()` returns an error). + +[^optimizing-shiny-code-5]: We're providing a short introduction with key concepts, but for a more thorough introduction, please refer to the [online documentation](https://rstudio.github.io/promises/index.html). + +Here is an example of using this skeleton: + +```{r 16-optimizing-shiny-code-11, eval = FALSE} +library(future) +library(promises) +# We're opening several R session (future specific) +plan(multisession) +# We send our code to be run in another session +future({ + Sys.sleep(3) + return(rnorm(5)) +}) %...>% ( + # When the code is returned, we print the result + function(result){ + print(result) + } +) %...!% ( + # If ever the code from the future() returns an error, + # we throw an error to the console + function(error){ + stop(error) + } +) +``` + +If you run this in your console, you will see that you have access to the R console directly after launching the code. +And a couple of seconds later (a little bit more than 3), the result of the `rnorm(5)` will be printed to the console. + +Note that you can also write a one-line function with `.` as a parameter, instead of building the full anonymous function (we will use this notation in the rest of the chapter): + +```{r 16-optimizing-shiny-code-12, eval=FALSE} +library(future) +library(promises) +plan(multisession) +# Same code as before, using the anonymous notation +future({ + Sys.sleep(15) + return(rnorm(5)) +}) %...>% + print(.) %...!% + stop(.) +``` + +Let's port this to `{shiny}`: + +```{r 16-optimizing-shiny-code-13, eval = FALSE} +library(shiny) +library(future) +library(promises) +plan(multisession) +ui <- function(){ + tagList( + # This will receive the output of the future + verbatimTextOutput("rnorm") + ) +} + +server <- function( + input, + output, + session +){ + output$rnorm <- renderPrint({ + # Sending the rnorm to be run in another session + future({ + Sys.sleep(3) + return(rnorm(5)) + }) %...>% + print(.) %...!% + stop(.) + }) +} + +shinyApp(ui, server) +``` + +If you have run this, it does not seem like a revolution: but trust us, the `Sys.sleep()` is not blocking as it allows other users to launch the same computation at the same moment. + +#### B. Inner-session asynchronicity {.unnumbered} + +In the previous section, we implemented cross-session asynchronicity, meaning that the code is non-blocking, but **when two or more users access the same app: the code is still blocking at an inner-session level**. +In other words, the code in the `renderPrint()` will still block the rest of the app for a single user. + +Let's have a look at this code: + +```{r 16-optimizing-shiny-code-14, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + # This will receive the output of the future + verbatimTextOutput("rnorm"), + # This plot will only be drawn when the future + # is resolved + plotOutput("plot") + ) +} + +server <- function( + input, + output, + session +){ + output$rnorm <- renderPrint({ + # Sending the rnorm to be run in another session + # At this point, {shiny} is waiting for the future + # to be solved before doing anything else + future({ + Sys.sleep(3) + return(rnorm(5)) + }) %...>% + print(.) %...!% + stop(.) + }) + + # This plot will only be drawn once the future is resolved + output$plot <- renderPlot({ + plot(iris) + }) +} + +shinyApp(ui, server) +``` + +Here, you would expect the plot to be available before the `rnorm()`, but it is not: `{promises}` is still blocking at an inner-session level, so elements are still rendered sequentially. +To bypass that, we will need to use a `reactiveValue()` structure. + +```{r 16-optimizing-shiny-code-15, eval = FALSE} +library(shiny) +library(promises) +library(future) +plan(multisession) + +ui <- function(){ + tagList( + # This will receive the output of the future + verbatimTextOutput("rnorm"), + # This plot will be drawn before the future is resolved + plotOutput("plot") + ) +} +server <- function( + input, + output, + session +) { + + # Initiating a reactiveValues that will receive the + # results from the future + rv <- reactiveValues( + output = NULL + ) + + future({ + Sys.sleep(5) + rnorm(5) + }) %...>% + # When the future is resolved, we assign the + # output to rv$output + (function(result){ + rv$output <- result + }) %...!% + # If ever the future outputs an error, we switch + # back to NULL for rv$output, and throw a warning + # with the error + (function(error){ + rv$output <- NULL + warning(error) + }) + + # output$rnorm will be printed whenever rv$output + # is available (i.e. after around 5 seconds) + output$rnorm <- renderPrint({ + req(rv$output) + }) + + # output$plot will be drawn immediately + output$plot <- renderPlot({ + plot(iris) + }) +} + +shinyApp(ui, server) +``` + +Let's detail this code step-by-step: + +- `rv <- reactiveValues` creates a `reactiveValue()` that will contain `NULL`, and which will serve the content of `renderPrint()` when the `future()` is resolved. + It is initiated as `NULL` so that the `renderPrint()` is silent at launch. + +- `%...>% rv$output <- result %...!%` is the `{promises}` structure we have seen before. + +- `%...!% (function(error){ rv$output <- NULL ; warning(e) })` is what happens when the `future({})` fails: we are setting the `rv$res` value back to `NULL` so that the `renderPrint()` does not fail and prints an error in case of failure. + +#### C. Potential pitfalls of asynchronous `{shiny}` {.unnumbered} + +There is one thing to be aware of if you plan on using this async methodology: you are not in a sequential context anymore. +Hence, the first `future({})` you will send is not necessarily the first you will get back. +For example, if you send SQL requests to be run asynchronously and each call takes between 1 and 10 seconds to return, there is a chance that the first request to return will be the last one you sent. +To handle that, we can adopt two different strategies, depending on what we need: + +- We need only the last expression sent. In other words, if we send three expressions to be evaluated somewhere, we only need to get back the last one. + To handle that, the best way is to have an id that is also sent to the future, and when the future comes back, we check that this id is the one we are expecting. If it is, we update the `reactiveValues()`. If it is not, we ignore it. + +```{r 16-optimizing-shiny-code-16, eval = FALSE} +library(shiny) +library(promises) +library(future) +plan(multisession) + +ui <- function(){ + tagList( + # This button trigger a future, we can click several times + # on it when the app is running + actionButton("go", "go"), + # This will receive the output of the future + verbatimTextOutput("rnorm"), + # This plot will be drawn before the future is resolved + plotOutput("plot") + ) +} + +server <- function( + input, + output, + session +) { + + # In our reactiveValues, we also keep track + # of the latest sent id + rv <- reactiveValues( + res = NULL, + last_id = 0 + ) + + + observeEvent( input$go , { + # When the user clicks on the button, the last_id + # is incremented of one + rv$last_id <- rv$last_id + 1 + last_id <- rv$last_id + + # We send the code to be run in the future. One out of + # two calls will sleep for 3 seconds + future({ + if (last_id %% 2 == 0){ + Sys.sleep(3) + } + # We return from the future the id of the current + # code block + list( + id = last_id, + res = rnorm(5) + ) + }) %...>% + (function(result){ + # Printing to the console which future + # we are coming from + cli::cat_rule( + sprintf("Back from %s", result$id) + ) + # Change the value of `rv$res` only if + # the current id is the same as the last_id + if (result$id == rv$last_id){ + rv$res <- result$res + } + }) %...!% + (function(error){ + warning(error) + }) + # Note that every render() function should return + # something: here it will only work if the + # renderPrint() returns a value, even if + # invisible. We use cat_rule to simulate that. + cli::cat_rule( + sprintf("%s sent", rv$last_id) + ) + }) + + # output$rnorm will be printed whenever rv$output + # is available, i.e. returned from the future + # and the last one sent. + output$rnorm <- renderPrint({ + req(rv$res) + }) + + # output$plot will be drawn immediately + output$plot <- renderPlot({ + plot(iris) + }) +} + +shinyApp(ui, server) +``` + +- We need to treat the outputs in the order they are received. In that case, instead of waiting for the very last input, you will need to build a structure that will receive the output, check if this output is the "next in line", store it if it is not, or return it if it is, and see if there is another output in the queue. This type of implementation is a little bit more complex, so we will not detail a full implementation in this chapter, but here is a small example of using `{liteq}` [@R-liteq]. + +```{r 16-optimizing-shiny-code-17, eval = FALSE} +library(promises) +library(future) +plan(multisession) + +library(liteq) +# We create a small db in a tempfile() +temp_queue <- tempfile() +queue <- ensure_queue("jobs", db = temp_queue) +for (i in 1:5){ + future({ + # Faking a random computation time + Sys.sleep( sample(1:5, 1) ) + return( + list( + id = i, + res = rnorm(5) + ) + ) + }) %...>% + # Whenever we receive an output, we add it to + # the queue database + (function(results){ + publish( + queue, + title = as.character(results$i), + message = paste( + results$res, + collapse = "," + ) + ) + }) %...!% + # If ever we have an error, we return it as a warning + warning(.) +} +Sys.sleep(10) +# List the messages. As you can see, the entries in title +# are not in numerical order because they didn't came back +# in the same order as they were sent +list_messages(queue) +``` + +``` + id title status +1 1 3 READY +2 2 4 READY +3 3 2 READY +4 4 1 READY +5 5 5 READY +``` + +For an example of an application built using `{promise}` and `{future}`, feel free to browse [engineering-shiny.org/shinyfuture/](https://engineering-shiny.org/shinyfuture/): there you will find an example of blocking and non-blocking processes. + +```{r 16-optimizing-shiny-code-18, include = FALSE, error = TRUE} +try(fs::dir_delete(tpd)) +``` diff --git a/17-javascript.Rmd b/17-javascript.Rmd new file mode 100644 index 00000000..2222e923 --- /dev/null +++ b/17-javascript.Rmd @@ -0,0 +1,784 @@ +# Using JavaScript {#using-javascript} + +```{r 17-javascript-1, include = FALSE} +knitr::opts_chunk$set( comment = "", eval = FALSE) +``` + +#### Prelude {.unnumbered} + +Note you can build a successful, production-grade `{shiny}`[@R-shiny] application without ever writing a single line of JavaScript code. +Even more when you can use a lot of tools that already bundle JavaScript functionalities: a great example of that being `{shinyjs}` [@R-shinyjs], which allows you to interact with your application using JavaScript, without writing a single line of JavaScript. + +We chose to include this chapter in this book as it will help you get a better understanding on how `{shiny}` works at its core, and show you that getting at ease with JavaScript can help you get better at building web applications using R in the long run. +It can also help you extend `{shiny}` with other JavaScript libraries, for example, using `{htmlwidgets}` [@R-htmlwidgets], when you get better at writing JavaScript. + +That being said, note also that every inclusion of external JavaScript code or library can present a security risk for your application, so don't include code you don't know/understand in your application unless you are sure of what you are doing. +As a rule of thumb, always go for an existing and tested solution when you need JavaScript widgets/functionalities, instead of trying to implement them yourself. + +## Introduction + +At its core, **building a `{shiny}` app is building a JavaScript application** that can talk with an R session. +This process is invisible to most `{shiny}` developers, who usually do everything in R. +In fact, most of the `{shiny}` apps out there are 100% written with R. + +In fact, when you are writing UI elements in `{shiny}`, **what you are actually doing is building a series of HTML tags**. + +For example, this simple `{shiny}` [@R-shiny] code returns a series of HTML tags: + +``` {.r} +fluidPage( + h2("hey"), + textInput("act", "Ipt") +) +``` + +``` {.html} +<div class="container-fluid"> + <h2>hey</h2> + <div class="form-group shiny-input-container"> + <label class="control-label" for="act">Ipt</label> + <input id="act" type="text" class="form-control" value=""/> + </div> +</div> +``` + +Later on, when the app is launched, `{shiny}` binds events to UI elements, and these JavaScript events will communicate with R, in the sense that they will send data to R, and receive data from R. +What happens under the hood is a little bit complex and out of scope for this book, but the general idea is that R talks to your browser through a web socket (that you can imagine as a small "phone line" with both software listening at each end, both being able to send messages to the other),[^javascript-1] and this browser talks to R through the same web socket. + +[^javascript-1]: See this post on dev.to <https://dev.to/buzzingbuzzer/comment/g0g> for a quick introduction to the general concept of web sockets. + +Most of the time, when the JavaScript side of the websocket receives one of these events, the page the user sees is modified (for example, a plot is drawn). +On the R end of the websocket, i.e. when R receives data from the web page, a value is fetched, and something is computed. + +It's important to note here that the **communication happens in both directions**: from R to JavaScript, and from JavaScript to R. +In fact, when we write a piece of code like `sliderInput("first_input", "Select a number", 1, 10, 5)`, what we are doing is creating a binding between JavaScript and R, where the JavaScript runtime (in the browser) listens to any event happening on the slider with the id `"plop"`, and whenever it detects that something happens to this element, something (most of the time its value) is sent back to R, and R does computation based on that value. +With `output$bla <- renderPlot({})`, what we are doing is making the two communicate the other way around: we are telling JavaScript to listen to any incoming data from R for the `id` `"bla"`, and whenever JavaScript sees incoming data from R, it puts it into the proper HTML tag (here, JavaScript inserts the image received from R in the `<img>` tags with the id `bla`). + +Even if everything is written in R, we **are** writing a web application, i.e.. +HTML, CSS and JavaScript elements. +Once you have realized that, the possibilities are endless: in fact almost anything doable in a "classic" web app can be done in `{shiny}` with a little bit of tweaking. +What this also implies is that getting (even a little bit) better at writing HTML, CSS, and especially JavaScript will make your app better, smaller, and more user-friendly, as JavaScript is a language that has been designed to interact with a web page: change element appearances, hide and show things, click somewhere, show alerts and prompts, etc. +**Knowing just enough JavaScript can improve the quality of your app**: especially when you have been using R to render some complex UIs: think conditional panels, simulating a button click from the server, hide and show elements, etc. +All these things are good examples of where you should be using JavaScript instead of building more or less complex `renderUI` or `insertUI` patterns in your server. + +Moreover, the number of JavaScript libraries available on the web is tremendous; and the good news is that `{shiny}` has everything it needs to bundle external JavaScript libraries inside your application.[^javascript-2] + +[^javascript-2]: This can also be done by wrapping a JS libraries inside a package, which will later be used inside an application. + See for example `{glouton}` [@R-glouton], which is a wrapper around the [`js-cookie` >https://github.com/js-cookie/js-cookie> JavaScript library. + +This is what this section of the book aims at: giving you just enough JavaScript knowledge to lighten your `{shiny}` app, in order to improve the global user and developer experience. +In this chapter, we will first review some JavaScript basics which can be used "client-side" only, i.e. only in your browser. +Then, we will talk about making R and JS communicate with each other, and explore some common patterns for JavaScript in `{shiny}`. +Finally, we will quickly present some of the functions available in `{golem}` [@R-golem] that can be used to launch JavaScript. + +*Note that this chapter does not try to be a comprehensive JavaScript course. External resources are linked all throughout this chapter and at the end.* + +\newpage + +## A quick introduction to JavaScript + +### About JavaScript + +JavaScript is a programming language which has been designed to work in the browser.[^javascript-3] +To play with a JavaScript console, the fastest way is to open your favorite web browser, and to open the developer tools. +In Google Chrome, it's available under View \> Developer \> Developer Tools. +This will open a new interface where you can have access to a JavaScript console under the Console tab. +Here, you can try your first JavaScript code! +For example, you can try running `var message = "Hello world"; alert(message);`. + +[^javascript-3]: You can now work with JavaScript in a server with Node.JS, but this won't be a useful software when working with `{shiny}`. + See linked resources to learn more. + +As you might have guessed, we will not be focusing on playing with JavaScript in your browser console: what we want to know is how to insert JavaScript code inside a `{shiny}` application. + +### Including JavaScript code in your app + +There are three ways to include the JavaScript code inside your web app: + +- As an external file, which is served to the browser alongside your main application page +- Inside a `<script>` HTML tag inside your page +- Inline, on a specific tag, for example by adding an `onclick` event straight on a tag + +*Note that good practice when it comes to including JavaScript is to add the code inside an external file.* + +If you are working with `{golem}`, including a JavaScript file is achieved via two functions: + +- `golem::add_js_file("name")`, which adds a standard JavaScript file, i.e. one which is not meant to be used to communicate with R. We'll see in the first part of this chapter how to add JavaScript code there. +- `golem::add_js_handler("name")`, which creates a file with a skeleton for `{shiny}` handlers. We'll see this second type of element in the `JavaScript <-> R communication` part. +- `golem::add_js_binding("name")`, for more advanced use cases, when you want to build your own custom inputs, i.e. when you want to create a custom HTML element that can be used to interact with `{shiny}`. See [shiny.rstudio.com/articles/js-custom-input.html](https://shiny.rstudio.com/articles/js-custom-input.html) for more information about how to complete this skeleton. + +OK, good, but what do we do now? +Note that in this chapter, we will not be covering basic JavaScript object and manipulation. +Feel free to refer to the first chapter of [JavaScript 4 `{shiny}` - Field Notes](http://connect.thinkr.fr/js4shinyfieldnotes/intro.html) for a detailed introduction to objects and object manipulation, or follow one of the resources linked at the end of this chapter. + +### Understanding HTML, class, and id + +You have to think of a web page as a tree, where the top of the web page is the root node, and every element in the page is a node in this tree (this tree is called a DOM, for Document Object Model). +**You can work on any of these HTML nodes with JavaScript**: modify it, bind events to it and/or listen to events, hide and show, etc. +But first, **you have to find a way to identify these elements**: either as a group of elements or as a unique element inside the whole tree. +That is what HTML semantic elements, classes, and ids are made for. +Consider this piece of code: + +```{r 17-javascript-2, echo = TRUE, eval = FALSE} +library(shiny) +fluidPage( + titlePanel("Hello Shiny"), + textInput("act", "Ipt") +) +``` + +``` {.html} +<div class="container-fluid"> + <h2>Hello Shiny</h2> + <div class="form-group shiny-input-container"> + <label class="control-label" for="act">Ipt</label> + <input id="act" type="text" class="form-control" value=""/> + </div> +</div> +``` + +This `{shiny}` code creates a piece of HTML code containing three nodes: a `div` with a specific class (a Bootstrap container), an `h2`, which is a level-two header, and a button which has an id and a class. +Both are included in the `div`. +Let's detail what we have here: + +- HTML tags, which are the building blocks of the "tree": here `div`, `h2` and `button` are HTML tags. +- The button has an `id`, which is short for "identifier". Note that this id has to be unique: the id of an element allows you to refer to this exact element. In the context of `{shiny}`, it allows JavaScript and R to talk to each other. For example, if you are rendering a plot, you have to be sure it is rendered at the correct spot in the UI, hence the need for a unique id in `renderPlot()`. Same goes for your inputs: if you are computing a value based on an input value, you have to be sure that this value is the correct one. +- Elements can have a class which can apply to multiple elements. This can be used in JavaScript, but it is also very useful for styling elements in CSS. + +### Querying in Vanilla JavaScript + +In "Vanilla" JavaScript (i.e. without any external plugins installed), you can query these elements using methods from the `document` object. +For example: + +``` {.javascript} +// Given +<div id = "first" name="number" class = "widediv">Hey</div> + +// Query with the ID +document.querySelector("#first") +document.getElementById("first") + +// With the class +document.querySelectorAll(".widediv") +document.getElementsByClassName("widediv") + +// With the name attribute +document.getElementsByName("number") + +// Using the tag name +document.getElementsByTagName("div") +``` + +Note that some of these methods have been introduced with ES6, which is a version of JavaScript that came out in 2015. +This version of JavaScript is supported by most browsers since mid-2016 (and June 2017 for Firefox) (see [JavaScript Versions](https://www.w3schools.com/js/js_versions.asp) from W3Schools). +Most of your users should now be using a browser version that is compatible with ES6, but that is something that you might want to keep in mind: browser version matters when it comes to using JavaScript. +Indeed, some companies (for internal reason) are still using old versions of Internet Explorer: a constraint you want to be aware of before starting to build the app, hence a question that you want to ask during the Design step. + +### About DOM events + +When users navigate to a web page, they will generate events on the page: clicking, hovering over elements, pressing keys, etc. +All these events are listened to by the JavaScript runtime, plus some events that are not generated by the users: for example, there is a "ready" event generated when the web page has finished loading. +Most of these events are linked to a specific node in the tree: for example, if you click on something, you are clicking on a node in the DOM. +That is where JavaScript events come into play: when an event is triggered in JavaScript, you can link to it a "reaction", in other words a piece of JavaScript code that is executed when this event occurs. + +Here are some examples of events: + +- `click` / `dblclick` + +- `focus` + +- `keypress`, `keydown`, `keyup` + +- `mousedown`, `mouseenter`, `mouseleave`, `mousemove`, `mouseout`, `mouseover`, `mouseup` + +- `scroll` + +For a full list, please refer to <https://developer.mozilla.org/fr/docs/Web/Events>. + +Once you have this list in mind, you can select elements in the DOM, add an `addEventListener` to them, and define a callback function (which is executed when the event is triggered). +For example, the code below adds an event to the `input` when a key is pressed, showing a native `alert()` to the user. + +``` {.html} +<input type="text" id = "firstinput"> +<script> + document.getElementById("firstinput").addEventListener( + "keypress", + function(){ + alert("Pressed!") + } + ) +</script> +``` + +Note that `{shiny}` also generates events, meaning that you can customize the behavior of your application based on these events. +Here is a code that launches an alert when `{shiny}` is connected: + +``` {.javascript} +$(document).on('shiny:connected', function(event) { + alert('Connected to the server'); +}); +``` + +But wait, what is this weird `$()`? +That's `jQuery`, and we will discover it in the very next section! + +### About `jQuery` and `jQuery` selectors + +The `jQuery` framework is natively included in `{shiny}`. + +> jQuery is a fast, small, and feature-rich JavaScript library. +> It makes things like HTML document traversal and manipulation, event handling, animation, and Ajax much simpler with an easy-to-use API that works across a multitude of browsers.\ +> +> _jQuery home page_ (<https://jquery.com>) + +`jQuery` is a very popular JavaScript library which is designed to manipulate the DOM, its events, and its elements. +It can be used to do a lot of things, like hide and show objects, change object classes, click somewhere, etc. +And to be able to do that, it comes with the notion of selectors, which will be put between `$()`. +You can use, for example: + +- `$("#firstinput")` to refer to the element with the id `firstinput` + +- `$(".widediv")` to refer to element(s) of class `widediv` + +- `$("button:contains('this')")` to refer to the buttons with a text containing `'this'` + +You can also use special HTML attributes, which are specific to a tag. +For example, the following HTML code: + +``` {.html} +<a href = "https://thinkr.fr" data-value = "panel2">ThinkR</a> +``` + +contains the `href` and `data-value` attributes. +You can refer to these with `[]` after the tag name. + +- `$("a[href = 'https://thinkr.fr']")` refers to link(s) with `href` being `https://thinkr.fr` + +- `$('a[data-value="panel2"]')` refers to link(s) with `data-value` being `"panel2"` + +These and other selectors are **used to identify one or more node(s) in the big tree which is a web page**. +Once we have identified these elements, we can either extract or change data contained in these nodes, or invoke methods contained within these nodes. +Indeed JavaScript, like R, can be used as a functional language, but most of what we do is done in an object-oriented way. +In other words, you will interact with objects from the web page, and these objects will contain data and methods. + +Note that this is not specific to `jQuery`: elements can also be selected with standard JavaScript. +`jQuery` has the advantage of simplifying selections and actions and is a cross-platform library, making it easier to ship applications that can work on all major browsers. +And it comes with `{shiny}` for free! + +Choosing `jQuery` or vanilla JavaScript is up to you: and in the rest of this chapter we will try to mix both syntaxes, and put both when possible, so that you can choose the one you are the most comfortable with. + +## Client-side JavaScript + +It is hard to give an exhaustive list of what you can do with JavaScript inside `{shiny}`. +As a `{shiny}` app is part JavaScript, part R, once you have a good grasp of JavaScript you can quickly enhance any of your applications. +That being said, a few common things can be done that would allow you to immediately optimize your application: i.e. small JavaScript functions that will prevent you from writing complex algorithmic logic in your application server. + +### Common patterns + +- `alert("message")` uses the built-in alert-box mechanism from the user's browser (i.e., the `alert()` function is not part of `jQuery` but it is built inside the user's browser). + It works well as it relies on the browser instead of relying on R or on a specific JavaScript library. + You can use this functionality to replace a call to `{shinyalert}` [@R-shinyalert]: the result is a little less aesthetically pleasing, but is easier to implement and maintain. + +- `var x = prompt("this", "that");` this function opens the built-in prompt, which is a text area where the user can input text. + With this code, when the user clicks "OK", the text is stored in the `x` variable, which you can then send back to R (see later in this chapter for more info on how to do that). + This can replace something like the following: + +```{r 17-javascript-3 } +# Initiating a modalDialog that will ask the user to enter +# some information +mod <- function() { + # The modal box definition + modalDialog( + # Simple body with a textInput + tagList( + textInput("info", "Your info here") + ), + footer = tagList( + modalButton("Cancel"), + actionButton("ok", "OK") + ) + ) +} + +# When the user clicks on the "show" button in the UI, +# the modalDialog() is displayed +observeEvent(input$show, { + showModal(mod()) +}) + +# Whenever the "ok" button is clicked, the modal is removed +observeEvent(input$ok, { + print(input$info) + removeModal() +}) +``` + +- `$('#id').css('color', 'green');`, or in vanilla JavaScript `document.getElementById("demo").style.color = "green";` changes the CSS attributes of the selected element(s). + Here, we are switching to green on the `#id` element. + +- `$("#id").text("this")`, or in vanilla JavaScript `document.getElementById("id").innerText = "this";` changes the text content to "this". + This can be used to replace the following: + +```{r 17-javascript-4, eval = FALSE} +output$ui <- renderUI({ + # Conditionnal rendering of the UI + if (this){ + tags$p("First") + } else { + tags$p("Second") + } +}) +``` + +- `$("#id").remove();`, or in vanilla JavaScript `var elem = document.querySelector('#some-element'); elem.parentNode.removeChild(elem);` completely removes the element from the DOM. It can be used as a replacement for `shiny::removeUI()`, or as a conditional UI. Note that this code doesn't remove the input values on the server side: the elements only disappear from the UI, but nothing is sent to the server side. For a safe implementation, see `{shinyjs}`. + +### Where to put them: Back to JavaScript Events + +OK, now that we have some ideas about JS code that can be used in `{shiny}`, where do we put it? +HTML and JS have a concept called `events`, which are, well, events that happen when the user manipulates the web page: when the user clicks, hovers (the mouse goes over an element), presses keys on the keyboard, etc. +All these events can be used to trigger a JavaScript function. + +Here are some examples of adding JavaScript functions to DOM events: + +- `onclick` + +The `onclick` attribute can be added straight inside the HTML tag when possible: + +```{r 17-javascript-5, eval = FALSE} +# Building a button using the native HTML tag +# (i.e. not using the actionButton() function) +# This button only goal is to launch this JS code +# when it is clicked +tags$button( + "Show", + onclick = "$('#plot').show()" +) +``` + +Or with `shiny::tagAppendAttributes()`: + +```{r 17-javascript-6, eval = FALSE} +# Using tagAppendAttributes() allows to add attributes to the +# outputed UI element +plotOutput( + "plot" +) %>% tagAppendAttributes( + onclick = "alert('hello world')" +) +``` + +Here is, for example, a small `{shiny}` app that implements this behavior: + +```{r 17-javascript-7, eval = FALSE} +library(shiny) +library(magrittr) +ui <- function(){ + fluidPage( + # We create a plotOutput, which will show an alert when + # it is clicked + plotOutput( + "plot" + ) %>% tagAppendAttributes( + onclick = "alert('iris plot!')" + ) + ) +} + +server <- function(input, output, session){ + output$plot <- renderPlot({ + plot(iris) + }) +} + +shinyApp(ui, server) +``` + +You can find a real-life example of this `tagAppendAttributes` in the `{tidytuesday201942}` [@R-tidytuesday201942] app: + +- [R/mod\_dataviz.R\#L109](https://github.com/ColinFay/tidytuesday201942/blob/master/R/mod_dataviz.R#L109), where clicking the plot generates the creation of a `{shiny}` input (we will see this below) + +That, of course, works well with very short JavaScript code. +For longer JavaScript code, you can write a function inside an external file, and add it to your app. +In `{golem}`, this works by launching the `add_js_file("name")`, which will create a `.js` file. +The JavaScript file is then automatically linked in your application. + +This, for example, could be: + +- In `inst/app/www/script.js` + +```{js 17-javascript-8, eval = FALSE, echo = TRUE} +function alertme(id){ + // Asking information + var name = prompt("Who are you?"); + // Showing an alert + alert("Hello " + name + "! You're seeing " + id); +} +``` + +- Then in R + +```{r 17-javascript-9, eval = FALSE} +plotOutput( + "plot" +) %>% tagAppendAttributes( + # Calling the function which has been defined in the + # external script + onclick = "alertme('plot')" +) +``` + +Inside this `inst/app/www/script.js`, you can also attach a new behavior with `jQuery` to one or several elements. +For example, you can add this `alertme` / `onclick` behavior to all plots of the app: + +```{js 17-javascript-10, eval = FALSE, echo = TRUE} +function alertme(id){ + var name = prompt("Who are you?"); + alert("Hello " + name + "! You're seeing " + id); +} + +/* We're adding this so that the function is launched only +when the document is ready */ +$(function(){ + // Selecting all `{shiny}` plots + $(".shiny-plot-output").on("click", function(){ + /* Calling the alertme function with the id + of the clicked plot */ + alertme(this.id); + }); +}); +``` + +Then, all the plots from your app will receive this on-click event.[^javascript-4] + +[^javascript-4]: This `click` behavior can also be done through `$(".shiny-plot-output").click(...)`. + We chose to display the `on("click")` pattern as it can be generalized to all DOM events. + +Note that there is a series of events which are specific to `{shiny}`, but that can be used just like the one we have just seen: + +```{js 17-javascript-11, eval = FALSE, echo = TRUE} +// We define a function that will ask for the visitor name, and +// then show an alert to welcome the visitor +function alertme(){ + var name = prompt("Who are you?"); + alert("Hello " + name + "! Welcome to my app"); +} + +$(function(){ + // Waiting for `{shiny}` to be connected + $(document).on('shiny:connected', function(event) { + alertme(); + }); +}); +``` + +See [JavaScript Events in `{shiny}`](https://shiny.rstudio.com/articles/js-events.html) for the full list of JavaScript events available in `{shiny}`. + +## JavaScript \<-\> `{shiny}` communication + +Now that we have seen some client-side optimization, i.e. +R does not do anything with these events when they happen (in fact R is not even aware they happened), let's now see how we can make these two communicate with each other. + +### From R to JavaScript + +Calling JS from the server side (i.e. from R) is done by defining a series of `CustomMessageHandler` functions: these are functions with one argument that can then be called using the `session$sendCustomMessage()` method from the server side. +Or if you are using `{golem}`, using the `invoke_js()` function. + +You can define them using this skeleton: + +```{r 17-javascript-12, echo = FALSE, eval = TRUE} +readLines("golex/inst/app/www/first_handler.js") %>% + glue::as_glue() +``` + +This skeleton is the one from `golem::add_js_handler("first_handler")`. + +Then, it can be called from server side with: + +```{r 17-javascript-13, eval = FALSE} +session$sendCustomMessage("fun", list()) +# OR +golem::invoke_js("fun", ...) +``` + +Note that the `list()` argument from your function will be converted to JSON, and read as such from JavaScript. +In other words, if you have an argument called `x`, and you call the function with `list(a = 1, b = 12)`, then in JavaScript you will be able to use `x.a` and `x.b`. + +For example: + +- In `inst/app/www/script.js`: + +```{js 17-javascript-14, eval = FALSE, echo = TRUE} +// We define a handler called "computed", that can be called +// from the server side of the {shiny} application +Shiny.addCustomMessageHandler('computed', function(mess) { + // The received value (in mess) is serialized in JSON, + // so we can access the list element with object.name + alert("Computed " + mess.what + " in " + mess.sec + " secs"); +}) +``` + +- Then in R: + +```{r 17-javascript-15, eval = FALSE} +observe({ + # Register the starting time + deb <- Sys.time() + # Mimic a long computation + Sys.sleep( + sample(1:5, 1) + ) + # Calling the computed handler + golem::invoke_js( + "computed", + # We send a list, that will be turned into JSON + list( + what = "time", + sec = round(Sys.time() - deb) + ) + ) +}) +``` + +### From JavaScript to R + +How can you do the opposite (from JavaScript to R)? +`{shiny}` apps, in the browser, contain an object called `Shiny`, which may be used to send values to R by creating an `InputValue`. +For example, with: + +```{js 17-javascript-16, eval = FALSE, echo = TRUE} +// This function from the Shiny JavaScript object +// Allows to register an input name, and a value +Shiny.setInputValue("rand", Math.random()) +``` + +you will bind an input that can be caught from the server side with: + +```{r 17-javascript-17, eval = FALSE} +# Once the input is set, it can be caught with R using: +observeEvent( input$rand , { + print( input$rand ) +}) +``` + +`Shiny.setInputValue`[^javascript-5] can, of course, be used inside any JavaScript function. +Here is a small example that wraps up some of the things we have seen previously: + +[^javascript-5]: Note that the old name of this function is `Shiny.onInputChange`. + +- In `inst/app/www/script.js` + +```{js 17-javascript-18, eval = FALSE, echo = TRUE} +function alertme(){ + var name = prompt("Who are you?"); + alert("Hello " + name + "! Welcome to my app"); + Shiny.setInputValue("username", name) +} + +$(function(){ + // Waiting for `{shiny}` to be connected + $(document).on('shiny:connected', function(event) { + alertme(); + }); + + $(".shiny-plot-output").on("click", function(){ + /* Calling the alertme function with the id + of the clicked plot. + The `this` object here refers to the clicked element*/ + Shiny.setInputValue("last_plot_clicked", this.id); + }); +}); +``` + +These events (getting the user name and the last plot clicked), can then be caught from the server side with: + +```{r 17-javascript-19, eval = FALSE} +# We wait for the output of alertme(), which will set the +# "username" input value +observeEvent( input$username , { + cli::cat_rule("User name:") + print(input$username) +}) + +# This will print the id of the last clicked plot +observeEvent( input$last_plot_clicked , { + cli::cat_rule("Last plot clicked:") + print(input$last_plot_clicked) +}) +``` + +Which will give: + + > golex::run_app() + Loading required package: shiny + + Listening on http://127.0.0.1:5495 + ── User name: ───────────────────────────────────────────────────── + [1] "Colin" + ── Last plot clicked: ───────────────────────────────────────────── + [1] "plota" + ── Last plot clicked: ───────────────────────────────────────────── + [1] "plotb" + +**Important note**: If you are using modules, you will need to pass the namespacing of the `id` to be able to get it back from the server. +This can be done using the `session$ns` function, which comes by default in any golem-generated module. +In other words, you will need to write something like the following: + +```{js 17-javascript-20, eval = FALSE, echo = TRUE} +$( document ).ready(function() { + // Setting a custom handler that will + // ask the users their name + // then set the returned value to a Shiny input + Shiny.addCustomMessageHandler('whoareyou', function(arg) { + var name = prompt("Who are you?") + Shiny.setInputValue(arg.id, name); + }) +}); +``` + +```{r 17-javascript-21 } +mod_my_first_module_ui <- function(id){ + ns <- NS(id) + tagList( + actionButton( + ns("showname"), "Enter your name" + ) + ) +} + +mod_my_first_module_server <- function(input, output, session){ + ns <- session$ns + # Whenever the button is clicked, + # we call the CustomMessageHandler + observeEvent( input$showname , { + # Calling the "whoareyou" handler + golem::invoke_js( + "whoareyou", + # The id is namespaced, + # so that we get it back on the server-side + list( + id = ns("name") + ) + ) + }) + + # Waiting for input$name to be set with JavaScript + observeEvent( input$name , { + cli::cat_rule("Username is:") + print(input$name) + }) +} +``` + +## About `{shinyjs}` JS functions + +As said in the introduction to this chapter, running JavaScript code that you don't fully control/understand can be tricky and might open doors for external attacks. +In many cases, for the most common JavaScript manipulations, it's safer to go for a package that has already been proved efficient: `{shinyjs}`. + +This package, licensed in MIT since version 2.0.0, can be used to perform common JavaScript tasks: show, hide, alert, click, etc. + +See [deanattali.com/shinyjs/](https://deanattali.com/shinyjs/) for more information about how to use this package. + +## One last thing: API calls + +If your application uses API calls, chances are that right now you have been doing them straight from R. +But there are downsides to that approach. +Notably, if the API limits requests based on an IP and your application gets a lot of traffic, your users will end up being unable to use the app because of this restriction. + +So, why not switch to writing these API calls in JavaScript? +As JavaScript is run inside the user's browser, the limitation will apply to the user's IPs, not the one where the application is deployed, allowing you to more easily scale your application. + +You can write this API call using the `fetch()` JavaScript function. +It can then be used inside a `{shiny}` JavaScript handler, or as a response to a DOM event (for example, with `tags$button("Get Me One!", onclick = "get_rand_beer()")`, as we will see below). + +Here is the general skeleton that would work when the API does not need authentication and returns JSON. + +- Inside JavaScript (here, we create a function that will be available on an `onclick` event) + +``` {.javascript} +// FUNCTION definition +const get_rand_beer = () => { + // Fetching the data + fetch("https://api.punkapi.com/v2/beers/random") + // What do we do when we receive the data + .then((data) =>{ + // TTurn the data to JSON + data.json().then((res) => { + // Send the json to R + Shiny.setInputValue("beer", res, {priority: 'event'}) + }) + }) + // Define what happens if we fail to fetch + .catch((error) => { + alert("Error catching result from API") + }) +}; +``` + +- Observe the event in your server: + +```{r 17-javascript-22, eval = FALSE} +observeEvent( input$beer , { + # Do things with beer +}) + +``` + +Note that the data shared between R and JavaScript is serialized to JSON, so you will have to manipulate that format once you receive it in R. + +Learn more about `fetch()` at [Using Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). + +## Learn more about JavaScript + +If you want to interact straight from R with NodeJS (JavaScript in the terminal), you can try the `{bubble}` [@R-bubble] package. +Be aware that you will need to have a working NodeJS installation on your machine. + +It can be installed from GitHub + +```{r 17-javascript-23, eval = FALSE} +remotes::install_github("ColinFay/bubble") +``` + +You can use it in RMarkdown chunks by setting the `{knitr}` engine: + +```{r 17-javascript-24, eval = FALSE} +bubble::set_node_engine() +``` + +Or straight in the command line with: + +```{r 17-javascript-25, eval = FALSE} +node_repl() +``` + +Want to learn more? +Here is a list of external resources to learn more about JavaScript: + +### `{shiny}` and JavaScript + +- We have written an online, freely available book about `{shiny}` and JavaScript: [_JavaScript 4 `{shiny}` - Field Notes_](http://connect.thinkr.fr/js4shinyfieldnotes/). + +- [JavaScript for `{shiny}` Users](https://js4shiny.com/), companion website to the rstudio::conf(2020) workshop. + +- [Build custom input objects](https://shiny.rstudio.com/articles/building-inputs.html). + +- [Packaging JavaScript code for `{shiny}`](https://shiny.rstudio.com/articles/packaging-javascript.html). + +- [Communicating with `{shiny}` via JavaScript](https://shiny.rstudio.com/articles/communicating-with-js.html). + +### JavaScript basics + +- [Mozilla JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript) +- [w3schools JavaScript](https://www.w3schools.com/js/default.asp) +- [Free Code Camp](https://www.freecodecamp.org/) +- [JavaScript for Cats](http://jsforcats.com/) +- [Learn JS](https://www.learn-js.org/) + +### jQuery + +- [jQuery Learning Center](https://learn.jquery.com/) +- [w3schools jQuery](https://www.w3schools.com/jquery/default.asp) + +### Intermediate/advanced JavaScript + +- [Eloquent JavaScript](https://eloquentjavascript.net/) +- [You Don't Know JS Yet](https://github.com/getify/You-Dont-Know-JS) diff --git a/18-css.Rmd b/18-css.Rmd new file mode 100644 index 00000000..6be1f9f5 --- /dev/null +++ b/18-css.Rmd @@ -0,0 +1,282 @@ +# A Gentle Introduction to CSS {#css} + +## What is CSS? + +### About CSS + +CSS, for `Cascading Style Sheets`, is one of the main technologies that powers the web today, along with HTML and JavaScript. +HTML is a series of tags that define your web page structure, and JavaScript is a programming language that allows you to manipulate the page (well, it can do a lot more than that, but we are simplifying to make it understandable). +**CSS is what handles the design, i.e. the visual rendering of the web page: the color of the header, the font, the background, and everything that makes a web page look like it is not from 1983** (again, we are simplifying for the sake of clarity). +In every browser, each HTML element has a default style: for example, all `<h1>` have the size `2em` and are in bold, and `<strong>` is in bold. +But we might not be happy with what a "standard page" (with no CSS) looks like: that is the very reason for CSS, modifying the visual rendering of the page. + +If you want to get an idea of the importance of CSS, try installing extensions like [Web Developer](https://chrome.google.com/webstore/detail/web-developer/bfbameneiokkgbdmiekhjnmfkcnldhhm) for Google Chrome. +Then, if you go on the extension and choose CSS, click "Disable All Style", to see what a page without CSS looks like. + +For example, Figure \@ref(fig:18-css-1) is what [rtask.thinkr.fr](https://rtask.thinkr.fr) looks like with CSS, and Figure \@ref(fig:18-css-2) and Figure \@ref(fig:18-css-3) shows what it looks like without CSS. + +(ref:rtaskcss) <https://rtask.thinkr.fr> with CSS. + +```{r 18-css-1, echo=FALSE, fig.cap="(ref:rtaskcss)", out.width="100%"} +knitr::include_graphics("img/rtask_with_css.png") +``` + +(ref:rtaskwithoutcss) <https://rtask.thinkr.fr> without CSS. + +```{r 18-css-2, echo=FALSE, fig.cap="(ref:rtaskwithoutcss)", out.width="100%"} +knitr::include_graphics("img/rtask_without_css.png") +``` + +(ref:rtaskwithoutcss2) <https://rtask.thinkr.fr> without CSS. + +```{r 18-css-3, echo=FALSE, fig.cap="(ref:rtaskwithoutcss2)", out.width="100%"} +knitr::include_graphics("img/rtask_without_css2.png") +``` + +CSS now seems pretty useful right? + +### `{shiny}`'s default: `fluidPage()` + +In `{shiny}`, there is a default CSS: the one from Bootstrap 3. +As you can see, if you have created a `fluidPage()` before, there is already styling applied. + +Compare: + +- No `fluidPage`, Figure \@ref(fig:18-css-5) + +```{r 18-css-4, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + h1("Hey"), + h2("You"), + p("You rock!"), + selectInput("what", "Do you", unique(iris$Species)) + ) +} + +server <- function( + input, + output, + session +){ + +} + +shinyApp(ui, server) +``` + +(ref:nocssshiny) `{shiny}` without CSS. + +```{r 18-css-5, echo=FALSE, fig.cap="(ref:nocssshiny)", out.width="100%"} +knitr::include_graphics("img/no-css-shiny.png") +``` + +- With `fluidPage`, Figure \@ref(fig:18-css-7) + +```{r 18-css-6, eval = FALSE} +library(shiny) +ui <- function(){ + fluidPage( + h1("Hey"), + h2("You"), + p("You rock!"), + selectInput("what", "Do you", unique(iris$Species)) + ) +} + +server <- function( + input, + output, + session +){ + +} + +shinyApp(ui, server) +``` + +(ref:cssshiny) `{shiny}` with its default CSS. + +```{r 18-css-7, echo=FALSE, fig.cap="(ref:cssshiny)", out.width="100%"} +knitr::include_graphics("img/css-shiny.png") +``` + +Yes, that is subtle, but you can see how it makes the difference on larger apps. + +## Getting started with CSS + +CSS is a descriptive language, meaning that you will have to declare the style either on a tag or inside an external file. +We will see how to integrate CSS inside your `{shiny}` application in the next section, but before that, let's start with a short introduction to CSS.[^css-1] + +[^css-1]: Of course, this part will not make you an expert CSS programmer, but we hope you will get an idea of how it works, enough to get you started and want to learn more! + +### About CSS syntax + +CSS syntax is composed of two elements: a selector and a declaration block. +The CSS selector describes how to identify the HTML tags that will be affected by the style declared with key-value pairs in the declaration block that follows. +And because an example will be easier to understand, here is a simple CSS rule: + +``` {.css} +h2 { + color:red; +} +``` + +Here, the selector is `h2`, meaning that the HTML tags aimed by the style are the `<h2>` tags. +The declaration block contains the key-value pair telling that the `color` will be `red`. +Note that each key-value pair must end with a semicolon. + +### CSS selectors + +CSS selectors are a wide topic, as there are many combinations of things you might want to select inside an HTML page. + +The first selector types are the "standard" ones: `name`, `id`, or `class`. +These refer to the elements composing an HTML tag: for example, with `<h2 id = "tileone" class = "standard">One</h2>`, the name is `h2`, the id is `tileone`, and the class is `standard`.[^css-2] + +[^css-2]: Note that in HTML, id must be unique, but class must not. + +To select these three elements in CSS: + +- Write the name as-is: `h2`. +- Prefix the id with `#`: `#tileone`. +- Prefix the class with `.`: `.standard`. + +You can also combine these elements: for example, `h2.standard` will select all the `h2` tags with a class `standard`, and `h2,h3` will select `h2` and `h3`. + +You can build more complex selectors: for example `div.standard > p` will select all the `<p>` tags that are contained inside a `div` of class `standard` (CSS combinator), or `a:hover`, which dictates the style of the `a` tags when they are hovered by the mouse (CSS pseudo-class), `div.standard::first-letter`, which selects the first letter of the `div` of class `standard` (CSS pseudo-elements), and `h2[data-value="hey"]`, which selects all the `h2` with a `data-value` attribute set to `"hey"` (CSS attribute selector). + +As you can see, lots of complex selectors can be built with CSS, to target very specific elements of your UI. +But mastering these complex selectors is not the main goal of this chapter, hence we will just be using standard selectors in the rest of the examples in this book. + +### CSS properties + +Now that you have selected elements, it is time to apply some styles! +Between the brackets of the declaration block, you will have to define a series of key-value elements defining the properties of the style: the key here is the CSS property, followed by its value. + +For example, `color: red;` or `text-align: center;` defines that for the selected HTML elements, the color will be red, or the text centered. +We will not cover all the possible properties, as there are hundreds of them. +Feel free to refer to the [CSS Reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference) page from Mozilla for an exhaustive list of available properties. + +## Integrate CSS files in your `{shiny}` app + +Now that you have an idea of how to start writing your own CSS, how do you integrate it into your `{shiny}` application? +There are three methods that can be used: writing it inline, integrating it inside a `tags$script()` straight into your application UI code, or by writing it into an external file. +Note that good practice is considered to be the integration of an external file. + +### Inline CSS + +If you need to add style to one specific element, you can write it straight inside the HTML tag, as seen in Figure \@ref(fig:18-css-9): + +```{r 18-css-8, eval = FALSE } +library(shiny) +ui <- function(){ + tagList( + h2(style = "color:red;", "This is red") + ) +} + +server <- function( + input, + output, + session +){ + +} + +shinyApp(ui, server) +``` + +(ref:cssshinyred) `{shiny}` with a red title. + +```{r 18-css-9, echo=FALSE, fig.cap="(ref:cssshinyred)", out.width="100%"} +knitr::include_graphics("img/css-red.png") +``` + +But this method loses all the advantages of CSS, notably, the possibility of applying style to multiple elements. +Use it with caution. + +### Writing in a `tags$style()` + +If you had a `tags$style()` somewhere inside your UI code (generally at the very beginning of your UI), you could then add CSS code straight to your application. + +Figure \@ref(fig:18-css-11) is an example: + +```{r 18-css-10, eval = FALSE} +library(shiny) +ui <- function(){ + tagList( + tags$style( + "h2{ + color:red; + }" + ), + h2("This is red") + ) +} + +server <- function( + input, + output, + session +){ + +} + +shinyApp(ui, server) +``` + +(ref:cssshinyred2) `{shiny}` with a red title. + +```{r 18-css-11, echo=FALSE, fig.cap="(ref:cssshinyred2)", out.width="100%"} +knitr::include_graphics("img/css-red.png") +``` + +This works, but should not be considered as the best option: indeed, if you have a large amount of CSS code to insert in your app, it can make the code harder to read as it adds a large amount of visual noise. + +The best solution, then, is to go with the alternative of writing the CSS inside a separate file: it allows you to separate things and to make the UI code smaller, as it is easier to maintain a separate CSS file than a CSS written straight into R code. + +### Including external files + +To include an external CSS file, you will have to use another tag: `tags$link()`. +What this tag will contain is these three elements: + +- `rel="stylesheet"` +- `type="text/css"` +- `href="www/custom.css"` + +The first two are standard: you do not need to change them; they are necessary to indicate to the HTML page that you are creating a stylesheet, with the type being text/css. +The `href` is the one you will need to change: this path points to where your style file is located. + +If you are building your application with `{golem}` [@R-golem], the good news is that this file creation and linking is transparent: if you call `golem::add_css_file("name")`, a file will be created at `inst/app/www`, and this file will be automatically linked inside your UI thanks to the `bundle_resources()` function. + +### Using R packages + +If you want to use an external CSS template, there are several packages that exist that can implement new custom UI designs for your application. +Here are some: + +- `{resume}`[@R-resume], provides an implementation of the [Bootstrap Resume Template](https://github.com/BlackrockDigital/startbootstrap-resume). + +- `{nessy}` [@R-nessy], a port of [NES CSS](https://github.com/nostalgic-css/NES.css). + +- `{skeleton}` [@R-skeleton], [Skeleton CSS](http://getskeleton.com/). + +- `{shinyMobile}` [@R-shinyMobile], shiny API for Framework7 (IOS/android). + +- `{shinydashboardPlus}` [@R-shinydashboardPlus], extensions for shinydashboard. + +- `{bs4Dash}` [@R-bs4Dash], Bootstrap 4 shinydashboard using AdminLTE3: an example is available at [engineering-shiny.org/bs4dashdemo/](https://engineering-shiny.org/bs4dashdemo/) + +- `{fullPage}`[@R-fullPage], fullPage.js, pagePiling.js, and multiScroll.js for shiny. + +And all the amazing things done at [RinteRface](https://github.com/RinteRface). + +## External resources + +If you want to learn more about CSS, there are three places where you can get started: + +- [FreeCodeCamp](https://www.freecodecamp.org/learn), which contains many course hours covering HTML and CSS. + +- [W3 Schools CSS Tutorial](https://www.w3schools.com/css/) + +- [Learn to style HTML using CSS](https://developer.mozilla.org/en-US/docs/Learn/CSS) diff --git a/19-appendix.Rmd b/19-appendix.Rmd new file mode 100644 index 00000000..aa771438 --- /dev/null +++ b/19-appendix.Rmd @@ -0,0 +1,495 @@ +# (PART) Appendix {.unnumbered} + +# Appendix A - Use Case: Building an App from Start to Finish {.unnumbered} + +This chapter aims at exemplifying the workflow developed in this book using a "real-life" example. +In this appendix, we will be building a `{shiny}` application from start to finish. +We've chosen to build an application that doesn't rely on heavy computation/data analysis, so that we can focus on the engineering process, not on the internals of the analytic methodology, nor on spending time explaining the dataset. + +## About the application {.unnumbered} + +In this appendix, we will build a "minify" application, an application that takes a CSS, JavaScript, HTML or JSON input, and outputs a minified version of this file. +We will rely on the `{minifyr}` package to build this application. + +Here is an example of what the specifications for this app could look like: + +``` {.txt} +Hello! + +We want to build a small application that can +minify CSS, JavaScript, HTML and JSON. + +In this app, user will be able either to paste +the content or to upload a file. + +Once the content is pasted/upladed, they select +the type, which is pre-selected based on the +file extension. Then they click on a button, +and the content is minified. + +They can then copy the output, or download it as a file. + +Cheers! +``` + +## Step 1: Design {.unnumbered} + +### Deciphering the specifications {.unnumbered} + +#### General observations {.unnumbered} + +- As this app is pretty straightforward, it would be better to handle everything in the same page, *i.e* everything should happen on the same page (no tab). + +- It would be a plus to have the "before minification"/"after minification" gain, so that the users have a better idea of the purpose of the application. + +#### User experience considerations {.unnumbered} + +- We should provide a link to an explanation of minification. + +- The user might get different results based on the minifying algorithm they use, which can be surprising at first. + The application should alert about this. + +- For long printed outputs, if we use `verbatimTextOuput`, we should be careful about the page width, as these elements will natively overflow on the x-axis of the page. + This should be doable with the following CSS: `pre{ white-space: pre-wrap; word-break: keep-all; }`. + +- We should be careful about using semantic HTML for the inputs and outputs. + +#### Technical points {.unnumbered} + +- As `{minifyr}` wraps a NodeJS module, we will need to install NodeJS when deploying. + +- To be sure that the process works, we should check the validity of the file extension from the UI and from the server side. + +### Building a concept map {.unnumbered} + +Figure \@ref(fig:19-appendix-1) is the concept map for this application, using Xmind. + +(ref:conceptmapminifyr) A concept map for the minifying application. + +```{r 19-appendix-1, echo=FALSE, fig.cap="(ref:conceptmapminifyr)", out.width="100%"} +knitr::include_graphics("img/minifyr-map.png") +``` + +### Asking questions {.unnumbered} + +#### About the end users {.unnumbered} + +- *Who are the end users of your app?* + +This application is mainly useful for web developers. + +- *Are they tech-literate?* + +Yes. + +- *In which context will they be using your app?* + +Notably at work, or while building pet projects. + +- *On what machines?* + +Laptop/personal computer. +Small chance of using this on a smartphone. + +- *What browser version will they be using?* + +Hard to say, but given that we aim for a public of community developers, probably modern browsers. + +- *Will they be using the app in their office, on their phone while driving a tractor, in a plant, or while wearing lab coats?* + +Nothing of the like: they should be using this application while developing, so chances are they are using it at a desk. + +### Building personas {.unnumbered} + +Let's pick two random names for our personas, and two fake companies where they might be working. + +```{r 19-appendix-2, eval = TRUE} +nms <- withr::with_seed( + 608, { + charlatan::ch_name(2) + + } +) +nms + +company <- withr::with_seed( + 608, { + charlatan::ch_company(2) + } +) +company +``` + +#### `r nms[1]`: `{shiny}` developer at `r company[1]` {.unnumbered} + +`r nms[1]` is a `{shiny}` developer at `r company[1]`. +She's been learning R in graduate school while working on her master's degree in statistics. +When she started at `r company[1]`, she was mainly doing data analysis in Rmd, but has gradually switched to building `{shiny}` applications full-time. +She discovered minification while reading the "Engineering Production-Grade Shiny App" book, and now wants to add this to her `{shiny}` application. + +#### `r nms[2]`: web developer and trainer at `r company[2]` {.unnumbered} + +`r nms[2]` is a web developer at `r company[2]`. +He studied web development at the university, where he learned about minification. +He is now also in charge of training new recruits for the company where he works, and also gives some lectures at the university he attended. +Most of the minification he does is automated, but he is looking for a tool he can use during training and classes to explain how minification works. + +This step is available at <https://github.com/ColinFay/minifying/tree/master/step-1-design>. + +## Step 2: Prototyping {.unnumbered} + +In this step, we will be building the back-end of the application on one side, and the UI on the other side. +Once we have the back-end settled and the UI defined, we will be working on making the two work with each other. + +### Back-end in Rmd {.unnumbered} + +Our back-end will be composed of two functions: + +```{r 19-appendix-3, echo = FALSE, eval = TRUE} +library(minifyr) +minif <- ls("package:minifyr", pattern = "minifyr_js_") +minif <- paste0("`", minif, "()`") +minif <- knitr::combine_words(minif) +``` + +- `guess_minifier`, which will take a function and return the available algorithms for that file: for example, if you have a JavaScript file, you'll be able to use the `r minif` functions. If the type is not guessed based on the extension, the function should fail gracefully, and not make `{shiny}` crash. We'll chose to return an empty string if this extension is not guessed. + +```{r 19-appendix-4} +library(minifyr) +guess_minifier <- function(file){ + # We'll start by getting the file extension + ext <- tools::file_ext(file) + # Check that the extension is correct, if not, return early + # It's important to do this kind of check also + # on the server side as HTML manual tempering + # would allow to also send other type of files + if ( + ! ext %in% c("js", "css", "html", "json") + ){ + # Return early + return(list()) + } + # We'll then retrieve the available + # pattern based on the extension + patt <- switch( + ext, + js = "minifyr_js_.+", + html = "minifyr_html_.+", + css = "minifyr_css_.+", + json = "minifyr_json_.+" + ) + # List all the available functions to minify the file + list( + file = file, + ext = ext, + # We return this pattern so that + # it will be used to update the selectInput that + # is used to select an algo + pattern = patt, + functions = grep( + patt, + names( + loadNamespace("minifyr") + ), + value = TRUE + ) + ) + +} + +# minifyr comes with a series of examples, +# so we can use them as tests +guess_minifier( + minifyr_example("css") +)[2:4] +guess_minifier( + minifyr_example("js") +)[2:4] +guess_minifier( + minifyr_example("html") +)[2:4] +guess_minifier( + minifyr_example("json") +)[2:4] +# Try with a non valid extension +guess_minifier( + "path/to/text.docx" +) +``` + +- A `compress()` function, which takes three parameters: the file as `input`, the `algo` outputted from our last function, and the selection, which is the one selected by the user. The compressed file will be outputted to a tempfile. + +```{r 19-appendix-5, eval = FALSE} +compress <- function(algo, selection){ + # Creating a tempfile using our algo object + tps <- tempfile(fileext = sprintf(".%s", algo$ext)) + # Getting the function with the selection + converter <- get( + grep(selection, algo$functions, value = TRUE) + ) + # Do the conversion + converter(algo$file, tps) + # Return the temp file + return(tps) +} + +algo <- guess_minifier( + minifyr_example("js") +) + +compress( + algo = algo, + selection = "babel" +) +``` + +```{r 19-appendix-1-bis, echo = FALSE} +compress <- function(algo, selection){ + # Creating a tempfile using our algo object + tps <- tempfile(fileext = sprintf(".%s", algo$ext)) + # Getting the function with the selection + converter <- get( + grep(selection, algo$functions, value = TRUE) + ) + # Do the conversion + converter(algo$file, tps) + # Return the temp file + return(tps) +} + +algo <- guess_minifier( + minifyr_example("js") +) +``` + +- Finally, a `compare()` function, that can compare the size of two files, so that we can measure the minification gain. This function will take two file paths. + +```{r 19-appendix-6} +compare <- function(original, minified){ + # Get the file size of both + original <- fs::file_info(original)$size + minified <- fs::file_info(minified)$size + return(original - minified) +} + +``` + +So, for the complete process: + +```{r 19-appendix-7} +algo <- guess_minifier( + minifyr_example("js") +) + +compressed <- compress( + algo = algo, + selection = "babel" +) +compare( + minifyr_example("js"), + compressed +) +``` + +Now, time to move this into a vignette! + +### UI prototyping {.unnumbered} + +Let's start by drawing a small mockup of our front-end using [Excalidraw](https://excalidraw.com/), as seen in Figure \@ref(fig:19-appendix-8). + +(ref:excalidraw) A mockup for the UI of our application, made with Excalidraw <https://excalidraw.com/>. + +```{r 19-appendix-8, echo=FALSE, fig.cap="(ref:excalidraw)", out.width="100%"} +knitr::include_graphics("img/excalidraw.png") +``` + +We would love this application to be "full screen", and to do that, we'll take inspiration from the [split-screen layout](https://www.w3schools.com/howto/howto_css_split_screen.asp) available at W3Schools. +To mock up the UI, we will also use the `{shinipsum}` package. + +In this first step, we will start generating the module skeleton for the application. +Here, we will have a `left` module for the left part of the app, and a `right` module for the right part. +Each will receive their corresponding `class`, based on the CSS from W3. +Now that these two spots are available, we'll add the two modules, with some fake output, to simulate our application behavior. +The left side will be functional in the sense that uploading a file will randomly add algorithms to the `selectInput()`, and clicking on `Launch the minification` will regenerate a fake text. + +Now, let's pick a soft palette of colors, using [coolors.co](https://coolors.co/palettes/), and a font family from [fonts.google.com](https://fonts.google.com/). +We went for: + +- One of the monochrome palettes from [coolors.co](https://coolors.co/fbfbf2-e5e6e4-cfd2cd-a6a2a2-847577). +- The `Sora` font [fonts.google.com/specimen/Sora](https://fonts.google.com/specimen/Sora). There is not that much text displayed on the screen, so this font should work well. + +We then used CSS to arrange our page: size, padding, alignment, colors, etc. +If you want to know more about this file, it's located in the `inst/app/www` folder of the package. + +This step is available at [github.com/ColinFay/minifying, folder step-2-prototype](https://github.com/ColinFay/minifying/tree/master/step-2-prototype). + +## Step 3: Build {.unnumbered} + +Now we've got the back-end in an Rmd, the front end working with `{shinipsum}`. +Now is the time to make the two work together! + +Here is the logic we will be adding to the application: + +- When a file is uploaded, we also check the format from the server-side: the UI restriction with the `accept` parameter of `fileInput()` will not be enough to stop users who **really** want to upload something else. + +- If the file comes with the right extension, we update the algorithm selection and read it inside the "Original content" block. + +- Once the user clicks on "Launch the minification", we create a temp file and minify the original file inside this temp file. + +- When the file is minified, we update the gain output to reflect how many bytes have been gained from the minification, and add the result of this minification to the "Minified content" block. + +- Finally, when the "Download the output" button is clicked, the minified file is downloaded. + +During this process, we will migrate the functions from the Rmd to files inside the `R/` folder, use external dependencies, and document our business logic functions. +You can refer to the `dev/02_dev.R` file if you want to read the exact steps taken here. + +This step is available at <https://github.com/ColinFay/minifying/tree/master/step-3-build>. + +## Step 4: Strengthen {.unnumbered} + +As of now, we have a working application. +Time to strengthen it! + +Here are the few steps we will be working on: + +- Turning our business logic into an R6 class. + This R6 class will generate an object at the very start of our app, and it will be passed into the modules. + +- As the minification process takes a couple of seconds, we will add a small progress bar so that the user knows something is happening. + +- As we will use R6, we will need to manually set the reactive context invalidation. + To do so, we will use `triggers` from `{gargoyle}`. + +- Chances are, users will be testing several algorithms when using the application, and we don't want the minification process to happen another time when it is called on the same file and with the same algorithm. + This is even even more important because the process involves calling an external NodeJS process. + To prevent that, we will be caching the function that does the computation. + +- Create an unseen input that will upload data, so that we can build an interactivity test using `{crrry}`. + This input will look like the following on the server: + +```{r 19-appendix-9} +observeEvent( input$testingtrigger , { + + if (golem::app_dev()){ + file$original_file <- minifyr::minifyr_example( + ext = input$testingtext + ) + file$guess_minifier() + file$type <- input$upload$type + file$minified_file <- NULL + file$original_name <- input$upload$name + gargoyle::trigger("uploaded") + } + +}) +``` + +We use this pattern so that we can combine it with a testing suite with `{crrry}`, using the following pattern: + +```{r 19-appendix-10, eval = FALSE} +test <- crrry::CrrryProc$new( + chrome_bin = pagedown::find_chrome(), + # Process to launch locally + fun = "golem::document_and_reload();run_app()", + # Note that you will need httpuv >= 1.5.2 for randomPort + chrome_port = httpuv::randomPort(), + headless = FALSE +) + +test$wait_for_shiny_ready() +ext <- c("css", "js", "json", "html") +for (i in 1:length(ext)){ + # Set the extension value + test$shiny_set_input("left_ui_1-testingtext", ext[i]) + # Trigger the file to be read + test$shiny_set_input("left_ui_1-testingtrigger", i) + # Launch the minification + test$shiny_set_input("left_ui_1-launch", i) +} + +test$stop() +``` + +It's safer to wrap these tests between `if(interactive())`, as running the checks outside of your current session might not launch the app correctly, and launching external processes (the one running the app with Chrome) might fail when run non-interactively. +And on top of that, running these inside your CI might cause some pain, and of course, it will not work on CRAN checks. + +We'll also be building "standard" function checks, which you can find in the `test/` folder. + +This step is available at [github.com/ColinFay/minifying, folder step-4-strengthen](https://github.com/ColinFay/minifying/tree/master/step-4-strengthen). + +## Step 5: Deploy {.unnumbered} + +As an example, we will deploy this app in three media: as a package, on RStudio Connect, and with Docker. + +### Before deployment checklist {.unnumbered} + +- [x] *`devtools::check()`, run from the command line, return 0 errors, 0 warnings, 0 notes* + +- [x] *The current version number is valid, i.e* if the current app is an update, the version number has been bumped: it makes sense, before the first deployment, to keep a version number of `0.0.0.9000`, and increment this dev version whenever we implement changes or do test deployments. + Because this is a "true" deployment, we bumped the version to `0.1.0`. + +- [x] *Everything is fully documented*: we have documented all the functions, even the internal, there is a Vignette that describes the business logic, and the README is filled. + +- [x] *Test coverage is good, i.e. you cover a sufficient amount of the codebase, and these tests cover the core/strategic algorithms*. + +- [x] *The person to call if something goes wrong is clear to everyone involved in the product.*. + +- [x] *The debugging process is clear to everyone involved in the project, including how to communicate bugs to the developer team, and how long it will take to get changes implemented*: this project will be made open source, so the bug will have to be listed on the Github repo. + To help that, we added a link to the GitHub repository on the application. + +- [x] *(If relevant) The server it is deployed on has all the necessary software installed (Docker, Connect, `Shiny Server`, etc.) to make the application run*. + +- [x] *The server has all the system requirements needed (the system libraries), and if not, they are installed with the application (if it's dockerized)*: NodeJS will need to be installed on Docker and on the server running RStudio Connect. + A check is also added on top of `run_app()` for the availability of NodeJS on the system, especially for people installing it as a package. + This check will also check if `node-minify` has been installed, and if not, it will be installed. + This check might take some time to run, but it will only be performed the first time the app is launched. + +- [x] *The application, if deployed on a server, will be deployed on a port which will be accessible by the users*: when building the Dockerfile using `{golem}`, the correct port is exposed (*i.e* the app will run on port 80, which is also made available). + For the other medium, the port will be automatically chosen, either by `{shiny}` or by Connect. + +- [x] *(If relevant) The environment variables from the production server are managed inside the application*: not relevant. + +- [x] *(If relevant) The app is launched on the correct port, or at least this port can be configured via an environment variable*: not relevant. + +- [x] *(If relevant) The server where the app is deployed has access to the data sources (database, API...)*: not relevant. + +- [x] *If the app records data and there are backups for these data*: not relevant + +### Deploy as a tar.gz {.unnumbered} + +To share an application as a tar.gz, you can call `devtools::build()`, which will compile a `tar.gz` file inside the parent folder of your current application. +You can then share this archive, and install it with `remotes::install_local("path/to/tar.gz")`. +Note that this can also be done with base R, but `{remotes}` offers a smarter way when it comes to managing the dependencies of your archived package. + +This `tar.gz` can also be sent to a package repository; be it the CRAN or any other package manager you might have in your company. + +### Deploy on RStudio Connect {.unnumbered} + +Once we are sure that the server running Connect has NodeJS installed, and that we have installed the minify module with `minifyr::minifyr_npm_install()`, we can create the app.R using `golem::add_rstudioconnect_file()`, and then push to the Connect server. + +### Deploy with Docker {.unnumbered} + +To create the `Dockerfile`, we'll start by launching `golem::add_dockerfile()`. +This function will compute the system requirements,[^appendix-1] and create a generic `Dockerfile` for your application. +Once this is done, we will create/update the `.dockerignore` file at the root of the package, so that unwanted files are not bundled with our Docker image. + +[^appendix-1]: Note that at the time of writing these lines, there is also an issue with the dependencies collected by the `sysreq` API, leading to an issue when attempting to compile the Dockerfile. + Removing the installation of `libgit2-dev` solved the issue. + +Inside our `Dockerfile`, we will also change the default repo to use <https://packagemanager.rstudio.com/all/latest>, which proposes precompiled packages for our system, making the installation faster. +We will also add an installation of NodeJS, which is needed by our application.. + +Then, we can go to our terminal and compile the image! + +``` {.bash} +docker build -t minifying . +``` + +Now we've got a working image! +We can try it with: + +``` {.bash} +docker run -p 2811:80 minifying +``` + +This step is available at [github.com/ColinFay/minifying, folder step-5-deploy](https://github.com/ColinFay/minifying/tree/master/step-5-deploy). diff --git a/20-session-info.Rmd b/20-session-info.Rmd new file mode 100644 index 00000000..295548fc --- /dev/null +++ b/20-session-info.Rmd @@ -0,0 +1,20 @@ +# Appendix B - Session Info {-} + +The current version of this book has been compiled on: + +```{r 20-session-info-1, eval = TRUE} +Sys.Date() +``` + +with the following configuration: + +```{r 20-session-info-2, eval = FALSE} +xfun::session_info() +``` + +```{r 20-session-info-1-bis, echo = FALSE, eval = TRUE} +a <- xfun::session_info() +a[[5]] <- gsub("/ C", "\n/ C", a[[5]]) +a +``` + diff --git a/21-references.Rmd b/21-references.Rmd new file mode 100644 index 00000000..b216bb75 --- /dev/null +++ b/21-references.Rmd @@ -0,0 +1,3 @@ +`r if (knitr::is_html_output()) ' +# References {-} +'` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..447f69a4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +## Contributing + +First of all, thank you for taking time to read our book! + +### What you can help with + +There are two ways to contribute to the book: + ++ Spot typos and / or mistakes in the text or in the code blocks + ++ Contribute written content + +### How to + ++ Open an issue with the content and / or with the error you have spotted + ++ If you want to make written contribution, please open a pull request with your correction / contribution + + + In that case, please include as a comment to your pull request "I assign the + copyright of this contribution to Colin Fay, Vincent Guyader, Cervan Girard and + Sébastien Rochette". This will be needed for the publication of the printed + book. + + + Add your name in the Acknowledgment section in the introduction. + ++ If you want to contribute written content, you might want to open an issue first so that we can discuss the topic first. diff --git a/DESCRIPTION b/DESCRIPTION new file mode 100644 index 00000000..bccb795f --- /dev/null +++ b/DESCRIPTION @@ -0,0 +1,171 @@ +Type: Compendium +Package: building.shiny.apps.workflow +Title: Engineering Production-Grade Shiny Apps - A Workflow +Version: 0.0.1 +Authors@R: + c(person(given = "Colin", + family = "Fay", + role = c("aut", "cre"), + email = "colin@thinkr.fr", + comment = c(ORCID = "0000-0001-7343-1846")), + person(given = "Sébastien", + family = "Rochette", + role = "aut", + email = "sebastien@thinkr.fr", + comment = c(ORCID = "0000-0002-1565-9313")), + person(given = "Vincent", + family = "Guyader", + role = "aut", + email = "vincent@thinkr.fr", + comment = c(ORCID = "0000-0003-0671-9270")), + person(given = "Cervan", + family = "Girard", + role = "aut", + email = "cervan@thinkr.fr", + comment = c(ORCID = "0000-0002-4816-4624")), + person(given = "ThinkR", + role = "cph")) +Description: Open source book on R for reproducible, robust and + maintainable Shiny applications for production. +License: MIT + file LICENSE +URL: https://github.com/ThinkR-open/building-shiny-apps-workflow +BugReports: + https://github.com/ThinkR-open/building-shiny-apps-workflow/issues +Depends: + bookdown (>= 0.18) +Imports: + attachment (>= 0.1.0), + attempt (>= 0.3.1), + bank (>= 0.0.0.9000), + bench (>= 1.1.1), + bs4Dash (>= 0.5.0), + bubble (>= 0.0.0.9003), + chromote (>= 0.0.0.9001), + cli (>= 2.0.2), + cloc (>= 0.3.0), + config (>= 0.3), + covr (>= 3.5.0), + covrpage (>= 0.0.70), + crrri (>= 0.0.12), + crrry (>= 0.0.0.9001), + cyclocomp (>= 1.1.0), + data.table (>= 1.12.8), + DBI (>= 1.1.0), + dbplyr (>= 1.4.3), + dccvalidator (>= 0.2.0), + desc (>= 1.2.0), + devtools (>= 2.3.0), + dichromat (>= 2.0.0), + dockerfiler (>= 0.1.3), + dockerstats (>= 0.0.0.9000), + dplyr (>= 1.0.2), + DT (>= 0.15), + fakir (>= 0.2.0), + foreign, + fs (>= 1.4.1), + fullPage (>= 0.1.0), + future (>= 1.17.0), + gargoyle (>= 0.0.0.9000), + geojsonsf (>= 1.3.3), + ggbeeswarm (>= 0.6.0), + ggplot2 (>= 3.3.0), + git2r (>= 0.27.1), + glouton (>= 0.0.0.9000), + glue (>= 1.4.2), + golem (>= 0.3.0), + haven (>= 2.2.0), + here (>= 0.1), + hexmake (>= 0.0.0.9000), + htmltools (>= 0.5.0.9002), + htmlwidgets (>= 1.5.2), + httpuv (>= 1.5.4), + hunspell (>= 3.0), + jsonlite (>= 1.7.1), + knitr (>= 1.30), + liteq (>= 1.1.0), + lubridate (>= 1.7.8), + magrittr (>= 1.5), + matlab (>= 1.0.2), + memoise (>= 1.1.0.9000), + minifyr (>= 0.0.0.9000), + namer (>= 0.1.5), + nessy (>= 0.0.0.9001), + packageMetrics2 (>= 1.0.1.9000), + pagedown (>= 0.10), + pkgbuild (>= 1.0.8), + plotly (>= 4.9.2.1), + processx (>= 3.4.4), + profmem (>= 0.5.0), + profvis (>= 0.3.6), + promises (>= 1.1.1), + purrr (>= 0.3.4), + R.cache (>= 0.14.0), + rcmdcheck (>= 1.3.3), + Rcpp (>= 1.0.5), + RcppSimdJson (>= 0.1.0), + readr (>= 1.3.1), + readxl (>= 1.3.1), + remotes (>= 2.2.0), + renv (>= 0.12.2), + resume (>= 0.0.0.9000), + rhub (>= 1.1.1), + rmarkdown (>= 2.5), + roxygen2 (>= 7.1.0), + rsconnect (>= 0.8.16), + RSQLite (>= 2.2.0), + rstudioapi (>= 0.11), + scales (>= 1.1.0), + sever (>= 0.0.4), + sf (>= 0.9.3), + shinipsum (>= 0.0.0.9000), + shiny (>= 1.5.0), + shinyalert (>= 1.1), + shinydashboardPlus (>= 0.7.0), + shinyFeedback (>= 0.2.0), + shinyjs (>= 1.1), + shinyloadtest (>= 1.0.1), + shinyMobile (>= 0.1.0), + shinytest (>= 1.3.1), + skeleton (>= 0.0.0.9000), + testthat (>= 3.0.0), + tibble (>= 3.0.1), + tictoc (>= 1.0), + tidymodules (>= 0.1.1), + tidytuesday201942 (>= 0.0.0.9000), + tidyverse (>= 1.3.0), + tools (>= 3.6.1), + tufte (>= 0.7), + usethis (>= 1.6.3), + uuid (>= 0.1.4), + viridis (>= 0.5.1), + vroom (>= 1.2.0), + whereami (>= 0.1.9), + xfun (>= 0.19) +Suggests: + spelling (>= 2.1) +Remotes: + colinfay/bubble, + ColinFay/crrry, + ColinFay/dockerstats, + ColinFay/gargoyle, + colinfay/glouton, + colinfay/hexmake, + Colinfay/minifyr, + ColinFay/nessy, + colinfay/resume, + colinfay/skeleton, + ColinFay/tidytuesday201942, + hrbrmstr/cloc, + MangoTheCat/packageMetrics2, + metrumresearchgroup/covrpage, + Novartis/tidymodules, + RinteRface/fullPage, + RLesur/crrri, + rstudio/chromote, + thinkr-open/bank, + ThinkR-open/fakir, + ThinkR-open/golem, + Thinkr-open/shinipsum +Encoding: UTF-8 +Language: en-US +LazyData: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ab088be3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +FROM rocker/r-ver:3.6.1 +RUN apt-get update && apt-get install -y gdal-bin git-core libcairo2-dev libcurl4-openssl-dev libgdal-dev libgeos-dev libgeos++-dev libgit2-dev libpng-dev libssh2-1-dev libssl-dev libudunits2-dev libxml2-dev make pandoc pandoc-citeproc zlib1g-dev && rm -rf /var/lib/apt/lists/* +RUN echo "options(repos = c(CRAN = 'https://cran.rstudio.com/'), download.file.method = 'libcurl')" >> /usr/local/lib/R/etc/Rprofile.site +RUN R -e 'install.packages("remotes")' +RUN R -e 'remotes::install_github("r-lib/remotes", ref = "97bbf81")' +RUN Rscript -e 'remotes::install_version("bookdown",upgrade="never", version = "0.18")' +RUN Rscript -e 'remotes::install_version("attachment",upgrade="never", version = "0.1.0")' +RUN Rscript -e 'remotes::install_version("bench",upgrade="never", version = "1.1.1")' +RUN Rscript -e 'remotes::install_version("cli",upgrade="never", version = "2.0.2")' +RUN Rscript -e 'remotes::install_version("cyclocomp",upgrade="never", version = "1.1.0")' +RUN Rscript -e 'remotes::install_version("DBI",upgrade="never", version = "1.1.0")' +RUN Rscript -e 'remotes::install_version("desc",upgrade="never", version = "1.2.0")' +RUN Rscript -e 'remotes::install_version("devtools",upgrade="never", version = "2.2.2")' +RUN Rscript -e 'remotes::install_version("dichromat",upgrade="never", version = "2.0-0")' +RUN Rscript -e 'remotes::install_version("dplyr",upgrade="never", version = "0.8.5")' +RUN Rscript -e 'remotes::install_version("fs",upgrade="never", version = "1.4.0")' +RUN Rscript -e 'remotes::install_version("future",upgrade="never", version = "1.16.0")' +RUN Rscript -e 'remotes::install_version("ggplot2",upgrade="never", version = "3.3.0")' +RUN Rscript -e 'remotes::install_version("ggbeeswarm",upgrade="never", version = "0.6.0")' +RUN Rscript -e 'remotes::install_version("glue",upgrade="never", version = "1.3.2")' +RUN Rscript -e 'remotes::install_version("golem",upgrade="never", version = "0.2.1.9000")' +RUN Rscript -e 'remotes::install_version("here",upgrade="never", version = "0.1")' +RUN Rscript -e 'remotes::install_version("htmltools",upgrade="never", version = "0.4.0")' +RUN Rscript -e 'remotes::install_version("knitr",upgrade="never", version = "1.28")' +RUN Rscript -e 'remotes::install_version("lubridate",upgrade="never", version = "1.7.4")' +RUN Rscript -e 'remotes::install_version("magrittr",upgrade="never", version = "1.5")' +RUN Rscript -e 'remotes::install_version("matlab",upgrade="never", version = "1.0.2")' +RUN Rscript -e 'remotes::install_version("profmem",upgrade="never", version = "0.5.0")' +RUN Rscript -e 'remotes::install_version("purrr",upgrade="never", version = "0.3.3")' +RUN Rscript -e 'remotes::install_version("Rcpp",upgrade="never", version = "1.0.4")' +RUN Rscript -e 'remotes::install_version("readr",upgrade="never", version = "1.3.1")' +RUN Rscript -e 'remotes::install_version("remotes",upgrade="never", version = "2.1.1")' +RUN Rscript -e 'remotes::install_version("RSQLite",upgrade="never", version = "2.2.0")' +RUN Rscript -e 'remotes::install_version("shiny",upgrade="never", version = "1.4.0.2")' +RUN Rscript -e 'remotes::install_version("tibble",upgrade="never", version = "3.0.0")' +RUN Rscript -e 'remotes::install_version("tictoc",upgrade="never", version = "1.0")' +RUN Rscript -e 'remotes::install_version("viridis",upgrade="never", version = "0.5.1")' +RUN Rscript -e 'remotes::install_version("rmarkdown",upgrade="never", version = "2.1")' +RUN Rscript -e 'remotes::install_version("DT",upgrade="never", version = "0.13")' +RUN Rscript -e 'remotes::install_version("httpuv",upgrade="never", version = "1.5.2")' +RUN Rscript -e 'remotes::install_version("pagedown",upgrade="never", version = "0.9")' +RUN Rscript -e 'remotes::install_version("shinyloadtest",upgrade="never", version = "1.0.1")' +RUN Rscript -e 'remotes::install_version("dockerstats",upgrade="never", version = "0.0.0.9000")' +RUN Rscript -e 'remotes::install_version("scales",upgrade="never", version = "1.1.0")' +RUN Rscript -e 'remotes::install_version("dockerfiler",upgrade="never", version = "0.1.3")' +RUN Rscript -e 'remotes::install_version("jsonlite",upgrade="never", version = "1.6.1")' +RUN Rscript -e 'remotes::install_version("namer",upgrade="never", version = "0.1.5")' +RUN Rscript -e 'remotes::install_version("renv",upgrade="never", version = "0.9.3")' +RUN Rscript -e 'remotes::install_version("attempt",upgrade="never", version = "0.3.0")' +RUN Rscript -e 'remotes::install_version("rstudioapi",upgrade="never", version = "0.11")' +RUN Rscript -e 'remotes::install_version("xfun",upgrade="never", version = "0.12")' +RUN Rscript -e 'remotes::install_version("config",upgrade="never", version = "0.3")' +RUN Rscript -e 'remotes::install_version("git2r",upgrade="never", version = "0.26.1")' +RUN Rscript -e 'remotes::install_version("rsconnect",upgrade="never", version = "0.8.16")' +RUN Rscript -e 'remotes::install_version("promises",upgrade="never", version = "1.1.0")' +RUN Rscript -e 'remotes::install_version("testthat",upgrade="never", version = "2.3.2")' +RUN Rscript -e 'remotes::install_version("hexmake",upgrade="never", version = "0.0.0.9000")' +RUN Rscript -e 'remotes::install_version("shinyalert",upgrade="never", version = "1.0")' +RUN Rscript -e 'remotes::install_version("plotly",upgrade="never", version = "4.9.2")' +RUN Rscript -e 'remotes::install_version("shinyMobile",upgrade="never", version = "0.1.0")' +RUN Rscript -e 'remotes::install_version("resume",upgrade="never", version = "0.0.0.9000")' +RUN Rscript -e 'remotes::install_version("bs4Dash",upgrade="never", version = "0.5.0")' +RUN Rscript -e 'remotes::install_version("shinydashboardPlus",upgrade="never", version = "0.7.0")' +RUN Rscript -e 'remotes::install_version("sf",upgrade="never", version = "0.7-7")' +RUN Rscript -e 'remotes::install_version("tidyverse",upgrade="never", version = "1.3.0")' +RUN Rscript -e 'remotes::install_version("shinytest",upgrade="never", version = "1.3.1")' +RUN Rscript -e 'remotes::install_version("processx",upgrade="never", version = "3.4.2")' +RUN Rscript -e 'remotes::install_version("geojsonsf",upgrade="never", version = "1.3.3")' +RUN Rscript -e 'remotes::install_version("pkgbuild",upgrade="never", version = "1.0.6")' +RUN Rscript -e 'remotes::install_version("profvis",upgrade="never", version = "0.3.6")' +RUN Rscript -e 'remotes::install_version("dbplyr",upgrade="never", version = "1.4.2")' +RUN Rscript -e 'remotes::install_version("vroom",upgrade="never", version = "1.2.0")' +RUN Rscript -e 'remotes::install_version("data.table",upgrade="never", version = "1.12.8")' +RUN Rscript -e 'remotes::install_version("readxl",upgrade="never", version = "1.3.1")' +RUN Rscript -e 'remotes::install_version("R.cache",upgrade="never", version = "0.14.0")' +RUN Rscript -e 'remotes::install_github("hrbrmstr/cloc@f1bcd536543ccc50194b8c385d3b85424cb802b8")' +RUN Rscript -e 'remotes::install_github("r-lib/memoise@4aefd9f985f872d85d88461454eb2eb21d732bf7")' +RUN Rscript -e 'remotes::install_github("mangothecat/packageMetrics2@2c19392f44436d7da966170aa7bc30d10217c6e7")' +RUN Rscript -e 'remotes::install_github("colinfay/tidytuesday201942@a696cdb8827ad6608cd5d8e3c11070c5eec490cd")' +RUN Rscript -e 'remotes::install_github("r-lib/liteq@f7968687c63e1343503bf4c8828366222cfdbb58")' +RUN Rscript -e 'remotes::install_github("Thinkr-open/shinipsum@42f6171f3b0dfd374e9daeba305f27bd049b569e")' +RUN Rscript -e 'remotes::install_github("Thinkr-open/fakir@2fc362155388c9d995387d48dbfc060a7b39d382")' +RUN Rscript -e 'remotes::install_github("colinfay/crrry@856491a0b37225d74c7f6b3b464e3ab729b75b11")' +RUN Rscript -e 'remotes::install_github("r-lib/usethis@362ef0ef9483cd0b948b80590985ebb085c1f8bd")' +RUN Rscript -e 'remotes::install_github("ColinFay/nessy@eec2be62dbc74ee447fed1ef235901ed1566f89c")' +RUN Rscript -e 'remotes::install_github("colinfay/skeleton@eeeccc0ce3f5a27e92d4fed12cfc96a453d04cc2")' +RUN Rscript -e 'remotes::install_github("RinteRface/fullPage@2effa8d6b59407314de6292da4acb078f3a66f39")' +RUN Rscript -e 'remotes::install_github("RLesur/crrri@5fab7a1ed1f44565c6e6338c29effb03629420eb")' +RUN Rscript -e 'remotes::install_github("rstudio/chromote@a28092d8ea08eb6625583ca223f31ed6d0a08980")' +RUN Rscript -e 'remotes::install_github("ColinFay/gargoyle@ab4bd5d4f864f12cd7f70a1caa8ebc4b10c0d0e9")' +RUN Rscript -e 'remotes::install_github("colinfay/glouton@75363f66fd3f068c56d74efa090aabeff37bfb79")' +RUN Rscript -e 'remotes::install_github("colinfay/bubble@eccb94a53f5c6725c92721d4780dbee0529314af")' +RUN Rscript -e 'remotes::install_github("r-lib/lifecycle@355dcba8530bcce57c15847165ca568f9f81b43e")' +RUN Rscript -e 'remotes::install_github("r-lib/fastmap@61c609993a40b8101b141b2c940bf8ccbaef4dfa")' +RUN mkdir /build_zone +ADD . /build_zone +WORKDIR /build_zone +RUN R -e 'remotes::install_local(upgrade="never")' +EXPOSE 80 +CMD R -e "options('shiny.port'=80,shiny.host='0.0.0.0');Compendium::run_app()" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..40e3606a --- /dev/null +++ b/LICENSE @@ -0,0 +1,2 @@ +YEAR: 2020 +COPYRIGHT HOLDER: ThinkR diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..c8c26564 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2020 ThinkR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a549117f..b21037d9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ -__WORK IN PROGRESS__ +<!-- badges: start --> + [![R build status](https://github.com/ThinkR-open/building-shiny-apps-workflow/workflows/R-CMD-check/badge.svg)](https://github.com/ThinkR-open/building-shiny-apps-workflow/actions) + <!-- badges: end --> -A book about building big shiny apps. \ No newline at end of file +A book about "Engineering Production-Grade Shiny Apps". + +To be published in the R Series in 2020. + +<https://engineering-shiny.org/> + +## Want to help? + +Any feedback on the book is very welcome. +Please be sure that you comment on the **WIP version of the book**, which is at the `wip` branch. + +Feel free to [open an issue](https://github.com/ThinkR-open/building-shiny-apps-workflow/issues), or to make a PR if you spot a typo (We're not native English speakers, so there might be some issues waiting to be found ;) ). diff --git a/_book/engineering-production-grade-shiny-apps.pdf b/_book/engineering-production-grade-shiny-apps.pdf new file mode 100644 index 00000000..820dd1ae Binary files /dev/null and b/_book/engineering-production-grade-shiny-apps.pdf differ diff --git a/_bookdown.yml b/_bookdown.yml index 504098d5..c6dc4789 100644 --- a/_bookdown.yml +++ b/_bookdown.yml @@ -1,15 +1,37 @@ -book_filename: "building-big-shiny-apps" -delete_merged_file: true +book_filename: engineering-production-grade-shiny-apps +clean: [packages.bib, bookdown.bbl] +delete_merged_file: yes language: + label: + fig: "FIGURE " + tab: "TABLE " ui: + edit: "Edit" chapter_name: "Chapter " -output_dir: "docs" -rmd_files: [ - "index.rmd", - "big-shiny.Rmd", - "challenges.Rmd", - "step-by-step.Rmd", - "golem.Rmd", - "prototyping-fakir-shinipsum.Rmd", - "tools.Rmd" -] +output_dir: _site +before_chapter_script: golembuild.R +after_chapter_script: golemdestroy.R +rmd_files: +- index.Rmd +- 00-app-presentation.Rmd +- 01-big-shiny.Rmd +- 02-planning-ahead.Rmd +- 03-structure.Rmd +- 04-golem.Rmd +- 05-workflow.Rmd +- 06-ux-matters.Rmd +- 07-step-by-step-design.Rmd +- 08-step-by-step-prototype.Rmd +- 09-prototyping.Rmd +- 10-step-by-step-build.Rmd +- 11-step-by-step-secure.Rmd +- 12-secure.Rmd +- 13-deploy.Rmd +- 14-when_optimize.Rmd +- 15-common-app-caveats.Rmd +- 16-optimizing-shiny-code.Rmd +- 17-javascript.Rmd +- 18-css.Rmd +- 19-appendix.Rmd +- 20-session-info.Rmd +- 21-references.Rmd \ No newline at end of file diff --git a/_output.yml b/_output.yml index 7f2d91c3..5e686c45 100644 --- a/_output.yml +++ b/_output.yml @@ -1,15 +1,51 @@ bookdown::gitbook: - css: style.css + css: [css/style.css, css/thinkr.css, css/style_gitbook.css] + toc_depth: 3 config: toc: + collapse: section before: | - <li><a href="./">Building Big Shiny Apps - A Workflow</a></li> + <li><a href="./">Engineering Production-Grade Shiny Apps</a></li> after: | - <li><a href="https://github.com/rstudio/bookdown" target="blank">Published with bookdown</a></li> + <li><a href="https://rtask.thinkr.fr">A book by the ThinkR team</a></li> + edit: + link: https://github.com/ThinkR-open/building-shiny-apps-workflow/edit/master/%s + text: "Edit" + sharing: + github: true + facebook: false + includes: + in_header: ga.html + after_body: gitbook-footer-thinkr.html bookdown::pdf_book: includes: - in_header: preamble.tex + in_header: latex/preamble.tex + before_body: latex/before_body.tex + after_body: latex/after_body.tex + keep_tex: true + dev: "cairo_pdf" latex_engine: xelatex citation_package: natbib - keep_tex: yes -bookdown::epub_book: default + template: null + pandoc_args: --top-level-division=chapter + toc_depth: 3 + toc_unnumbered: false + toc_appendix: true + quote_footer: ["\\VA{", "}{}"] +bookdown::epub_book: + stylesheet: css/style.css +redirects: + successfulshinyapp: successful-shiny-app + planning: planning-ahead + structure: structuring-project + matters: ux-matters + step-design: dont-rush-into-coding + settingupsuccess: setting-up-for-success + stepprotopype: building-ispum-app + stepbuild: build-app-golem + step-secure: build-yourself-safety-net + secure: version-control + deploy-golem: deploy + when-optimize: need-for-optimization + optim-caveat: common-app-caveats + optimjs: using-javascript \ No newline at end of file diff --git a/before-build-spellcheck.R b/before-build-spellcheck.R new file mode 100644 index 00000000..2d58a63e --- /dev/null +++ b/before-build-spellcheck.R @@ -0,0 +1,41 @@ +if (!requireNamespace("gh")){ + install.packages("gh") +} +if (!requireNamespace("spelling")){ + install.packages("spelling") +} +if (!requireNamespace("knitr")){ + install.packages("knitr") +} +try({ + gh::gh( + "POST /repos/:owner/:repo/issues", + owner = gsub("([^/]*)/.*", "\\1", Sys.getenv("GITHUB_REPOSITORY")), + repo = gsub("[^/]*/(.*)", "\\1", Sys.getenv("GITHUB_REPOSITORY")), + title = sprintf( + "Spellcheck GA %s - %s", + Sys.getenv("GITHUB_ACTION"), Sys.Date() + ), + .token = Sys.getenv("GH_TOKEN"), + body = paste( + capture.output( + knitr::kable( + do.call( + rbind, + lapply( + list.files( + path = ".", + pattern = ".Rmd$" + ), + function(x){ + spelling::spell_check_files(x) + } + ) + ) + ) + ), + collapse = "\n" + ) + ) +}) + diff --git a/before-build.R b/before-build.R new file mode 100644 index 00000000..af89373f --- /dev/null +++ b/before-build.R @@ -0,0 +1,70 @@ +# To do locally on Colin's computer, thanks +options(repos = c(REPO_NAME = "https://packagemanager.rstudio.com/all/latest")) + +remotes::install_github("lbartnik/subprocess") +remotes::install_github("rstudio/websocket") +remotes::install_github("thinkr-open/bank") +paks <- c( + "cloc", "dplyr", "cyclocomp", "tidytuesday201942", "shiny", "packageMetrics2", + "remotes", "readr", "here", "tibble", "knitr", "desc", "attachment", "magrittr", + "tools", "fs", "glue", "dichromat", "purrr", "htmltools", "matlab", "viridis", + "golem", "shinipsum", "ggplot2", "DT", "fakir", "shinyloadtest", "dockerstats", + "attempt", "dockerfiler", "Rcpp", "profmem", "bench", "jsonlite", "cli", "memoise", + "tictoc", "promises", "future", "liteq", "DBI", "RSQLite", "xfun", + 'bookdown', 'knitr', 'rmarkdown', 'tidyverse', + 'testthat', 'usethis', 'config', 'hexmake', 'shinyalert', + 'plotly', 'shinyMobile', 'resume', 'nessy','skeleton', + 'fullPage', 'bs4Dash', 'shinydashboardPlus', + 'sf', 'devtools', 'crrri', + 'chromote', + 'crrry', 'shinytest', 'processx', + 'renv', 'geojsonsf', 'pkgbuild', 'profvis', + 'gargoyle', 'dplyr', 'dbplyr', 'vroom', + 'data.table', 'jsonlite', 'readxl', + 'R.cache', 'glouton', 'bubble', 'roxygen2', + 'covr', 'rcmdcheck', 'covrpage', + 'dccvalidator', 'minifyr', 'sever', 'shinyFeedback', + "whereami", "RcppSimdJson", "foreign", "haven", + "tidymodules", "shinyjs", "htmlwidgets", + "hunspell", "rhub", "spelling", "tufte", "uuid", + "attachment", "remotes", "usethis", "namer", "desc", "spelling", "tufte", + "dockerstats", "spelling", "tidymodules", "bank" +) + +paks <- unique(paks) +# for (i in paks){ +# if (!requireNamespace(i)){ +# install.packages(i) +# } +# } +cran_paks <- tools::CRAN_package_db() +desc_pak <- desc::desc_get_deps()$package + +for (pak in paks){ + if ( !(pak %in% desc_pak)){ + try({ + if (pak %in% cran_paks$Package){ + usethis::use_package(pak) + } else { + usethis::use_dev_package(pak) + } + }) + } +} + +usethis::use_tidy_description() + +remotes::install_local(Ncpus = 4, upgrade = "never", force = TRUE) + +knitr::write_bib(c( + unique(paks) +), 'packages.bib') + +# purrr::walk( +# list.files(path = ".", pattern = ".Rmd$"), +# function(x){ +# cli::cat_rule(x) +# namer::name_chunks(x) +# } +# ) + diff --git a/big-shiny.Rmd b/big-shiny.Rmd deleted file mode 100644 index 836462f1..00000000 --- a/big-shiny.Rmd +++ /dev/null @@ -1,14 +0,0 @@ -# (PART) Building Shiny Apps {-} - -# About Big Shiny Apps {#bigshinyapp} - -If you're reading this page, chances are you already know what a Shiny App is — a web application that communicates with R, built in R, and working with R. Almost anybody can create a prototype for a small data product in a matter of hours. And no knowledge of HTML, CSS or JavaScript is required, making it really easy to use — you can rapidly create a POC. But what to do now you want to build a big Shiny App? - -What's a big Shiny App? - -+ Well, first, one that includes several thousand lines of code (R and others). -+ It's also one that is potentially developed by several coders, working on the same application at the same time. -+ It's an application where scaling matters. -+ Maintainability and ease of upgrading are important. -+ In many cases, Shiny Apps in production are not used by "tech literate" people. -+ People rely on this application for making real-world decisions, with real consequences. diff --git a/blankgolem/.Rbuildignore b/blankgolem/.Rbuildignore new file mode 100644 index 00000000..43419585 --- /dev/null +++ b/blankgolem/.Rbuildignore @@ -0,0 +1,6 @@ +^.*\.Rproj$ +^\.Rproj\.user$ +^data-raw$ +dev_history.R +^dev$ +$run_dev.* diff --git a/blankgolem/DESCRIPTION b/blankgolem/DESCRIPTION new file mode 100644 index 00000000..96e78029 --- /dev/null +++ b/blankgolem/DESCRIPTION @@ -0,0 +1,17 @@ +Package: blankgolem +Title: An Amazing Shiny App +Version: 0.0.0.9000 +Authors@R: + person(given = "firstname", + family = "lastname", + role = c("aut", "cre"), + email = "your@email.com") +Description: What the package does (one paragraph). +License: What license is it under? +Imports: + config, + golem, + shiny +Encoding: UTF-8 +LazyData: true +RoxygenNote: 6.1.1 diff --git a/blankgolem/NAMESPACE b/blankgolem/NAMESPACE new file mode 100644 index 00000000..2b8d54c7 --- /dev/null +++ b/blankgolem/NAMESPACE @@ -0,0 +1,10 @@ +# Generated by roxygen2: do not edit by hand + +export(run_app) +import(shiny) +importFrom(golem,activate_js) +importFrom(golem,add_resource_path) +importFrom(golem,bundle_resources) +importFrom(golem,favicon) +importFrom(golem,with_golem_options) +importFrom(shiny,shinyApp) diff --git a/blankgolem/R/app_config.R b/blankgolem/R/app_config.R new file mode 100644 index 00000000..3a6f6292 --- /dev/null +++ b/blankgolem/R/app_config.R @@ -0,0 +1,36 @@ +#' Access files in the current app +#' +#' NOTE: If you manually change your package name in the DESCRIPTION, +#' don't forget to change it here too, and in the config file. +#' For a safer name change mechanism, use the `golem::set_golem_name()` function. +#' +#' @param ... character vectors, specifying subdirectory and file(s) +#' within your package. The default, none, returns the root of the app. +#' +#' @noRd +app_sys <- function(...){ + system.file(..., package = "blankgolem") +} + + +#' Read App Config +#' +#' @param value Value to retrieve from the config file. +#' @param config R_CONFIG_ACTIVE value. +#' @param use_parent Logical, scan the parent directory for config file. +#' +#' @noRd +get_golem_config <- function( + value, + config = Sys.getenv("R_CONFIG_ACTIVE", "default"), + use_parent = TRUE +){ + config::get( + value = value, + config = config, + # Modify this if your config file is somewhere else: + file = app_sys("golem-config.yml"), + use_parent = use_parent + ) +} + diff --git a/blankgolem/R/app_server.R b/blankgolem/R/app_server.R new file mode 100644 index 00000000..de544077 --- /dev/null +++ b/blankgolem/R/app_server.R @@ -0,0 +1,10 @@ +#' The application server-side +#' +#' @param input,output,session Internal parameters for {shiny}. +#' DO NOT REMOVE. +#' @import shiny +#' @noRd +app_server <- function( input, output, session ) { + # Your application server logic + +} diff --git a/blankgolem/R/app_ui.R b/blankgolem/R/app_ui.R new file mode 100644 index 00000000..e820e02c --- /dev/null +++ b/blankgolem/R/app_ui.R @@ -0,0 +1,42 @@ +#' The application User-Interface +#' +#' @param request Internal parameter for `{shiny}`. +#' DO NOT REMOVE. +#' @import shiny +#' @noRd +app_ui <- function(request) { + tagList( + # Leave this function for adding external resources + golem_add_external_resources(), + # Your application UI logic + fluidPage( + h1("blankgolem") + ) + ) +} + +#' Add external Resources to the Application +#' +#' This function is internally used to add external +#' resources inside the Shiny application. +#' +#' @import shiny +#' @importFrom golem add_resource_path activate_js favicon bundle_resources +#' @noRd +golem_add_external_resources <- function(){ + + add_resource_path( + 'www', app_sys('app/www') + ) + + tags$head( + favicon(), + bundle_resources( + path = app_sys('app/www'), + app_title = 'blankgolem' + ) + # Add here other external resources + # for example, you can add shinyalert::useShinyalert() + ) +} + diff --git a/blankgolem/R/run_app.R b/blankgolem/R/run_app.R new file mode 100644 index 00000000..1082e5ad --- /dev/null +++ b/blankgolem/R/run_app.R @@ -0,0 +1,25 @@ +#' Run the Shiny Application +#' +#' @param ... arguments to pass to golem_opts +#' @inheritParams shiny::shinyApp +#' +#' @export +#' @importFrom shiny shinyApp +#' @importFrom golem with_golem_options +run_app <- function( + onStart = NULL, + options = list(), + enableBookmarking = NULL, + ... +) { + with_golem_options( + app = shinyApp( + ui = app_ui, + server = app_server, + onStart = onStart, + options = options, + enableBookmarking = enableBookmarking + ), + golem_opts = list(...) + ) +} diff --git a/blankgolem/dev/01_start.R b/blankgolem/dev/01_start.R new file mode 100644 index 00000000..020989c3 --- /dev/null +++ b/blankgolem/dev/01_start.R @@ -0,0 +1,65 @@ +# Building a Prod-Ready, Robust Shiny Application. +# +# README: each step of the dev files is optional, and you don't have to +# fill every dev scripts before getting started. +# 01_start.R should be filled at start. +# 02_dev.R should be used to keep track of your development during the project. +# 03_deploy.R should be used once you need to deploy your app. +# +# +######################################## +#### CURRENT FILE: ON START SCRIPT ##### +######################################## + +## Fill the DESCRIPTION ---- +## Add meta data about your application +## +## /!\ Note: if you want to change the name of your app during development, +## either re-run this function, call golem::set_golem_name(), or don't forget +## to change the name in the app_sys() function in app_config.R /!\ +## +golem::fill_desc( + pkg_name = "blankgolem", # The Name of the package containing the App + pkg_title = "PKG_TITLE", # The Title of the package containing the App + pkg_description = "PKG_DESC.", # The Description of the package containing the App + author_first_name = "AUTHOR_FIRST", # Your First Name + author_last_name = "AUTHOR_LAST", # Your Last Name + author_email = "AUTHOR@MAIL.COM", # Your Email + repo_url = NULL # The URL of the GitHub Repo (optional) +) + +## Set {golem} options ---- +golem::set_golem_options() + +## Create Common Files ---- +## See ?usethis for more information +usethis::use_mit_license( name = "Golem User" ) # You can set another license here +usethis::use_readme_rmd( open = FALSE ) +usethis::use_code_of_conduct() +usethis::use_lifecycle_badge( "Experimental" ) +usethis::use_news_md( open = FALSE ) + +## Use git ---- +usethis::use_git() + +## Init Testing Infrastructure ---- +## Create a template for tests +golem::use_recommended_tests() + +## Use Recommended Packages ---- +golem::use_recommended_deps() + +## Favicon ---- +# If you want to change the favicon (default is golem's one) +golem::use_favicon() # path = "path/to/ico". Can be an online file. +golem::remove_favicon() + +## Add helper functions ---- +golem::use_utils_ui() +golem::use_utils_server() + +# You're now set! ---- + +# go to dev/02_dev.R +rstudioapi::navigateToFile( "dev/02_dev.R" ) + diff --git a/blankgolem/dev/02_dev.R b/blankgolem/dev/02_dev.R new file mode 100644 index 00000000..b50aeaa8 --- /dev/null +++ b/blankgolem/dev/02_dev.R @@ -0,0 +1,95 @@ +# Building a Prod-Ready, Robust Shiny Application. +# +# README: each step of the dev files is optional, and you don't have to +# fill every dev scripts before getting started. +# 01_start.R should be filled at start. +# 02_dev.R should be used to keep track of your development during the project. +# 03_deploy.R should be used once you need to deploy your app. +# +# +################################### +#### CURRENT FILE: DEV SCRIPT ##### +################################### + +# Engineering + +## Dependencies ---- +## Add one line by package you want to add as dependency +usethis::use_package( "thinkr" ) + +## Add modules ---- +## Create a module infrastructure in R/ +golem::add_module( name = "name_of_module1" ) # Name of the module +golem::add_module( name = "name_of_module2" ) # Name of the module + +## Add helper functions ---- +## Creates ftc_* and utils_* +golem::add_fct( "helpers" ) +golem::add_utils( "helpers" ) + +## External resources +## Creates .js and .css files at inst/app/www +golem::add_js_file( "script" ) +golem::add_js_handler( "handlers" ) +golem::add_css_file( "custom" ) + +## Add internal datasets ---- +## If you have data in your package +usethis::use_data_raw( name = "my_dataset", open = FALSE ) + +## Tests ---- +## Add one line by test you want to create +usethis::use_test( "app" ) + +# Documentation + +## Vignette ---- +usethis::use_vignette("blankgolem") +devtools::build_vignettes() + +## Code Coverage---- +## Set the code coverage service ("codecov" or "coveralls") +usethis::use_coverage() + +# Create a summary readme for the testthat subdirectory +covrpage::covrpage() + +## CI ---- +## Use this part of the script if you need to set up a CI +## service for your application +## +## (You'll need GitHub there) +usethis::use_github() + +# GitHub Actions +usethis::use_github_action() +# Chose one of the three +# See https://usethis.r-lib.org/reference/use_github_action.html +usethis::use_github_action_check_release() +usethis::use_github_action_check_standard() +usethis::use_github_action_check_full() +# Add action for PR +usethis::use_github_action_pr_commands() + +# Travis CI +usethis::use_travis() +usethis::use_travis_badge() + +# AppVeyor +usethis::use_appveyor() +usethis::use_appveyor_badge() + +# Circle CI +usethis::use_circleci() +usethis::use_circleci_badge() + +# Jenkins +usethis::use_jenkins() + +# GitLab CI +usethis::use_gitlab_ci() + +# You're now set! ---- +# go to dev/03_deploy.R +rstudioapi::navigateToFile("dev/03_deploy.R") + diff --git a/blankgolem/dev/03_deploy.R b/blankgolem/dev/03_deploy.R new file mode 100644 index 00000000..4e36df69 --- /dev/null +++ b/blankgolem/dev/03_deploy.R @@ -0,0 +1,37 @@ +# Building a Prod-Ready, Robust Shiny Application. +# +# README: each step of the dev files is optional, and you don't have to +# fill every dev scripts before getting started. +# 01_start.R should be filled at start. +# 02_dev.R should be used to keep track of your development during the project. +# 03_deploy.R should be used once you need to deploy your app. +# +# +###################################### +#### CURRENT FILE: DEPLOY SCRIPT ##### +###################################### + +# Test your app + +## Run checks ---- +## Check the package before sending to prod +devtools::check() +rhub::check_for_cran() + +# Deploy + +## RStudio ---- +## If you want to deploy on RStudio related platforms +golem::add_rstudioconnect_file() +golem::add_shinyappsio_file() +golem::add_shinyserver_file() + +## Docker ---- +## If you want to deploy via a generic Dockerfile +golem::add_dockerfile() + +## If you want to deploy to ShinyProxy +golem::add_dockerfile_shinyproxy() + +## If you want to deploy to Heroku +golem::add_dockerfile_heroku() diff --git a/blankgolem/dev/run_dev.R b/blankgolem/dev/run_dev.R new file mode 100644 index 00000000..6729c32b --- /dev/null +++ b/blankgolem/dev/run_dev.R @@ -0,0 +1,12 @@ +# Set options here +options(golem.app.prod = FALSE) # TRUE = production mode, FALSE = development mode + +# Detach all loaded packages and clean your environment +golem::detach_all_attached() +# rm(list=ls(all.names = TRUE)) + +# Document and reload your package +golem::document_and_reload() + +# Run the application +run_app() diff --git a/blankgolem/inst/app/www/favicon.ico b/blankgolem/inst/app/www/favicon.ico new file mode 100644 index 00000000..4c0982c0 Binary files /dev/null and b/blankgolem/inst/app/www/favicon.ico differ diff --git a/blankgolem/inst/golem-config.yml b/blankgolem/inst/golem-config.yml new file mode 100644 index 00000000..3dbeaf77 --- /dev/null +++ b/blankgolem/inst/golem-config.yml @@ -0,0 +1,8 @@ +default: + golem_name: blankgolem + golem_version: 0.0.0.9000 + app_prod: no +production: + app_prod: yes +dev: + golem_wd: !expr here::here() diff --git a/blankgolem/man/run_app.Rd b/blankgolem/man/run_app.Rd new file mode 100644 index 00000000..b7413767 --- /dev/null +++ b/blankgolem/man/run_app.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/run_app.R +\name{run_app} +\alias{run_app} +\title{Run the Shiny Application} +\usage{ +run_app(onStart = NULL, options = list(), enableBookmarking = NULL, + ...) +} +\arguments{ +\item{onStart}{A function that will be called before the app is actually run. +This is only needed for \code{shinyAppObj}, since in the \code{shinyAppDir} +case, a \code{global.R} file can be used for this purpose.} + +\item{options}{Named options that should be passed to the \code{runApp} call +(these can be any of the following: "port", "launch.browser", "host", "quiet", +"display.mode" and "test.mode"). You can also specify \code{width} and +\code{height} parameters which provide a hint to the embedding environment +about the ideal height/width for the app.} + +\item{enableBookmarking}{Can be one of \code{"url"}, \code{"server"}, or +\code{"disable"}. This is equivalent to calling the +\code{\link[=enableBookmarking]{enableBookmarking()}} function just before calling +\code{shinyApp()}. With the default value (\code{NULL}), the app will +respect the setting from any previous calls to \code{enableBookmarking()}. +See \code{\link[=enableBookmarking]{enableBookmarking()}} for more information.} + +\item{...}{arguments to pass to golem_opts} +} +\description{ +Run the Shiny Application +} diff --git a/book.bib b/book.bib index f52f3d22..4abcc92f 100644 --- a/book.bib +++ b/book.bib @@ -1,10 +1,169 @@ -@Book{xie2015, - title = {Dynamic Documents with {R} and knitr}, - author = {Yihui Xie}, - publisher = {Chapman and Hall/CRC}, - address = {Boca Raton, Florida}, +@book{ericraymond2003, + Author = {Eric S. Raymond}, + title = {The Art of UNIX Programming (The Addison-Wesley Professional Computng Series)}, + description = {The Art of UNIX Programming (The Addison-Wesley Professional Computng Series) (Book, 2003)}, + publisher = {Addison-Wesley}, + year = {2003}, + month = {oct}, + isbn = {0131429019} +} + +@book{stevekrug2014, + Author = {Steve Krug}, + title = {Don't Make Me Think, Revisited: A Common Sense Approach to Web Usability (3rd Edition) (Voices That Matter)}, + description = {Don't Make Me Think, Revisited: A Common Sense Approach to Web Usability (3rd Edition) (Voices That Matter) (Book, 2014)}, + publisher = {New Riders}, + year = {2014}, + month = {jan}, + isbn = {0321965515} +} + +@book{genekim2016, + Author = {Gene Kim}, + title = {The DevOps Handbook: How to Create World-Class Agility, Reliability, and Security in Technology Organizations}, + description = {The DevOps Handbook: How to Create World-Class Agility, Reliability, and Security in Technology Organizations (Book, 2016)}, + publisher = {IT Revolution Press}, + interhash = {2a24773a5d228001d0f18fbad40f7d75}, + intrahash = {f2639d2493a5a3bb5664d7b95f7de9cb}, + year = {2016}, + month = {oct}, + isbn = {1942788002}, + url = {https://www.xarg.org/ref/a/1942788002/} +} + +@book{genekim2019, + Author = {Gene Kim}, + title = {The unicorn project: a novel about developers, digital disruption, and thriving in the age of data}, + description = {The unicorn project: a novel about developers, digital disruption, and thriving in the age of data (Book, 2019)}, + publisher = {IT Revolution Press}, + year = {2019}, + month = {dec}, + isbn = {1942788762}, + url = {https://itrevolution.com/the-unicorn-project/} +} + +@misc{rockerverse, +Author = {Daniel Nüst and Dirk Eddelbuettel and Dom Bennett and Robrecht Cannoodt and Dav Clark and Gergely Daroczi and Mark Edmondson and Colin Fay and Ellis Hughes and Lars Kjeldgaard and Sean Lopp and Ben Marwick and Heather Nolis and Jacqueline Nolis and Hong Ooi and Karthik Ram and Noam Ross and Lori Shepherd and Péter Sólymos and Tyson Lee Swetnam and Nitesh Turaga and Charlotte Van Petegem and Jason Williams and Craig Willis and Nan Xiao}, +Title = {The Rockerverse: Packages and Applications for Containerization with R}, +Year = {2020}, +Eprint = {arXiv:2001.10641}, +} + +@book{hadleywickham2017, + Author = {Hadley Wickham and Garrett Grolemund}, + title = {R for Data Science: Import, Tidy, Transform, Visualize, and Model Data}, + description = {R for Data Science: Import, Tidy, Transform, Visualize, and Model Data (Book, 2017)}, + publisher = {O'Reilly Media}, + interhash = {9efb24f0eb2290a0e399b5a26ac16d1d}, + intrahash = {9ae7940e660ee0380a9fd2f267e12014}, + year = {2017}, + month = {jan}, + isbn = {1491910399}, + url = {https://www.xarg.org/ref/a/1491910399/} +} + +@article{RJ-2017-065, + author = {Carl Boettiger and Dirk Eddelbuettel}, + title = {{An Introduction to Rocker: Docker Containers for R}}, + year = {2017}, + journal = {{The R Journal}}, + doi = {10.32614/RJ-2017-065}, + url = {https://doi.org/10.32614/RJ-2017-065}, + pages = {527--536}, + volume = {9}, + number = {2} +} + +@book{hadleywickham2019, + Author = {Hadley Wickham}, + title = {Advanced R, Second Edition}, + description = {Advanced R, Second Edition}, + publisher = {Chapman and Hall/CRC}, + year = {2019}, + month = {jun}, + isbn = {0815384572} +} + +@book{colingillespie2017, + Author = {Colin Gillespie and Robin Lovelace}, + title = {Efficient R programming}, + publisher = {O'Reilly Media, Inc, USA}, + year = {2017}, + month = {apr}, + isbn = {0131429019} +} + +@book{brianchristian2016, + Author = {Brian Christian and Tom Griffiths}, + title = {Algorithms to Live by : The Computer Science of Human Decisions}, + publisher = {Henry Holt}, + year = {2016}, + month = {apr}, + isbn = {1627790365} +} + +@book{rpkg, + Author = {Hadley Wickham and Jennifer Bryan}, + title = {R Packages}, + year = {2020}, + url = {https://r-pkgs.org/} +} + +@book{lemaire2020, + Author = {Maude Lemaire}, + title = {Refactoring At Scale}, + publisher = {Henry Holt}, + year = {2020}, + month = {dec}, + isbn = {9781492075516}, + url = {https://learning.oreilly.com/library/view/refactoring-at-scale/9781492075523/} +} + +@inproceedings{Sakamoto2015, + doi = {10.1109/dinwc.2015.7054230}, + url = {https://doi.org/10.1109/dinwc.2015.7054230}, year = {2015}, - edition = {2nd}, - note = {ISBN 978-1498716963}, - url = {http://yihui.name/knitr/}, + month = feb, + publisher = {{IEEE}}, + author = {Yasutaka Sakamoto and Shinsuke Matsumoto and Seiki Tokunaga and Sachio Saiki and Masahide Nakamura}, + title = {Empirical study on effects of script minification and {HTTP} compression for traffic reduction}, + booktitle = {2015 Third International Conference on Digital Information, Networking, and Wireless Communications ({DINWC})} +} + +@article{Chapman2008, + doi = {10.1177/154193120805201602}, + url = {https://doi.org/10.1177/154193120805201602}, + year = {2008}, + month = sep, + publisher = {{SAGE} Publications}, + volume = {52}, + number = {16}, + pages = {1107--1111}, + author = {Christopher N. Chapman and Edwin Love and Russell P. Milham and Paul ElRif and James L. Alford}, + title = {Quantitative Evaluation of Personas as Information}, + journal = {Proceedings of the Human Factors and Ergonomics Society Annual Meeting} +} + +@incollection{Billestrup2014, + doi = {10.1007/978-3-662-44811-3_16}, + url = {https://doi.org/10.1007/978-3-662-44811-3_16}, + year = {2014}, + publisher = {Springer Berlin Heidelberg}, + pages = {251--258}, + author = {Jane Billestrup and Jan Stage and Anders Bruun and Lene Nielsen and Kira S. Nielsen}, + title = {Creating and Using Personas in Software Development: Experiences from Practice}, + booktitle = {Human-Centered Software Engineering} } + +@article{Simon1956, + doi = {10.1037/h0042769}, + url = {https://doi.org/10.1037/h0042769}, + year = {1956}, + publisher = {American Psychological Association ({APA})}, + volume = {63}, + number = {2}, + pages = {129--138}, + author = {H. A. Simon}, + title = {Rational choice and the structure of the environment.}, + journal = {Psychological Review} +} \ No newline at end of file diff --git a/building-big-shiny-apps.Rproj b/building-big-shiny-apps.Rproj index 827cca17..d93dbc00 100644 --- a/building-big-shiny-apps.Rproj +++ b/building-big-shiny-apps.Rproj @@ -13,3 +13,5 @@ RnwWeave: Sweave LaTeX: pdfLaTeX BuildType: Website + +MarkdownWrap: Sentence diff --git a/cache/2db76ac4c62c46a6 b/cache/2db76ac4c62c46a6 new file mode 100644 index 00000000..69983a2e Binary files /dev/null and b/cache/2db76ac4c62c46a6 differ diff --git a/cache/3689a82603512976 b/cache/3689a82603512976 new file mode 100644 index 00000000..f5f59a87 Binary files /dev/null and b/cache/3689a82603512976 differ diff --git a/cache/75d4bb9816f58bf7 b/cache/75d4bb9816f58bf7 new file mode 100644 index 00000000..32b30a4b Binary files /dev/null and b/cache/75d4bb9816f58bf7 differ diff --git a/cache/7dfa5dc09e254c66 b/cache/7dfa5dc09e254c66 new file mode 100644 index 00000000..46a3399c Binary files /dev/null and b/cache/7dfa5dc09e254c66 differ diff --git a/cache/918aa8c80e393496 b/cache/918aa8c80e393496 new file mode 100644 index 00000000..be93f66b Binary files /dev/null and b/cache/918aa8c80e393496 differ diff --git a/cache/ac65d68512196d61 b/cache/ac65d68512196d61 new file mode 100644 index 00000000..f9ac8774 Binary files /dev/null and b/cache/ac65d68512196d61 differ diff --git a/cache/afbd289c0467bb6b b/cache/afbd289c0467bb6b new file mode 100644 index 00000000..6e9a2c19 Binary files /dev/null and b/cache/afbd289c0467bb6b differ diff --git a/cache/ce728ab7ca59587d b/cache/ce728ab7ca59587d new file mode 100644 index 00000000..deb97937 Binary files /dev/null and b/cache/ce728ab7ca59587d differ diff --git a/cache/d05414be6e8dc65d b/cache/d05414be6e8dc65d new file mode 100644 index 00000000..c73ca915 Binary files /dev/null and b/cache/d05414be6e8dc65d differ diff --git a/cache/edb80f34dd3879c9 b/cache/edb80f34dd3879c9 new file mode 100644 index 00000000..8dbb06fc Binary files /dev/null and b/cache/edb80f34dd3879c9 differ diff --git a/challenges.Rmd b/challenges.Rmd deleted file mode 100644 index ef80f2b4..00000000 --- a/challenges.Rmd +++ /dev/null @@ -1,31 +0,0 @@ -# Challenges {#challenges} - -## Finding a good UI (and stick with it) - -Choosing a UI is hard — we have a natural tendency, as coders, to be focused on the backend, i.e the algorithmic part of the application. But let's state the truth: no matter how complex and innovative your backend can be, your application is bad is your UI is bad. That's the hard truth. If people can't understand how to use your application, your application doesn't work. No matter how incredible the backend is. - -Try to find a simple, and efficient UI. One that people can understand and use in a matter of seconds. Don't implement features or visual elements that are not actually needed, just "in case". And spend time working on that UI, really thinking about what visual elements you are implementing. - -## Working as a team - -Big Shiny Apps usually means that several peoples will work on the application. For example, at ThinkR, 3 to 4 people usually work on the application. So, how do we organize that? - -### From the tools point of view: - -+ Use version control (not sure I have to expand on that topic ;) ) -+ Think of your shiny app as a tree, and divide it as much as possible into little pieces. Then, create one Shiny module by piece. This allows you to split the work, and also to have smaller files — it's easier to work on 20 files of 200 lines than on one big app.R file. - -### From the organisational point of view - - + Define one person in charge of having the big picture of the app. This person will kick off the project, and write the skeleton of the app, with the good modules and files structure. This person will also be in charge of accepting new merge requests from other developers, and to orchestrate the master and dev branches. -+ List the tasks, and open one issue for each task on your version control system. Each issue will be solved in a separate branch. -+ Finally, assign one module to one developer — if it seems that working on one module is a two-person job, divide again into two other submodules. This is a relatively complex task, as the output of one module influences the input of another, so be sure to assign them well. - -### Making the app production ready - -This includes two things: scaling and maintaining. As said in the disclaimer, I won't expand on the topic of scaling, as many have written about that, but here is one piece of advice: make the R process running the app do as less as possible, and in particular prevent it from doing what it's not supposed to do. Which includes: use JavaScript so that the client browser renders things (instead of making R do the work — basic JS is easy to learn), use parallelization and async, and if possible, make the heavy lifting be done outside the R session running the app. - -Maintainance, on the other, is something to think about from the beginning. It includes being able to ensure that the application will work on the long run, and that new features can be easily implemented. - -+ Working on the long run: separate the code with "business logic" (aka the data manipulation and the algorithm, that can work outside the context of the app) from the code building the application. That way, you can write regression tests for these functions to ensure they are stable. -+ Implement new elements: as we are working with modules, it's easy to insert new elements inside the global application. diff --git a/chapter-abstracts.Rmd b/chapter-abstracts.Rmd new file mode 100644 index 00000000..7c9bb49c --- /dev/null +++ b/chapter-abstracts.Rmd @@ -0,0 +1,206 @@ +--- +title: "Chapter abstract" +output: pdf_document +--- + +```{r setup, include=FALSE} +knitr::opts_chunk$set(echo = TRUE) +``` + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay + +## Chapter Number & Title -- Chapter 1, Chapter 1 About Successful {shiny} Apps + +The first chapter of the book will give the reader a short introduction to the `{shiny}` package. It will then lay the foundations for the rest of the book by defining key concepts necessary for a clear understanding of the upcoming chapters. +Here, we will address the following questions: how can we define complexity when it comes to software engineering? +How can we define a successful software project? +How can we measure both these aspects when it comes to R and `{shiny}`? + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 2, Planning Ahead + +This second chapter of the book will cover a crucial concept when it comes to leading a successful software engineering project: planning. + +In this chapter, the reader will be presented project management and planning in the context of a `{shiny}` project: why it's important to plan ahead, how to leverage the KISS principle, and how to practically organize a team of `{shiny}` developers, both from the tooling and management point of views. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay + +## Chapter Number & Title -- Chapter 3, Structuring your Project + +Chapter 3 will cover the technical aspects of structuring your `{shiny}` projects for production. + +In this chapter, we will cover the importance of building a `{shiny}` application as a package and all the benefits that will come with this infrastructure choice: metadata, documentation, native testing, and the ability to leverage all the toolkit available to the R developers. Then, we will move to another key concept that will power any large-scale `{shiny}` application: modules. Finally, we will introduce why convention matters when it comes to working as a team, and how to put this into practice when building applications using the `{golem}` framework. + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 4, Introduction to {golem} + +Chapter will introduce `{golem}`, a framework for building production-grade `{shiny}` applications. + +In this chapter, we will give an introduction to the general philosophy behind `{golem}`, and will describe in details the structure of a `{golem}` project: what makes it similar to a standard R package (`DESCRIPTION`, `NAMESPACE`, `man/`, ...), and what makes it unique. Notably, we will spend some time reviewing the functions contained in the default `{golem}` project, the very same functions that will be the starting point of your future `{shiny}` applications. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay, Sebastien Rochette + +## Chapter Number & Title -- Chapter 5, The workflow + +This fifth chapter succinctly presents the steps of the workflow later developed in the book: Design, Prototype, Build, Strengthen, and Deploy. + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 6, UX Matters + +Chapter 6 covers the basics of what make a web application user-friendly. + +First, we cover the importance of simplicity when it comes to designing an interface, keeping in mind the "Don't make me think" mantra. When reading the web, users tend to scan, instead of cautiously make logical decisions about how to behave. In other word, they do not really read but tend to scan the content, making it crucial for the page to be as simple as possible, so that the visitors easily find their way through the interface. + +Then, we cover the importance of lowering complexity of a software by restraining from implementing way too many features: when building an application with `{shiny}`, it's crucial to think about the necessity of the features we're implementing, so that we don't end with too much reactivity, and/or an application that is way to slow to be used. + +Finally, we cover one of the most important topic when it comes to making a successful application: accessibility. In other words, how do we make an application usable by the widest audience possible? How do we work on making our application usable by people with visual, mobility, or cognitive disabilities. This chapter will introduce the notion of semantic HTML in the context of `{shiny}`, structure, and, something important in a context where we build applications with data visualization: choice of colors. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay + +## Chapter Number & Title -- Chapter 7, + +This seventh chapter covers the need for getting yourself prepared upfront when it comes to engineering a `{shiny}`. + +Here, we will cover the importance of starting with planning, thinking, and evaluating existing solution before rushing into coding. We will also introduce concept maps, and give a series of tools to evaluate the project before even writing a single line of code. In other words, we'll see how to get started with user interview, how to create personas, and why it's important to evaluate pre-existing codebase before starting the project. + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 8, Setting up for success with {golem} + +Chapter 8 covers what to do now that you are ready to code your application. + +In other words, now that the coding part is officially ready to start, how do we set everything up using `{golem}`? This chapter will cover how to create a new project using `{golem}`, and how to set the files so that your team can work in a structured project. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay, Sebastien Rochette + +## Chapter Number & Title -- Chapter 9, Building an “ipsum-app” + +Chapter 9 describes step 2 of the workflow: Prototyping. + +The first part of this chapter covers the why of prototyping, and the importance of focusing on making the application work before working on anything else. Then, we will detail what prototyping means in the context of `{shiny}`: notably, we will present `{shinipsum}`, a package designed to create random elements to fill a `{shiny}` user interface. + +Then, the last part will present the "Rmd First" methodology, that describes the importance of building the application back-end inside RMarkdown documents, so that you can concentrate on the business logic of your application, instead of working on an entanglement of back-end functions and application logic. + + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 10, Building the app with {golem} + +This chapter 10 covers the third part of the workflow: building the application itself. + +Inside this chapter will be described how to add external dependencies to your application, how to build sub-modules and utilitarian functions, and how to document, test, and measure the code coverage of your application. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay + +## Chapter Number & Title -- Chapter 11, Build yourself a safety net + +The fourth part of the workflow is described in chapter 11 and 12. + +In this chapter 11, we come back in more details on the importance of testing your application, and on the toolkit `{shiny}` developers can leverage to test their applications: `{testthat}` for the business logic, `puppeteer`, `{shinytest}` and `{crrry}` for the front-end, and `{shinyloadtest}` and `{dockerstats}` for the application load, i.e. the computer resources necessary to make your application work. + +In this chapter, we will also present two tools that are crucial to reproducibility: `{renv}`, a package to manage local dependencies at a project level, and `Docker`, one of the most popular software today to write and deploy software. + + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay, Vincent Guyader + +## Chapter Number & Title – Chapter 12, Version Control + +Chapter 12 covers another essential part of working on solid grounds when building a production-grade software: version control. + +Version control is a methodology, based on a software, that allows to track change of a software through time, and to work simultaneously on various versions of the same codebase without interference. In this chapter, we will be focusing on `git`, one of the most popular version control system, and present a methodology called 'git flow'. + +This chapter will also cover how you can use version control server like GitLab or GitHub to build Continuous Integration and Continous Deployment (CI/CD) processes, allowing to automate actions whenever new content is integrated to the main codebase. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay + +## Chapter Number & Title -- Chapter 13, Deploy your application + +Chapter 13 covers the last part of the workflow: deployment. + +This chapter starts with a checklist of things to do before sending an application to production. Then, we move to the three main ways to share a `{shiny}` application: as a package, via a package manager or by sharing a `tar.gz`, using one of RStudio deployment platforms, or finally how you can leverage Docker to deploy your application to production. + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 14, The Need for Optimization + +Chapter 14 starts with the reflection around the necessity to optimize, and around managing the optimization process when building your application. + +When building a `{shiny}` application that will be send to production, schedule matters, and focusing on optimizing too soon, or too much, might endanger the whole success of the project. On the other hand, choices made when optimizing the application might have a big impact on the longevity of the project. + +If you decide to go along the optimization road, you better start by benchmarking what and where you need to optimize, so that you're sure you're not working on optimizing parts of the application that do not need to be optimized. The second part of the chapter presents tools you can use to perform this code profiling. + +## Book Title -- Engineering Production-Grade Shiny Apps + +## Chapter Author -- Colin Fay + +## Chapter Number & Title -- Chapter 15, Common Application Caveats + +Some code bottlenecks (parts of the codebase that slow the application) might not be caught by simple profiling tools: sometime a slow application can be explained by caveats in the way the application is designed. + +This chapter will present three main sources of design patterns that might be slowing your application: uncontrolled reactivity, where too much happens (also known as "reactivity hell"), making R perform too much computation, and data source management. + + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 16, Optimizing {shiny} Code + +There are several methods you can choose to optimize your application so that it works faster, and handles memory in a more efficient way. This chapter presents three of them. + +First, focusing on the R code itself. Then, caching elements. Caching is the process of storing computation results from a function so that it can be reused, instead of recomputing the function every time. In this chapter, we will see how you can implement caching with R code, and how to use the `{shiny}`-specific functions to do that. Finally, we will present a way to build asynchronousity inside a `{shiny}` application using `{promises}` and `{future}`. + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 17, Using JavaScript + +One of the best way to enhance your application, in the long run, is to get comfortable with JavaScript, a scripting language that runs in your web browser. + +With JavaScript, you'll be able to enhance the user experience by leveraging in-browser events, and lower the computation performed by R as you can perform them inside the browser, leaving more space to the server for computation. And in the long run, when you're comfortable with JavaScript, a whole world of `{shiny}` extensions opens. + +But in the meantime, you might be looking for a place to start. Search no more, this chapter is the perfect place to get started with JavaScript for `{shiny}`. + +## Book Title – Engineering Production-Grade Shiny Apps + +## Chapter Author – Colin Fay + +## Chapter Number & Title – Chapter 18, A Gentle Introduction to CSS + +In this last chapter of the book, we will introduce the last of the HTML/JavaScript/CSS web technology trio. + +CSS, for Cascading StyleSheets, is what powers the design of the web page. When making `{shiny}` applications that will be sent to production, chances are that you'll want to enhance its design. Then, CSS is where you should start. + +This chapter gives a short introduction to this language, so that you're more comfortable tackling design tasks on your next application. diff --git a/chapter-abstracts.pdf b/chapter-abstracts.pdf new file mode 100644 index 00000000..71af1c84 Binary files /dev/null and b/chapter-abstracts.pdf differ diff --git a/chunkall.R b/chunkall.R new file mode 100644 index 00000000..e9eb4b66 --- /dev/null +++ b/chunkall.R @@ -0,0 +1,7 @@ +lapply( + list.files(path = ".", pattern = ".Rmd$"), + function(x){ + namer::unname_all_chunks(x) + namer::name_chunks(x) + } +) \ No newline at end of file diff --git a/docs/style.css b/css/style.css similarity index 54% rename from docs/style.css rename to css/style.css index f317b438..3e433944 100644 --- a/docs/style.css +++ b/css/style.css @@ -12,3 +12,12 @@ pre { pre code { white-space: inherit; } +p.flushright { + text-align: right; +} +blockquote > p:last-child { + text-align: right; +} +blockquote > p:first-child { + text-align: inherit; +} diff --git a/css/style_gitbook.css b/css/style_gitbook.css new file mode 100644 index 00000000..1d65af18 --- /dev/null +++ b/css/style_gitbook.css @@ -0,0 +1,124 @@ +/* Special text */ +.advert { + color: #FF8929; + font-style: italic; +} +.codecommand, .codebox code { + background-color: #E0E0E0; + font-family: monospace; +} +.Large { + font-size: 2rem; +} +.exercise, .exo { + color: #00BA10; + font-size: 1.1rem; +} +.math.inline { + background-color: aliceblue; +} + +/* Special backgrounds*/ +.blueShaded { + background-color: #D6E8F5; + font-family: monospace; +} +.blueShaded pre:not([class]) { + background-color: #D6E8F5; +} +.redbox { + background-color: #FF7F7F; + padding: 2px 5px; +} + +/* Document formatting*/ +body { + font-family: "Noto Sans", sans-serif; + font-size: 1.5rem; + text-align: justify; +} + +h3 { + font-style: italic; +} + +/* Figures */ +.figure { + margin-bottom: 1.5em; +} + +/* Custom specific to my bookdown template */ +.body-inner { +/* background-color: #1e73be;*/ + background-color: #f6f6f6; +} +.book .book-body .page-wrapper .page-inner { + max-width: 1025px; +} +.book .book-body .page-wrapper .page-inner section { + padding: 5px 3em; +} +.book .book-body .page-wrapper .page-inner section.normal p.caption { + margin: 0 10%; + text-align: center; + font-size: 1.4rem; +} + +.page-inner { + max-width: 1025px; + margin-left: auto; + margin-right: auto; + background-color: white; + /* padding: 1em 30px 15px; */ + } + +.book .book-body .navigation { + font-size: 70px; + color: #ccc; + text-align: center; +} + +.book .book-body .page-wrapper .page-inner section.normal a { + color: #DE633C; +} + +/* thinkr.css +.container-fluid.main-container { + margin-top: 80px; +} +h1.title { + margin-top: 80px; +} +.container-fluid.main-container h1.title { + margin-top: 0px; +} +*/ +.logos { + position: fixed; + top: 50px; + width: 100px; + left: 330px; + z-index: 1; +} +.logos img { + margin: 5px auto; /* Modified for gitbook */ + display: block; + height: 60px; +} +a { + color: #15b7d6; +} +h1 { + color: #DE633C; +} +h2 { + color: #15b7d6; +} + +/* Footer with logo */ +footer { + text-align: center; +} +footer img { + max-height: 40px; +} diff --git a/css/thinkr.css b/css/thinkr.css new file mode 100644 index 00000000..294d4ee4 --- /dev/null +++ b/css/thinkr.css @@ -0,0 +1,32 @@ +.container-fluid.main-container { + margin-top: 80px; +} +h1.title { + margin-top: 80px; +} +.container-fluid.main-container h1.title { + margin-top: 0px; +} +.logos { + position: fixed; + top: 0px; + width: 100%; + left: 0; + background: white; + border-bottom: solid 1px grey; +} +.logos img { + margin: 5px; + display: block; + height: 60px; +} +a { + color: #15b7d6; +} +h1 { + color: #DE633C; +} +h2 { + color: #15b7d6; +} + diff --git a/data-raw/.Rprofile b/data-raw/.Rprofile new file mode 100644 index 00000000..81b960f5 --- /dev/null +++ b/data-raw/.Rprofile @@ -0,0 +1 @@ +source("renv/activate.R") diff --git a/data-raw/Dockerfile b/data-raw/Dockerfile new file mode 100644 index 00000000..fec75320 --- /dev/null +++ b/data-raw/Dockerfile @@ -0,0 +1,17 @@ +FROM rocker/r-ver:3.6.1 +RUN apt-get update && apt-get install -y git-core libcurl4-openssl-dev libssh2-1-dev libssl-dev libxml2-dev make zlib1g-dev && rm -rf /var/lib/apt/lists/* +RUN echo "options(repos = c(CRAN = 'https://cran.rstudio.com/'), download.file.method = 'libcurl')" >> /usr/local/lib/R/etc/Rprofile.site +RUN R -e 'install.packages("remotes")' +RUN R -e 'remotes::install_github("r-lib/remotes", ref = "97bbf81")' +RUN Rscript -e 'remotes::install_version("config",upgrade="never", version = "0.3")' +RUN Rscript -e 'remotes::install_version("shiny",upgrade="never", version = "1.4.0")' +RUN Rscript -e 'remotes::install_github("thinkr-open/golem@bf9d0411e337d80d878ed62168360f920668acc2")' +RUN Rscript -e 'remotes::install_github("rstudio/htmltools@e07546ccb476a3f1c5cbe6178424635a886f8008")' +RUN Rscript -e 'remotes::install_github("rstudio/rstudioapi@66e81da53485b036794f2e737b26ed3d53557013")' +RUN Rscript -e 'remotes::install_github("r-lib/fastmap@61c609993a40b8101b141b2c940bf8ccbaef4dfa")' +RUN mkdir /build_zone +ADD . /build_zone +WORKDIR /build_zone +RUN R -e 'remotes::install_local(upgrade="never")' +EXPOSE 80 +CMD R -e "options('shiny.port'=80,shiny.host='0.0.0.0');golex::run_app()" diff --git a/data-raw/data-raw.Rproj b/data-raw/data-raw.Rproj new file mode 100644 index 00000000..8e3c2ebc --- /dev/null +++ b/data-raw/data-raw.Rproj @@ -0,0 +1,13 @@ +Version: 1.0 + +RestoreWorkspace: Default +SaveWorkspace: Default +AlwaysSaveHistory: Default + +EnableCodeIndexing: Yes +UseSpacesForTab: Yes +NumSpacesForTab: 2 +Encoding: UTF-8 + +RnwWeave: Sweave +LaTeX: pdfLaTeX diff --git a/data-raw/f_golem.RDS b/data-raw/f_golem.RDS new file mode 100644 index 00000000..608f5ce9 Binary files /dev/null and b/data-raw/f_golem.RDS differ diff --git a/data-raw/f_shiny.RDS b/data-raw/f_shiny.RDS new file mode 100644 index 00000000..198c5a4b Binary files /dev/null and b/data-raw/f_shiny.RDS differ diff --git a/data-raw/output.json b/data-raw/output.json new file mode 100644 index 00000000..455433de --- /dev/null +++ b/data-raw/output.json @@ -0,0 +1,6380 @@ +{ + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36", + "environment": { + "networkUserAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3694.0 Mobile Safari/537.36 Chrome-Lighthouse", + "hostUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36", + "benchmarkIndex": 485 + }, + "lighthouseVersion": "5.6.0", + "fetchTime": "2020-04-12T20:22:02.495Z", + "requestedUrl": "http://localhost:2811/", + "finalUrl": "http://localhost:2811/", + "runWarnings": [], + "audits": { + "is-on-https": { + "id": "is-on-https", + "title": "Uses HTTPS", + "description": "All sites should be protected with HTTPS, even ones that do not handle sensitive data. HTTPS prevents intruders from tampering with or passively listening in on the communications between your app and your users, and is a prerequisite for HTTP/2 and many new web platform APIs. [Learn more](https://web.dev/is-on-https).", + "score": 1, + "scoreDisplayMode": "binary", + "displayValue": "", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "redirects-http": { + "id": "redirects-http", + "title": "Does not redirect HTTP traffic to HTTPS", + "description": "If you have already set up HTTPS, make sure that you redirect all HTTP traffic to HTTPS in order to enable secure web features for all your users. [Learn more](https://web.dev/redirects-http).", + "score": 0, + "scoreDisplayMode": "binary" + }, + "service-worker": { + "id": "service-worker", + "title": "Does not register a service worker that controls page and `start_url`", + "description": "The service worker is the technology that enables your app to use many Progressive Web App features, such as offline, add to homescreen, and push notifications. [Learn more](https://web.dev/service-worker).", + "score": 0, + "scoreDisplayMode": "binary" + }, + "works-offline": { + "id": "works-offline", + "title": "Current page does not respond with a 200 when offline", + "description": "If you are building a Progressive Web App, consider using a service worker so that your app can work offline. [Learn more](https://web.dev/works-offline).", + "score": 0, + "scoreDisplayMode": "binary", + "warnings": [] + }, + "viewport": { + "id": "viewport", + "title": "Has a `<meta name=\"viewport\">` tag with `width` or `initial-scale`", + "description": "Add a `<meta name=\"viewport\">` tag to optimize your app for mobile screens. [Learn more](https://web.dev/viewport).", + "score": 1, + "scoreDisplayMode": "binary", + "warnings": [] + }, + "without-javascript": { + "id": "without-javascript", + "title": "Contains some content when JavaScript is not available", + "description": "Your app should display some content when JavaScript is disabled, even if it is just a warning to the user that JavaScript is required to use the app. [Learn more](https://web.dev/without-javascript).", + "score": 1, + "scoreDisplayMode": "binary" + }, + "first-contentful-paint": { + "id": "first-contentful-paint", + "title": "First Contentful Paint", + "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://web.dev/first-contentful-paint).", + "score": 0.23, + "scoreDisplayMode": "numeric", + "numericValue": 5438.5419999999995, + "displayValue": "5.4 s" + }, + "first-meaningful-paint": { + "id": "first-meaningful-paint", + "title": "First Meaningful Paint", + "description": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://web.dev/first-meaningful-paint).", + "score": 0.23, + "scoreDisplayMode": "numeric", + "numericValue": 5438.5419999999995, + "displayValue": "5.4 s" + }, + "load-fast-enough-for-pwa": { + "id": "load-fast-enough-for-pwa", + "title": "Page load is fast enough on mobile networks", + "description": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://web.dev/load-fast-enough-for-pwa).", + "score": 1, + "scoreDisplayMode": "binary", + "numericValue": 5513.5419999999995 + }, + "speed-index": { + "id": "speed-index", + "title": "Speed Index", + "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://web.dev/speed-index).", + "score": 0.56, + "scoreDisplayMode": "numeric", + "numericValue": 5438.5419999999995, + "displayValue": "5.4 s" + }, + "screenshot-thumbnails": { + "id": "screenshot-thumbnails", + "title": "Screenshot Thumbnails", + "description": "This is what the load of your site looked like.", + "score": null, + "scoreDisplayMode": "informative", + "details": { + "type": "filmstrip", + "scale": 3000, + "items": [ + { + "timing": 300, + "timestamp": 406980360873, + "data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIAMAAeAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD/9k=" + }, + { + "timing": 600, + "timestamp": 406980660873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 900, + "timestamp": 406980960873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 1200, + "timestamp": 406981260873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 1500, + "timestamp": 406981560873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 1800, + "timestamp": 406981860873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 2100, + "timestamp": 406982160873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 2400, + "timestamp": 406982460873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 2700, + "timestamp": 406982760873, + "data": "data:image/jpeg;base64,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" + }, + { + "timing": 3000, + "timestamp": 406983060873, + "data": "data:image/jpeg;base64,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" + } + ] + } + }, + "final-screenshot": { + "id": "final-screenshot", + "title": "Final Screenshot", + "description": "The last screenshot captured of the pageload.", + "score": null, + "scoreDisplayMode": "informative", + "details": { + "type": "screenshot", + "timing": 1121, + "timestamp": 406981182313, + "data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAH0ATgDASIAAhEBAxEB/8QAHAABAAMAAwEBAAAAAAAAAAAAAAUGBwEECAMC/8QASBAAAQQBAgMEBwUGAwcEAQUAAQACAwQFBhEHEiETMTezIjhBQlF1gRQXMmF2CBUjcZXSFlVXM1JWYpGToSRygrEYJZKj4fD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A8qIiICIiAiIgIiICIiAiIgIiICIiAiIgLt0ff+i6i7dH3/og7aIiAiIgIiICIiAiIgIiICIiAiIgIiIOEREEUiLT+GOjMFNpnJ6113LZbpuhMK0VWsdpbtggHswfYACNz/5GxQZgi2I8QeGLDyx8JYnMHQOfmpdyPz9FcfeHw0/0jg/rU39qDHkWw/eHw0/0jg/rU39qfeHw0/0jg/rU39qDHkWw/eHw0/0jg/rU39qfeHw0/wBI4P61N/agx5FsP3h8NP8ASOD+tTf2p94fDT/SOD+tTf2oMeRbD94fDT/SOD+tTf2p94fDT/SOD+tTf2oMeRbD94fDT/SOD+tTf2p94fDT/SOD+tTf2oMeRbD94fDT/SOD+tTf2p94fDT/AEjg/rU39qDHkWw/eHw0/wBI4P61N/apLA/djxLvN0/T03Y0dnLO7KFuK66zC+XboyQO2237ug+qDDF26Pv/AEX6zWNs4bMXcZfZ2dunM+CVvwc0kH/yF+aPv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbDlen7KuF29uppN/wDsvWPLYMt6quE/U0nkvQY+iIgIiICIiAiIgIiICIiAiIgKzcMSRxJ0nt0//VqnnMVZVl4ZeJOk/m1TzmIJbjr04wat2/zCT/7VPo+/9FcOO3jDq35hIqfR9/6IO2iIgIiICIiAiIgIiICIiAiIgIiICIiDhERBFLYMt6quE/U0nkvWPrYMt6quE/U0nkvQY+iIgIiICIiAiIgIiICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv8A0QdtERAREQEREBERAREQEREBERAREQEREHCIiCKWwZb1VcJ+ppPJesfWwZb1VcJ+ppPJegx9ERBqHCThO7iFhszk356ph6uLc0TPsR8zeUtJLi7mAAAHtVoi/Z6GWhnbpLXenM5eiYZDVhkAcQPzDnbfDcjbu6qf/ZlZjpOEfEpmclnhxbowLUkA3kbH2T+YtGx67KY4DxcLsdrR40NmstY1LYqyw025aLlh325j+Brdzs32nuB26oPKtmCWtZlgsMMc0Tyx7D3tcDsR/wBV8l6HraeFbgbxXl1BSpy6hpZrsn2uya57X9rDzcjyNwCS7/qofSeLx8v7KusMjLRqvyEOWYyOy6JplY3ev0DttwOp6fmUELT4SQ5DXen9NYzVeNvOytR1l1ms0SNrlrXOMbgHHr6PxHes61BjXYbPZHGPkEr6VmSuZANg4scW77fnsvXYxWPxf7QfDNuMo1abZcJK+QV4mx87uyk6nYdT+aq+GqYXR+meInETI4ipl8s3PWKVKK2wPji/i7b7Hu6uJJ79mgAjdBi2jeHk+p9Dap1JFkI68eCjEj4HRlxm3BPQ79O5UVesNL6xra04D8Sbv7ixuJykdYstHHRdlFOOUljuXc7OHpA/T6eT0BERAREQFZeGXiTpP5tU85irSsvDLxJ0n82qecxBLcdvGHVvzCRU+j7/ANFcOO3jDq35hIqfR9/6IO2iIgIiICIiAiIgIiICIiAiIgIiICIiDhERBFLYMt6quE/U0nkvWPrYMt6quE/U0nkvQY+iIg2PgnxK01o/SmpsFqvG5K9VzPKx7afKPQ5HNcCS9pB6+xWDT3EvhJo3IDM6S0TmjmoWOEDrln0GkggncyP26Ejfl36lefEQbdoTi7inw6zxnEOhatYvU9g25nUNuaGQnrsHEdOjdjv05R3r66j4m6Ii4RZjQukMPl60ViwyWGxacxxlIexznyEO3B9EgAAjYD89sMRB6DtcbcDNxR0bqZuPygp4XGvpzxlkfaPeWObu309tvSHeQorSPFrAsj1hgNX4q5d0nn78t5ghLRPXe5+4O2+3sYeh6Fvt3WIog9AS8UeH2D4bak0fo7C5uOPJQODbloxufLK4bbybO6NAAA2/Pp8fP6IgIiICIiArLwy8SdJ/NqnnMVaVl4ZeJOk/m1TzmIJbjt4w6t+YSKn0ff8Aorhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REG06P0lgdXcAdR3KOPYzV2Al7eSdr3F0sH4urd9vw9oOg9wKUi4b4ePhboWjPTjbrDV2QZ2Vp7nb16xcCXBu+x9Hk9nvn8lFfsn5OzV4osxba77OPy9WWrbjA3aGhpcHH+W238nFdvjhr80+OdCxhWsdR0m+GrVhB2YTGQZG/l13Z/JoQWLPzcJdH63i0LZ0Y/IQxPjrXMvLaeJWyOA9IAd+3MN9i327Dp1++E4O4DE8SNd4PJ1v3jQp4c5DHOle4Oj5t9ty0jcggj89t11s3d4Oat1nDrnIakv4+V7o7FvDOpvc6SVgHQOAIAOw32J369Rv0/GnON2Ev8ZtQ5jUEU9PT2Wx/7sieGcz4Y27crnBu/f6W+2+3MO/ZBUOBOlMJqPTHES1mqLLU+MxRsVHue5vZSckp5hsRv1a3v37leKmA4baU4MaR1jqfTz8jkLLTGK8czmi1KS7q/c7ANDT/ANe4r46WzvC7h7pbWuLw2qbmWvZnHSQxzvpSMj37ORrIxsO/d/Vx2Hd3Kj8QtZYTMcFtCafx9t0uVxZkNuExPaI999vSI2Pf7CUGY5qxWt5i9ZoVhUpzTvkhrg79kwuJazf8hsPoukiICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REGkcLeIGuMFTm01oVvaTZCV0gZFVEsweWgEtPs6NH5DbdcZ/g3xFxtGbKZPTtx8XWWZ7JGTPHtLnNa4u/MnZaNwQkbozgLrbXWPjYc52v2GCZzeYwt/hgEf/KXmPsPKN1RuFPFHVmN4jYie1nMjer27ccNqCzYdKyVj3Bp6OJAI33BHdsgytF6p1xwi03mNecRKtUWK+Xhx7Mvj4IHtbES5rudpbt3do0dx99UihweoWP2e59ZvltDPiN9uOAPHZ/Z2S8pJbtv+EOdvv8ABBhqL0JjeEGmmWuGGLykuR/e2pYpLV5rJmgRRCEvaGjl6HctHXf8LlJ4rhpwnv68v6Bgv6hk1Ax0oZc3YIWPaC4xjp6RaB1JHUtPUIPNCLv57GyYbOZHGTOD5aViSu5w7iWOLSf/AAuggIiICIiArLwy8SdJ/NqnnMVaVl4ZeJOk/m1TzmIJbjt4w6t+YSKn0ff+iuHHbxh1b8wkVPo+/wDRB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REG2cB9bYCHTeoNBa3ndVwWa9OK57K82wG5+H4WEHuBb16FWHSnDTROh9RV9Tak4hYPI4zHSCzXrU3h8s72ndm7Q4nvAOw33+IC8/VcZft1LFqrStT1q43mmjic5kY/5nAbD6r50KVrIWmVqFae1YfvyxQRl7zsNzsB17kG4aT4uMu/tIt1XfeKWIvuNB4mcAIqxbysLz3DZwa4+wdVf5teaTi4w09NQ5Sh/gcadfh3zCw3sAXAvJ599gdmtbv8V5MnhlrzyQzxvimjcWPY9pa5rh0IIPcV80HpK7rzEZP9q3EZL95U4tOYpppQWnStbAGNgfuQ7u2L3EA/yVf0Ln8RU/asuZqzk6cOIdk8hILr5miEte2Xldz77bHcbH27hYaiCwcQbMNzXuo7NSVk1ebI2JI5I3czXtMjiCD7QQq+iICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REHpT9m3E289wc4lYrGxiS7cY2GFhcGhzjG4AbnoF3eAfBnWukOKOKzWdxsMGPgZMJJG2Y3kF0TmjoDv3kKF4C23VOBfFKSGwYLDYOaN7H8rgeyd1B791C/sx6iy1vjNhYcjmL09Z0djmZPae5h/gv23BO3eg4bw3drXiRxByeTykWG05iclZfdvyt5tiZXbNa3cbnp/wDXeSAujq/hVh4dGv1doXUp1BgaswhvA1zFPX3IHNynbcdR3gd4PUb7a9w2z0dq1xW0njZcT/iKxm7N2jDk2h8Fn0wC0g9+3Z/TmB9hXR1xPq/CcLsvR1O/S+IyGXe2nWwuLoNdNb5iBzczX7DvPXY7bfEgIKM/9n61lbOlrWjsv+9tPZkc0uQdDyfYwOrudocfYCNt/wAQIOyy7iFhsVp7Vl3E4LKvy1Wo7snWzEIw+QfiDQCdwD039ux9i9S6NyenuEGCwHD/AFFfkmyGf7STISxWNo6JlbygAg+iNwG7jbqC74LzNxW0RZ0DrG3h539vW/2tSwO6aEk8p6e0dxHxBQU9ERAREQEREBWXhl4k6T+bVPOYq0rLwy8SdJ/NqnnMQS3Hbxh1b8wkVPo+/wDRXDjt4w6t+YSKn0ff+iDtoiICIiAiIgIiICIiAiIgIiICIiAiIg4REQRS2DLeqrhP1NJ5L1j62DLeqrhP1NJ5L0GPoiIC/TmuadnNIPf1C9H/ALK+Gqy6W1dnMXjaWV1hSAbQrWtiGbsJaRv3cztxv0/DtuNyu7mOIMuU09msJx00lJj5XRH93XIMa9jmSbHucSR0OxBB2PUHdB5iaS0gtJBHUEL9SyPldzSvc93xcdyvTGm+E2Dv/s+zWJcrpePL2bEc7cxLOAyq13ZnsHye68dQW/FypOucZqGfg5w9jlq4iSlZmdFR+xRv+1yPJIAkJ6Hc9235IMaRbjF+zrmN4KNvU+m6uop4+1jxEtr+MRtvt0Hf09gI7+qluFmAmxHCnjJj81SbHkqMAje2RoLo3Bkncf8Aodx+SDzwiv8AnOF+U09oWpqTUF2ljnXT/wCkxsxd9qmHTqGgbAbHfqRsNt+pAVpxH7P+Ys0KDsxqDA4TKZFgfTxl6xyzyg9wLduh/Ibn49eiDF0WnaZ4LaozuptQaf2rU8rhohLLDO47S7/hDCAQd+hBOw6qJ4n8PZ+H8+PrXcvjchcsseZoqUnP9mc0gFrvz6/AdxQUdERAVl4ZeJOk/m1TzmKtKy8MvEnSfzap5zEEtx28YdW/MJFT6Pv/AEVw47eMOrfmEip9H3/og7aIiAiIgIiICIiAiIgIiICIiAiIgIiIOEREEUtgy3qq4T9TSeS9Y+tgy3qq4T9TSeS9Bj6IiDbuC2hJtRaIzWa0XncjS13jpeWKpBZbCySI8pG/Tfr6Y6nbcDdbLwota+yGI1BU40Uo/wDCsVJ3PPkYo2SbjvHo97eXmPMR3gbFeOcTlL+HusuYm7ZpW2fhmryujePqDupTO611PqCsK2b1BlL9fffsrFp72b/HlJ2QbfojDXtR/spahxmnqst64cyHNgiG7y0OhPd/Lqp+DIU8Rw84FXcq5sdSDJbyPf3M/EA4/kCQfovMuF1FmsEJRhMvkMeJf9oKth8Qf/PlI3XwtZfJW6FejbyFyelXJMNeWZzo4t/91pOw+iD0LxA4Ua3zv7QFq7jGWq9K5aZZgzUZJZXjDRseYEbObtsG7gnYbdOqs3CHIjTGM4wXshf/AMVHGPidJZmPS46ON42JJd03G2/XoF5pg1zqqDE/uuDUeXjx3LyCu25IGBv+7tv3fkoqnl8lRp2qlLIXK9W0OWxDFM5jJh8HtB2d3nv+KD0ZxirVda4zSvFvEmWfHRPhhy1MuL/svI8b7D2DckHu33afaSr5xUpzZTXGMyGK4a1dWQXIYXVsy27I1rB3jm5ejAO8HuIPx3Xjqnm8rRx9ihSyd6vRsb9tWisPZHLuNjzNB2PTp1XfxWtdT4jHGhi9Q5anSO/8CC29jBv37AHp9EHrvEZ2/NxQ4iWLLcdDlMdp1kbzj53SsEje0eN3Oa08w5gCNumy8TPe6R7nyOLnuO5cTuSfiu7RzOTx7rLqGSu1XWmlk5hncwzNPeH7H0h1PQroICIiArLwy8SdJ/NqnnMVaVl4ZeJOk/m1TzmIJbjt4w6t+YSKn0ff+iuHHbxh1b8wkVPo+/8ARB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REBFzynl5tjy926FpABIIB7j8UHCIiAi/bY3uG7WOI+IC/JBB2PQoOERctaXHZoJPwCDhERAREQFZeGXiTpP5tU85irSsvDLxJ0n82qecxBLcdvGHVvzCRU+j7/0Vw47eMOrfmEip9H3/og7aIiAiIgIiICIiAiIgIiICIiAiIgIiIOEREEUtgy3qq4T9TSeS9Y+tgy3qq4T9TSeS9Bj6IiDedDYrEZf9m67Xz+ejwNQakDxafVfYBcK42Zys69dyd/yVpyvD/HaqwXCbTmOzf27DshyU78hDCYXSRse1zuVj+47+j16e3uWX6cq3stwsbp//EujaGNsZA5ExX7jorbJGtMexGxAaQNx036g7q1YubP4uvpWKjrfh5ENOCdtU/b3ntWzHeRsoLdnA93QDp+fVB+NScNtH4lmEy1tmWo42e+KF3HjIVrNlrXNPJPG6LmBG46tI3+CrfGrh5ieGsWPxf2q5ez1l0ll023JXZW5i2MAFu5edtz12Hd8Crv+8bkF/GT4zJ8IqFbHzutxVIbMgidYLeUSu6blzR+Eb7D4dAoTUlbM6m03Dh8/rjh/f7C1JahuzZF5sxmQ7vYHcu3IT12IPs+A2D76d1bnNHfs107+mr76NuXUr4XyNY127DATts4Ed7R/0Vlm0fDxbxuhNQ5traeRt0r0+VfUYyKS5HXe0Nc3fZoe7mA5j06/AKC0nJd07pQaddm+E2XxjbTrjWZWw+ctkLQ3cdAO4fD2lfaxldTT6tp552ueHLDUrupxY+O4W1BXcNnRdnydWn29d+g69Ag/NrhHpA5nTdi3kp8DjL88ta3StZCvPLG9rHOjLZWEtDX8u27h0P8AMKYwPDmngeJOnHYaLUOBddq3ed75q9lnowuIMUzQ5rg4d4Ldxv02UY7IZKDJ4yfE57hTjaNAyFuPr2CYJu0Gz+0Dmku3HTvG3sXbZn8zTt4t2G1DwsxlHHGd0NCvdkEJfKwse93TcnZx26gD4IK5gOH2iqz9EYfU785Lm9UQxWmz0pI2w1mSO2jbsWkuJ26n2LsT8PdCYGjVt579/WTaz9zDRRVp42gMjmLGyOJb3gAbgd5Ps2WhaEu4PC43SEWS15oqzLiOj5LEbJ7EDebcx15t27NI6ek0kb9D3Kgasiu5uSOCPV/D+KhWzFnL1Q7JP7TmmlL+V55diB07gP5oMp4laci0lrzN4KvM+eCjYMccjwOZzdgRvt7diFWVrWsNK2tV6myGcyWtdAx3LsnaSNgyTmsB2A6AtJ9nxWa57G/ujLT0RdpX+y2/9RSl7SF+7QfRdsN9t9j07wUEerLwy8SdJ/NqnnMVaVl4ZeJOk/m1TzmIJbjt4w6t+YSKn0ff+iuHHbxh1b8wkVPo+/8ARB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REBFsdHg5FleC2P1vSzdanYIm+0V78gjjfySvaOR/scQ0eie8+0KncIsdicnr6jW1FWNrFCKxNYia4tLmxwSP6EEHfdoKCmotrh4YUaemc/XvNdLmpr0MeLna17+Wr9pji7YMb1eJO16DYn0DsozKcLMZjKkuRt6hsNxcVR1l7m0o5JuZthkJZyMmLepkaerwR1BAQZOi1ccI2cuYa7MyCWqbRrE1GsjnbDD23UvkB3I6bMEnKe/YL9UuEde/dsY6nn3HKUbUFW9HJT5IozIx73FknaemGiNw6hu529h3QZMi0SzoiniNfaTx8lxmSxuVswc7SWNka0z9m9jxHI8A9NwQ49CO4qz3uG2Juty9rBsl/dd27Wgx8wa+Z9NxmeyeEtb1c5uw29paWn2lBiiLV7/AAuxePiNy5qKwzGmvFM1zKUcspc+eSHk5WTFnQx7/j7jt0I2X7ZwjrWMhPBRz7poqNi5VvySVGxFj64YXdlzSgPBMgALnM+JQZKin9b6ebpjPvx8d2K7EYo5o5mFvVr2h2zg1zgHDfYgOI3HeVAICsvDLxJ0n82qecxVpWXhl4k6T+bVPOYgluO3jDq35hIqfR9/6K4cdvGHVvzCRU+j7/0QdtERAREQEREBERAREQEREBERAREQEREHCIiCKWwZb1VcJ+ppPJesfWwZb1VcJ+ppPJegx9ERB3bOVv2sdUx9i5PJRqBwr13PJji5nFzuVvcCSSSV+cZkbeLti1j5nQThj4+du2/K9pY4dfi1xH1Ww1+GulLfAjEasu56PCZqT7QOWcl7LZZM9rWhg3cHbADdoI+I9qpfBiOg/iHRdlqMOQoxQWppa0zQ5sgZXkfsQfzaEETFrbUkUteWPL2RJXhgrxO3G7I4Xh8TR07muAIX7y2udRZbHPoXshzUnNLDCyCONoaXteWgNaNhzMa7Ye0b+0rWa/DzF0tO5rECuLmQy1utPjrDGMdKyi+1FHE6PmIAdIJH95A9AblRGZ4daVxeJlzck+Wlx0dJ8/YQ2Y3vdI2zHDsJTEG7ESb9GnYjvKCi1+I2qoGObHlnHmLyS+GN59Noa8AlpIa4AAtHQ7dQp3M8V8hawNWhjIbFCxFNXndZNvtTzQsLW8nohwHXrzueegG4HRWEcJ8O0ZeN9nJB7ftj6Ej5I2h7IIBKAWBri87nlcd4wPZuei+lHhJhb12xjmWMxXs0rlWrNcm5OwsiSJ8jjEOXcfg2buXb8zT+SDK8hqnM5DLUclauk3KJaarmRsY2HldzDlY0Bo9Ik93eV9cTrLUOJZMzHZWxAya0269rSNnTN32fsR39T/P2q25HSWJw2v8ARcdCV9vHZOeB74Zz2oA+0cjm8/IwSNIHeGjvIPcpfLaL0lXkvzXP3wLDKNjLujqyRMiDG3XQCJoLCRuADv7Nu4oM/wArrfUOVptqXchzVmbBsTIY42tAdzAANaNgHbkDuBJ+JXzr6y1BXtyWYsnKJZLE1uQlrSHySgNkLgRsQ4AAgjb8lpdXhThZpL7HT5Ngk7Z1CR8sY3DKrJ9i0MLpCC8Ncf4bR02JJ2HOP4TYa/alxrbGXgt1Z6MMt2UMNaft28xMQ5d+g6N9I77g9O5BkOay17N5B97KWHWLLw1pcQAA1oAa0AbAAAAAAbBdFXXiVpfG6dkxUuHszTVr0L3lkru0LHNeWnaTkYHg7exvQ7j2KlICsvDLxJ0n82qecxVpWXhl4k6T+bVPOYgluO3jDq35hIqfR9/6K4cdvGHVvzCRU+j7/wBEHbREQEREBERAREQEREBERAREQEREBERBwiIgilsGW9VXCfqaTyXrH1sGW9VXCfqaTyXoMfREQft8skjI2Pke5kYLWNJJDRuTsPh1JP1X7q2Z6k3a1ZpYZeVzeeNxa7ZwII3HsIJB/IraYsFw3fwFwmRz952P1W8WOy+xASTT7TyBokj3A5dgBzEt7u/psqbwPtNo8SKNx0UcwrVrkwjlG7XltaVwBH0QVFmZyjCwsyN1pYxkbSJ3Dlaw8zGjr0DSAQPYRuF2MlqbO5OF8WSzWTtxP35mT2nva7ctPUE9erG//tHwW61dL4irp7L6fx4r2G6gsU8jFIZQx0dOS3EyvGX7Hkds6Xm6Hbp0KjdUaF03gcFYzk+AnIioveKb5bELDK21FGCefaT8Eu5B236EAboMgras1FWjfHWz2ViY9/aOay5I0OdttzHY9TsAN/yU1m+I2YymAq4ljIaUMEkUvPWklDi6JpDC0OeWx7bk7Rho36+wLTa/C3CTMzQhxc8sTnXfskrJZZXwmOuJI2HlAY30yB6Zc5+/QDvKpwz05PIYreLsUsdHdpwVcsbTi3JMkje5xaPw+k5rQC0dOfbvCDDb2ZymQyLMheyNyzfZyllmadz5G8vVuzidxt7F+JcrkJebtb9t/PGYXc0zjvGXc5Yev4S70tu7fr3q38WtPY7T+RxbcdWnpPs1e1nqyte3s3c7mghsn8RoIAOzvzI6EKhoJqpqrUNON7KmdysDHvD3NjtyNDnAAAnY94AA/kB8FNZfiLl8jpuvhmxw1IInROMkD5eZxiHoENc8sj69T2bW7nYlUtEHdy2VyGYtCzlr1q9YDQwS2ZnSO5R3DdxJ2XSREBWXhl4k6T+bVPOYq0rLwy8SdJ/NqnnMQS3Hbxh1b8wkVPo+/wDRXDjt4w6t+YSKn0ff+iDtoiICIiAiIgIiICIiAiIgIiICIiAiIg4REQRS2DLeqrhP1NJ5L1j62DLeqrhP1NJ5L0GPoiIPr2E3ZRy9lJ2Ujixj+U7OcNtwD7SNx0/MfFcTRSV5pIZ43xTRuLXseC1zSOhBB7itFw2WwNXQOmq+Xo/vCwzL3JAyO92DoGllXZzgGncHlOxO34T+a0LPVtFZCPUt3fG27M9vJyTWHTwczZu1f9n5HOlDuUjsyOVjmu3dufgHnXcrlz3O/E4nrv1K3x+H0dNl6TsX/hrlihsPvxumY8G8I2bxwiSRjXQ9XFhLuUkP3J2aDL4/TGmp9XUY8RjdNXKt3J12XWWLUZDIHV4CRXAk2Lu1dPuGcxBDRtykbh5sD3NGwc4DffoVPZjV2Xy2KbjrclZtbmZJJ2NWOJ0zmNLWOkc1oLyATtuT3n2rUaOO0kalIWINPDCfZqbo7Rst+2PuGaMTMe3n5uTYyggtDQ0Ag+00ziJNhbmHq2cXTxdO1Hlb1Ts6J25qzBCYXOHMd+rpAH+3b8kFCc4uO7iSfiVwiICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv8A0QdtERAREQEREBERAREQEREBERAREQEREHCIiCKWwZb1VcJ+ppPJesfWwZb1VcJ+ppPJegx9ERB+3xvY1jnsc1rxzMJGwcNyNx8eoI+i7eExN/OZOHHYirLbvTc3ZwxDdztgXHYfyBK1+vxG0fV4EYfS2QwTM3nI/tBDpQY20y6Z7muEg9InYg7N6H2n2Kg8Kc3Dp3W1bKT2RVEFe12cuxPLIa8jWd3/ADFoQV+th8hZxd7IwVJn0aJY2zOG+jEXnZocfzIK7OH1Nm8LUsVcTlLlOvOd5I4ZS0OO22/5HbpuOuy2Yat0aaVnF1r8VbHZeetlbwfXL2xzvtQukh5dvSbFGx23sPMVxqrVmnqmDsXMTdw82fFJ9eNzYWTuD/tUTmkEwMYT2Zk2Ib0AI36IMCRehK2b0VJFmg3I4iOHIOuc8T4BG1r31wIiAIXOcO0JI2ewMIPQ+2XmbhcVUkv6ihoDTgyNAUKzscYpKrDDIdnuMXpjfkc7btN+UnruNw8yIr7xdyOIyOTxjsSask8dXktzVnh4kfzuLd3CKIOcGkDcM7thuSFQkBERAREQFZeGXiTpP5tU85irSsvDLxJ0n82qecxBLcdvGHVvzCRU+j7/ANFcOO3jDq35hIqfR9/6IO2iIgIiICIiAiIgIiICIiAiIgIiICIiDhERBFLYMt6quE/U0nkvWPrYMt6quE/U0nkvQY+iIgtOnNDZfP0YbVI0o22ZnVqjLNpkT7UrQCWRNcfSI5mj4buA71XZKtiNjnyQStY08pcWEAH4brSOHuqMNjsFUp5bJPZWhtumt463jWXYbDDy9YSdjFIQCCdx7Dv7FaclrDD4rCYjmyV2bHyYKSBmneXngcZJpeRz3c3KC30SSRzbsbt0PQMYxmFyWUuR1aNOaWxJG+ZjNtuZjGlziN/YACV2cnprI4ynWsW2RNFhsT4o2ytdI5sjC5p5B122H0PRa+OKOLgy0s7c/mrMdy/YsxvdCWOxkEleWMQsHP1HPJGSGkN/gtI69360zrXF5J2MxNOe2cj9nZCy1ytY+KRtGWLtWFzhu8PcCBuCfZ12QYzX0/k7GNuXoqkhgqSRxSjb0g5/Py7N7z/s3/y2XOUrZiLE4ufJfajj52ONMyyFzOVri13KN+nUH4LaNba3fpqvUxkGeycuYbVxjbFoOLZXCKSy+RshDiQdpY/RJJ+PXcKicT9XU9TY/Hx0b11zKtq4W1JmkMbHJO98bm9SB6Dg3bbpt8EGeIiICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REE3a0rm6umqWoJcdP+5bhcIrbRzR7teWEOI/Cd2nodt/YvpobTFrWOqKeCoTQQWbXPyyTkhg5WOedyAfY0q7UONGZxHCqjorCVoK0TGzNs25GiV8gkke/la0jZo2dtudz8NlSdEZ2PTuoW5GWOSRra9iECM7O3khfGD9C8H6IO5R0JmLWms7meRkcWJsx05IHb9rLK94YWMAHUtJbv/wC4Lru0Nqtt8UTpvMC4Y+2EP2OTn5N+Xm227t+m/wAVf38W8fPJCLGIsCu8V57kcM3IZ7Ysxy2JQ4dW84iaBt1Gy6+qeJWJv6Us4nF1LsUstaSsJDDDAzZ1mKbflj7vwOB7ydwSTudgoDNJ6hfXvzsweTMNBzmW3iq/aBzRu4PO3okDqd+5cyaR1HHTq25MDlG1bTmNglNV/LKX/hDTt1J9m3etOh4q6fc+3NYxuQbPY+1Mc4MilJbNXEW4c/rGRsSQzbm6bn2Kds63wWnIbGoIbMV/IZS9Tsy0objJmNayGRr+RvLuzbn6CQA77DYgEkMUi0tlm6ox+AyFOfHZC7PFAxlyJ0Zb2jg1riCN9tz37KT01oPIZ/I3KdWzVjkq34Me8yF2xfNIY2kbDuBaSfyUhqjXUFzUmmshiazhDg+R0TZYIYA9zZjLtyRANa3c7d59p9uymMXr/TeAyrruEo5aT7Zl62TtstOj/hMhkdJ2UfL+Iku/EdugHRBCxcOJrsTJ8PnMXka3bSV5ZImzM7KRkMk2xD2NJ3bE7YjfqOuyoS1TCcVHyQwR6pN6/wDZ7ks0L4+QOjilrywvaO7ru9h6/wC6Vn+eGFE0X7gdkTFy/wAT7aGB3Nv7OU9yCLREQFZeGXiTpP5tU85irSsvDLxJ0n82qecxBLcdvGHVvzCRU+j7/wBFcOO3jDq35hIqfR9/6IO2iIgIiICIiAiIgIiICIiAiIgIiICIiDhERBFLYMt6quE/U0nkvWPrYMt6quE/U0nkvQY+iIgIiICIiAiIgIiICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REGvcMOH+HyvD7Jasy9HN5w17optxmHLRIwcocZX7gnl67DYf8A9Rma4d18xrH93cNp58lTNMXZ23i2B+P9j453O5Wgt6dfz9q73DDJ6bg00+FmqL+jNWRWecZKIzSQ24Nukb2MOwIPxHw791oN7iFoTNammGRyUVm9DhGUW5y9Qea9ywHbudLAzYuG3dzDbv8Ay3DHslwr1bj5brJaEUjalE5N0kNmORj6wOxkYQ70wD37bkLrYnhvqnKswr6ON7RmXjlmqkzMbvHGdnyP3PoNBPe7YH2brZcpxK0vT1RoK/UzNa/j6tSfE5eCvjn1WdhIdnObGGgBnXcNHX0fiupj+KemKvEHIUa84ZpIYFuBx1uxUMzYw0NdzyQkAua53NzDbc9EGUZDhvqPHaow2Du1GCfLSMZTmhlbNDMHODeZr2kggb9fgtPy+gOG2LwWdzc7NS26NDOHCxNx9iIukcyFjnSHmYenPz9x7tlKScRdM4uGvPBm6F69p+hakx0dLF/Y65uT7MaIm8u+zW8znF2wJI2VG0vxMl0xwWs4vB5qzS1PNnTbcI2O5nVzC1pJeRy9XN7t9+iD8634Wwm3pI6BiytkairSTxUMiGNsQchALnkbNDCOu52Gw71X7XCfWEGdxuKZjWWZ8i176staxHJDIGfjPaA8o5fbuRstXw3F/AWLmlstqK62fNT4a5hsxYdSL+x5ngwyObyhsjdgd2t/3j/Jfn7x8DjMjprHV9VxwwVJ57EtzCYVletXc9hY0dk5u8gO55+nw26hBTNJ8D87f1riMTnDBWxl9kkovVbUMzXNYNj2ZDtnODi0Fo67EnbYKCxvCTVeSu5GGnVqdjRnFaSzLdhZC6QgEMbIXcrnbEdATtv1WpSa+0Li9baGyQs0J72PtTvyN/EY6SrX7J8bmt/hHvfu4Elo+P8AJVp1zRmc0U3R9vV/7vbisrLdr5A0ZXQ3YpAN/QA5mvadwN/p39Ap+H4Sa0y17KVK2I5J8ZM2G4J544hAXAuBJc4ejsN+Ybjbb4r9P4Raybhpcl+7YzEyN04hbZjM74WnYyti35nM6d4HUdR0V94g8UMDntMa8pY21ZjlvyY6CkJY3B9qKAAPe8gbDfbfYnfbZWSPivpmRuO1HHlqFG9Vxjaz6Iwglvds2Ms5Y7BHKIz+fcCR/IPL6svDLxJ0n82qecxVtx5nE7AbnforJwy8SdJ/NqnnMQS3Hbxh1b8wkVPo+/8ARXDjt4w6t+YSKn0ff+iDtoiICIiAiIgIiICIiAiIgIiICIiAiIg4REQRS2DLeqrhP1NJ5L1j62DLeqrhP1NJ5L0GPoiILtV0S2zwlm1fFPO+1HlhjvsrI928pi5+ffv336L757h7PS0nonI4/wC13MnqM2G/Ymw+kx0bw0BoHU777q06N4gz6R4EW6un802jqKTPCTsmcpkNcwgF2xBG3MB1/JXvB8V8VPNwxyeqcxDbyUNfJQX53N3dVfKQ2N7wzYgbDbp12JKDDr/DTWVDMU8Vb0/djv3GudWiIB7XlG7g0g7Egd433ULc05mKWHblbmOsQY907qrZpW8oMrd+Zg36kjY7/DZb9lMrinUtO6edqjSWKbHmP3m+xhXzytqMY07yCaV5/iOPQRhvf1J71W/2gtW4XiHjcXn8HkzCaks1STDTkNe0F5cLDGgbHnBHN7QdvgUGHoiICIiAiIgIiICsvDLxJ0n82qecxVpWXhl4k6T+bVPOYgluO3jDq35hIqfR9/6K4cdvGHVvzCRU+j7/ANEHbREQEREBERAREQEREBERAREQEREBERBwiIgilsGW9VXCfqaTyXrH1sGW9VXCfqaTyXoMfREQXDT+vsjg8TDj62M09PFFzESW8TBPKd3E9XuaSe/pv3DYKQ+9XLf5LpL+g1f7FTLOKv1cbTyFinPHRuc32ew5h7OXlcWuDXdxIII2Xb0lp3Ias1BVwuGjZJfs83Zte8MB5Wlx6n8mlBaPvVy3+S6S/oNX+xPvVy3+S6S/oNX+xVyppTLWtPZjNx1+WhiZY4bTnnlLXvdyhoHtO/f8FHHGXxOITStCYgOEfZO5iD0B22QXT71ct/kukv6DV/sT71ct/kukv6DV/sVHbStOZM9tacshO0rhGdo//cfZ9UdStNhZK6tOIpNuR5jPK7f4H2oLx96uW/yXSX9Bq/2J96uW/wAl0l/Qav8AYqdSxNy3mamKZC6O7ZmZBHHKOT03kBu+/d1IXYOnsgKeXs8jOyxUzK9n0xuHvc5rdh7erCgtP3q5b/JdJf0Gr/Yn3q5b/JdJf0Gr/YqPNStQuc2atPGWbcwdGRtv3b/z9i+MjHxSOZI1zHtOzmuGxB+BCC/ferlv8l0l/Qav9ip+dykuay0+QsQ1IJZti6OpA2CJuzQPRY0ADu9nt3Kj0QFZeGXiTpP5tU85irSsvDLxJ0n82qecxBLcdvGHVvzCRU+j7/0Vw47eMOrfmEip9H3/AKIO2iIgIiICIiAiIgIiICIiAiIgIiICIiDhERBFLYMt6quE/U0nkvWPrYMt6quE/U0nkvQY+iIg2KjxkjxXBfH6IpYWtbnAm+02L8bZI2c8r3DkZ7XAOHpHuPsPeqFw7zkOnNUx5Oy6VrI61mNrohu4PfBIxhH/AMnBfXB6SiuYWHK5jNUsNSszurVXWI5JHTPaG8x2Y07MbzN3cfj0B6rq2NHZ2OLI2IMdPcoUJHxzXajTLX9D8ThI3oW9x3+BCDUDxP05JtD9nv1qlp9fIX2wMbzOum1FLYczrtsGxDl39v8ANfrVPFDFyacsw4TJZBuXfTkqNsRxyxn0rUUvR75Xv25WyDqe/wBgBVAwvDPVeSy+MoyYa7Sbfl7KOxZrvbGOhcSTtv0aCeneB0XyuaAzoyklLE0L2UdHGJXmCnK0sBc4Ddrmg7nlP/8AgUGnwcTdLyDKOmt5KM33XBKx8L3hxmriIPAbK1nfuXc7XnoNtvZP29U0NO0znMnes2qVzIUZ6uMMkb46rGwSAmDklPMGczTuOT8LR0Pd5oswS1bMtezG+KeJ5jkjeNnNcDsQR7CCvkg1DVGtsXf11pDIwufNVw74TPOIZGueGzmQgdpLI92wOw5nfEDYbKWwHFqvDkMlLnK8Vms7LVrlaKChBCTGyR7nFxYGlzgHNI5ieo7x3rGUQb4zivgaWVrW3GzkWV632cxCB7Wzc04k53GeSVxMW3Mzc/jd02AO+H5l8cmWuyQWprcT5nubYmbyySgknmcNzs4956nqumiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REFzxOfwVrTNDD6opZGRuOsSzVpqErGlzJOUvieHA9N27hw6jc9Cp6HiNiW/ZbbcLPVuY6O7BQq1pgKojsGQgPBG/o9oR0/EA0HbZQf3Z6ml0JS1fRouu4awJOZ1bd74OR7mHnb3geiTuNxt37KK0JgYdSakhx1q1JVgMM88kscYkcGxRPkOzSQCTybd470Fxr8Tq8edyN99Cw9tnI1LrWGQbtbDBLEW/zPaDb8gpzRep8JnMZjKOZnjx8OIloTNc66K75HQiQOd1aQ5uzh0B5we4Hc7Ux+gTlMfUyWj7j8lj5XzRTOuRspuqOja1zjIS8sDeV7SHc35dCurHw31VLfhpw4syzzSuhjEc8bmvIhE24cHbFpjPMHb7O67EkIITVV2LJaozF+sSYLVyaePcbHlc8uH/gqKVyxnDbU+SqVrNalXEVnsxCZrkMRkdIC6NrQ54Jc4DcN7z8F9n8NsxOKTsaIpWTVIJnvszR1mtll5uWFpkcA5/oHYDqfggo6K1N0DqN2NfdFBuzNy6v28f2gAS9kXdjzdpt2no78u26++pNBX9OaXblcpLXbOb5omCCeKcNIZzO5nMceVwPQtOxQU5Fp9jg/lGUbD4MhjZ7UbKEjWfbIGRkWY3u2Mhk2BDmBoHvcwI6bKrz6F1DBjLN6ei2OOuJHSRPnjEwbG8se8Rc3OWNcCC4DboevQoKwiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REF7i4p6nq6Ao6Qxlz934quJOd1bdss/PI55Dn94HpbbDbf27qA0Xn/APDWoYsm6q24xsU0L4XPLOdskToz6Q7ujyp7CaBs5zSWIyuMDXy2MhYq2RLbhha1jGwFpaHkEn+I/fbfuHd7ZbOcIctDazk2Lcw46nZuNrNn5zLNDXe5r37tYWDbld0JaTynYFBH1uIcFSicPTwFeHTksU0dmkLDzJO6QxkyGU9Q4GGPbYbDl7juV2qnFrI0H3hjqFevDJDUhps53ONMVxytLXHq5zmF7XE9/Mf5Kt6u0bf0rDUkyVii8XN31hXlLzPDsC2dvT/Zu32BOxJB6dCqyg0DIcS7VzMQ3jjYImQZeDKQwMeeWNsLGsZCP+UNaBuvs7iWy3WgqZjBQXadd0E0MYsPjLZYucBxIHVpD9i38hsQs5RBrcPGq7HVnP7qjbfnDnSSxWHMidKZ+3ExiA2L+cAEkncDYbKrar1jUzODdjcfg48ayXIOyUzxZdKXSubykDcdG/AdT+ZVNRBdspr197DxUhjY4ngY/tZRKT2hqRvjYeXbpu143/Nv5qczHGLJZTC5KjLUfG+22zE0x3JGxMjmlfI4OjGwe4c7mhxIG224Oyy1EBERAVl4ZeJOk/m1TzmKtKy8MvEnSfzap5zEEtx28YdW/MJFT6Pv/RXDjt4w6t+YSKn0ff8Aog7aIiAiIgIiICIiAiIgIiICIiAiIgIiIOEREEUtgy3qq4T9TSeS9Y+tgy3qq4T9TSeS9Bj6IiCUObtHEY3GkRfZ6FmW3EeX0i+QRh2536j+E3b6q02eKWbtwXY7lXG2HWJLDw+SJ+8IncXSNYA7bYlziNwS3mJBBVCRBZtV60yeqK0MGWZVc2vI51YxxcprxkAdizr0iHKNm9djv16nesoiAiIgIiICIiAiIgKy8MvEnSfzap5zFWlZeGXiTpP5tU85iCW47eMOrfmEip9H3/orhx28YdW/MJFT6Pv/AEQdtERAREQEREBERAREQEREBERAREQEREHCIiCKWwZb1VcJ+ppPJesfWwZb1VcJ+ppPJegx9ERARbFo+LGaL4Pu1rNh6GXzmQyRx9Nt+LtYKrGs5nP5D0LiQR1/L8wfn9kx3F3J6eqYbCV9O51zZjlLcEIjouiYObtWtB6OAHUdNye9BkKLXqvCTF53912tIaqGTxc+TjxVuWSk6GSrI/8AC/kLvSYf5j/725yfCnA1MZlLtbWP2pmDvx0sxy457RAHvLOeP0vT2II26dx/LcMgReoeKWlcQX65xel8fgIm4WjSjMQxHLYY+V7duWfn6uO4Jfseh29m6pjeCuHdkspg260jdqXE0X3LtNtBxjHKwOLGScwDiNwD09vd0IQYiiIgIiICIiArLwy8SdJ/NqnnMVaVl4ZeJOk/m1TzmIJbjt4w6t+YSKn0ff8Aorhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpbBlvVVwn6mk8l6x9bBlvVVwn6mk8l6DH0REG4cJqWormgrGIyOi59SaOyE5tQmG0yCWCdu7C+NxP/LsQR7PzO9wDtfYvL6eZpjh6+jprDMmibjZbbJHWRMNpTJJzd59nTofivL6IPUNGnqbTrcZU0dw0v0cZDlIsrcZYyccstl0f4Yw7ua0fyJ/871w6c16cHrrH/4Jtb6mvRXBJ9si/wDT8kzpOXb3t+bbfp3LAUQem8zHr/I5TXVyLQtus/UzKbWH7bE/7IYOX0vZz78vd02/NaWdLW62a1PnZdN405e9h5YZchUvucbkzowA2Ou4DsiSBuST3fmV4ZRBoH3N6/8A+G7P/di/uT7m9f8A/Ddn/uxf3LP0QaB9zev/APhuz/3Yv7k+5vX/APw3Z/7sX9yz9EGgfc3r/wD4bs/92L+5U/PYe/gMtPjMvWdVvQbCSJxBLd2hw6gkdxBUeiArLwy8SdJ/NqnnMVaVl4ZeJOk/m1TzmIJbjt4w6t+YSKn0ff8Aorhx28YdW/MJFT6Pv/RB20REBERAREQEREBERAREQEREBERAREQcIiIIpa9lXH/8WsK32f4lkP8A/C9EQZCiIg5I2A/MK18LNO09V67xmGyT546tkyB7oHBrxyxucNiQR3gexEQTvHHQ2M0JnqNLDzXJYp4DK42XtcQd9unK0LNkRBNYXFwXsLn7czpBLQrxyxBpABLpmMPN07tnH4dVCoiCY1fjIcPqW/j6rpHQV5ORpkILiNgeuwCh0RAREQEREBWThoduI+lT8MtU85qIgluOR5uL2rCfbfkVQo+/9ERB2t03REDdN0RA3TdEQN03REDdN0RA3TdEQN03REDdN0RA3TdEQN0REH//2Q==" + } + }, + "estimated-input-latency": { + "id": "estimated-input-latency", + "title": "Estimated Input Latency", + "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://web.dev/estimated-input-latency).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 12.8, + "displayValue": "10 ms" + }, + "total-blocking-time": { + "id": "total-blocking-time", + "title": "Total Blocking Time", + "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds.", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 30.5, + "displayValue": "30 ms" + }, + "max-potential-fid": { + "id": "max-potential-fid", + "title": "Max Potential First Input Delay", + "description": "The maximum potential First Input Delay that your users could experience is the duration, in milliseconds, of the longest task. [Learn more](https://developers.google.com/web/updates/2018/05/first-input-delay).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 63.5, + "displayValue": "60 ms" + }, + "errors-in-console": { + "id": "errors-in-console", + "title": "No browser errors logged to the console", + "description": "Errors logged to the console indicate unresolved problems. They can come from network request failures and other browser concerns. [Learn more](https://web.dev/errors-in-console)", + "score": 1, + "scoreDisplayMode": "binary", + "numericValue": 0, + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "time-to-first-byte": { + "id": "time-to-first-byte", + "title": "Server response times are low (TTFB)", + "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://web.dev/time-to-first-byte).", + "score": 1, + "scoreDisplayMode": "binary", + "numericValue": 203.066, + "displayValue": "Root document took 200 ms", + "details": { + "type": "opportunity", + "overallSavingsMs": -396.93399999999997, + "headings": [], + "items": [] + } + }, + "first-cpu-idle": { + "id": "first-cpu-idle", + "title": "First CPU Idle", + "description": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input. [Learn more](https://web.dev/first-cpu-idle).", + "score": 0.65, + "scoreDisplayMode": "numeric", + "numericValue": 5438.5419999999995, + "displayValue": "5.4 s" + }, + "interactive": { + "id": "interactive", + "title": "Time to Interactive", + "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive).", + "score": 0.71, + "scoreDisplayMode": "numeric", + "numericValue": 5513.5419999999995, + "displayValue": "5.5 s" + }, + "user-timings": { + "id": "user-timings", + "title": "User Timing marks and measures", + "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://web.dev/user-timings).", + "score": null, + "scoreDisplayMode": "notApplicable", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "critical-request-chains": { + "id": "critical-request-chains", + "title": "Avoid chaining critical requests", + "description": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://web.dev/critical-request-chains).", + "score": null, + "scoreDisplayMode": "informative", + "displayValue": "18 chains found", + "details": { + "type": "criticalrequestchain", + "chains": { + "654AEFACF61C1FC7D96BCCD4253DFF32": { + "request": { + "url": "http://localhost:2811/", + "startTime": 406980.063519, + "endTime": 406980.270063, + "responseReceivedTime": 406980.267531, + "transferSize": 24076 + }, + "children": { + "20679.2": { + "request": { + "url": "http://localhost:2811/shared/json2-min.js", + "startTime": 406980.295527, + "endTime": 406980.330982, + "responseReceivedTime": 406980.330593, + "transferSize": 3188 + } + }, + "20679.3": { + "request": { + "url": "http://localhost:2811/shared/jquery.min.js", + "startTime": 406980.297054, + "endTime": 406980.337932, + "responseReceivedTime": 406980.328931, + "transferSize": 88343 + } + }, + "20679.4": { + "request": { + "url": "http://localhost:2811/shared/shiny.css", + "startTime": 406980.297767, + "endTime": 406980.325861, + "responseReceivedTime": 406980.32414399995, + "transferSize": 8662 + } + }, + "20679.5": { + "request": { + "url": "http://localhost:2811/shared/shiny.min.js", + "startTime": 406980.298738, + "endTime": 406980.338212, + "responseReceivedTime": 406980.32441, + "transferSize": 93264 + } + }, + "20679.6": { + "request": { + "url": "http://localhost:2811/golem_resources-0.0.1/handlers.js", + "startTime": 406980.299847, + "endTime": 406980.324766, + "responseReceivedTime": 406980.32334999996, + "transferSize": 584 + } + }, + "20679.7": { + "request": { + "url": "http://localhost:2811/golem_resources-0.0.1/script.js", + "startTime": 406980.300931, + "endTime": 406980.325046, + "responseReceivedTime": 406980.323787, + "transferSize": 661 + } + }, + "20679.8": { + "request": { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "startTime": 406980.301785, + "endTime": 406980.368914, + "responseReceivedTime": 406980.365515, + "transferSize": 10950 + } + }, + "20679.9": { + "request": { + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js", + "startTime": 406980.302677, + "endTime": 406980.378573, + "responseReceivedTime": 406980.374613, + "transferSize": 45337 + } + }, + "20679.10": { + "request": { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "startTime": 406980.303271, + "endTime": 406980.363962, + "responseReceivedTime": 406980.358462, + "transferSize": 48833 + } + }, + "20679.11": { + "request": { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "startTime": 406980.303606, + "endTime": 406980.358941, + "responseReceivedTime": 406980.35792, + "transferSize": 26877 + } + }, + "20679.12": { + "request": { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "startTime": 406980.304068, + "endTime": 406980.372572, + "responseReceivedTime": 406980.35398, + "transferSize": 121642 + } + }, + "20679.13": { + "request": { + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js", + "startTime": 406980.304753, + "endTime": 406980.380933, + "responseReceivedTime": 406980.375863, + "transferSize": 39878 + } + }, + "20679.14": { + "request": { + "url": "http://localhost:2811/shared/bootstrap/shim/html5shiv.min.js", + "startTime": 406980.30563, + "endTime": 406980.377835, + "responseReceivedTime": 406980.37627500005, + "transferSize": 2919 + } + }, + "20679.15": { + "request": { + "url": "http://localhost:2811/shared/bootstrap/shim/respond.min.js", + "startTime": 406980.306037, + "endTime": 406980.395003, + "responseReceivedTime": 406980.394047, + "transferSize": 4660 + } + }, + "20679.16": { + "request": { + "url": "http://localhost:2811/driver-assets/css/driver.min.css", + "startTime": 406980.306706, + "endTime": 406980.351938, + "responseReceivedTime": 406980.350864, + "transferSize": 4492 + } + }, + "20679.17": { + "request": { + "url": "http://localhost:2811/driver-assets/js/driver.min.js", + "startTime": 406980.307879, + "endTime": 406980.410748, + "responseReceivedTime": 406980.39371100004, + "transferSize": 47126 + } + }, + "20679.18": { + "request": { + "url": "http://localhost:2811/cicerone-assets/cicerone.js", + "startTime": 406980.308273, + "endTime": 406980.391128, + "responseReceivedTime": 406980.389469, + "transferSize": 3126 + } + }, + "20679.19": { + "request": { + "url": "http://localhost:2811/www/custom.css", + "startTime": 406980.308706, + "endTime": 406980.386036, + "responseReceivedTime": 406980.356278, + "transferSize": 1175 + } + } + } + } + }, + "longestChain": { + "duration": 347.2290000063367, + "length": 2, + "transferSize": 47126 + } + } + }, + "redirects": { + "id": "redirects", + "title": "Avoid multiple page redirects", + "description": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://web.dev/redirects).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0 + } + }, + "installable-manifest": { + "id": "installable-manifest", + "title": "Web app manifest does not meet the installability requirements", + "description": "Browsers can proactively prompt users to add your app to their homescreen, which can lead to higher engagement. [Learn more](https://web.dev/installable-manifest).", + "score": 0, + "scoreDisplayMode": "binary", + "explanation": "Failures: No manifest was fetched.", + "details": { + "type": "debugdata", + "items": [ + { + "failures": [ + "No manifest was fetched" + ], + "isParseFailure": true, + "parseFailureReason": "No manifest was fetched" + } + ] + } + }, + "apple-touch-icon": { + "id": "apple-touch-icon", + "title": "Does not provide a valid `apple-touch-icon`", + "description": "For ideal appearance on iOS when users add a progressive web app to the home screen, define an `apple-touch-icon`. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://web.dev/apple-touch-icon/).", + "score": 0, + "scoreDisplayMode": "binary", + "warnings": [] + }, + "splash-screen": { + "id": "splash-screen", + "title": "Is not configured for a custom splash screen", + "description": "A themed splash screen ensures a high-quality experience when users launch your app from their homescreens. [Learn more](https://web.dev/splash-screen).", + "score": 0, + "scoreDisplayMode": "binary", + "explanation": "Failures: No manifest was fetched.", + "details": { + "type": "debugdata", + "items": [ + { + "failures": [ + "No manifest was fetched" + ], + "isParseFailure": true, + "parseFailureReason": "No manifest was fetched" + } + ] + } + }, + "themed-omnibox": { + "id": "themed-omnibox", + "title": "Does not set a theme color for the address bar.", + "description": "The browser address bar can be themed to match your site. [Learn more](https://web.dev/themed-omnibox).", + "score": 0, + "scoreDisplayMode": "binary", + "explanation": "Failures: No manifest was fetched,\nNo `<meta name=\"theme-color\">` tag found.", + "details": { + "type": "debugdata", + "items": [ + { + "failures": [ + "No manifest was fetched", + "No `<meta name=\"theme-color\">` tag found" + ], + "themeColor": null, + "isParseFailure": true, + "parseFailureReason": "No manifest was fetched" + } + ] + } + }, + "content-width": { + "id": "content-width", + "title": "Content is sized correctly for the viewport", + "description": "If the width of your app's content does not match the width of the viewport, your app might not be optimized for mobile screens. [Learn more](https://web.dev/content-width).", + "score": 1, + "scoreDisplayMode": "binary", + "explanation": "" + }, + "image-aspect-ratio": { + "id": "image-aspect-ratio", + "title": "Displays images with correct aspect ratio", + "description": "Image display dimensions should match natural aspect ratio. [Learn more](https://web.dev/image-aspect-ratio).", + "score": 1, + "scoreDisplayMode": "binary", + "warnings": [], + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "deprecations": { + "id": "deprecations", + "title": "Avoids deprecated APIs", + "description": "Deprecated APIs will eventually be removed from the browser. [Learn more](https://web.dev/deprecations).", + "score": 1, + "scoreDisplayMode": "binary", + "displayValue": "", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "mainthread-work-breakdown": { + "id": "mainthread-work-breakdown", + "title": "Minimizes main-thread work", + "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/mainthread-work-breakdown)", + "score": 0.95, + "scoreDisplayMode": "numeric", + "numericValue": 1641.6120000000005, + "displayValue": "1.6 s", + "details": { + "type": "table", + "headings": [ + { + "key": "groupLabel", + "itemType": "text", + "text": "Category" + }, + { + "key": "duration", + "itemType": "ms", + "granularity": 1, + "text": "Time Spent" + } + ], + "items": [ + { + "group": "scriptEvaluation", + "groupLabel": "Script Evaluation", + "duration": 594.928 + }, + { + "group": "other", + "groupLabel": "Other", + "duration": 348.64000000000044 + }, + { + "group": "styleLayout", + "groupLabel": "Style & Layout", + "duration": 313.52000000000004 + }, + { + "group": "paintCompositeRender", + "groupLabel": "Rendering", + "duration": 161.94 + }, + { + "group": "parseHTML", + "groupLabel": "Parse HTML & CSS", + "duration": 161.48000000000005 + }, + { + "group": "scriptParseCompile", + "groupLabel": "Script Parsing & Compilation", + "duration": 61.10399999999999 + } + ] + } + }, + "bootup-time": { + "id": "bootup-time", + "title": "JavaScript execution time", + "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/bootup-time).", + "score": 0.99, + "scoreDisplayMode": "numeric", + "numericValue": 548.6440000000001, + "displayValue": "0.5 s", + "details": { + "type": "table", + "headings": [ + { + "key": "url", + "itemType": "url", + "text": "URL" + }, + { + "key": "total", + "granularity": 1, + "itemType": "ms", + "text": "Total CPU Time" + }, + { + "key": "scripting", + "granularity": 1, + "itemType": "ms", + "text": "Script Evaluation" + }, + { + "key": "scriptParseCompile", + "granularity": 1, + "itemType": "ms", + "text": "Script Parse" + } + ], + "items": [ + { + "url": "Other", + "total": 1008.3560000000004, + "scripting": 33.239999999999995, + "scriptParseCompile": 3.1439999999999997 + }, + { + "url": "http://localhost:2811/shared/jquery.min.js", + "total": 415.55600000000004, + "scripting": 395.3200000000001, + "scriptParseCompile": 6.628 + }, + { + "url": "http://localhost:2811/shared/shiny.min.js", + "total": 110.31200000000001, + "scripting": 95.85600000000001, + "scriptParseCompile": 14.456000000000001 + } + ], + "summary": { + "wastedMs": 548.6440000000001 + } + } + }, + "uses-rel-preload": { + "id": "uses-rel-preload", + "title": "Preload key requests", + "description": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://web.dev/uses-rel-preload).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0 + } + }, + "uses-rel-preconnect": { + "id": "uses-rel-preconnect", + "title": "Preconnect to required origins", + "description": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://web.dev/uses-rel-preconnect).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "warnings": [], + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0 + } + }, + "font-display": { + "id": "font-display", + "title": "All text remains visible during webfont loads", + "description": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://web.dev/font-display).", + "score": 1, + "scoreDisplayMode": "binary", + "warnings": [], + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "diagnostics": { + "id": "diagnostics", + "title": "Diagnostics", + "description": "Collection of useful page vitals.", + "score": null, + "scoreDisplayMode": "informative", + "details": { + "type": "debugdata", + "items": [ + { + "numRequests": 21, + "numScripts": 11, + "numStylesheets": 7, + "numFonts": 0, + "numTasks": 388, + "numTasksOver10ms": 10, + "numTasksOver25ms": 5, + "numTasksOver50ms": 2, + "numTasksOver100ms": 0, + "numTasksOver500ms": 0, + "rtt": 0.36000000000000004, + "throughput": 66545115.4946778, + "maxRtt": 0.36000000000000004, + "maxServerLatency": 22.308, + "totalByteWeight": 579766, + "totalTaskTime": 410.40299999999957, + "mainDocumentTransferSize": 24076 + } + ] + } + }, + "network-requests": { + "id": "network-requests", + "title": "Network Requests", + "description": "Lists the network requests that were made during page load.", + "score": null, + "scoreDisplayMode": "informative", + "numericValue": 21, + "details": { + "type": "table", + "headings": [ + { + "key": "url", + "itemType": "url", + "text": "URL" + }, + { + "key": "startTime", + "itemType": "ms", + "granularity": 1, + "text": "Start Time" + }, + { + "key": "endTime", + "itemType": "ms", + "granularity": 1, + "text": "End Time" + }, + { + "key": "transferSize", + "itemType": "bytes", + "displayUnit": "kb", + "granularity": 1, + "text": "Transfer Size" + }, + { + "key": "resourceSize", + "itemType": "bytes", + "displayUnit": "kb", + "granularity": 1, + "text": "Resource Size" + }, + { + "key": "statusCode", + "itemType": "text", + "text": "Status Code" + }, + { + "key": "mimeType", + "itemType": "text", + "text": "MIME Type" + }, + { + "key": "resourceType", + "itemType": "text", + "text": "Resource Type" + } + ], + "items": [ + { + "url": "http://localhost:2811/", + "startTime": 0, + "endTime": 206.54399995692074, + "transferSize": 24076, + "resourceSize": 23922, + "statusCode": 200, + "mimeType": "text/html", + "resourceType": "Document" + }, + { + "url": "http://localhost:2811/shared/json2-min.js", + "startTime": 232.00799996266142, + "endTime": 267.46299996739253, + "transferSize": 3188, + "resourceSize": 2991, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/shared/jquery.min.js", + "startTime": 233.53500000666827, + "endTime": 274.4129999773577, + "transferSize": 88343, + "resourceSize": 88145, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/shared/shiny.css", + "startTime": 234.24799996428192, + "endTime": 262.3419999727048, + "transferSize": 8662, + "resourceSize": 8479, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/shared/shiny.min.js", + "startTime": 235.21899996558204, + "endTime": 274.6929999557324, + "transferSize": 93264, + "resourceSize": 93066, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/golem_resources-0.0.1/handlers.js", + "startTime": 236.32799996994436, + "endTime": 261.24699995853007, + "transferSize": 584, + "resourceSize": 388, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/golem_resources-0.0.1/script.js", + "startTime": 237.41199995856732, + "endTime": 261.5269999951124, + "transferSize": 661, + "resourceSize": 465, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "startTime": 238.2660000002943, + "endTime": 305.3949999739416, + "transferSize": 10950, + "resourceSize": 10766, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js", + "startTime": 239.15799998212606, + "endTime": 315.0540000060573, + "transferSize": 45337, + "resourceSize": 45139, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "startTime": 239.75199996493757, + "endTime": 300.4429999855347, + "transferSize": 48833, + "resourceSize": 48649, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "startTime": 240.08699995465577, + "endTime": 295.4219999955967, + "transferSize": 26877, + "resourceSize": 26693, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "startTime": 240.54899998009205, + "endTime": 309.0530000044964, + "transferSize": 121642, + "resourceSize": 121457, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js", + "startTime": 241.23399995733052, + "endTime": 317.41399999009445, + "transferSize": 39878, + "resourceSize": 39680, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/html5shiv.min.js", + "startTime": 242.11099999956787, + "endTime": 314.31599997449666, + "transferSize": 2919, + "resourceSize": 2722, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/respond.min.js", + "startTime": 242.51799995545298, + "endTime": 331.4839999657124, + "transferSize": 4660, + "resourceSize": 4463, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/driver-assets/css/driver.min.css", + "startTime": 243.18699998548254, + "endTime": 288.41899998951703, + "transferSize": 4492, + "resourceSize": 4309, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/driver-assets/js/driver.min.js", + "startTime": 244.36000001151115, + "endTime": 347.2290000063367, + "transferSize": 47126, + "resourceSize": 46928, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/cicerone-assets/cicerone.js", + "startTime": 244.75399998482317, + "endTime": 327.6089999708347, + "transferSize": 3126, + "resourceSize": 2929, + "statusCode": 200, + "mimeType": "application/javascript", + "resourceType": "Script" + }, + { + "url": "http://localhost:2811/www/custom.css", + "startTime": 245.18699996406212, + "endTime": 322.51699996413663, + "transferSize": 1175, + "resourceSize": 993, + "statusCode": 200, + "mimeType": "text/css", + "resourceType": "Stylesheet" + }, + { + "url": "http://localhost:2811/www/favicon.ico", + "startTime": 566.6060000075959, + "endTime": 575.0499999849126, + "transferSize": 3973, + "resourceSize": 3774, + "statusCode": 200, + "mimeType": "image/vnd.microsoft.icon", + "resourceType": "Other" + }, + { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABA0AAASwCAYAAACQKbNGAAAACXBIWXMAAFxGAABcRgEUlENBAAAgAE", + "startTime": 1472.5829999661073, + "endTime": 1473.4489999827929, + "transferSize": 0, + "resourceSize": 88631, + "statusCode": 200, + "mimeType": "image/png", + "resourceType": "Image" + } + ] + } + }, + "network-rtt": { + "id": "network-rtt", + "title": "Network Round Trip Times", + "description": "Network round trip times (RTT) have a large impact on performance. If the RTT to an origin is high, it is an indication that servers closer to the user could improve performance. [Learn more](https://hpbn.co/primer-on-latency-and-bandwidth/).", + "score": null, + "scoreDisplayMode": "informative", + "numericValue": 0.36000000000000004, + "displayValue": "0 ms", + "details": { + "type": "table", + "headings": [ + { + "key": "origin", + "itemType": "text", + "text": "URL" + }, + { + "key": "rtt", + "itemType": "ms", + "granularity": 1, + "text": "Time Spent" + } + ], + "items": [ + { + "origin": "http://localhost:2811", + "rtt": 0.36000000000000004 + } + ] + } + }, + "network-server-latency": { + "id": "network-server-latency", + "title": "Server Backend Latencies", + "description": "Server latencies can impact web performance. If the server latency of an origin is high, it is an indication the server is overloaded or has poor backend performance. [Learn more](https://hpbn.co/primer-on-web-performance/#analyzing-the-resource-waterfall).", + "score": null, + "scoreDisplayMode": "informative", + "numericValue": 22.308, + "displayValue": "20 ms", + "details": { + "type": "table", + "headings": [ + { + "key": "origin", + "itemType": "text", + "text": "URL" + }, + { + "key": "serverResponseTime", + "itemType": "ms", + "granularity": 1, + "text": "Time Spent" + } + ], + "items": [ + { + "origin": "http://localhost:2811", + "serverResponseTime": 22.308 + } + ] + } + }, + "main-thread-tasks": { + "id": "main-thread-tasks", + "title": "Tasks", + "description": "Lists the toplevel main thread tasks that executed during page load.", + "score": null, + "scoreDisplayMode": "informative", + "numericValue": 12, + "details": { + "type": "table", + "headings": [ + { + "key": "startTime", + "itemType": "ms", + "granularity": 1, + "text": "Start Time" + }, + { + "key": "duration", + "itemType": "ms", + "granularity": 1, + "text": "End Time" + } + ], + "items": [ + { + "duration": 7.91, + "startTime": 216.299 + }, + { + "duration": 17.59, + "startTime": 240.121 + }, + { + "duration": 5.053, + "startTime": 281.427 + }, + { + "duration": 29.857, + "startTime": 287.806 + }, + { + "duration": 22.913, + "startTime": 317.725 + }, + { + "duration": 13.315, + "startTime": 353.408 + }, + { + "duration": 27.793, + "startTime": 374.25 + }, + { + "duration": 73.074, + "startTime": 402.385 + }, + { + "duration": 19.796, + "startTime": 475.476 + }, + { + "duration": 67.181, + "startTime": 501.028 + }, + { + "duration": 11.258, + "startTime": 1474.464 + }, + { + "duration": 28.881, + "startTime": 1485.741 + } + ] + } + }, + "metrics": { + "id": "metrics", + "title": "Metrics", + "description": "Collects all available metrics.", + "score": null, + "scoreDisplayMode": "informative", + "numericValue": 5513.5419999999995, + "details": { + "type": "debugdata", + "items": [ + { + "firstContentfulPaint": 5439, + "firstMeaningfulPaint": 5439, + "firstCPUIdle": 5439, + "interactive": 5514, + "speedIndex": 5439, + "estimatedInputLatency": 13, + "totalBlockingTime": 31, + "observedNavigationStart": 0, + "observedNavigationStartTs": 406980060873, + "observedFirstPaint": 593, + "observedFirstPaintTs": 406980654113, + "observedFirstContentfulPaint": 593, + "observedFirstContentfulPaintTs": 406980654113, + "observedFirstMeaningfulPaint": 593, + "observedFirstMeaningfulPaintTs": 406980654113, + "observedLargestContentfulPaint": 593, + "observedLargestContentfulPaintTs": 406980654113, + "observedTraceEnd": 1580, + "observedTraceEndTs": 406981640616, + "observedLoad": 397, + "observedLoadTs": 406980458339, + "observedDomContentLoaded": 386, + "observedDomContentLoadedTs": 406980446804, + "observedFirstVisualChange": 521, + "observedFirstVisualChangeTs": 406980581873, + "observedLastVisualChange": 1104, + "observedLastVisualChangeTs": 406981164873, + "observedSpeedIndex": 533, + "observedSpeedIndexTs": 406980594003 + }, + { + "lcpInvalidated": false + } + ] + } + }, + "offline-start-url": { + "id": "offline-start-url", + "title": "`start_url` does not respond with a 200 when offline", + "description": "A service worker enables your web app to be reliable in unpredictable network conditions. [Learn more](https://web.dev/offline-start-url).", + "score": 0, + "scoreDisplayMode": "binary", + "explanation": "No usable web app manifest found on page.", + "warnings": [] + }, + "performance-budget": { + "id": "performance-budget", + "title": "Performance budget", + "description": "Keep the quantity and size of network requests under the targets set by the provided performance budget. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/budgets).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "resource-summary": { + "id": "resource-summary", + "title": "Keep request counts low and transfer sizes small", + "description": "To set budgets for the quantity and size of page resources, add a budget.json file. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/budgets).", + "score": null, + "scoreDisplayMode": "informative", + "displayValue": "21 requests • 566 KB", + "details": { + "type": "table", + "headings": [ + { + "key": "label", + "itemType": "text", + "text": "Resource Type" + }, + { + "key": "requestCount", + "itemType": "numeric", + "text": "Requests" + }, + { + "key": "size", + "itemType": "bytes", + "text": "Transfer Size" + } + ], + "items": [ + { + "resourceType": "total", + "label": "Total", + "requestCount": 21, + "size": 579766 + }, + { + "resourceType": "script", + "label": "Script", + "requestCount": 11, + "size": 329086 + }, + { + "resourceType": "stylesheet", + "label": "Stylesheet", + "requestCount": 7, + "size": 222631 + }, + { + "resourceType": "document", + "label": "Document", + "requestCount": 1, + "size": 24076 + }, + { + "resourceType": "other", + "label": "Other", + "requestCount": 1, + "size": 3973 + }, + { + "resourceType": "image", + "label": "Image", + "requestCount": 1, + "size": 0 + }, + { + "resourceType": "media", + "label": "Media", + "requestCount": 0, + "size": 0 + }, + { + "resourceType": "font", + "label": "Font", + "requestCount": 0, + "size": 0 + }, + { + "resourceType": "third-party", + "label": "Third-party", + "requestCount": 1, + "size": 0 + } + ] + } + }, + "third-party-summary": { + "id": "third-party-summary", + "title": "Minimize third-party usage", + "description": "Third-party code can significantly impact load performance. Limit the number of redundant third-party providers and try to load third-party code after your page has primarily finished loading. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/loading-third-party-javascript/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "pwa-cross-browser": { + "id": "pwa-cross-browser", + "title": "Site works cross-browser", + "description": "To reach the most number of users, sites should work across every major browser. [Learn more](https://web.dev/pwa-cross-browser).", + "score": null, + "scoreDisplayMode": "manual" + }, + "pwa-page-transitions": { + "id": "pwa-page-transitions", + "title": "Page transitions do not feel like they block on the network", + "description": "Transitions should feel snappy as you tap around, even on a slow network. This experience is key to a user's perception of performance. [Learn more](https://web.dev/pwa-page-transitions).", + "score": null, + "scoreDisplayMode": "manual" + }, + "pwa-each-page-has-url": { + "id": "pwa-each-page-has-url", + "title": "Each page has a URL", + "description": "Ensure individual pages are deep linkable via URL and that URLs are unique for the purpose of shareability on social media. [Learn more](https://web.dev/pwa-each-page-has-url).", + "score": null, + "scoreDisplayMode": "manual" + }, + "accesskeys": { + "id": "accesskeys", + "title": "`[accesskey]` values are unique", + "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://web.dev/accesskeys/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-allowed-attr": { + "id": "aria-allowed-attr", + "title": "`[aria-*]` attributes match their roles", + "description": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn more](https://web.dev/aria-allowed-attr/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-required-attr": { + "id": "aria-required-attr", + "title": "`[role]`s have all required `[aria-*]` attributes", + "description": "Some ARIA roles have required attributes that describe the state of the element to screen readers. [Learn more](https://web.dev/aria-required-attr/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-required-children": { + "id": "aria-required-children", + "title": "Elements with an ARIA `[role]` that require children to contain a specific `[role]` have all required children.", + "description": "Some ARIA parent roles must contain specific child roles to perform their intended accessibility functions. [Learn more](https://web.dev/aria-required-children/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-required-parent": { + "id": "aria-required-parent", + "title": "`[role]`s are contained by their required parent element", + "description": "Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more](https://web.dev/aria-required-parent/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-roles": { + "id": "aria-roles", + "title": "`[role]` values are valid", + "description": "ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more](https://web.dev/aria-roles/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-valid-attr-value": { + "id": "aria-valid-attr-value", + "title": "`[aria-*]` attributes have valid values", + "description": "Assistive technologies, like screen readers, can not interpret ARIA attributes with invalid values. [Learn more](https://web.dev/aria-valid-attr-value/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "aria-valid-attr": { + "id": "aria-valid-attr", + "title": "`[aria-*]` attributes are valid and not misspelled", + "description": "Assistive technologies, like screen readers, can not interpret ARIA attributes with invalid names. [Learn more](https://web.dev/aria-valid-attr/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "audio-caption": { + "id": "audio-caption", + "title": "`<audio>` elements contain a `<track>` element with `[kind=\"captions\"]`", + "description": "Captions make audio elements usable for deaf or hearing-impaired users, providing critical information such as who is talking, what they're saying, and other non-speech information. [Learn more](https://web.dev/audio-caption/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "button-name": { + "id": "button-name", + "title": "Buttons have an accessible name", + "description": "When a button does not have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://web.dev/button-name/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "bypass": { + "id": "bypass", + "title": "The page contains a heading, skip link, or landmark region", + "description": "Adding ways to bypass repetitive content lets keyboard users navigate the page more efficiently. [Learn more](https://web.dev/bypass/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "color-contrast": { + "id": "color-contrast", + "title": "Background and foreground colors have a sufficient contrast ratio", + "description": "Low-contrast text is difficult or impossible for many users to read. [Learn more](https://web.dev/color-contrast/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "definition-list": { + "id": "definition-list", + "title": "`<dl>`'s contain only properly-ordered `<dt>` and `<dd>` groups, `<script>` or `<template>` elements.", + "description": "When definition lists are not properly marked up, screen readers may produce confusing or inaccurate output. [Learn more](https://web.dev/definition-list/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "dlitem": { + "id": "dlitem", + "title": "Definition list items are wrapped in `<dl>` elements", + "description": "Definition list items (`<dt>` and `<dd>`) must be wrapped in a parent `<dl>` element to ensure that screen readers can properly announce them. [Learn more](https://web.dev/dlitem/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "document-title": { + "id": "document-title", + "title": "Document has a `<title>` element", + "description": "The title gives screen reader users an overview of the page, and search engine users rely on it heavily to determine if a page is relevant to their search. [Learn more](https://web.dev/document-title/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "duplicate-id": { + "id": "duplicate-id", + "title": "`[id]` attributes on the page are unique", + "description": "The value of an id attribute must be unique to prevent other instances from being overlooked by assistive technologies. [Learn more](https://web.dev/duplicate-id/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "frame-title": { + "id": "frame-title", + "title": "`<frame>` or `<iframe>` elements have a title", + "description": "Screen reader users rely on frame titles to describe the contents of frames. [Learn more](https://web.dev/frame-title/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "html-has-lang": { + "id": "html-has-lang", + "title": "`<html>` element does not have a `[lang]` attribute", + "description": "If a page does not specify a lang attribute, a screen reader assumes that the page is in the default language that the user chose when setting up the screen reader. If the page isn't actually in the default language, then the screen reader might not announce the page's text correctly. [Learn more](https://web.dev/html-has-lang/).", + "score": 0, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [ + { + "key": "node", + "itemType": "node", + "text": "Failing Elements" + } + ], + "items": [ + { + "node": { + "type": "node", + "selector": "html", + "path": "1,HTML", + "snippet": "<html class=\"\">", + "explanation": "Fix any of the following:\n The <html> element does not have a lang attribute", + "nodeLabel": "html" + } + } + ], + "debugData": { + "type": "debugdata", + "impact": "serious", + "tags": [ + "cat.language", + "wcag2a", + "wcag311" + ] + } + } + }, + "html-lang-valid": { + "id": "html-lang-valid", + "title": "`<html>` element has a valid value for its `[lang]` attribute", + "description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) helps screen readers announce text properly. [Learn more](https://web.dev/html-lang-valid/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "image-alt": { + "id": "image-alt", + "title": "Image elements do not have `[alt]` attributes", + "description": "Informative elements should aim for short, descriptive alternate text. Decorative elements can be ignored with an empty alt attribute. [Learn more](https://web.dev/image-alt/).", + "score": 0, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [ + { + "key": "node", + "itemType": "node", + "text": "Failing Elements" + } + ], + "items": [ + { + "node": { + "type": "node", + "selector": "img", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,1,DIV,0,DIV,0,DIV,0,IMG", + "snippet": "<img src=\"data:image/png;base64,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\">", + "explanation": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute or the title attribute is empty\n Element's default semantics were not overridden with role=\"presentation\"\n Element's default semantics were not overridden with role=\"none\"", + "nodeLabel": "img" + } + } + ], + "debugData": { + "type": "debugdata", + "impact": "critical", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a" + ] + } + } + }, + "input-image-alt": { + "id": "input-image-alt", + "title": "`<input type=\"image\">` elements have `[alt]` text", + "description": "When an image is being used as an `<input>` button, providing alternative text can help screen reader users understand the purpose of the button. [Learn more](https://web.dev/input-image-alt/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "label": { + "id": "label", + "title": "Form elements do not have associated labels", + "description": "Labels ensure that form controls are announced properly by assistive technologies, like screen readers. [Learn more](https://web.dev/label/).", + "score": 0, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [ + { + "key": "node", + "itemType": "node", + "text": "Failing Elements" + } + ], + "items": [ + { + "node": { + "type": "node", + "selector": "#main_ui_1-left_ui_1-pkg_name_ui_1-p_color", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,0,DIV,0,DIV,2,DETAILS,1,DIV,0,DIV,2,DIV,2,INPUT", + "snippet": "<input type=\"color\" id=\"main_ui_1-left_ui_1-pkg_name_ui_1-p_color\" value=\"#FFFFFF\" name=\"p_color\" oninput=\"Shiny.setInputValue('main_ui_1-left_ui_1-pkg_name_ui_1-p_color', event.target.value)\">", + "explanation": "Fix any of the following:\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Form element does not have an implicit (wrapped) <label>\n Form element does not have an explicit <label>\n Element has no title attribute or the title attribute is empty", + "nodeLabel": "input" + } + }, + { + "node": { + "type": "node", + "selector": "#main_ui_1-left_ui_1-image_ui_1-uploaddiv > .input-group > input[placeholder=\"No\\ file\\ selected\"][readonly=\"readonly\"][type=\"text\"]", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,0,DIV,0,DIV,3,DETAILS,1,DIV,0,DIV,2,DIV,0,DIV,1,DIV,1,INPUT", + "snippet": "<input type=\"text\" class=\"form-control\" placeholder=\"No file selected\" readonly=\"readonly\">", + "explanation": "Fix any of the following:\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Form element does not have an implicit (wrapped) <label>\n Form element does not have an explicit <label>\n Element has no title attribute or the title attribute is empty", + "nodeLabel": "input" + } + }, + { + "node": { + "type": "node", + "selector": "#main_ui_1-left_ui_1-hexa_ui_1-h_fill", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,0,DIV,0,DIV,4,DETAILS,1,DIV,0,DIV,1,DIV,2,INPUT", + "snippet": "<input type=\"color\" id=\"main_ui_1-left_ui_1-hexa_ui_1-h_fill\" value=\"#1881C2\" name=\"p_color\" oninput=\"Shiny.setInputValue('main_ui_1-left_ui_1-hexa_ui_1-h_fill', event.target.value)\">", + "explanation": "Fix any of the following:\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Form element does not have an implicit (wrapped) <label>\n Form element does not have an explicit <label>\n Element has no title attribute or the title attribute is empty", + "nodeLabel": "input" + } + }, + { + "node": { + "type": "node", + "selector": "#main_ui_1-left_ui_1-hexa_ui_1-h_color", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,0,DIV,0,DIV,4,DETAILS,1,DIV,0,DIV,2,DIV,2,INPUT", + "snippet": "<input type=\"color\" id=\"main_ui_1-left_ui_1-hexa_ui_1-h_color\" value=\"#87B13F\" name=\"p_color\" oninput=\"Shiny.setInputValue('main_ui_1-left_ui_1-hexa_ui_1-h_color', event.target.value)\">", + "explanation": "Fix any of the following:\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Form element does not have an implicit (wrapped) <label>\n Form element does not have an explicit <label>\n Element has no title attribute or the title attribute is empty", + "nodeLabel": "input" + } + }, + { + "node": { + "type": "node", + "selector": "#main_ui_1-left_ui_1-url_ui_1-u_color", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,0,DIV,0,DIV,6,DETAILS,1,DIV,0,DIV,3,DIV,2,INPUT", + "snippet": "<input type=\"color\" id=\"main_ui_1-left_ui_1-url_ui_1-u_color\" value=\"#000000\" name=\"u_color\" oninput=\"Shiny.setInputValue('main_ui_1-left_ui_1-url_ui_1-u_color', event.target.value)\">", + "explanation": "Fix any of the following:\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Form element does not have an implicit (wrapped) <label>\n Form element does not have an explicit <label>\n Element has no title attribute or the title attribute is empty", + "nodeLabel": "input" + } + }, + { + "node": { + "type": "node", + "selector": ".col-sm-4:nth-child(3) > .form-group.shiny-input-container > .input-group > input[placeholder=\"No\\ file\\ selected\"][readonly=\"readonly\"][type=\"text\"]", + "path": "1,HTML,1,BODY,0,DIV,0,DIV,0,DIV,0,DIV,0,DIV,8,DETAILS,1,DIV,0,DIV,2,DIV,0,DIV,1,DIV,1,INPUT", + "snippet": "<input type=\"text\" class=\"form-control\" placeholder=\"No file selected\" readonly=\"readonly\">", + "explanation": "Fix any of the following:\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Form element does not have an implicit (wrapped) <label>\n Form element does not have an explicit <label>\n Element has no title attribute or the title attribute is empty", + "nodeLabel": "input" + } + } + ], + "debugData": { + "type": "debugdata", + "impact": "critical", + "tags": [ + "cat.forms", + "wcag2a", + "wcag332", + "wcag131", + "section508", + "section508.22.n" + ] + } + } + }, + "layout-table": { + "id": "layout-table", + "title": "Presentational `<table>` elements avoid using `<th>`, `<caption>` or the `[summary]` attribute.", + "description": "A table being used for layout purposes should not include data elements, such as the th or caption elements or the summary attribute, because this can create a confusing experience for screen reader users. [Learn more](https://web.dev/layout-table/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "link-name": { + "id": "link-name", + "title": "Links have a discernible name", + "description": "Link text (and alternate text for images, when used as links) that is discernible, unique, and focusable improves the navigation experience for screen reader users. [Learn more](https://web.dev/link-name/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "list": { + "id": "list", + "title": "Lists contain only `<li>` elements and script supporting elements (`<script>` and `<template>`).", + "description": "Screen readers have a specific way of announcing lists. Ensuring proper list structure aids screen reader output. [Learn more](https://web.dev/list/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "listitem": { + "id": "listitem", + "title": "List items (`<li>`) are contained within `<ul>` or `<ol>` parent elements", + "description": "Screen readers require list items (`<li>`) to be contained within a parent `<ul>` or `<ol>` to be announced properly. [Learn more](https://web.dev/listitem/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "meta-refresh": { + "id": "meta-refresh", + "title": "The document does not use `<meta http-equiv=\"refresh\">`", + "description": "Users do not expect a page to refresh automatically, and doing so will move focus back to the top of the page. This may create a frustrating or confusing experience. [Learn more](https://web.dev/meta-refresh/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "meta-viewport": { + "id": "meta-viewport", + "title": "`[user-scalable=\"no\"]` is not used in the `<meta name=\"viewport\">` element and the `[maximum-scale]` attribute is not less than 5.", + "description": "Disabling zooming is problematic for users with low vision who rely on screen magnification to properly see the contents of a web page. [Learn more](https://web.dev/meta-viewport/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "object-alt": { + "id": "object-alt", + "title": "`<object>` elements have `[alt]` text", + "description": "Screen readers cannot translate non-text content. Adding alt text to `<object>` elements helps screen readers convey meaning to users. [Learn more](https://web.dev/object-alt/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "tabindex": { + "id": "tabindex", + "title": "No element has a `[tabindex]` value greater than 0", + "description": "A value greater than 0 implies an explicit navigation ordering. Although technically valid, this often creates frustrating experiences for users who rely on assistive technologies. [Learn more](https://web.dev/tabindex/).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "td-headers-attr": { + "id": "td-headers-attr", + "title": "Cells in a `<table>` element that use the `[headers]` attribute refer to table cells within the same table.", + "description": "Screen readers have features to make navigating tables easier. Ensuring `<td>` cells using the `[headers]` attribute only refer to other cells in the same table may improve the experience for screen reader users. [Learn more](https://web.dev/td-headers-attr/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "th-has-data-cells": { + "id": "th-has-data-cells", + "title": "`<th>` elements and elements with `[role=\"columnheader\"/\"rowheader\"]` have data cells they describe.", + "description": "Screen readers have features to make navigating tables easier. Ensuring table headers always refer to some set of cells may improve the experience for screen reader users. [Learn more](https://web.dev/th-has-data-cells/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "valid-lang": { + "id": "valid-lang", + "title": "`[lang]` attributes have a valid value", + "description": "Specifying a valid [BCP 47 language](https://www.w3.org/International/questions/qa-choosing-language-tags#question) on elements helps ensure that text is pronounced correctly by a screen reader. [Learn more](https://web.dev/valid-lang/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "video-caption": { + "id": "video-caption", + "title": "`<video>` elements contain a `<track>` element with `[kind=\"captions\"]`", + "description": "When a video provides a caption it is easier for deaf and hearing impaired users to access its information. [Learn more](https://web.dev/video-caption/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "video-description": { + "id": "video-description", + "title": "`<video>` elements contain a `<track>` element with `[kind=\"description\"]`", + "description": "Audio descriptions provide relevant information for videos that dialogue cannot, such as facial expressions and scenes. [Learn more](https://web.dev/video-description/).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "custom-controls-labels": { + "id": "custom-controls-labels", + "title": "Custom controls have associated labels", + "description": "Custom interactive controls have associated labels, provided by aria-label or aria-labelledby. [Learn more](https://web.dev/custom-controls-labels/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "custom-controls-roles": { + "id": "custom-controls-roles", + "title": "Custom controls have ARIA roles", + "description": "Custom interactive controls have appropriate ARIA roles. [Learn more](https://web.dev/custom-control-roles/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "focus-traps": { + "id": "focus-traps", + "title": "User focus is not accidentally trapped in a region", + "description": "A user can tab into and out of any control or region without accidentally trapping their focus. [Learn more](https://web.dev/focus-traps/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "focusable-controls": { + "id": "focusable-controls", + "title": "Interactive controls are keyboard focusable", + "description": "Custom interactive controls are keyboard focusable and display a focus indicator. [Learn more](https://web.dev/focusable-controls/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "heading-levels": { + "id": "heading-levels", + "title": "Headings do not skip levels", + "description": "Headings are used to create an outline for the page and heading levels are not skipped. [Learn more](https://web.dev/heading-levels/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "interactive-element-affordance": { + "id": "interactive-element-affordance", + "title": "Interactive elements indicate their purpose and state", + "description": "Interactive elements, such as links and buttons, should indicate their state and be distinguishable from non-interactive elements. [Learn more](https://web.dev/interactive-element-affordance/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "logical-tab-order": { + "id": "logical-tab-order", + "title": "The page has a logical tab order", + "description": "Tabbing through the page follows the visual layout. Users cannot focus elements that are offscreen. [Learn more](https://web.dev/logical-tab-order/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "managed-focus": { + "id": "managed-focus", + "title": "The user's focus is directed to new content added to the page", + "description": "If new content, such as a dialog, is added to the page, the user's focus is directed to it. [Learn more](https://web.dev/managed-focus/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "offscreen-content-hidden": { + "id": "offscreen-content-hidden", + "title": "Offscreen content is hidden from assistive technology", + "description": "Offscreen content is hidden with display: none or aria-hidden=true. [Learn more](https://web.dev/offscreen-content-hidden/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "use-landmarks": { + "id": "use-landmarks", + "title": "HTML5 landmark elements are used to improve navigation", + "description": "Landmark elements (<main>, <nav>, etc.) are used to improve the keyboard navigation of the page for assistive technology. [Learn more](https://web.dev/use-landmarks/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "visual-order-follows-dom": { + "id": "visual-order-follows-dom", + "title": "Visual order on the page follows DOM order", + "description": "DOM order matches the visual order, improving navigation for assistive technology. [Learn more](https://web.dev/visual-order-follows-dom/).", + "score": null, + "scoreDisplayMode": "manual" + }, + "uses-long-cache-ttl": { + "id": "uses-long-cache-ttl", + "title": "Serve static assets with an efficient cache policy", + "description": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://web.dev/uses-long-cache-ttl).", + "score": 0.11, + "scoreDisplayMode": "numeric", + "numericValue": 551717, + "displayValue": "18 resources found", + "details": { + "type": "table", + "headings": [ + { + "key": "url", + "itemType": "url", + "text": "URL" + }, + { + "key": "cacheLifetimeMs", + "itemType": "ms", + "text": "Cache TTL", + "displayUnit": "duration" + }, + { + "key": "totalBytes", + "itemType": "bytes", + "text": "Size", + "displayUnit": "kb", + "granularity": 1 + } + ], + "items": [ + { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 121642, + "wastedBytes": 121642 + }, + { + "url": "http://localhost:2811/shared/shiny.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 93264, + "wastedBytes": 93264 + }, + { + "url": "http://localhost:2811/shared/jquery.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 88343, + "wastedBytes": 88343 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 48833, + "wastedBytes": 48833 + }, + { + "url": "http://localhost:2811/driver-assets/js/driver.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 47126, + "wastedBytes": 47126 + }, + { + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 45337, + "wastedBytes": 45337 + }, + { + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 39878, + "wastedBytes": 39878 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 26877, + "wastedBytes": 26877 + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 10950, + "wastedBytes": 10950 + }, + { + "url": "http://localhost:2811/shared/shiny.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 8662, + "wastedBytes": 8662 + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/respond.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 4660, + "wastedBytes": 4660 + }, + { + "url": "http://localhost:2811/driver-assets/css/driver.min.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 4492, + "wastedBytes": 4492 + }, + { + "url": "http://localhost:2811/shared/json2-min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 3188, + "wastedBytes": 3188 + }, + { + "url": "http://localhost:2811/cicerone-assets/cicerone.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 3126, + "wastedBytes": 3126 + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/html5shiv.min.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 2919, + "wastedBytes": 2919 + }, + { + "url": "http://localhost:2811/www/custom.css", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 1175, + "wastedBytes": 1175 + }, + { + "url": "http://localhost:2811/golem_resources-0.0.1/script.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 661, + "wastedBytes": 661 + }, + { + "url": "http://localhost:2811/golem_resources-0.0.1/handlers.js", + "cacheLifetimeMs": 0, + "cacheHitProbability": 0, + "totalBytes": 584, + "wastedBytes": 584 + } + ], + "summary": { + "wastedBytes": 551717 + } + } + }, + "total-byte-weight": { + "id": "total-byte-weight", + "title": "Avoids enormous network payloads", + "description": "Large network payloads cost users real money and are highly correlated with long load times. [Learn more](https://web.dev/total-byte-weight).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 579766, + "displayValue": "Total size was 566 KB", + "details": { + "type": "table", + "headings": [ + { + "key": "url", + "itemType": "url", + "text": "URL" + }, + { + "key": "totalBytes", + "itemType": "bytes", + "text": "Size" + } + ], + "items": [ + { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "totalBytes": 121642 + }, + { + "url": "http://localhost:2811/shared/shiny.min.js", + "totalBytes": 93264 + }, + { + "url": "http://localhost:2811/shared/jquery.min.js", + "totalBytes": 88343 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "totalBytes": 48833 + }, + { + "url": "http://localhost:2811/driver-assets/js/driver.min.js", + "totalBytes": 47126 + }, + { + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js", + "totalBytes": 45337 + }, + { + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js", + "totalBytes": 39878 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "totalBytes": 26877 + }, + { + "url": "http://localhost:2811/", + "totalBytes": 24076 + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "totalBytes": 10950 + } + ] + } + }, + "offscreen-images": { + "id": "offscreen-images", + "title": "Defer offscreen images", + "description": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://web.dev/offscreen-images).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "warnings": [], + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0, + "overallSavingsBytes": 0 + } + }, + "render-blocking-resources": { + "id": "render-blocking-resources", + "title": "Eliminate render-blocking resources", + "description": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://web.dev/render-blocking-resources).", + "score": 0, + "scoreDisplayMode": "numeric", + "numericValue": 5164, + "displayValue": "Potential savings of 5,160 ms", + "details": { + "type": "opportunity", + "headings": [ + { + "key": "url", + "valueType": "url", + "label": "URL" + }, + { + "key": "totalBytes", + "valueType": "bytes", + "label": "Size" + }, + { + "key": "wastedMs", + "valueType": "timespanMs", + "label": "Potential Savings" + } + ], + "items": [ + { + "url": "http://localhost:2811/shared/json2-min.js", + "totalBytes": 3188, + "wastedMs": 172 + }, + { + "url": "http://localhost:2811/shared/jquery.min.js", + "totalBytes": 88343, + "wastedMs": 3172 + }, + { + "url": "http://localhost:2811/shared/shiny.css", + "totalBytes": 8662, + "wastedMs": 472 + }, + { + "url": "http://localhost:2811/shared/shiny.min.js", + "totalBytes": 93264, + "wastedMs": 3322 + }, + { + "url": "http://localhost:2811/golem_resources-0.0.1/handlers.js", + "totalBytes": 584, + "wastedMs": 322 + }, + { + "url": "http://localhost:2811/golem_resources-0.0.1/script.js", + "totalBytes": 661, + "wastedMs": 322 + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "totalBytes": 10950, + "wastedMs": 472 + }, + { + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js", + "totalBytes": 45337, + "wastedMs": 1672 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "totalBytes": 48833, + "wastedMs": 1822 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "totalBytes": 26877, + "wastedMs": 1072 + }, + { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "totalBytes": 121642, + "wastedMs": 3322 + }, + { + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js", + "totalBytes": 39878, + "wastedMs": 1522 + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/html5shiv.min.js", + "totalBytes": 2919, + "wastedMs": 172 + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/respond.min.js", + "totalBytes": 4660, + "wastedMs": 322 + }, + { + "url": "http://localhost:2811/driver-assets/css/driver.min.css", + "totalBytes": 4492, + "wastedMs": 322 + }, + { + "url": "http://localhost:2811/driver-assets/js/driver.min.js", + "totalBytes": 47126, + "wastedMs": 1222 + }, + { + "url": "http://localhost:2811/cicerone-assets/cicerone.js", + "totalBytes": 3126, + "wastedMs": 172 + }, + { + "url": "http://localhost:2811/www/custom.css", + "totalBytes": 1175, + "wastedMs": 172 + } + ], + "overallSavingsMs": 5164 + } + }, + "unminified-css": { + "id": "unminified-css", + "title": "Minify CSS", + "description": "Minifying CSS files can reduce network payload sizes. [Learn more](https://web.dev/unminified-css).", + "score": 0.88, + "scoreDisplayMode": "numeric", + "numericValue": 150, + "displayValue": "Potential savings of 5 KB", + "details": { + "type": "opportunity", + "headings": [ + { + "key": "url", + "valueType": "url", + "label": "URL" + }, + { + "key": "totalBytes", + "valueType": "bytes", + "label": "Size" + }, + { + "key": "wastedBytes", + "valueType": "bytes", + "label": "Potential Savings" + } + ], + "items": [ + { + "url": "http://localhost:2811/shared/shiny.css", + "totalBytes": 8662, + "wastedBytes": 2572, + "wastedPercent": 29.69689821912962 + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "totalBytes": 10950, + "wastedBytes": 2348, + "wastedPercent": 21.44184318097362 + } + ], + "overallSavingsMs": 150, + "overallSavingsBytes": 4920 + } + }, + "unminified-javascript": { + "id": "unminified-javascript", + "title": "Minify JavaScript", + "description": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://web.dev/unminified-javascript).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "warnings": [], + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0, + "overallSavingsBytes": 0 + } + }, + "unused-css-rules": { + "id": "unused-css-rules", + "title": "Remove unused CSS", + "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://web.dev/unused-css-rules).", + "score": 0.5, + "scoreDisplayMode": "numeric", + "numericValue": 750, + "displayValue": "Potential savings of 201 KB", + "details": { + "type": "opportunity", + "headings": [ + { + "key": "url", + "valueType": "url", + "label": "URL" + }, + { + "key": "totalBytes", + "valueType": "bytes", + "label": "Size" + }, + { + "key": "wastedBytes", + "valueType": "bytes", + "label": "Potential Savings" + } + ], + "items": [ + { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "wastedBytes": 119587, + "wastedPercent": 98.31051318573651, + "totalBytes": 121642 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "wastedBytes": 48833, + "wastedPercent": 100, + "totalBytes": 48833 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "wastedBytes": 26877, + "wastedPercent": 100, + "totalBytes": 26877 + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "wastedBytes": 10950, + "wastedPercent": 100, + "totalBytes": 10950 + } + ], + "overallSavingsMs": 750, + "overallSavingsBytes": 206247 + } + }, + "uses-webp-images": { + "id": "uses-webp-images", + "title": "Serve images in next-gen formats", + "description": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "warnings": [], + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0, + "overallSavingsBytes": 0 + } + }, + "uses-optimized-images": { + "id": "uses-optimized-images", + "title": "Efficiently encode images", + "description": "Optimized images load faster and consume less cellular data. [Learn more](https://web.dev/uses-optimized-images).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "warnings": [], + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0, + "overallSavingsBytes": 0 + } + }, + "uses-text-compression": { + "id": "uses-text-compression", + "title": "Enable text compression", + "description": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://web.dev/uses-text-compression).", + "score": 0.36, + "scoreDisplayMode": "numeric", + "numericValue": 1950, + "displayValue": "Potential savings of 415 KB", + "details": { + "type": "opportunity", + "headings": [ + { + "key": "url", + "valueType": "url", + "label": "URL" + }, + { + "key": "totalBytes", + "valueType": "bytes", + "label": "Size" + }, + { + "key": "wastedBytes", + "valueType": "bytes", + "label": "Potential Savings" + } + ], + "items": [ + { + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css", + "totalBytes": 121457, + "wastedBytes": 101731 + }, + { + "url": "http://localhost:2811/shared/shiny.min.js", + "totalBytes": 93066, + "wastedBytes": 67345 + }, + { + "url": "http://localhost:2811/shared/jquery.min.js", + "totalBytes": 88145, + "wastedBytes": 57427 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css", + "totalBytes": 48649, + "wastedBytes": 38048 + }, + { + "url": "http://localhost:2811/driver-assets/js/driver.min.js", + "totalBytes": 46928, + "wastedBytes": 34406 + }, + { + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js", + "totalBytes": 45139, + "wastedBytes": 29602 + }, + { + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js", + "totalBytes": 39680, + "wastedBytes": 28738 + }, + { + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css", + "totalBytes": 26693, + "wastedBytes": 22480 + }, + { + "url": "http://localhost:2811/", + "totalBytes": 23922, + "wastedBytes": 20959 + }, + { + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css", + "totalBytes": 10766, + "wastedBytes": 8342 + }, + { + "url": "http://localhost:2811/shared/shiny.css", + "totalBytes": 8479, + "wastedBytes": 6030 + }, + { + "url": "http://localhost:2811/driver-assets/css/driver.min.css", + "totalBytes": 4309, + "wastedBytes": 3286 + }, + { + "url": "http://localhost:2811/cicerone-assets/cicerone.js", + "totalBytes": 2929, + "wastedBytes": 2325 + }, + { + "url": "http://localhost:2811/shared/bootstrap/shim/respond.min.js", + "totalBytes": 4463, + "wastedBytes": 2251 + }, + { + "url": "http://localhost:2811/shared/json2-min.js", + "totalBytes": 2991, + "wastedBytes": 1686 + } + ], + "overallSavingsMs": 1950, + "overallSavingsBytes": 424656 + } + }, + "uses-responsive-images": { + "id": "uses-responsive-images", + "title": "Properly size images", + "description": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://web.dev/uses-responsive-images).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "warnings": [], + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0, + "overallSavingsBytes": 0 + } + }, + "efficient-animated-content": { + "id": "efficient-animated-content", + "title": "Use video formats for animated content", + "description": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://web.dev/efficient-animated-content)", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 0, + "displayValue": "", + "details": { + "type": "opportunity", + "headings": [], + "items": [], + "overallSavingsMs": 0, + "overallSavingsBytes": 0 + } + }, + "appcache-manifest": { + "id": "appcache-manifest", + "title": "Avoids Application Cache", + "description": "Application Cache is deprecated. [Learn more](https://web.dev/appcache-manifest).", + "score": 1, + "scoreDisplayMode": "binary" + }, + "doctype": { + "id": "doctype", + "title": "Page has the HTML doctype", + "description": "Specifying a doctype prevents the browser from switching to quirks-mode. [Learn more](https://web.dev/doctype).", + "score": 1, + "scoreDisplayMode": "binary" + }, + "dom-size": { + "id": "dom-size", + "title": "Avoids an excessive DOM size", + "description": "A large DOM will increase memory usage, cause longer [style calculations](https://developers.google.com/web/fundamentals/performance/rendering/reduce-the-scope-and-complexity-of-style-calculations), and produce costly [layout reflows](https://developers.google.com/speed/articles/reflow). [Learn more](https://web.dev/dom-size).", + "score": 1, + "scoreDisplayMode": "numeric", + "numericValue": 245, + "displayValue": "245 elements", + "details": { + "type": "table", + "headings": [ + { + "key": "statistic", + "itemType": "text", + "text": "Statistic" + }, + { + "key": "element", + "itemType": "code", + "text": "Element" + }, + { + "key": "value", + "itemType": "numeric", + "text": "Value" + } + ], + "items": [ + { + "statistic": "Total DOM Elements", + "element": "", + "value": "245" + }, + { + "statistic": "Maximum DOM Depth", + "element": { + "type": "code", + "value": "<div class=\"item\" data-value=\"sans\">" + }, + "value": "15" + }, + { + "statistic": "Maximum Child Elements", + "element": { + "type": "code", + "value": "<div class=\"rounded\">" + }, + "value": "15" + } + ] + } + }, + "external-anchors-use-rel-noopener": { + "id": "external-anchors-use-rel-noopener", + "title": "Links to cross-origin destinations are safe", + "description": "Add `rel=\"noopener\"` or `rel=\"noreferrer\"` to any external links to improve performance and prevent security vulnerabilities. [Learn more](https://web.dev/external-anchors-use-rel-noopener).", + "score": 1, + "scoreDisplayMode": "binary", + "warnings": [], + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "geolocation-on-start": { + "id": "geolocation-on-start", + "title": "Avoids requesting the geolocation permission on page load", + "description": "Users are mistrustful of or confused by sites that request their location without context. Consider tying the request to a user action instead. [Learn more](https://web.dev/geolocation-on-start).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "no-document-write": { + "id": "no-document-write", + "title": "Avoids `document.write()`", + "description": "For users on slow connections, external scripts dynamically injected via `document.write()` can delay page load by tens of seconds. [Learn more](https://web.dev/no-document-write).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "no-vulnerable-libraries": { + "id": "no-vulnerable-libraries", + "title": "Avoids front-end JavaScript libraries with known security vulnerabilities", + "description": "Some third-party scripts may contain known security vulnerabilities that are easily identified and exploited by attackers. [Learn more](https://web.dev/no-vulnerable-libraries).", + "score": 1, + "scoreDisplayMode": "binary", + "displayValue": "", + "details": { + "type": "table", + "headings": [], + "items": [], + "summary": {} + } + }, + "js-libraries": { + "id": "js-libraries", + "title": "Detected JavaScript libraries", + "description": "All front-end JavaScript libraries detected on the page. [Learn more](https://web.dev/js-libraries).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [ + { + "key": "name", + "itemType": "text", + "text": "Name" + }, + { + "key": "version", + "itemType": "text", + "text": "Version" + } + ], + "items": [ + { + "name": "Bootstrap", + "version": "3.4.1", + "npm": "bootstrap" + }, + { + "name": "jQuery", + "version": "3.4.1", + "npm": "jquery" + }, + { + "name": "jQuery (Fast path)", + "npm": "jquery" + }, + { + "name": "core-js", + "version": "core-js-global@2.6.9", + "npm": "core-js" + } + ], + "summary": {} + } + }, + "notification-on-start": { + "id": "notification-on-start", + "title": "Avoids requesting the notification permission on page load", + "description": "Users are mistrustful of or confused by sites that request to send notifications without context. Consider tying the request to user gestures instead. [Learn more](https://web.dev/notification-on-start).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "password-inputs-can-be-pasted-into": { + "id": "password-inputs-can-be-pasted-into", + "title": "Allows users to paste into password fields", + "description": "Preventing password pasting undermines good security policy. [Learn more](https://web.dev/password-inputs-can-be-pasted-into).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "uses-http2": { + "id": "uses-http2", + "title": "Does not use HTTP/2 for all of its resources", + "description": "HTTP/2 offers many benefits over HTTP/1.1, including binary headers, multiplexing, and server push. [Learn more](https://web.dev/uses-http2).", + "score": 0, + "scoreDisplayMode": "binary", + "displayValue": "20 requests not served via HTTP/2", + "details": { + "type": "table", + "headings": [ + { + "key": "url", + "itemType": "url", + "text": "URL" + }, + { + "key": "protocol", + "itemType": "text", + "text": "Protocol" + } + ], + "items": [ + { + "protocol": "http/1.1", + "url": "http://localhost:2811/" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/json2-min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/jquery.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/shiny.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/shiny.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/golem_resources-0.0.1/handlers.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/golem_resources-0.0.1/script.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/selectize/css/selectize.bootstrap3.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/selectize/js/selectize.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/font-awesome-5.3.1/css/all.min.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/font-awesome-5.3.1/css/v4-shims.min.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/bootstrap/css/bootstrap.min.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/bootstrap/js/bootstrap.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/bootstrap/shim/html5shiv.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/shared/bootstrap/shim/respond.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/driver-assets/css/driver.min.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/driver-assets/js/driver.min.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/cicerone-assets/cicerone.js" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/www/custom.css" + }, + { + "protocol": "http/1.1", + "url": "http://localhost:2811/www/favicon.ico" + } + ] + } + }, + "uses-passive-event-listeners": { + "id": "uses-passive-event-listeners", + "title": "Uses passive listeners to improve scrolling performance", + "description": "Consider marking your touch and wheel event listeners as `passive` to improve your page's scroll performance. [Learn more](https://web.dev/uses-passive-event-listeners).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "meta-description": { + "id": "meta-description", + "title": "Document does not have a meta description", + "description": "Meta descriptions may be included in search results to concisely summarize page content. [Learn more](https://web.dev/meta-description).", + "score": 0, + "scoreDisplayMode": "binary" + }, + "http-status-code": { + "id": "http-status-code", + "title": "Page has successful HTTP status code", + "description": "Pages with unsuccessful HTTP status codes may not be indexed properly. [Learn more](https://web.dev/http-status-code).", + "score": 1, + "scoreDisplayMode": "binary" + }, + "font-size": { + "id": "font-size", + "title": "Document uses legible font sizes", + "description": "Font sizes less than 12px are too small to be legible and require mobile visitors to “pinch to zoom” in order to read. Strive to have >60% of page text ≥12px. [Learn more](https://web.dev/font-size).", + "score": 1, + "scoreDisplayMode": "binary", + "displayValue": "100% legible text", + "details": { + "type": "table", + "headings": [ + { + "key": "source", + "itemType": "url", + "text": "Source" + }, + { + "key": "selector", + "itemType": "code", + "text": "Selector" + }, + { + "key": "coverage", + "itemType": "text", + "text": "% of Page Text" + }, + { + "key": "fontSize", + "itemType": "text", + "text": "Font Size" + } + ], + "items": [ + { + "source": "Legible text", + "selector": "", + "coverage": "100.00%", + "fontSize": "≥ 12px" + } + ] + } + }, + "link-text": { + "id": "link-text", + "title": "Links have descriptive text", + "description": "Descriptive link text helps search engines understand your content. [Learn more](https://web.dev/link-text).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [], + "summary": {} + } + }, + "is-crawlable": { + "id": "is-crawlable", + "title": "Page isn’t blocked from indexing", + "description": "Search engines are unable to include your pages in search results if they do not have permission to crawl them. [Learn more](https://web.dev/is-crawable).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "robots-txt": { + "id": "robots-txt", + "title": "robots.txt is valid", + "description": "If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed. [Learn more](https://web.dev/robots-txt).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "tap-targets": { + "id": "tap-targets", + "title": "Tap targets are sized appropriately", + "description": "Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://web.dev/tap-targets).", + "score": 1, + "scoreDisplayMode": "binary", + "displayValue": "100% appropriately sized tap targets", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "hreflang": { + "id": "hreflang", + "title": "Document has a valid `hreflang`", + "description": "hreflang links tell search engines what version of a page they should list in search results for a given language or region. [Learn more](https://web.dev/hreflang).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "plugins": { + "id": "plugins", + "title": "Document avoids plugins", + "description": "Search engines can not index plugin content, and many devices restrict plugins or do not support them. [Learn more](https://web.dev/plugins).", + "score": 1, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [], + "items": [] + } + }, + "canonical": { + "id": "canonical", + "title": "Document has a valid `rel=canonical`", + "description": "Canonical links suggest which URL to show in search results. [Learn more](https://web.dev/canonical).", + "score": null, + "scoreDisplayMode": "notApplicable" + }, + "structured-data": { + "id": "structured-data", + "title": "Structured data is valid", + "description": "Run the [Structured Data Testing Tool](https://search.google.com/structured-data/testing-tool/) and the [Structured Data Linter](http://linter.structured-data.org/) to validate structured data. [Learn more](https://web.dev/structured-data).", + "score": null, + "scoreDisplayMode": "manual" + } + }, + "configSettings": { + "output": [ + "json" + ], + "maxWaitForFcp": 30000, + "maxWaitForLoad": 45000, + "throttlingMethod": "simulate", + "throttling": { + "rttMs": 150, + "throughputKbps": 1638.4, + "requestLatencyMs": 562.5, + "downloadThroughputKbps": 1474.5600000000002, + "uploadThroughputKbps": 675, + "cpuSlowdownMultiplier": 4 + }, + "auditMode": false, + "gatherMode": false, + "disableStorageReset": false, + "emulatedFormFactor": "mobile", + "channel": "cli", + "budgets": null, + "locale": "en-US", + "blockedUrlPatterns": null, + "additionalTraceCategories": null, + "extraHeaders": null, + "precomputedLanternData": null, + "onlyAudits": null, + "onlyCategories": null, + "skipAudits": null + }, + "categories": { + "performance": { + "title": "Performance", + "auditRefs": [ + { + "id": "first-contentful-paint", + "weight": 3, + "group": "metrics" + }, + { + "id": "first-meaningful-paint", + "weight": 1, + "group": "metrics" + }, + { + "id": "speed-index", + "weight": 4, + "group": "metrics" + }, + { + "id": "interactive", + "weight": 5, + "group": "metrics" + }, + { + "id": "first-cpu-idle", + "weight": 2, + "group": "metrics" + }, + { + "id": "max-potential-fid", + "weight": 0, + "group": "metrics" + }, + { + "id": "estimated-input-latency", + "weight": 0 + }, + { + "id": "total-blocking-time", + "weight": 0 + }, + { + "id": "render-blocking-resources", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "uses-responsive-images", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "offscreen-images", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "unminified-css", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "unminified-javascript", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "unused-css-rules", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "uses-optimized-images", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "uses-webp-images", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "uses-text-compression", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "uses-rel-preconnect", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "time-to-first-byte", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "redirects", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "uses-rel-preload", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "efficient-animated-content", + "weight": 0, + "group": "load-opportunities" + }, + { + "id": "total-byte-weight", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "uses-long-cache-ttl", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "dom-size", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "critical-request-chains", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "user-timings", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "bootup-time", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "mainthread-work-breakdown", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "font-display", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "performance-budget", + "weight": 0, + "group": "budgets" + }, + { + "id": "resource-summary", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "third-party-summary", + "weight": 0, + "group": "diagnostics" + }, + { + "id": "network-requests", + "weight": 0 + }, + { + "id": "network-rtt", + "weight": 0 + }, + { + "id": "network-server-latency", + "weight": 0 + }, + { + "id": "main-thread-tasks", + "weight": 0 + }, + { + "id": "diagnostics", + "weight": 0 + }, + { + "id": "metrics", + "weight": 0 + }, + { + "id": "screenshot-thumbnails", + "weight": 0 + }, + { + "id": "final-screenshot", + "weight": 0 + } + ], + "id": "performance", + "score": 0.53 + }, + "accessibility": { + "title": "Accessibility", + "description": "These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.", + "manualDescription": "These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).", + "auditRefs": [ + { + "id": "accesskeys", + "weight": 0, + "group": "a11y-navigation" + }, + { + "id": "aria-allowed-attr", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "aria-required-attr", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "aria-required-children", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "aria-required-parent", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "aria-roles", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "aria-valid-attr-value", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "aria-valid-attr", + "weight": 0, + "group": "a11y-aria" + }, + { + "id": "audio-caption", + "weight": 0, + "group": "a11y-audio-video" + }, + { + "id": "button-name", + "weight": 10, + "group": "a11y-names-labels" + }, + { + "id": "bypass", + "weight": 3, + "group": "a11y-navigation" + }, + { + "id": "color-contrast", + "weight": 3, + "group": "a11y-color-contrast" + }, + { + "id": "definition-list", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "dlitem", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "document-title", + "weight": 3, + "group": "a11y-names-labels" + }, + { + "id": "duplicate-id", + "weight": 1, + "group": "a11y-best-practices" + }, + { + "id": "frame-title", + "weight": 0, + "group": "a11y-names-labels" + }, + { + "id": "html-has-lang", + "weight": 3, + "group": "a11y-language" + }, + { + "id": "html-lang-valid", + "weight": 0, + "group": "a11y-language" + }, + { + "id": "image-alt", + "weight": 10, + "group": "a11y-names-labels" + }, + { + "id": "input-image-alt", + "weight": 0, + "group": "a11y-names-labels" + }, + { + "id": "label", + "weight": 10, + "group": "a11y-names-labels" + }, + { + "id": "layout-table", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "link-name", + "weight": 3, + "group": "a11y-names-labels" + }, + { + "id": "list", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "listitem", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "meta-refresh", + "weight": 0, + "group": "a11y-best-practices" + }, + { + "id": "meta-viewport", + "weight": 10, + "group": "a11y-best-practices" + }, + { + "id": "object-alt", + "weight": 0, + "group": "a11y-names-labels" + }, + { + "id": "tabindex", + "weight": 3, + "group": "a11y-navigation" + }, + { + "id": "td-headers-attr", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "th-has-data-cells", + "weight": 0, + "group": "a11y-tables-lists" + }, + { + "id": "valid-lang", + "weight": 0, + "group": "a11y-language" + }, + { + "id": "video-caption", + "weight": 0, + "group": "a11y-audio-video" + }, + { + "id": "video-description", + "weight": 0, + "group": "a11y-audio-video" + }, + { + "id": "logical-tab-order", + "weight": 0 + }, + { + "id": "focusable-controls", + "weight": 0 + }, + { + "id": "interactive-element-affordance", + "weight": 0 + }, + { + "id": "managed-focus", + "weight": 0 + }, + { + "id": "focus-traps", + "weight": 0 + }, + { + "id": "custom-controls-labels", + "weight": 0 + }, + { + "id": "custom-controls-roles", + "weight": 0 + }, + { + "id": "visual-order-follows-dom", + "weight": 0 + }, + { + "id": "offscreen-content-hidden", + "weight": 0 + }, + { + "id": "heading-levels", + "weight": 0 + }, + { + "id": "use-landmarks", + "weight": 0 + } + ], + "id": "accessibility", + "score": 0.61 + }, + "best-practices": { + "title": "Best Practices", + "auditRefs": [ + { + "id": "appcache-manifest", + "weight": 1 + }, + { + "id": "is-on-https", + "weight": 1 + }, + { + "id": "uses-http2", + "weight": 1 + }, + { + "id": "uses-passive-event-listeners", + "weight": 1 + }, + { + "id": "no-document-write", + "weight": 1 + }, + { + "id": "external-anchors-use-rel-noopener", + "weight": 1 + }, + { + "id": "geolocation-on-start", + "weight": 1 + }, + { + "id": "doctype", + "weight": 1 + }, + { + "id": "no-vulnerable-libraries", + "weight": 1 + }, + { + "id": "js-libraries", + "weight": 0 + }, + { + "id": "notification-on-start", + "weight": 1 + }, + { + "id": "deprecations", + "weight": 1 + }, + { + "id": "password-inputs-can-be-pasted-into", + "weight": 1 + }, + { + "id": "errors-in-console", + "weight": 1 + }, + { + "id": "image-aspect-ratio", + "weight": 1 + } + ], + "id": "best-practices", + "score": 0.93 + }, + "seo": { + "title": "SEO", + "description": "These checks ensure that your page is optimized for search engine results ranking. There are additional factors Lighthouse does not check that may affect your search ranking. [Learn more](https://support.google.com/webmasters/answer/35769).", + "manualDescription": "Run these additional validators on your site to check additional SEO best practices.", + "auditRefs": [ + { + "id": "viewport", + "weight": 1, + "group": "seo-mobile" + }, + { + "id": "document-title", + "weight": 1, + "group": "seo-content" + }, + { + "id": "meta-description", + "weight": 1, + "group": "seo-content" + }, + { + "id": "http-status-code", + "weight": 1, + "group": "seo-crawl" + }, + { + "id": "link-text", + "weight": 1, + "group": "seo-content" + }, + { + "id": "is-crawlable", + "weight": 1, + "group": "seo-crawl" + }, + { + "id": "robots-txt", + "weight": 0, + "group": "seo-crawl" + }, + { + "id": "image-alt", + "weight": 1, + "group": "seo-content" + }, + { + "id": "hreflang", + "weight": 1, + "group": "seo-content" + }, + { + "id": "canonical", + "weight": 0, + "group": "seo-content" + }, + { + "id": "font-size", + "weight": 1, + "group": "seo-mobile" + }, + { + "id": "plugins", + "weight": 1, + "group": "seo-content" + }, + { + "id": "tap-targets", + "weight": 1, + "group": "seo-mobile" + }, + { + "id": "structured-data", + "weight": 0 + } + ], + "id": "seo", + "score": 0.82 + }, + "pwa": { + "title": "Progressive Web App", + "description": "These checks validate the aspects of a Progressive Web App. [Learn more](https://developers.google.com/web/progressive-web-apps/checklist).", + "manualDescription": "These checks are required by the baseline [PWA Checklist](https://developers.google.com/web/progressive-web-apps/checklist) but are not automatically checked by Lighthouse. They do not affect your score but it is important that you verify them manually.", + "auditRefs": [ + { + "id": "load-fast-enough-for-pwa", + "weight": 7, + "group": "pwa-fast-reliable" + }, + { + "id": "works-offline", + "weight": 5, + "group": "pwa-fast-reliable" + }, + { + "id": "offline-start-url", + "weight": 1, + "group": "pwa-fast-reliable" + }, + { + "id": "is-on-https", + "weight": 2, + "group": "pwa-installable" + }, + { + "id": "service-worker", + "weight": 1, + "group": "pwa-installable" + }, + { + "id": "installable-manifest", + "weight": 2, + "group": "pwa-installable" + }, + { + "id": "redirects-http", + "weight": 2, + "group": "pwa-optimized" + }, + { + "id": "splash-screen", + "weight": 1, + "group": "pwa-optimized" + }, + { + "id": "themed-omnibox", + "weight": 1, + "group": "pwa-optimized" + }, + { + "id": "content-width", + "weight": 1, + "group": "pwa-optimized" + }, + { + "id": "viewport", + "weight": 2, + "group": "pwa-optimized" + }, + { + "id": "without-javascript", + "weight": 1, + "group": "pwa-optimized" + }, + { + "id": "apple-touch-icon", + "weight": 1, + "group": "pwa-optimized" + }, + { + "id": "pwa-cross-browser", + "weight": 0 + }, + { + "id": "pwa-page-transitions", + "weight": 0 + }, + { + "id": "pwa-each-page-has-url", + "weight": 0 + } + ], + "id": "pwa", + "score": 0.48 + } + }, + "categoryGroups": { + "metrics": { + "title": "Metrics" + }, + "load-opportunities": { + "title": "Opportunities", + "description": "These suggestions can help your page load faster. They do not [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score." + }, + "budgets": { + "title": "Budgets", + "description": "Performance budgets set standards for the performance of your site." + }, + "diagnostics": { + "title": "Diagnostics", + "description": "More information about the performance of your application. These numbers do not [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score." + }, + "pwa-fast-reliable": { + "title": "Fast and reliable" + }, + "pwa-installable": { + "title": "Installable" + }, + "pwa-optimized": { + "title": "PWA Optimized" + }, + "a11y-best-practices": { + "title": "Best practices", + "description": "These items highlight common accessibility best practices." + }, + "a11y-color-contrast": { + "title": "Contrast", + "description": "These are opportunities to improve the legibility of your content." + }, + "a11y-names-labels": { + "title": "Names and labels", + "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader." + }, + "a11y-navigation": { + "title": "Navigation", + "description": "These are opportunities to improve keyboard navigation in your application." + }, + "a11y-aria": { + "title": "ARIA", + "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader." + }, + "a11y-language": { + "title": "Internationalization and localization", + "description": "These are opportunities to improve the interpretation of your content by users in different locales." + }, + "a11y-audio-video": { + "title": "Audio and video", + "description": "These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments." + }, + "a11y-tables-lists": { + "title": "Tables and lists", + "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader." + }, + "seo-mobile": { + "title": "Mobile Friendly", + "description": "Make sure your pages are mobile friendly so users don’t have to pinch or zoom in order to read the content pages. [Learn more](https://developers.google.com/search/mobile-sites/)." + }, + "seo-content": { + "title": "Content Best Practices", + "description": "Format your HTML in a way that enables crawlers to better understand your app’s content." + }, + "seo-crawl": { + "title": "Crawling and Indexing", + "description": "To appear in search results, crawlers need access to your app." + } + }, + "timing": { + "entries": [ + { + "startTime": 1751.78, + "name": "lh:init:config", + "duration": 698.04, + "entryType": "measure" + }, + { + "startTime": 1753.95, + "name": "lh:config:requireGatherers", + "duration": 90.18, + "entryType": "measure" + }, + { + "startTime": 1844.3, + "name": "lh:config:requireAudits", + "duration": 574.16, + "entryType": "measure" + }, + { + "startTime": 2450.3, + "name": "lh:runner:run", + "duration": 9351.66, + "entryType": "measure" + }, + { + "startTime": 2458.16, + "name": "lh:init:connect", + "duration": 1566.3, + "entryType": "measure" + }, + { + "startTime": 4024.67, + "name": "lh:gather:loadBlank", + "duration": 606.96, + "entryType": "measure" + }, + { + "startTime": 4631.89, + "name": "lh:gather:getVersion", + "duration": 1, + "entryType": "measure" + }, + { + "startTime": 4633.18, + "name": "lh:gather:getBenchmarkIndex", + "duration": 576.64, + "entryType": "measure" + }, + { + "startTime": 5209.99, + "name": "lh:gather:setupDriver", + "duration": 87.5, + "entryType": "measure" + }, + { + "startTime": 5297.77, + "name": "lh:gather:loadBlank", + "duration": 44.68, + "entryType": "measure" + }, + { + "startTime": 5342.61, + "name": "lh:gather:setupPassNetwork", + "duration": 1.97, + "entryType": "measure" + }, + { + "startTime": 5344.72, + "name": "lh:driver:cleanBrowserCaches", + "duration": 47.91, + "entryType": "measure" + }, + { + "startTime": 5392.9, + "name": "lh:gather:beforePass", + "duration": 8.72, + "entryType": "measure" + }, + { + "startTime": 5392.95, + "name": "lh:gather:beforePass:CSSUsage", + "duration": 0.11, + "entryType": "measure" + }, + { + "startTime": 5393.08, + "name": "lh:gather:beforePass:ViewportDimensions", + "duration": 0.03, + "entryType": "measure" + }, + { + "startTime": 5393.13, + "name": "lh:gather:beforePass:RuntimeExceptions", + "duration": 0.09, + "entryType": "measure" + }, + { + "startTime": 5393.25, + "name": "lh:gather:beforePass:ConsoleMessages", + "duration": 4.16, + "entryType": "measure" + }, + { + "startTime": 5397.48, + "name": "lh:gather:beforePass:AnchorElements", + "duration": 0.07, + "entryType": "measure" + }, + { + "startTime": 5397.57, + "name": "lh:gather:beforePass:ImageElements", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.62, + "name": "lh:gather:beforePass:LinkElements", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.66, + "name": "lh:gather:beforePass:MetaElements", + "duration": 0.03, + "entryType": "measure" + }, + { + "startTime": 5397.71, + "name": "lh:gather:beforePass:ScriptElements", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.75, + "name": "lh:gather:beforePass:IFrameElements", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.79, + "name": "lh:gather:beforePass:MainDocumentContent", + "duration": 0.01, + "entryType": "measure" + }, + { + "startTime": 5397.82, + "name": "lh:gather:beforePass:AppCacheManifest", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.86, + "name": "lh:gather:beforePass:Doctype", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.9, + "name": "lh:gather:beforePass:DOMStats", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5397.93, + "name": "lh:gather:beforePass:OptimizedImages", + "duration": 0.01, + "entryType": "measure" + }, + { + "startTime": 5397.96, + "name": "lh:gather:beforePass:PasswordInputsWithPreventedPaste", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5398, + "name": "lh:gather:beforePass:ResponseCompression", + "duration": 0.04, + "entryType": "measure" + }, + { + "startTime": 5398.06, + "name": "lh:gather:beforePass:TagsBlockingFirstPaint", + "duration": 3.39, + "entryType": "measure" + }, + { + "startTime": 5401.5, + "name": "lh:gather:beforePass:FontSize", + "duration": 0.03, + "entryType": "measure" + }, + { + "startTime": 5401.53, + "name": "lh:gather:beforePass:EmbeddedContent", + "duration": 0.02, + "entryType": "measure" + }, + { + "startTime": 5401.57, + "name": "lh:gather:beforePass:RobotsTxt", + "duration": 0.01, + "entryType": "measure" + }, + { + "startTime": 5401.58, + "name": "lh:gather:beforePass:TapTargets", + "duration": 0.01, + "entryType": "measure" + }, + { + "startTime": 5401.6, + "name": "lh:gather:beforePass:Accessibility", + "duration": 0.01, + "entryType": "measure" + }, + { + "startTime": 5401.72, + "name": "lh:gather:beginRecording", + "duration": 115.29, + "entryType": "measure" + }, + { + "startTime": 5401.98, + "name": "lh:gather:getVersion", + "duration": 0.64, + "entryType": "measure" + }, + { + "startTime": 5517.33, + "name": "lh:gather:loadPage-defaultPass", + "duration": 1571.98, + "entryType": "measure" + }, + { + "startTime": 7089.53, + "name": "lh:gather:pass", + "duration": 10.27, + "entryType": "measure" + }, + { + "startTime": 7099.96, + "name": "lh:gather:getTrace", + "duration": 271.03, + "entryType": "measure" + }, + { + "startTime": 7371, + "name": "lh:gather:getDevtoolsLog", + "duration": 2.23, + "entryType": "measure" + }, + { + "startTime": 7374.44, + "name": "lh:gather:afterPass", + "duration": 936.02, + "entryType": "measure" + }, + { + "startTime": 7385.43, + "name": "lh:gather:afterPass:CSSUsage", + "duration": 133.88, + "entryType": "measure" + }, + { + "startTime": 7519.33, + "name": "lh:gather:afterPass:ViewportDimensions", + "duration": 2.66, + "entryType": "measure" + }, + { + "startTime": 7522.01, + "name": "lh:gather:afterPass:RuntimeExceptions", + "duration": 1.35, + "entryType": "measure" + }, + { + "startTime": 7523.38, + "name": "lh:gather:afterPass:ConsoleMessages", + "duration": 2.27, + "entryType": "measure" + }, + { + "startTime": 7525.68, + "name": "lh:gather:afterPass:AnchorElements", + "duration": 15.07, + "entryType": "measure" + }, + { + "startTime": 7540.76, + "name": "lh:gather:afterPass:ImageElements", + "duration": 28.66, + "entryType": "measure" + }, + { + "startTime": 7569.45, + "name": "lh:gather:afterPass:LinkElements", + "duration": 5.18, + "entryType": "measure" + }, + { + "startTime": 7574.65, + "name": "lh:gather:afterPass:MetaElements", + "duration": 3.39, + "entryType": "measure" + }, + { + "startTime": 7578.06, + "name": "lh:gather:afterPass:ScriptElements", + "duration": 28.38, + "entryType": "measure" + }, + { + "startTime": 7606.46, + "name": "lh:gather:afterPass:IFrameElements", + "duration": 4.43, + "entryType": "measure" + }, + { + "startTime": 7610.9, + "name": "lh:gather:afterPass:MainDocumentContent", + "duration": 2.78, + "entryType": "measure" + }, + { + "startTime": 7613.7, + "name": "lh:gather:afterPass:AppCacheManifest", + "duration": 4.18, + "entryType": "measure" + }, + { + "startTime": 7617.9, + "name": "lh:gather:afterPass:Doctype", + "duration": 3.11, + "entryType": "measure" + }, + { + "startTime": 7621.03, + "name": "lh:gather:afterPass:DOMStats", + "duration": 7.01, + "entryType": "measure" + }, + { + "startTime": 7628.05, + "name": "lh:gather:afterPass:OptimizedImages", + "duration": 2.15, + "entryType": "measure" + }, + { + "startTime": 7630.21, + "name": "lh:gather:afterPass:PasswordInputsWithPreventedPaste", + "duration": 3.33, + "entryType": "measure" + }, + { + "startTime": 7633.55, + "name": "lh:gather:afterPass:ResponseCompression", + "duration": 58.38, + "entryType": "measure" + }, + { + "startTime": 7691.95, + "name": "lh:gather:afterPass:TagsBlockingFirstPaint", + "duration": 7.75, + "entryType": "measure" + }, + { + "startTime": 7699.71, + "name": "lh:gather:afterPass:FontSize", + "duration": 140.76, + "entryType": "measure" + }, + { + "startTime": 7840.49, + "name": "lh:gather:afterPass:EmbeddedContent", + "duration": 3.44, + "entryType": "measure" + }, + { + "startTime": 7843.95, + "name": "lh:gather:afterPass:RobotsTxt", + "duration": 51.47, + "entryType": "measure" + }, + { + "startTime": 7895.44, + "name": "lh:gather:afterPass:TapTargets", + "duration": 28.36, + "entryType": "measure" + }, + { + "startTime": 7923.82, + "name": "lh:gather:afterPass:Accessibility", + "duration": 386.63, + "entryType": "measure" + }, + { + "startTime": 8370, + "name": "lh:gather:loadBlank", + "duration": 32.24, + "entryType": "measure" + }, + { + "startTime": 8402.29, + "name": "lh:gather:setupPassNetwork", + "duration": 3.46, + "entryType": "measure" + }, + { + "startTime": 8405.78, + "name": "lh:gather:beforePass", + "duration": 4.13, + "entryType": "measure" + }, + { + "startTime": 8405.81, + "name": "lh:gather:beforePass:ServiceWorker", + "duration": 2.25, + "entryType": "measure" + }, + { + "startTime": 8408.08, + "name": "lh:gather:beforePass:Offline", + "duration": 1.77, + "entryType": "measure" + }, + { + "startTime": 8409.87, + "name": "lh:gather:beforePass:StartUrl", + "duration": 0.03, + "entryType": "measure" + }, + { + "startTime": 8409.91, + "name": "lh:gather:beginRecording", + "duration": 0.09, + "entryType": "measure" + }, + { + "startTime": 8410.03, + "name": "lh:gather:loadPage-offlinePass", + "duration": 380.51, + "entryType": "measure" + }, + { + "startTime": 8790.57, + "name": "lh:gather:pass", + "duration": 0.27, + "entryType": "measure" + }, + { + "startTime": 8790.86, + "name": "lh:gather:getDevtoolsLog", + "duration": 0.53, + "entryType": "measure" + }, + { + "startTime": 8817.57, + "name": "lh:gather:afterPass", + "duration": 568.01, + "entryType": "measure" + }, + { + "startTime": 9088.49, + "name": "lh:gather:afterPass:ServiceWorker", + "duration": 22.99, + "entryType": "measure" + }, + { + "startTime": 9111.5, + "name": "lh:gather:afterPass:Offline", + "duration": 13.34, + "entryType": "measure" + }, + { + "startTime": 9124.86, + "name": "lh:gather:afterPass:StartUrl", + "duration": 260.71, + "entryType": "measure" + }, + { + "startTime": 9385.66, + "name": "lh:gather:loadBlank", + "duration": 125.6, + "entryType": "measure" + }, + { + "startTime": 9511.28, + "name": "lh:gather:setupPassNetwork", + "duration": 2.55, + "entryType": "measure" + }, + { + "startTime": 9513.91, + "name": "lh:gather:beforePass", + "duration": 0.46, + "entryType": "measure" + }, + { + "startTime": 9513.98, + "name": "lh:gather:beforePass:HTTPRedirect", + "duration": 0.27, + "entryType": "measure" + }, + { + "startTime": 9514.28, + "name": "lh:gather:beforePass:HTMLWithoutJavaScript", + "duration": 0.08, + "entryType": "measure" + }, + { + "startTime": 9514.38, + "name": "lh:gather:beginRecording", + "duration": 0.13, + "entryType": "measure" + }, + { + "startTime": 9514.54, + "name": "lh:gather:loadPage-redirectPass", + "duration": 998.51, + "entryType": "measure" + }, + { + "startTime": 10513.08, + "name": "lh:gather:pass", + "duration": 0.21, + "entryType": "measure" + }, + { + "startTime": 10513.31, + "name": "lh:gather:getDevtoolsLog", + "duration": 0.41, + "entryType": "measure" + }, + { + "startTime": 10514.55, + "name": "lh:gather:afterPass", + "duration": 14.9, + "entryType": "measure" + }, + { + "startTime": 10520.71, + "name": "lh:gather:afterPass:HTTPRedirect", + "duration": 3.66, + "entryType": "measure" + }, + { + "startTime": 10524.38, + "name": "lh:gather:afterPass:HTMLWithoutJavaScript", + "duration": 5.05, + "entryType": "measure" + }, + { + "startTime": 10529.74, + "name": "lh:gather:disconnect", + "duration": 11.43, + "entryType": "measure" + }, + { + "startTime": 10541.58, + "name": "lh:runner:auditing", + "duration": 1258.99, + "entryType": "measure" + }, + { + "startTime": 10548.02, + "name": "lh:audit:is-on-https", + "duration": 6.88, + "entryType": "measure" + }, + { + "startTime": 10548.94, + "name": "lh:computed:NetworkRecords", + "duration": 4.71, + "entryType": "measure" + }, + { + "startTime": 10555.89, + "name": "lh:audit:redirects-http", + "duration": 0.53, + "entryType": "measure" + }, + { + "startTime": 10556.96, + "name": "lh:audit:service-worker", + "duration": 1.13, + "entryType": "measure" + }, + { + "startTime": 10558.65, + "name": "lh:audit:works-offline", + "duration": 0.88, + "entryType": "measure" + }, + { + "startTime": 10560.06, + "name": "lh:audit:viewport", + "duration": 1.92, + "entryType": "measure" + }, + { + "startTime": 10560.4, + "name": "lh:computed:ViewportMeta", + "duration": 1.31, + "entryType": "measure" + }, + { + "startTime": 10562.7, + "name": "lh:audit:without-javascript", + "duration": 0.58, + "entryType": "measure" + }, + { + "startTime": 10563.71, + "name": "lh:audit:first-contentful-paint", + "duration": 48.22, + "entryType": "measure" + }, + { + "startTime": 10564.11, + "name": "lh:computed:FirstContentfulPaint", + "duration": 46.51, + "entryType": "measure" + }, + { + "startTime": 10564.31, + "name": "lh:computed:TraceOfTab", + "duration": 17.21, + "entryType": "measure" + }, + { + "startTime": 10581.68, + "name": "lh:computed:LanternFirstContentfulPaint", + "duration": 28.91, + "entryType": "measure" + }, + { + "startTime": 10581.96, + "name": "lh:computed:PageDependencyGraph", + "duration": 4.44, + "entryType": "measure" + }, + { + "startTime": 10586.47, + "name": "lh:computed:LoadSimulator", + "duration": 2.95, + "entryType": "measure" + }, + { + "startTime": 10586.66, + "name": "lh:computed:NetworkAnalysis", + "duration": 2.42, + "entryType": "measure" + }, + { + "startTime": 10612.32, + "name": "lh:audit:first-meaningful-paint", + "duration": 12.68, + "entryType": "measure" + }, + { + "startTime": 10613.06, + "name": "lh:computed:FirstMeaningfulPaint", + "duration": 11.56, + "entryType": "measure" + }, + { + "startTime": 10613.24, + "name": "lh:computed:LanternFirstMeaningfulPaint", + "duration": 11.37, + "entryType": "measure" + }, + { + "startTime": 10625.46, + "name": "lh:audit:load-fast-enough-for-pwa", + "duration": 13.35, + "entryType": "measure" + }, + { + "startTime": 10625.96, + "name": "lh:computed:Interactive", + "duration": 12.48, + "entryType": "measure" + }, + { + "startTime": 10626.08, + "name": "lh:computed:LanternInteractive", + "duration": 12.33, + "entryType": "measure" + }, + { + "startTime": 10639.23, + "name": "lh:audit:speed-index", + "duration": 401.83, + "entryType": "measure" + }, + { + "startTime": 10639.59, + "name": "lh:computed:SpeedIndex", + "duration": 401.3, + "entryType": "measure" + }, + { + "startTime": 10639.68, + "name": "lh:computed:LanternSpeedIndex", + "duration": 401.2, + "entryType": "measure" + }, + { + "startTime": 10639.77, + "name": "lh:computed:Speedline", + "duration": 397.78, + "entryType": "measure" + }, + { + "startTime": 11041.09, + "name": "lh:audit:screenshot-thumbnails", + "duration": 170.92, + "entryType": "measure" + }, + { + "startTime": 11212.03, + "name": "lh:audit:final-screenshot", + "duration": 0.7, + "entryType": "measure" + }, + { + "startTime": 11212.26, + "name": "lh:computed:Screenshots", + "duration": 0.41, + "entryType": "measure" + }, + { + "startTime": 11213.07, + "name": "lh:audit:estimated-input-latency", + "duration": 5.16, + "entryType": "measure" + }, + { + "startTime": 11213.28, + "name": "lh:computed:EstimatedInputLatency", + "duration": 4.8, + "entryType": "measure" + }, + { + "startTime": 11213.35, + "name": "lh:computed:LanternEstimatedInputLatency", + "duration": 4.72, + "entryType": "measure" + }, + { + "startTime": 11218.41, + "name": "lh:audit:total-blocking-time", + "duration": 8.15, + "entryType": "measure" + }, + { + "startTime": 11218.61, + "name": "lh:computed:TotalBlockingTime", + "duration": 7.71, + "entryType": "measure" + }, + { + "startTime": 11218.66, + "name": "lh:computed:LanternTotalBlockingTime", + "duration": 7.65, + "entryType": "measure" + }, + { + "startTime": 11226.83, + "name": "lh:audit:max-potential-fid", + "duration": 47.72, + "entryType": "measure" + }, + { + "startTime": 11227.67, + "name": "lh:computed:MaxPotentialFID", + "duration": 46.5, + "entryType": "measure" + }, + { + "startTime": 11227.75, + "name": "lh:computed:LanternMaxPotentialFID", + "duration": 46.39, + "entryType": "measure" + }, + { + "startTime": 11274.95, + "name": "lh:audit:errors-in-console", + "duration": 0.97, + "entryType": "measure" + }, + { + "startTime": 11276.61, + "name": "lh:audit:time-to-first-byte", + "duration": 0.73, + "entryType": "measure" + }, + { + "startTime": 11276.87, + "name": "lh:computed:MainResource", + "duration": 0.21, + "entryType": "measure" + }, + { + "startTime": 11277.53, + "name": "lh:audit:first-cpu-idle", + "duration": 10.18, + "entryType": "measure" + }, + { + "startTime": 11277.75, + "name": "lh:computed:FirstCPUIdle", + "duration": 9.7, + "entryType": "measure" + }, + { + "startTime": 11277.8, + "name": "lh:computed:LanternFirstCPUIdle", + "duration": 9.63, + "entryType": "measure" + }, + { + "startTime": 11287.98, + "name": "lh:audit:interactive", + "duration": 0.63, + "entryType": "measure" + }, + { + "startTime": 11288.87, + "name": "lh:audit:user-timings", + "duration": 0.95, + "entryType": "measure" + }, + { + "startTime": 11289.12, + "name": "lh:computed:UserTimings", + "duration": 0.43, + "entryType": "measure" + }, + { + "startTime": 11290, + "name": "lh:audit:critical-request-chains", + "duration": 1.45, + "entryType": "measure" + }, + { + "startTime": 11290.19, + "name": "lh:computed:CriticalRequestChains", + "duration": 0.54, + "entryType": "measure" + }, + { + "startTime": 11291.62, + "name": "lh:audit:redirects", + "duration": 0.6, + "entryType": "measure" + }, + { + "startTime": 11292.42, + "name": "lh:audit:installable-manifest", + "duration": 0.55, + "entryType": "measure" + }, + { + "startTime": 11292.6, + "name": "lh:computed:ManifestValues", + "duration": 0.08, + "entryType": "measure" + }, + { + "startTime": 11293.15, + "name": "lh:audit:apple-touch-icon", + "duration": 0.33, + "entryType": "measure" + }, + { + "startTime": 11293.65, + "name": "lh:audit:splash-screen", + "duration": 2.14, + "entryType": "measure" + }, + { + "startTime": 11296.01, + "name": "lh:audit:themed-omnibox", + "duration": 0.43, + "entryType": "measure" + }, + { + "startTime": 11296.61, + "name": "lh:audit:content-width", + "duration": 0.28, + "entryType": "measure" + }, + { + "startTime": 11297.12, + "name": "lh:audit:image-aspect-ratio", + "duration": 15.53, + "entryType": "measure" + }, + { + "startTime": 11312.85, + "name": "lh:audit:deprecations", + "duration": 0.35, + "entryType": "measure" + }, + { + "startTime": 11313.35, + "name": "lh:audit:mainthread-work-breakdown", + "duration": 5.78, + "entryType": "measure" + }, + { + "startTime": 11313.59, + "name": "lh:computed:MainThreadTasks", + "duration": 4.59, + "entryType": "measure" + }, + { + "startTime": 11319.31, + "name": "lh:audit:bootup-time", + "duration": 1.28, + "entryType": "measure" + }, + { + "startTime": 11320.75, + "name": "lh:audit:uses-rel-preload", + "duration": 1.53, + "entryType": "measure" + }, + { + "startTime": 11321.2, + "name": "lh:computed:LoadSimulator", + "duration": 0.09, + "entryType": "measure" + }, + { + "startTime": 11322.52, + "name": "lh:audit:uses-rel-preconnect", + "duration": 0.75, + "entryType": "measure" + }, + { + "startTime": 11323.52, + "name": "lh:audit:font-display", + "duration": 3.03, + "entryType": "measure" + }, + { + "startTime": 11326.6, + "name": "lh:audit:diagnostics", + "duration": 0.93, + "entryType": "measure" + }, + { + "startTime": 11327.56, + "name": "lh:audit:network-requests", + "duration": 2.88, + "entryType": "measure" + }, + { + "startTime": 11330.68, + "name": "lh:audit:network-rtt", + "duration": 0.6, + "entryType": "measure" + }, + { + "startTime": 11332.7, + "name": "lh:audit:network-server-latency", + "duration": 0.5, + "entryType": "measure" + }, + { + "startTime": 11333.22, + "name": "lh:audit:main-thread-tasks", + "duration": 0.26, + "entryType": "measure" + }, + { + "startTime": 11333.49, + "name": "lh:audit:metrics", + "duration": 1.28, + "entryType": "measure" + }, + { + "startTime": 11333.81, + "name": "lh:computed:LargestContentfulPaint", + "duration": 0.11, + "entryType": "measure" + }, + { + "startTime": 11335.01, + "name": "lh:audit:offline-start-url", + "duration": 1.27, + "entryType": "measure" + }, + { + "startTime": 11336.62, + "name": "lh:audit:performance-budget", + "duration": 2.43, + "entryType": "measure" + }, + { + "startTime": 11336.89, + "name": "lh:computed:ResourceSummary", + "duration": 2.05, + "entryType": "measure" + }, + { + "startTime": 11339.23, + "name": "lh:audit:resource-summary", + "duration": 25.3, + "entryType": "measure" + }, + { + "startTime": 11364.73, + "name": "lh:audit:third-party-summary", + "duration": 6.3, + "entryType": "measure" + }, + { + "startTime": 11371.22, + "name": "lh:audit:pwa-cross-browser", + "duration": 0.21, + "entryType": "measure" + }, + { + "startTime": 11371.64, + "name": "lh:audit:pwa-page-transitions", + "duration": 0.14, + "entryType": "measure" + }, + { + "startTime": 11371.9, + "name": "lh:audit:pwa-each-page-has-url", + "duration": 0.13, + "entryType": "measure" + }, + { + "startTime": 11372.17, + "name": "lh:audit:accesskeys", + "duration": 0.31, + "entryType": "measure" + }, + { + "startTime": 11372.62, + "name": "lh:audit:aria-allowed-attr", + "duration": 0.2, + "entryType": "measure" + }, + { + "startTime": 11372.97, + "name": "lh:audit:aria-required-attr", + "duration": 0.2, + "entryType": "measure" + }, + { + "startTime": 11373.37, + "name": "lh:audit:aria-required-children", + "duration": 0.26, + "entryType": "measure" + }, + { + "startTime": 11373.89, + "name": "lh:audit:aria-required-parent", + "duration": 0.38, + "entryType": "measure" + }, + { + "startTime": 11374.43, + "name": "lh:audit:aria-roles", + "duration": 0.25, + "entryType": "measure" + }, + { + "startTime": 11374.87, + "name": "lh:audit:aria-valid-attr-value", + "duration": 0.27, + "entryType": "measure" + }, + { + "startTime": 11375.28, + "name": "lh:audit:aria-valid-attr", + "duration": 44.31, + "entryType": "measure" + }, + { + "startTime": 11419.87, + "name": "lh:audit:audio-caption", + "duration": 0.36, + "entryType": "measure" + }, + { + "startTime": 11420.39, + "name": "lh:audit:button-name", + "duration": 1.18, + "entryType": "measure" + }, + { + "startTime": 11421.9, + "name": "lh:audit:bypass", + "duration": 1.08, + "entryType": "measure" + }, + { + "startTime": 11423.3, + "name": "lh:audit:color-contrast", + "duration": 1.09, + "entryType": "measure" + }, + { + "startTime": 11424.76, + "name": "lh:audit:definition-list", + "duration": 0.59, + "entryType": "measure" + }, + { + "startTime": 11425.64, + "name": "lh:audit:dlitem", + "duration": 0.59, + "entryType": "measure" + }, + { + "startTime": 11426.47, + "name": "lh:audit:document-title", + "duration": 1.11, + "entryType": "measure" + }, + { + "startTime": 11427.87, + "name": "lh:audit:duplicate-id", + "duration": 1.05, + "entryType": "measure" + }, + { + "startTime": 11429.24, + "name": "lh:audit:frame-title", + "duration": 0.56, + "entryType": "measure" + }, + { + "startTime": 11430.07, + "name": "lh:audit:html-has-lang", + "duration": 0.93, + "entryType": "measure" + }, + { + "startTime": 11431.19, + "name": "lh:audit:html-lang-valid", + "duration": 43.91, + "entryType": "measure" + }, + { + "startTime": 11475.38, + "name": "lh:audit:image-alt", + "duration": 0.64, + "entryType": "measure" + }, + { + "startTime": 11476.2, + "name": "lh:audit:input-image-alt", + "duration": 0.75, + "entryType": "measure" + }, + { + "startTime": 11477.2, + "name": "lh:audit:label", + "duration": 1.02, + "entryType": "measure" + }, + { + "startTime": 11478.56, + "name": "lh:audit:layout-table", + "duration": 0.71, + "entryType": "measure" + }, + { + "startTime": 11479.5, + "name": "lh:audit:link-name", + "duration": 0.96, + "entryType": "measure" + }, + { + "startTime": 11480.79, + "name": "lh:audit:list", + "duration": 0.66, + "entryType": "measure" + }, + { + "startTime": 11481.76, + "name": "lh:audit:listitem", + "duration": 0.92, + "entryType": "measure" + }, + { + "startTime": 11482.98, + "name": "lh:audit:meta-refresh", + "duration": 0.75, + "entryType": "measure" + }, + { + "startTime": 11484.17, + "name": "lh:audit:meta-viewport", + "duration": 1.95, + "entryType": "measure" + }, + { + "startTime": 11486.41, + "name": "lh:audit:object-alt", + "duration": 44.06, + "entryType": "measure" + }, + { + "startTime": 11530.77, + "name": "lh:audit:tabindex", + "duration": 0.66, + "entryType": "measure" + }, + { + "startTime": 11531.83, + "name": "lh:audit:td-headers-attr", + "duration": 0.88, + "entryType": "measure" + }, + { + "startTime": 11533.05, + "name": "lh:audit:th-has-data-cells", + "duration": 0.71, + "entryType": "measure" + }, + { + "startTime": 11533.98, + "name": "lh:audit:valid-lang", + "duration": 0.5, + "entryType": "measure" + }, + { + "startTime": 11534.65, + "name": "lh:audit:video-caption", + "duration": 0.49, + "entryType": "measure" + }, + { + "startTime": 11536.81, + "name": "lh:audit:video-description", + "duration": 0.92, + "entryType": "measure" + }, + { + "startTime": 11537.79, + "name": "lh:audit:custom-controls-labels", + "duration": 0.11, + "entryType": "measure" + }, + { + "startTime": 11537.92, + "name": "lh:audit:custom-controls-roles", + "duration": 0.09, + "entryType": "measure" + }, + { + "startTime": 11538.03, + "name": "lh:audit:focus-traps", + "duration": 0.08, + "entryType": "measure" + }, + { + "startTime": 11538.12, + "name": "lh:audit:focusable-controls", + "duration": 45.37, + "entryType": "measure" + }, + { + "startTime": 11583.55, + "name": "lh:audit:heading-levels", + "duration": 0.17, + "entryType": "measure" + }, + { + "startTime": 11583.84, + "name": "lh:audit:interactive-element-affordance", + "duration": 0.19, + "entryType": "measure" + }, + { + "startTime": 11584.05, + "name": "lh:audit:logical-tab-order", + "duration": 0.23, + "entryType": "measure" + }, + { + "startTime": 11584.34, + "name": "lh:audit:managed-focus", + "duration": 0.23, + "entryType": "measure" + }, + { + "startTime": 11584.62, + "name": "lh:audit:offscreen-content-hidden", + "duration": 0.1, + "entryType": "measure" + }, + { + "startTime": 11584.74, + "name": "lh:audit:use-landmarks", + "duration": 0.08, + "entryType": "measure" + }, + { + "startTime": 11584.84, + "name": "lh:audit:visual-order-follows-dom", + "duration": 0.08, + "entryType": "measure" + }, + { + "startTime": 11585.19, + "name": "lh:audit:uses-long-cache-ttl", + "duration": 1.44, + "entryType": "measure" + }, + { + "startTime": 11586.81, + "name": "lh:audit:total-byte-weight", + "duration": 0.48, + "entryType": "measure" + }, + { + "startTime": 11587.44, + "name": "lh:audit:offscreen-images", + "duration": 2.26, + "entryType": "measure" + }, + { + "startTime": 11589.95, + "name": "lh:audit:render-blocking-resources", + "duration": 9.05, + "entryType": "measure" + }, + { + "startTime": 11593.46, + "name": "lh:computed:FirstContentfulPaint", + "duration": 3.91, + "entryType": "measure" + }, + { + "startTime": 11593.54, + "name": "lh:computed:LanternFirstContentfulPaint", + "duration": 3.81, + "entryType": "measure" + }, + { + "startTime": 11599.24, + "name": "lh:audit:unminified-css", + "duration": 44.67, + "entryType": "measure" + }, + { + "startTime": 11644.1, + "name": "lh:audit:unminified-javascript", + "duration": 44.23, + "entryType": "measure" + }, + { + "startTime": 11688.61, + "name": "lh:audit:unused-css-rules", + "duration": 2.44, + "entryType": "measure" + }, + { + "startTime": 11691.33, + "name": "lh:audit:uses-webp-images", + "duration": 2.54, + "entryType": "measure" + }, + { + "startTime": 11695.13, + "name": "lh:audit:uses-optimized-images", + "duration": 2.65, + "entryType": "measure" + }, + { + "startTime": 11698.03, + "name": "lh:audit:uses-text-compression", + "duration": 4.8, + "entryType": "measure" + }, + { + "startTime": 11703.18, + "name": "lh:audit:uses-responsive-images", + "duration": 2.38, + "entryType": "measure" + }, + { + "startTime": 11705.83, + "name": "lh:audit:efficient-animated-content", + "duration": 3.98, + "entryType": "measure" + }, + { + "startTime": 11710.06, + "name": "lh:audit:appcache-manifest", + "duration": 0.39, + "entryType": "measure" + }, + { + "startTime": 11710.69, + "name": "lh:audit:doctype", + "duration": 0.4, + "entryType": "measure" + }, + { + "startTime": 11711.33, + "name": "lh:audit:dom-size", + "duration": 13.83, + "entryType": "measure" + }, + { + "startTime": 11725.53, + "name": "lh:audit:external-anchors-use-rel-noopener", + "duration": 1.91, + "entryType": "measure" + }, + { + "startTime": 11728.22, + "name": "lh:audit:geolocation-on-start", + "duration": 2.33, + "entryType": "measure" + }, + { + "startTime": 11730.96, + "name": "lh:audit:no-document-write", + "duration": 0.36, + "entryType": "measure" + }, + { + "startTime": 11731.53, + "name": "lh:audit:no-vulnerable-libraries", + "duration": 17.66, + "entryType": "measure" + }, + { + "startTime": 11749.81, + "name": "lh:audit:js-libraries", + "duration": 0.55, + "entryType": "measure" + }, + { + "startTime": 11750.67, + "name": "lh:audit:notification-on-start", + "duration": 0.79, + "entryType": "measure" + }, + { + "startTime": 11752, + "name": "lh:audit:password-inputs-can-be-pasted-into", + "duration": 0.5, + "entryType": "measure" + }, + { + "startTime": 11752.77, + "name": "lh:audit:uses-http2", + "duration": 1.12, + "entryType": "measure" + }, + { + "startTime": 11754.13, + "name": "lh:audit:uses-passive-event-listeners", + "duration": 0.35, + "entryType": "measure" + }, + { + "startTime": 11754.64, + "name": "lh:audit:meta-description", + "duration": 0.28, + "entryType": "measure" + }, + { + "startTime": 11755.08, + "name": "lh:audit:http-status-code", + "duration": 0.38, + "entryType": "measure" + }, + { + "startTime": 11755.63, + "name": "lh:audit:font-size", + "duration": 0.57, + "entryType": "measure" + }, + { + "startTime": 11756.44, + "name": "lh:audit:link-text", + "duration": 0.93, + "entryType": "measure" + }, + { + "startTime": 11757.66, + "name": "lh:audit:is-crawlable", + "duration": 0.59, + "entryType": "measure" + }, + { + "startTime": 11758.69, + "name": "lh:audit:robots-txt", + "duration": 0.36, + "entryType": "measure" + }, + { + "startTime": 11759.23, + "name": "lh:audit:tap-targets", + "duration": 0.61, + "entryType": "measure" + }, + { + "startTime": 11760.09, + "name": "lh:audit:hreflang", + "duration": 34.76, + "entryType": "measure" + }, + { + "startTime": 11796.24, + "name": "lh:audit:plugins", + "duration": 0.83, + "entryType": "measure" + }, + { + "startTime": 11797.4, + "name": "lh:audit:canonical", + "duration": 2.06, + "entryType": "measure" + }, + { + "startTime": 11800.22, + "name": "lh:audit:structured-data", + "duration": 0.33, + "entryType": "measure" + }, + { + "startTime": 11800.59, + "name": "lh:runner:generate", + "duration": 1.36, + "entryType": "measure" + } + ], + "total": 9351.66 + }, + "i18n": { + "rendererFormattedStrings": { + "auditGroupExpandTooltip": "Show audits", + "crcInitialNavigation": "Initial Navigation", + "crcLongestDurationLabel": "Maximum critical path latency:", + "errorLabel": "Error!", + "errorMissingAuditInfo": "Report error: no audit information", + "labDataTitle": "Lab Data", + "lsPerformanceCategoryDescription": "[Lighthouse](https://developers.google.com/web/tools/lighthouse/) analysis of the current page on an emulated mobile network. Values are estimated and may vary.", + "manualAuditsGroupTitle": "Additional items to manually check", + "notApplicableAuditsGroupTitle": "Not applicable", + "opportunityResourceColumnLabel": "Opportunity", + "opportunitySavingsColumnLabel": "Estimated Savings", + "passedAuditsGroupTitle": "Passed audits", + "snippetCollapseButtonLabel": "Collapse snippet", + "snippetExpandButtonLabel": "Expand snippet", + "thirdPartyResourcesLabel": "Show 3rd-party resources", + "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:", + "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).", + "warningAuditsGroupTitle": "Passed audits but with warnings", + "warningHeader": "Warnings: " + }, + "icuMessagePaths": { + "lighthouse-core/audits/is-on-https.js | title": [ + "audits[is-on-https].title" + ], + "lighthouse-core/audits/is-on-https.js | description": [ + "audits[is-on-https].description" + ], + "lighthouse-core/audits/redirects-http.js | failureTitle": [ + "audits[redirects-http].title" + ], + "lighthouse-core/audits/redirects-http.js | description": [ + "audits[redirects-http].description" + ], + "lighthouse-core/audits/service-worker.js | failureTitle": [ + "audits[service-worker].title" + ], + "lighthouse-core/audits/service-worker.js | description": [ + "audits[service-worker].description" + ], + "lighthouse-core/audits/works-offline.js | failureTitle": [ + "audits[works-offline].title" + ], + "lighthouse-core/audits/works-offline.js | description": [ + "audits[works-offline].description" + ], + "lighthouse-core/audits/viewport.js | title": [ + "audits.viewport.title" + ], + "lighthouse-core/audits/viewport.js | description": [ + "audits.viewport.description" + ], + "lighthouse-core/audits/without-javascript.js | title": [ + "audits[without-javascript].title" + ], + "lighthouse-core/audits/without-javascript.js | description": [ + "audits[without-javascript].description" + ], + "lighthouse-core/audits/metrics/first-contentful-paint.js | title": [ + "audits[first-contentful-paint].title" + ], + "lighthouse-core/audits/metrics/first-contentful-paint.js | description": [ + "audits[first-contentful-paint].description" + ], + "lighthouse-core/lib/i18n/i18n.js | seconds": [ + { + "values": { + "timeInMs": 5438.5419999999995 + }, + "path": "audits[first-contentful-paint].displayValue" + }, + { + "values": { + "timeInMs": 5438.5419999999995 + }, + "path": "audits[first-meaningful-paint].displayValue" + }, + { + "values": { + "timeInMs": 5438.5419999999995 + }, + "path": "audits[speed-index].displayValue" + }, + { + "values": { + "timeInMs": 5438.5419999999995 + }, + "path": "audits[first-cpu-idle].displayValue" + }, + { + "values": { + "timeInMs": 5513.5419999999995 + }, + "path": "audits.interactive.displayValue" + }, + { + "values": { + "timeInMs": 1641.6120000000005 + }, + "path": "audits[mainthread-work-breakdown].displayValue" + }, + { + "values": { + "timeInMs": 548.6440000000001 + }, + "path": "audits[bootup-time].displayValue" + } + ], + "lighthouse-core/audits/metrics/first-meaningful-paint.js | title": [ + "audits[first-meaningful-paint].title" + ], + "lighthouse-core/audits/metrics/first-meaningful-paint.js | description": [ + "audits[first-meaningful-paint].description" + ], + "lighthouse-core/audits/load-fast-enough-for-pwa.js | title": [ + "audits[load-fast-enough-for-pwa].title" + ], + "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": [ + "audits[load-fast-enough-for-pwa].description" + ], + "lighthouse-core/audits/metrics/speed-index.js | title": [ + "audits[speed-index].title" + ], + "lighthouse-core/audits/metrics/speed-index.js | description": [ + "audits[speed-index].description" + ], + "lighthouse-core/audits/metrics/estimated-input-latency.js | title": [ + "audits[estimated-input-latency].title" + ], + "lighthouse-core/audits/metrics/estimated-input-latency.js | description": [ + "audits[estimated-input-latency].description" + ], + "lighthouse-core/lib/i18n/i18n.js | ms": [ + { + "values": { + "timeInMs": 12.8 + }, + "path": "audits[estimated-input-latency].displayValue" + }, + { + "values": { + "timeInMs": 30.5 + }, + "path": "audits[total-blocking-time].displayValue" + }, + { + "values": { + "timeInMs": 63.5 + }, + "path": "audits[max-potential-fid].displayValue" + }, + { + "values": { + "timeInMs": 0.36000000000000004 + }, + "path": "audits[network-rtt].displayValue" + }, + { + "values": { + "timeInMs": 22.308 + }, + "path": "audits[network-server-latency].displayValue" + } + ], + "lighthouse-core/audits/metrics/total-blocking-time.js | title": [ + "audits[total-blocking-time].title" + ], + "lighthouse-core/audits/metrics/total-blocking-time.js | description": [ + "audits[total-blocking-time].description" + ], + "lighthouse-core/audits/metrics/max-potential-fid.js | title": [ + "audits[max-potential-fid].title" + ], + "lighthouse-core/audits/metrics/max-potential-fid.js | description": [ + "audits[max-potential-fid].description" + ], + "lighthouse-core/audits/errors-in-console.js | title": [ + "audits[errors-in-console].title" + ], + "lighthouse-core/audits/errors-in-console.js | description": [ + "audits[errors-in-console].description" + ], + "lighthouse-core/audits/time-to-first-byte.js | title": [ + "audits[time-to-first-byte].title" + ], + "lighthouse-core/audits/time-to-first-byte.js | description": [ + "audits[time-to-first-byte].description" + ], + "lighthouse-core/audits/time-to-first-byte.js | displayValue": [ + { + "values": { + "timeInMs": 203.066 + }, + "path": "audits[time-to-first-byte].displayValue" + } + ], + "lighthouse-core/audits/metrics/first-cpu-idle.js | title": [ + "audits[first-cpu-idle].title" + ], + "lighthouse-core/audits/metrics/first-cpu-idle.js | description": [ + "audits[first-cpu-idle].description" + ], + "lighthouse-core/audits/metrics/interactive.js | title": [ + "audits.interactive.title" + ], + "lighthouse-core/audits/metrics/interactive.js | description": [ + "audits.interactive.description" + ], + "lighthouse-core/audits/user-timings.js | title": [ + "audits[user-timings].title" + ], + "lighthouse-core/audits/user-timings.js | description": [ + "audits[user-timings].description" + ], + "lighthouse-core/audits/critical-request-chains.js | title": [ + "audits[critical-request-chains].title" + ], + "lighthouse-core/audits/critical-request-chains.js | description": [ + "audits[critical-request-chains].description" + ], + "lighthouse-core/audits/critical-request-chains.js | displayValue": [ + { + "values": { + "itemCount": 18 + }, + "path": "audits[critical-request-chains].displayValue" + } + ], + "lighthouse-core/audits/redirects.js | title": [ + "audits.redirects.title" + ], + "lighthouse-core/audits/redirects.js | description": [ + "audits.redirects.description" + ], + "lighthouse-core/audits/installable-manifest.js | failureTitle": [ + "audits[installable-manifest].title" + ], + "lighthouse-core/audits/installable-manifest.js | description": [ + "audits[installable-manifest].description" + ], + "lighthouse-core/audits/apple-touch-icon.js | failureTitle": [ + "audits[apple-touch-icon].title" + ], + "lighthouse-core/audits/apple-touch-icon.js | description": [ + "audits[apple-touch-icon].description" + ], + "lighthouse-core/audits/splash-screen.js | failureTitle": [ + "audits[splash-screen].title" + ], + "lighthouse-core/audits/splash-screen.js | description": [ + "audits[splash-screen].description" + ], + "lighthouse-core/audits/themed-omnibox.js | failureTitle": [ + "audits[themed-omnibox].title" + ], + "lighthouse-core/audits/themed-omnibox.js | description": [ + "audits[themed-omnibox].description" + ], + "lighthouse-core/audits/content-width.js | title": [ + "audits[content-width].title" + ], + "lighthouse-core/audits/content-width.js | description": [ + "audits[content-width].description" + ], + "lighthouse-core/audits/image-aspect-ratio.js | title": [ + "audits[image-aspect-ratio].title" + ], + "lighthouse-core/audits/image-aspect-ratio.js | description": [ + "audits[image-aspect-ratio].description" + ], + "lighthouse-core/audits/deprecations.js | title": [ + "audits.deprecations.title" + ], + "lighthouse-core/audits/deprecations.js | description": [ + "audits.deprecations.description" + ], + "lighthouse-core/audits/mainthread-work-breakdown.js | title": [ + "audits[mainthread-work-breakdown].title" + ], + "lighthouse-core/audits/mainthread-work-breakdown.js | description": [ + "audits[mainthread-work-breakdown].description" + ], + "lighthouse-core/audits/mainthread-work-breakdown.js | columnCategory": [ + "audits[mainthread-work-breakdown].details.headings[0].text" + ], + "lighthouse-core/lib/i18n/i18n.js | columnTimeSpent": [ + "audits[mainthread-work-breakdown].details.headings[1].text", + "audits[network-rtt].details.headings[1].text", + "audits[network-server-latency].details.headings[1].text" + ], + "lighthouse-core/audits/bootup-time.js | title": [ + "audits[bootup-time].title" + ], + "lighthouse-core/audits/bootup-time.js | description": [ + "audits[bootup-time].description" + ], + "lighthouse-core/lib/i18n/i18n.js | columnURL": [ + "audits[bootup-time].details.headings[0].text", + "audits[network-rtt].details.headings[0].text", + "audits[network-server-latency].details.headings[0].text", + "audits[uses-long-cache-ttl].details.headings[0].text", + "audits[total-byte-weight].details.headings[0].text", + "audits[render-blocking-resources].details.headings[0].label", + "audits[unminified-css].details.headings[0].label", + "audits[unused-css-rules].details.headings[0].label", + "audits[uses-text-compression].details.headings[0].label", + "audits[uses-http2].details.headings[0].text" + ], + "lighthouse-core/audits/bootup-time.js | columnTotal": [ + "audits[bootup-time].details.headings[1].text" + ], + "lighthouse-core/audits/bootup-time.js | columnScriptEval": [ + "audits[bootup-time].details.headings[2].text" + ], + "lighthouse-core/audits/bootup-time.js | columnScriptParse": [ + "audits[bootup-time].details.headings[3].text" + ], + "lighthouse-core/audits/uses-rel-preload.js | title": [ + "audits[uses-rel-preload].title" + ], + "lighthouse-core/audits/uses-rel-preload.js | description": [ + "audits[uses-rel-preload].description" + ], + "lighthouse-core/audits/uses-rel-preconnect.js | title": [ + "audits[uses-rel-preconnect].title" + ], + "lighthouse-core/audits/uses-rel-preconnect.js | description": [ + "audits[uses-rel-preconnect].description" + ], + "lighthouse-core/audits/font-display.js | title": [ + "audits[font-display].title" + ], + "lighthouse-core/audits/font-display.js | description": [ + "audits[font-display].description" + ], + "lighthouse-core/audits/network-rtt.js | title": [ + "audits[network-rtt].title" + ], + "lighthouse-core/audits/network-rtt.js | description": [ + "audits[network-rtt].description" + ], + "lighthouse-core/audits/network-server-latency.js | title": [ + "audits[network-server-latency].title" + ], + "lighthouse-core/audits/network-server-latency.js | description": [ + "audits[network-server-latency].description" + ], + "lighthouse-core/audits/offline-start-url.js | failureTitle": [ + "audits[offline-start-url].title" + ], + "lighthouse-core/audits/offline-start-url.js | description": [ + "audits[offline-start-url].description" + ], + "lighthouse-core/audits/performance-budget.js | title": [ + "audits[performance-budget].title" + ], + "lighthouse-core/audits/performance-budget.js | description": [ + "audits[performance-budget].description" + ], + "lighthouse-core/audits/resource-summary.js | title": [ + "audits[resource-summary].title" + ], + "lighthouse-core/audits/resource-summary.js | description": [ + "audits[resource-summary].description" + ], + "lighthouse-core/audits/resource-summary.js | displayValue": [ + { + "values": { + "requestCount": 21, + "byteCount": 579766 + }, + "path": "audits[resource-summary].displayValue" + } + ], + "lighthouse-core/lib/i18n/i18n.js | columnResourceType": [ + "audits[resource-summary].details.headings[0].text" + ], + "lighthouse-core/lib/i18n/i18n.js | columnRequests": [ + "audits[resource-summary].details.headings[1].text" + ], + "lighthouse-core/lib/i18n/i18n.js | columnTransferSize": [ + "audits[resource-summary].details.headings[2].text" + ], + "lighthouse-core/lib/i18n/i18n.js | totalResourceType": [ + "audits[resource-summary].details.items[0].label" + ], + "lighthouse-core/lib/i18n/i18n.js | scriptResourceType": [ + "audits[resource-summary].details.items[1].label" + ], + "lighthouse-core/lib/i18n/i18n.js | stylesheetResourceType": [ + "audits[resource-summary].details.items[2].label" + ], + "lighthouse-core/lib/i18n/i18n.js | documentResourceType": [ + "audits[resource-summary].details.items[3].label" + ], + "lighthouse-core/lib/i18n/i18n.js | otherResourceType": [ + "audits[resource-summary].details.items[4].label" + ], + "lighthouse-core/lib/i18n/i18n.js | imageResourceType": [ + "audits[resource-summary].details.items[5].label" + ], + "lighthouse-core/lib/i18n/i18n.js | mediaResourceType": [ + "audits[resource-summary].details.items[6].label" + ], + "lighthouse-core/lib/i18n/i18n.js | fontResourceType": [ + "audits[resource-summary].details.items[7].label" + ], + "lighthouse-core/lib/i18n/i18n.js | thirdPartyResourceType": [ + "audits[resource-summary].details.items[8].label" + ], + "lighthouse-core/audits/third-party-summary.js | title": [ + "audits[third-party-summary].title" + ], + "lighthouse-core/audits/third-party-summary.js | description": [ + "audits[third-party-summary].description" + ], + "lighthouse-core/audits/manual/pwa-cross-browser.js | title": [ + "audits[pwa-cross-browser].title" + ], + "lighthouse-core/audits/manual/pwa-cross-browser.js | description": [ + "audits[pwa-cross-browser].description" + ], + "lighthouse-core/audits/manual/pwa-page-transitions.js | title": [ + "audits[pwa-page-transitions].title" + ], + "lighthouse-core/audits/manual/pwa-page-transitions.js | description": [ + "audits[pwa-page-transitions].description" + ], + "lighthouse-core/audits/manual/pwa-each-page-has-url.js | title": [ + "audits[pwa-each-page-has-url].title" + ], + "lighthouse-core/audits/manual/pwa-each-page-has-url.js | description": [ + "audits[pwa-each-page-has-url].description" + ], + "lighthouse-core/audits/accessibility/accesskeys.js | title": [ + "audits.accesskeys.title" + ], + "lighthouse-core/audits/accessibility/accesskeys.js | description": [ + "audits.accesskeys.description" + ], + "lighthouse-core/audits/accessibility/aria-allowed-attr.js | title": [ + "audits[aria-allowed-attr].title" + ], + "lighthouse-core/audits/accessibility/aria-allowed-attr.js | description": [ + "audits[aria-allowed-attr].description" + ], + "lighthouse-core/audits/accessibility/aria-required-attr.js | title": [ + "audits[aria-required-attr].title" + ], + "lighthouse-core/audits/accessibility/aria-required-attr.js | description": [ + "audits[aria-required-attr].description" + ], + "lighthouse-core/audits/accessibility/aria-required-children.js | title": [ + "audits[aria-required-children].title" + ], + "lighthouse-core/audits/accessibility/aria-required-children.js | description": [ + "audits[aria-required-children].description" + ], + "lighthouse-core/audits/accessibility/aria-required-parent.js | title": [ + "audits[aria-required-parent].title" + ], + "lighthouse-core/audits/accessibility/aria-required-parent.js | description": [ + "audits[aria-required-parent].description" + ], + "lighthouse-core/audits/accessibility/aria-roles.js | title": [ + "audits[aria-roles].title" + ], + "lighthouse-core/audits/accessibility/aria-roles.js | description": [ + "audits[aria-roles].description" + ], + "lighthouse-core/audits/accessibility/aria-valid-attr-value.js | title": [ + "audits[aria-valid-attr-value].title" + ], + "lighthouse-core/audits/accessibility/aria-valid-attr-value.js | description": [ + "audits[aria-valid-attr-value].description" + ], + "lighthouse-core/audits/accessibility/aria-valid-attr.js | title": [ + "audits[aria-valid-attr].title" + ], + "lighthouse-core/audits/accessibility/aria-valid-attr.js | description": [ + "audits[aria-valid-attr].description" + ], + "lighthouse-core/audits/accessibility/audio-caption.js | title": [ + "audits[audio-caption].title" + ], + "lighthouse-core/audits/accessibility/audio-caption.js | description": [ + "audits[audio-caption].description" + ], + "lighthouse-core/audits/accessibility/button-name.js | title": [ + "audits[button-name].title" + ], + "lighthouse-core/audits/accessibility/button-name.js | description": [ + "audits[button-name].description" + ], + "lighthouse-core/audits/accessibility/bypass.js | title": [ + "audits.bypass.title" + ], + "lighthouse-core/audits/accessibility/bypass.js | description": [ + "audits.bypass.description" + ], + "lighthouse-core/audits/accessibility/color-contrast.js | title": [ + "audits[color-contrast].title" + ], + "lighthouse-core/audits/accessibility/color-contrast.js | description": [ + "audits[color-contrast].description" + ], + "lighthouse-core/audits/accessibility/definition-list.js | title": [ + "audits[definition-list].title" + ], + "lighthouse-core/audits/accessibility/definition-list.js | description": [ + "audits[definition-list].description" + ], + "lighthouse-core/audits/accessibility/dlitem.js | title": [ + "audits.dlitem.title" + ], + "lighthouse-core/audits/accessibility/dlitem.js | description": [ + "audits.dlitem.description" + ], + "lighthouse-core/audits/accessibility/document-title.js | title": [ + "audits[document-title].title" + ], + "lighthouse-core/audits/accessibility/document-title.js | description": [ + "audits[document-title].description" + ], + "lighthouse-core/audits/accessibility/duplicate-id.js | title": [ + "audits[duplicate-id].title" + ], + "lighthouse-core/audits/accessibility/duplicate-id.js | description": [ + "audits[duplicate-id].description" + ], + "lighthouse-core/audits/accessibility/frame-title.js | title": [ + "audits[frame-title].title" + ], + "lighthouse-core/audits/accessibility/frame-title.js | description": [ + "audits[frame-title].description" + ], + "lighthouse-core/audits/accessibility/html-has-lang.js | failureTitle": [ + "audits[html-has-lang].title" + ], + "lighthouse-core/audits/accessibility/html-has-lang.js | description": [ + "audits[html-has-lang].description" + ], + "lighthouse-core/audits/accessibility/axe-audit.js | failingElementsHeader": [ + "audits[html-has-lang].details.headings[0].text", + "audits[image-alt].details.headings[0].text", + "audits.label.details.headings[0].text" + ], + "lighthouse-core/audits/accessibility/html-lang-valid.js | title": [ + "audits[html-lang-valid].title" + ], + "lighthouse-core/audits/accessibility/html-lang-valid.js | description": [ + "audits[html-lang-valid].description" + ], + "lighthouse-core/audits/accessibility/image-alt.js | failureTitle": [ + "audits[image-alt].title" + ], + "lighthouse-core/audits/accessibility/image-alt.js | description": [ + "audits[image-alt].description" + ], + "lighthouse-core/audits/accessibility/input-image-alt.js | title": [ + "audits[input-image-alt].title" + ], + "lighthouse-core/audits/accessibility/input-image-alt.js | description": [ + "audits[input-image-alt].description" + ], + "lighthouse-core/audits/accessibility/label.js | failureTitle": [ + "audits.label.title" + ], + "lighthouse-core/audits/accessibility/label.js | description": [ + "audits.label.description" + ], + "lighthouse-core/audits/accessibility/layout-table.js | title": [ + "audits[layout-table].title" + ], + "lighthouse-core/audits/accessibility/layout-table.js | description": [ + "audits[layout-table].description" + ], + "lighthouse-core/audits/accessibility/link-name.js | title": [ + "audits[link-name].title" + ], + "lighthouse-core/audits/accessibility/link-name.js | description": [ + "audits[link-name].description" + ], + "lighthouse-core/audits/accessibility/list.js | title": [ + "audits.list.title" + ], + "lighthouse-core/audits/accessibility/list.js | description": [ + "audits.list.description" + ], + "lighthouse-core/audits/accessibility/listitem.js | title": [ + "audits.listitem.title" + ], + "lighthouse-core/audits/accessibility/listitem.js | description": [ + "audits.listitem.description" + ], + "lighthouse-core/audits/accessibility/meta-refresh.js | title": [ + "audits[meta-refresh].title" + ], + "lighthouse-core/audits/accessibility/meta-refresh.js | description": [ + "audits[meta-refresh].description" + ], + "lighthouse-core/audits/accessibility/meta-viewport.js | title": [ + "audits[meta-viewport].title" + ], + "lighthouse-core/audits/accessibility/meta-viewport.js | description": [ + "audits[meta-viewport].description" + ], + "lighthouse-core/audits/accessibility/object-alt.js | title": [ + "audits[object-alt].title" + ], + "lighthouse-core/audits/accessibility/object-alt.js | description": [ + "audits[object-alt].description" + ], + "lighthouse-core/audits/accessibility/tabindex.js | title": [ + "audits.tabindex.title" + ], + "lighthouse-core/audits/accessibility/tabindex.js | description": [ + "audits.tabindex.description" + ], + "lighthouse-core/audits/accessibility/td-headers-attr.js | title": [ + "audits[td-headers-attr].title" + ], + "lighthouse-core/audits/accessibility/td-headers-attr.js | description": [ + "audits[td-headers-attr].description" + ], + "lighthouse-core/audits/accessibility/th-has-data-cells.js | title": [ + "audits[th-has-data-cells].title" + ], + "lighthouse-core/audits/accessibility/th-has-data-cells.js | description": [ + "audits[th-has-data-cells].description" + ], + "lighthouse-core/audits/accessibility/valid-lang.js | title": [ + "audits[valid-lang].title" + ], + "lighthouse-core/audits/accessibility/valid-lang.js | description": [ + "audits[valid-lang].description" + ], + "lighthouse-core/audits/accessibility/video-caption.js | title": [ + "audits[video-caption].title" + ], + "lighthouse-core/audits/accessibility/video-caption.js | description": [ + "audits[video-caption].description" + ], + "lighthouse-core/audits/accessibility/video-description.js | title": [ + "audits[video-description].title" + ], + "lighthouse-core/audits/accessibility/video-description.js | description": [ + "audits[video-description].description" + ], + "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | failureTitle": [ + "audits[uses-long-cache-ttl].title" + ], + "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | description": [ + "audits[uses-long-cache-ttl].description" + ], + "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | displayValue": [ + { + "values": { + "itemCount": 18 + }, + "path": "audits[uses-long-cache-ttl].displayValue" + } + ], + "lighthouse-core/lib/i18n/i18n.js | columnCacheTTL": [ + "audits[uses-long-cache-ttl].details.headings[1].text" + ], + "lighthouse-core/lib/i18n/i18n.js | columnSize": [ + "audits[uses-long-cache-ttl].details.headings[2].text", + "audits[total-byte-weight].details.headings[1].text", + "audits[render-blocking-resources].details.headings[1].label", + "audits[unminified-css].details.headings[1].label", + "audits[unused-css-rules].details.headings[1].label", + "audits[uses-text-compression].details.headings[1].label" + ], + "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | title": [ + "audits[total-byte-weight].title" + ], + "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | description": [ + "audits[total-byte-weight].description" + ], + "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | displayValue": [ + { + "values": { + "totalBytes": 579766 + }, + "path": "audits[total-byte-weight].displayValue" + } + ], + "lighthouse-core/audits/byte-efficiency/offscreen-images.js | title": [ + "audits[offscreen-images].title" + ], + "lighthouse-core/audits/byte-efficiency/offscreen-images.js | description": [ + "audits[offscreen-images].description" + ], + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | title": [ + "audits[render-blocking-resources].title" + ], + "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": [ + "audits[render-blocking-resources].description" + ], + "lighthouse-core/lib/i18n/i18n.js | displayValueMsSavings": [ + { + "values": { + "wastedMs": 5164 + }, + "path": "audits[render-blocking-resources].displayValue" + } + ], + "lighthouse-core/lib/i18n/i18n.js | columnWastedBytes": [ + "audits[render-blocking-resources].details.headings[2].label", + "audits[unminified-css].details.headings[2].label", + "audits[unused-css-rules].details.headings[2].label", + "audits[uses-text-compression].details.headings[2].label" + ], + "lighthouse-core/audits/byte-efficiency/unminified-css.js | title": [ + "audits[unminified-css].title" + ], + "lighthouse-core/audits/byte-efficiency/unminified-css.js | description": [ + "audits[unminified-css].description" + ], + "lighthouse-core/lib/i18n/i18n.js | displayValueByteSavings": [ + { + "values": { + "wastedBytes": 4920 + }, + "path": "audits[unminified-css].displayValue" + }, + { + "values": { + "wastedBytes": 206247 + }, + "path": "audits[unused-css-rules].displayValue" + }, + { + "values": { + "wastedBytes": 424656 + }, + "path": "audits[uses-text-compression].displayValue" + } + ], + "lighthouse-core/audits/byte-efficiency/unminified-javascript.js | title": [ + "audits[unminified-javascript].title" + ], + "lighthouse-core/audits/byte-efficiency/unminified-javascript.js | description": [ + "audits[unminified-javascript].description" + ], + "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | title": [ + "audits[unused-css-rules].title" + ], + "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | description": [ + "audits[unused-css-rules].description" + ], + "lighthouse-core/audits/byte-efficiency/uses-webp-images.js | title": [ + "audits[uses-webp-images].title" + ], + "lighthouse-core/audits/byte-efficiency/uses-webp-images.js | description": [ + "audits[uses-webp-images].description" + ], + "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | title": [ + "audits[uses-optimized-images].title" + ], + "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | description": [ + "audits[uses-optimized-images].description" + ], + "lighthouse-core/audits/byte-efficiency/uses-text-compression.js | title": [ + "audits[uses-text-compression].title" + ], + "lighthouse-core/audits/byte-efficiency/uses-text-compression.js | description": [ + "audits[uses-text-compression].description" + ], + "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | title": [ + "audits[uses-responsive-images].title" + ], + "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | description": [ + "audits[uses-responsive-images].description" + ], + "lighthouse-core/audits/byte-efficiency/efficient-animated-content.js | title": [ + "audits[efficient-animated-content].title" + ], + "lighthouse-core/audits/byte-efficiency/efficient-animated-content.js | description": [ + "audits[efficient-animated-content].description" + ], + "lighthouse-core/audits/dobetterweb/appcache-manifest.js | title": [ + "audits[appcache-manifest].title" + ], + "lighthouse-core/audits/dobetterweb/appcache-manifest.js | description": [ + "audits[appcache-manifest].description" + ], + "lighthouse-core/audits/dobetterweb/doctype.js | title": [ + "audits.doctype.title" + ], + "lighthouse-core/audits/dobetterweb/doctype.js | description": [ + "audits.doctype.description" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | title": [ + "audits[dom-size].title" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | description": [ + "audits[dom-size].description" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | displayValue": [ + { + "values": { + "itemCount": 245 + }, + "path": "audits[dom-size].displayValue" + } + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | columnStatistic": [ + "audits[dom-size].details.headings[0].text" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | columnElement": [ + "audits[dom-size].details.headings[1].text" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | columnValue": [ + "audits[dom-size].details.headings[2].text" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMElements": [ + "audits[dom-size].details.items[0].statistic" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMDepth": [ + "audits[dom-size].details.items[1].statistic" + ], + "lighthouse-core/audits/dobetterweb/dom-size.js | statisticDOMWidth": [ + "audits[dom-size].details.items[2].statistic" + ], + "lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js | title": [ + "audits[external-anchors-use-rel-noopener].title" + ], + "lighthouse-core/audits/dobetterweb/external-anchors-use-rel-noopener.js | description": [ + "audits[external-anchors-use-rel-noopener].description" + ], + "lighthouse-core/audits/dobetterweb/geolocation-on-start.js | title": [ + "audits[geolocation-on-start].title" + ], + "lighthouse-core/audits/dobetterweb/geolocation-on-start.js | description": [ + "audits[geolocation-on-start].description" + ], + "lighthouse-core/audits/dobetterweb/no-document-write.js | title": [ + "audits[no-document-write].title" + ], + "lighthouse-core/audits/dobetterweb/no-document-write.js | description": [ + "audits[no-document-write].description" + ], + "lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js | title": [ + "audits[no-vulnerable-libraries].title" + ], + "lighthouse-core/audits/dobetterweb/no-vulnerable-libraries.js | description": [ + "audits[no-vulnerable-libraries].description" + ], + "lighthouse-core/audits/dobetterweb/js-libraries.js | title": [ + "audits[js-libraries].title" + ], + "lighthouse-core/audits/dobetterweb/js-libraries.js | description": [ + "audits[js-libraries].description" + ], + "lighthouse-core/lib/i18n/i18n.js | columnName": [ + "audits[js-libraries].details.headings[0].text" + ], + "lighthouse-core/audits/dobetterweb/js-libraries.js | columnVersion": [ + "audits[js-libraries].details.headings[1].text" + ], + "lighthouse-core/audits/dobetterweb/notification-on-start.js | title": [ + "audits[notification-on-start].title" + ], + "lighthouse-core/audits/dobetterweb/notification-on-start.js | description": [ + "audits[notification-on-start].description" + ], + "lighthouse-core/audits/dobetterweb/password-inputs-can-be-pasted-into.js | title": [ + "audits[password-inputs-can-be-pasted-into].title" + ], + "lighthouse-core/audits/dobetterweb/password-inputs-can-be-pasted-into.js | description": [ + "audits[password-inputs-can-be-pasted-into].description" + ], + "lighthouse-core/audits/dobetterweb/uses-http2.js | failureTitle": [ + "audits[uses-http2].title" + ], + "lighthouse-core/audits/dobetterweb/uses-http2.js | description": [ + "audits[uses-http2].description" + ], + "lighthouse-core/audits/dobetterweb/uses-http2.js | displayValue": [ + { + "values": { + "itemCount": 20 + }, + "path": "audits[uses-http2].displayValue" + } + ], + "lighthouse-core/audits/dobetterweb/uses-http2.js | columnProtocol": [ + "audits[uses-http2].details.headings[1].text" + ], + "lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js | title": [ + "audits[uses-passive-event-listeners].title" + ], + "lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js | description": [ + "audits[uses-passive-event-listeners].description" + ], + "lighthouse-core/audits/seo/meta-description.js | failureTitle": [ + "audits[meta-description].title" + ], + "lighthouse-core/audits/seo/meta-description.js | description": [ + "audits[meta-description].description" + ], + "lighthouse-core/audits/seo/http-status-code.js | title": [ + "audits[http-status-code].title" + ], + "lighthouse-core/audits/seo/http-status-code.js | description": [ + "audits[http-status-code].description" + ], + "lighthouse-core/audits/seo/font-size.js | title": [ + "audits[font-size].title" + ], + "lighthouse-core/audits/seo/font-size.js | description": [ + "audits[font-size].description" + ], + "lighthouse-core/audits/seo/font-size.js | displayValue": [ + { + "values": { + "decimalProportion": 1 + }, + "path": "audits[font-size].displayValue" + } + ], + "lighthouse-core/audits/seo/link-text.js | title": [ + "audits[link-text].title" + ], + "lighthouse-core/audits/seo/link-text.js | description": [ + "audits[link-text].description" + ], + "lighthouse-core/audits/seo/is-crawlable.js | title": [ + "audits[is-crawlable].title" + ], + "lighthouse-core/audits/seo/is-crawlable.js | description": [ + "audits[is-crawlable].description" + ], + "lighthouse-core/audits/seo/robots-txt.js | title": [ + "audits[robots-txt].title" + ], + "lighthouse-core/audits/seo/robots-txt.js | description": [ + "audits[robots-txt].description" + ], + "lighthouse-core/audits/seo/tap-targets.js | title": [ + "audits[tap-targets].title" + ], + "lighthouse-core/audits/seo/tap-targets.js | description": [ + "audits[tap-targets].description" + ], + "lighthouse-core/audits/seo/tap-targets.js | displayValue": [ + { + "values": { + "decimalProportion": 1 + }, + "path": "audits[tap-targets].displayValue" + } + ], + "lighthouse-core/audits/seo/hreflang.js | title": [ + "audits.hreflang.title" + ], + "lighthouse-core/audits/seo/hreflang.js | description": [ + "audits.hreflang.description" + ], + "lighthouse-core/audits/seo/plugins.js | title": [ + "audits.plugins.title" + ], + "lighthouse-core/audits/seo/plugins.js | description": [ + "audits.plugins.description" + ], + "lighthouse-core/audits/seo/canonical.js | title": [ + "audits.canonical.title" + ], + "lighthouse-core/audits/seo/canonical.js | description": [ + "audits.canonical.description" + ], + "lighthouse-core/audits/seo/manual/structured-data.js | title": [ + "audits[structured-data].title" + ], + "lighthouse-core/audits/seo/manual/structured-data.js | description": [ + "audits[structured-data].description" + ], + "lighthouse-core/config/default-config.js | performanceCategoryTitle": [ + "categories.performance.title" + ], + "lighthouse-core/config/default-config.js | a11yCategoryTitle": [ + "categories.accessibility.title" + ], + "lighthouse-core/config/default-config.js | a11yCategoryDescription": [ + "categories.accessibility.description" + ], + "lighthouse-core/config/default-config.js | a11yCategoryManualDescription": [ + "categories.accessibility.manualDescription" + ], + "lighthouse-core/config/default-config.js | bestPracticesCategoryTitle": [ + "categories[best-practices].title" + ], + "lighthouse-core/config/default-config.js | seoCategoryTitle": [ + "categories.seo.title" + ], + "lighthouse-core/config/default-config.js | seoCategoryDescription": [ + "categories.seo.description" + ], + "lighthouse-core/config/default-config.js | seoCategoryManualDescription": [ + "categories.seo.manualDescription" + ], + "lighthouse-core/config/default-config.js | pwaCategoryTitle": [ + "categories.pwa.title" + ], + "lighthouse-core/config/default-config.js | pwaCategoryDescription": [ + "categories.pwa.description" + ], + "lighthouse-core/config/default-config.js | pwaCategoryManualDescription": [ + "categories.pwa.manualDescription" + ], + "lighthouse-core/config/default-config.js | metricGroupTitle": [ + "categoryGroups.metrics.title" + ], + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": [ + "categoryGroups[load-opportunities].title" + ], + "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": [ + "categoryGroups[load-opportunities].description" + ], + "lighthouse-core/config/default-config.js | budgetsGroupTitle": [ + "categoryGroups.budgets.title" + ], + "lighthouse-core/config/default-config.js | budgetsGroupDescription": [ + "categoryGroups.budgets.description" + ], + "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": [ + "categoryGroups.diagnostics.title" + ], + "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": [ + "categoryGroups.diagnostics.description" + ], + "lighthouse-core/config/default-config.js | pwaFastReliableGroupTitle": [ + "categoryGroups[pwa-fast-reliable].title" + ], + "lighthouse-core/config/default-config.js | pwaInstallableGroupTitle": [ + "categoryGroups[pwa-installable].title" + ], + "lighthouse-core/config/default-config.js | pwaOptimizedGroupTitle": [ + "categoryGroups[pwa-optimized].title" + ], + "lighthouse-core/config/default-config.js | a11yBestPracticesGroupTitle": [ + "categoryGroups[a11y-best-practices].title" + ], + "lighthouse-core/config/default-config.js | a11yBestPracticesGroupDescription": [ + "categoryGroups[a11y-best-practices].description" + ], + "lighthouse-core/config/default-config.js | a11yColorContrastGroupTitle": [ + "categoryGroups[a11y-color-contrast].title" + ], + "lighthouse-core/config/default-config.js | a11yColorContrastGroupDescription": [ + "categoryGroups[a11y-color-contrast].description" + ], + "lighthouse-core/config/default-config.js | a11yNamesLabelsGroupTitle": [ + "categoryGroups[a11y-names-labels].title" + ], + "lighthouse-core/config/default-config.js | a11yNamesLabelsGroupDescription": [ + "categoryGroups[a11y-names-labels].description" + ], + "lighthouse-core/config/default-config.js | a11yNavigationGroupTitle": [ + "categoryGroups[a11y-navigation].title" + ], + "lighthouse-core/config/default-config.js | a11yNavigationGroupDescription": [ + "categoryGroups[a11y-navigation].description" + ], + "lighthouse-core/config/default-config.js | a11yAriaGroupTitle": [ + "categoryGroups[a11y-aria].title" + ], + "lighthouse-core/config/default-config.js | a11yAriaGroupDescription": [ + "categoryGroups[a11y-aria].description" + ], + "lighthouse-core/config/default-config.js | a11yLanguageGroupTitle": [ + "categoryGroups[a11y-language].title" + ], + "lighthouse-core/config/default-config.js | a11yLanguageGroupDescription": [ + "categoryGroups[a11y-language].description" + ], + "lighthouse-core/config/default-config.js | a11yAudioVideoGroupTitle": [ + "categoryGroups[a11y-audio-video].title" + ], + "lighthouse-core/config/default-config.js | a11yAudioVideoGroupDescription": [ + "categoryGroups[a11y-audio-video].description" + ], + "lighthouse-core/config/default-config.js | a11yTablesListsVideoGroupTitle": [ + "categoryGroups[a11y-tables-lists].title" + ], + "lighthouse-core/config/default-config.js | a11yTablesListsVideoGroupDescription": [ + "categoryGroups[a11y-tables-lists].description" + ], + "lighthouse-core/config/default-config.js | seoMobileGroupTitle": [ + "categoryGroups[seo-mobile].title" + ], + "lighthouse-core/config/default-config.js | seoMobileGroupDescription": [ + "categoryGroups[seo-mobile].description" + ], + "lighthouse-core/config/default-config.js | seoContentGroupTitle": [ + "categoryGroups[seo-content].title" + ], + "lighthouse-core/config/default-config.js | seoContentGroupDescription": [ + "categoryGroups[seo-content].description" + ], + "lighthouse-core/config/default-config.js | seoCrawlingGroupTitle": [ + "categoryGroups[seo-crawl].title" + ], + "lighthouse-core/config/default-config.js | seoCrawlingGroupDescription": [ + "categoryGroups[seo-crawl].description" + ] + } + }, + "stackPacks": [] +} diff --git a/data-raw/renv.lock b/data-raw/renv.lock new file mode 100644 index 00000000..b86828a8 --- /dev/null +++ b/data-raw/renv.lock @@ -0,0 +1,34 @@ +{ + "R": { + "Version": "3.6.1", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cran.rstudio.com" + } + ] + }, + "Packages": { + "attempt": { + "Package": "attempt", + "Version": "0.3.0", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "9aaae25e273927dba4e279caac478baa" + }, + "renv": { + "Package": "renv", + "Version": "0.9.3", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "c1a367437d8a8a44bec4b9d4974cb20c" + }, + "rlang": { + "Package": "rlang", + "Version": "0.4.5", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "1cc1b38e4db40ea6eb19ab8080bbed3b" + } + } +} diff --git a/data-raw/renv/.gitignore b/data-raw/renv/.gitignore new file mode 100644 index 00000000..82740ba9 --- /dev/null +++ b/data-raw/renv/.gitignore @@ -0,0 +1,3 @@ +library/ +python/ +staging/ diff --git a/data-raw/renv/activate.R b/data-raw/renv/activate.R new file mode 100644 index 00000000..4baa934f --- /dev/null +++ b/data-raw/renv/activate.R @@ -0,0 +1,185 @@ + +local({ + + # the requested version of renv + version <- "0.9.3" + + # avoid recursion + if (!is.na(Sys.getenv("RENV_R_INITIALIZING", unset = NA))) + return(invisible(TRUE)) + + # signal that we're loading renv during R startup + Sys.setenv("RENV_R_INITIALIZING" = "true") + on.exit(Sys.unsetenv("RENV_R_INITIALIZING"), add = TRUE) + + # signal that we've consented to use renv + options(renv.consent = TRUE) + + # load the 'utils' package eagerly -- this ensures that renv shims, which + # mask 'utils' packages, will come first on the search path + library(utils, lib.loc = .Library) + + # check to see if renv has already been loaded + if ("renv" %in% loadedNamespaces()) { + + # if renv has already been loaded, and it's the requested version of renv, + # nothing to do + spec <- .getNamespaceInfo(.getNamespace("renv"), "spec") + if (identical(spec[["version"]], version)) + return(invisible(TRUE)) + + # otherwise, unload and attempt to load the correct version of renv + unloadNamespace("renv") + + } + + # construct path to renv in library + libpath <- local({ + + root <- Sys.getenv("RENV_PATHS_LIBRARY", unset = "renv/library") + prefix <- paste("R", getRversion()[1, 1:2], sep = "-") + + # include SVN revision for development versions of R + # (to avoid sharing platform-specific artefacts with released versions of R) + devel <- + identical(R.version[["status"]], "Under development (unstable)") || + identical(R.version[["nickname"]], "Unsuffered Consequences") + + if (devel) + prefix <- paste(prefix, R.version[["svn rev"]], sep = "-r") + + file.path(root, prefix, R.version$platform) + + }) + + # try to load renv from the project library + if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { + + # warn if the version of renv loaded does not match + loadedversion <- utils::packageDescription("renv", fields = "Version") + if (version != loadedversion) { + + # assume four-component versions are from GitHub; three-component + # versions are from CRAN + components <- strsplit(loadedversion, "[.-]")[[1]] + remote <- if (length(components) == 4L) + paste("rstudio/renv", loadedversion, sep = "@") + else + paste("renv", loadedversion, sep = "@") + + fmt <- paste( + "renv %1$s was loaded from project library, but renv %2$s is recorded in lockfile.", + "Use `renv::record(\"%3$s\")` to record this version in the lockfile.", + "Use `renv::restore(packages = \"renv\")` to install renv %2$s into the project library.", + sep = "\n" + ) + + msg <- sprintf(fmt, loadedversion, version, remote) + warning(msg, call. = FALSE) + + } + + # load the project + return(renv::load()) + + } + + # failed to find renv locally; we'll try to install from GitHub. + # first, set up download options as appropriate (try to use GITHUB_PAT) + install_renv <- function() { + + message("Failed to find installation of renv -- attempting to bootstrap...") + + # ensure .Rprofile doesn't get executed + rpu <- Sys.getenv("R_PROFILE_USER", unset = NA) + Sys.setenv(R_PROFILE_USER = "<NA>") + on.exit({ + if (is.na(rpu)) + Sys.unsetenv("R_PROFILE_USER") + else + Sys.setenv(R_PROFILE_USER = rpu) + }, add = TRUE) + + # prepare download options + pat <- Sys.getenv("GITHUB_PAT") + if (nzchar(Sys.which("curl")) && nzchar(pat)) { + fmt <- "--location --fail --header \"Authorization: token %s\"" + extra <- sprintf(fmt, pat) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "curl", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } else if (nzchar(Sys.which("wget")) && nzchar(pat)) { + fmt <- "--header=\"Authorization: token %s\"" + extra <- sprintf(fmt, pat) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "wget", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } + + # fix up repos + repos <- getOption("repos") + on.exit(options(repos = repos), add = TRUE) + repos[repos == "@CRAN@"] <- "https://cloud.r-project.org" + options(repos = repos) + + # check for renv on CRAN matching this version + db <- as.data.frame(available.packages(), stringsAsFactors = FALSE) + if ("renv" %in% rownames(db)) { + entry <- db["renv", ] + if (identical(entry$Version, version)) { + message("* Installing renv ", version, " ... ", appendLF = FALSE) + dir.create(libpath, showWarnings = FALSE, recursive = TRUE) + utils::install.packages("renv", lib = libpath, quiet = TRUE) + message("Done!") + return(TRUE) + } + } + + # try to download renv + message("* Downloading renv ", version, " ... ", appendLF = FALSE) + prefix <- "https://api.github.com" + url <- file.path(prefix, "repos/rstudio/renv/tarball", version) + destfile <- tempfile("renv-", fileext = ".tar.gz") + on.exit(unlink(destfile), add = TRUE) + utils::download.file(url, destfile = destfile, mode = "wb", quiet = TRUE) + message("Done!") + + # attempt to install it into project library + message("* Installing renv ", version, " ... ", appendLF = FALSE) + dir.create(libpath, showWarnings = FALSE, recursive = TRUE) + + # invoke using system2 so we can capture and report output + bin <- R.home("bin") + exe <- if (Sys.info()[["sysname"]] == "Windows") "R.exe" else "R" + r <- file.path(bin, exe) + args <- c("--vanilla", "CMD", "INSTALL", "-l", shQuote(libpath), shQuote(destfile)) + output <- system2(r, args, stdout = TRUE, stderr = TRUE) + message("Done!") + + # check for successful install + status <- attr(output, "status") + if (is.numeric(status) && !identical(status, 0L)) { + text <- c("Error installing renv", "=====================", output) + writeLines(text, con = stderr()) + } + + + } + + try(install_renv()) + + # try again to load + if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { + message("Successfully installed and loaded renv ", version, ".") + return(renv::load()) + } + + # failed to download or load renv; warn the user + msg <- c( + "Failed to find an renv installation: the project will not be loaded.", + "Use `renv::activate()` to re-initialize the project." + ) + + warning(paste(msg, collapse = "\n"), call. = FALSE) + +}) diff --git a/data-raw/renv/settings.dcf b/data-raw/renv/settings.dcf new file mode 100644 index 00000000..bba46f4c --- /dev/null +++ b/data-raw/renv/settings.dcf @@ -0,0 +1,6 @@ +external.libraries: +ignored.packages: +package.dependency.fields: Imports, Depends, LinkingTo +snapshot.type: packrat +use.cache: TRUE +vcs.ignore.library: TRUE diff --git a/data-raw/renvinit/.Rprofile b/data-raw/renvinit/.Rprofile new file mode 100644 index 00000000..81b960f5 --- /dev/null +++ b/data-raw/renvinit/.Rprofile @@ -0,0 +1 @@ +source("renv/activate.R") diff --git a/data-raw/renvinit/renv.lock b/data-raw/renvinit/renv.lock new file mode 100644 index 00000000..6fd83bc4 --- /dev/null +++ b/data-raw/renvinit/renv.lock @@ -0,0 +1,20 @@ +{ + "R": { + "Version": "3.6.1", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://cran.rstudio.com" + } + ] + }, + "Packages": { + "renv": { + "Package": "renv", + "Version": "0.9.3", + "Source": "Repository", + "Repository": "CRAN", + "Hash": "c1a367437d8a8a44bec4b9d4974cb20c" + } + } +} diff --git a/data-raw/renvinit/renv/.gitignore b/data-raw/renvinit/renv/.gitignore new file mode 100644 index 00000000..82740ba9 --- /dev/null +++ b/data-raw/renvinit/renv/.gitignore @@ -0,0 +1,3 @@ +library/ +python/ +staging/ diff --git a/data-raw/renvinit/renv/activate.R b/data-raw/renvinit/renv/activate.R new file mode 100644 index 00000000..4baa934f --- /dev/null +++ b/data-raw/renvinit/renv/activate.R @@ -0,0 +1,185 @@ + +local({ + + # the requested version of renv + version <- "0.9.3" + + # avoid recursion + if (!is.na(Sys.getenv("RENV_R_INITIALIZING", unset = NA))) + return(invisible(TRUE)) + + # signal that we're loading renv during R startup + Sys.setenv("RENV_R_INITIALIZING" = "true") + on.exit(Sys.unsetenv("RENV_R_INITIALIZING"), add = TRUE) + + # signal that we've consented to use renv + options(renv.consent = TRUE) + + # load the 'utils' package eagerly -- this ensures that renv shims, which + # mask 'utils' packages, will come first on the search path + library(utils, lib.loc = .Library) + + # check to see if renv has already been loaded + if ("renv" %in% loadedNamespaces()) { + + # if renv has already been loaded, and it's the requested version of renv, + # nothing to do + spec <- .getNamespaceInfo(.getNamespace("renv"), "spec") + if (identical(spec[["version"]], version)) + return(invisible(TRUE)) + + # otherwise, unload and attempt to load the correct version of renv + unloadNamespace("renv") + + } + + # construct path to renv in library + libpath <- local({ + + root <- Sys.getenv("RENV_PATHS_LIBRARY", unset = "renv/library") + prefix <- paste("R", getRversion()[1, 1:2], sep = "-") + + # include SVN revision for development versions of R + # (to avoid sharing platform-specific artefacts with released versions of R) + devel <- + identical(R.version[["status"]], "Under development (unstable)") || + identical(R.version[["nickname"]], "Unsuffered Consequences") + + if (devel) + prefix <- paste(prefix, R.version[["svn rev"]], sep = "-r") + + file.path(root, prefix, R.version$platform) + + }) + + # try to load renv from the project library + if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { + + # warn if the version of renv loaded does not match + loadedversion <- utils::packageDescription("renv", fields = "Version") + if (version != loadedversion) { + + # assume four-component versions are from GitHub; three-component + # versions are from CRAN + components <- strsplit(loadedversion, "[.-]")[[1]] + remote <- if (length(components) == 4L) + paste("rstudio/renv", loadedversion, sep = "@") + else + paste("renv", loadedversion, sep = "@") + + fmt <- paste( + "renv %1$s was loaded from project library, but renv %2$s is recorded in lockfile.", + "Use `renv::record(\"%3$s\")` to record this version in the lockfile.", + "Use `renv::restore(packages = \"renv\")` to install renv %2$s into the project library.", + sep = "\n" + ) + + msg <- sprintf(fmt, loadedversion, version, remote) + warning(msg, call. = FALSE) + + } + + # load the project + return(renv::load()) + + } + + # failed to find renv locally; we'll try to install from GitHub. + # first, set up download options as appropriate (try to use GITHUB_PAT) + install_renv <- function() { + + message("Failed to find installation of renv -- attempting to bootstrap...") + + # ensure .Rprofile doesn't get executed + rpu <- Sys.getenv("R_PROFILE_USER", unset = NA) + Sys.setenv(R_PROFILE_USER = "<NA>") + on.exit({ + if (is.na(rpu)) + Sys.unsetenv("R_PROFILE_USER") + else + Sys.setenv(R_PROFILE_USER = rpu) + }, add = TRUE) + + # prepare download options + pat <- Sys.getenv("GITHUB_PAT") + if (nzchar(Sys.which("curl")) && nzchar(pat)) { + fmt <- "--location --fail --header \"Authorization: token %s\"" + extra <- sprintf(fmt, pat) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "curl", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } else if (nzchar(Sys.which("wget")) && nzchar(pat)) { + fmt <- "--header=\"Authorization: token %s\"" + extra <- sprintf(fmt, pat) + saved <- options("download.file.method", "download.file.extra") + options(download.file.method = "wget", download.file.extra = extra) + on.exit(do.call(base::options, saved), add = TRUE) + } + + # fix up repos + repos <- getOption("repos") + on.exit(options(repos = repos), add = TRUE) + repos[repos == "@CRAN@"] <- "https://cloud.r-project.org" + options(repos = repos) + + # check for renv on CRAN matching this version + db <- as.data.frame(available.packages(), stringsAsFactors = FALSE) + if ("renv" %in% rownames(db)) { + entry <- db["renv", ] + if (identical(entry$Version, version)) { + message("* Installing renv ", version, " ... ", appendLF = FALSE) + dir.create(libpath, showWarnings = FALSE, recursive = TRUE) + utils::install.packages("renv", lib = libpath, quiet = TRUE) + message("Done!") + return(TRUE) + } + } + + # try to download renv + message("* Downloading renv ", version, " ... ", appendLF = FALSE) + prefix <- "https://api.github.com" + url <- file.path(prefix, "repos/rstudio/renv/tarball", version) + destfile <- tempfile("renv-", fileext = ".tar.gz") + on.exit(unlink(destfile), add = TRUE) + utils::download.file(url, destfile = destfile, mode = "wb", quiet = TRUE) + message("Done!") + + # attempt to install it into project library + message("* Installing renv ", version, " ... ", appendLF = FALSE) + dir.create(libpath, showWarnings = FALSE, recursive = TRUE) + + # invoke using system2 so we can capture and report output + bin <- R.home("bin") + exe <- if (Sys.info()[["sysname"]] == "Windows") "R.exe" else "R" + r <- file.path(bin, exe) + args <- c("--vanilla", "CMD", "INSTALL", "-l", shQuote(libpath), shQuote(destfile)) + output <- system2(r, args, stdout = TRUE, stderr = TRUE) + message("Done!") + + # check for successful install + status <- attr(output, "status") + if (is.numeric(status) && !identical(status, 0L)) { + text <- c("Error installing renv", "=====================", output) + writeLines(text, con = stderr()) + } + + + } + + try(install_renv()) + + # try again to load + if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) { + message("Successfully installed and loaded renv ", version, ".") + return(renv::load()) + } + + # failed to download or load renv; warn the user + msg <- c( + "Failed to find an renv installation: the project will not be loaded.", + "Use `renv::activate()` to re-initialize the project." + ) + + warning(paste(msg, collapse = "\n"), call. = FALSE) + +}) diff --git a/data-raw/renvinit/renv/settings.dcf b/data-raw/renvinit/renv/settings.dcf new file mode 100644 index 00000000..bba46f4c --- /dev/null +++ b/data-raw/renvinit/renv/settings.dcf @@ -0,0 +1,6 @@ +external.libraries: +ignored.packages: +package.dependency.fields: Imports, Depends, LinkingTo +snapshot.type: packrat +use.cache: TRUE +vcs.ignore.library: TRUE diff --git a/data-raw/renvinit/renvinit.Rproj b/data-raw/renvinit/renvinit.Rproj new file mode 100644 index 00000000..8e3c2ebc --- /dev/null +++ b/data-raw/renvinit/renvinit.Rproj @@ -0,0 +1,13 @@ +Version: 1.0 + +RestoreWorkspace: Default +SaveWorkspace: Default +AlwaysSaveHistory: Default + +EnableCodeIndexing: Yes +UseSpacesForTab: Yes +NumSpacesForTab: 2 +Encoding: UTF-8 + +RnwWeave: Sweave +LaTeX: pdfLaTeX diff --git a/data-raw/script.R b/data-raw/script.R new file mode 100644 index 00000000..7a6c7e64 --- /dev/null +++ b/data-raw/script.R @@ -0,0 +1 @@ +library(attempt) diff --git a/data-raw/tests/mytest-expected/001.json b/data-raw/tests/mytest-expected/001.json new file mode 100644 index 00000000..8cfa0eb9 --- /dev/null +++ b/data-raw/tests/mytest-expected/001.json @@ -0,0 +1,11 @@ +{ + "input": { + + }, + "output": { + + }, + "export": { + + } +} diff --git a/data-raw/tests/mytest-expected/001.png b/data-raw/tests/mytest-expected/001.png new file mode 100644 index 00000000..dbad8db2 Binary files /dev/null and b/data-raw/tests/mytest-expected/001.png differ diff --git a/data-raw/tests/mytest.R b/data-raw/tests/mytest.R new file mode 100644 index 00000000..6800c204 --- /dev/null +++ b/data-raw/tests/mytest.R @@ -0,0 +1,4 @@ +app <- ShinyDriver$new("../") +app$snapshotInit("mytest") + +app$snapshot() diff --git a/dataset/code_coverage_golem.RDS b/dataset/code_coverage_golem.RDS new file mode 100644 index 00000000..346a9ba2 Binary files /dev/null and b/dataset/code_coverage_golem.RDS differ diff --git a/dataset/cyclo_golex.rds b/dataset/cyclo_golex.rds new file mode 100644 index 00000000..cd1f77cf Binary files /dev/null and b/dataset/cyclo_golex.rds differ diff --git a/dataset/cyclo_shiny.rds b/dataset/cyclo_shiny.rds new file mode 100644 index 00000000..ba3ffe83 Binary files /dev/null and b/dataset/cyclo_shiny.rds differ diff --git a/dataset/cyclo_tidytuesday.rds b/dataset/cyclo_tidytuesday.rds new file mode 100644 index 00000000..b6c42d75 Binary files /dev/null and b/dataset/cyclo_tidytuesday.rds differ diff --git a/dataset/frame_golem.rds b/dataset/frame_golem.rds new file mode 100644 index 00000000..d6310a24 Binary files /dev/null and b/dataset/frame_golem.rds differ diff --git a/dataset/frame_shiny.rds b/dataset/frame_shiny.rds new file mode 100644 index 00000000..7cd03210 Binary files /dev/null and b/dataset/frame_shiny.rds differ diff --git a/dataset/results.RDS b/dataset/results.RDS new file mode 100644 index 00000000..ef3d3cde Binary files /dev/null and b/dataset/results.RDS differ diff --git a/deploy.Rmd b/deploy.Rmd new file mode 100644 index 00000000..e69de29b diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..18c00b87 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -o errexit -o nounset +BASE_REPO=$PWD + +update_website() { + cd ..; mkdir gh-pages; cd gh-pages + git init + git config user.name "Sébastien Rochette" + git config user.email "sebastienrochettefr@gmail.com" + git config --global push.default simple + git remote add upstream "https://$GH_TOKEN@github.com/ThinkR-open/building-shiny-apps-workflow.git" + git fetch upstream + git checkout gh-pages + + cp -fvr $BASE_REPO/docs/* . + git add * + git commit -a -m "Updating book from $BASE_REPO (${TRAVIS_BUILD_NUMBER})" + git status + git push + git status + cd .. +} + +update_website \ No newline at end of file diff --git a/dev_history.R b/dev_history.R new file mode 100644 index 00000000..c56c7117 --- /dev/null +++ b/dev_history.R @@ -0,0 +1,40 @@ +usethis::use_build_ignore("dev_history.R") +usethis::use_git_ignore("building-shiny-apps-workflow.Rmd") +usethis::use_git_ignore("building-shiny-apps-workflow_*") +usethis::use_git_ignore("building-shiny-apps-workflow.*.md") +usethis::use_git_ignore("golex/") + +usethis::use_mit_license("ThinkR") + +# bookdown Imports are in Rmds +# remotes::install_github("ThinkR-open/attachment") +imports <- unique(c( + "bookdown", + # Calls in `r code` + "devtools", "knitr", "lubridate", + # Do not know why it is needed... + "future", + attachment::att_from_rmds(".", recursive = FALSE)) +) +attachment::att_to_desc_from_is(path.d = "DESCRIPTION", imports = imports) + +# Name chunks +namer::name_dir_chunks(".") + +# Install dependencies +# attachment::install_if_missing( +# attachment::att_from_description() +# ) +# ou bien +remotes::install_github("ThinkR-open/golem") +remotes::install_local(force = TRUE) + +# Test +pid <- rstudioapi::terminalExecute( + 'R -e \'bookdown::render_book("index.Rmd", output_format = "bookdown::gitbook", clean = FALSE, output_dir = "docs/wip") \'' +) +rstudioapi::terminalKill(pid) + + +bookdown::render_book("index.Rmd", output_format = "bookdown::gitbook", clean = FALSE, output_dir = "docs/wip") + diff --git a/docs/bigshinyapp.html b/docs/bigshinyapp.html deleted file mode 100644 index c3e76603..00000000 --- a/docs/bigshinyapp.html +++ /dev/null @@ -1,188 +0,0 @@ -<!DOCTYPE html> -<html > - -<head> - - <meta charset="UTF-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <title>Chapter 2 About Big Shiny Apps | Building Big Shiny Apps - A Workflow - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - -
-
- -
-
-

Chapter 2 About Big Shiny Apps

-

If you’re reading this page, chances are you already know what a Shiny App is — a web application that communicates with R, built in R, and working with R. Almost anybody can create a prototype for a small data product in a matter of hours. And no knowledge of HTML, CSS or JavaScript is required, making it really easy to use — you can rapidly create a POC. But what to do now you want to build a big Shiny App?

-

What’s a big Shiny App?

-
    -
  • Well, first, one that includes several thousand lines of code (R and others).
  • -
  • It’s also one that is potentially developed by several coders, working on the same application at the same time.
  • -
  • It’s an application where scaling matters.
  • -
  • Maintainability and ease of upgrading are important.
  • -
  • In many cases, Shiny Apps in production are not used by “tech literate” people.
  • -
  • People rely on this application for making real-world decisions, with real consequences.
  • -
- -
-
- -
-
-
- - -
-
- - - - - - - - - - - - diff --git a/docs/challenges.html b/docs/challenges.html deleted file mode 100644 index 03ac0a48..00000000 --- a/docs/challenges.html +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - - - Chapter 3 Challenges | Building Big Shiny Apps - A Workflow - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - -
-
- -
-
-

Chapter 3 Challenges

-
-

3.1 Finding a good UI (and stick with it)

-

Choosing a UI is hard — we have a natural tendency, as coders, to be focused on the backend, i.e the algorithmic part of the application. But let’s state the truth: no matter how complex and innovative your backend can be, your application is bad is your UI is bad. That’s the hard truth. If people can’t understand how to use your application, your application doesn’t work. No matter how incredible the backend is.

-

Try to find a simple, and efficient UI. One that people can understand and use in a matter of seconds. Don’t implement features or visual elements that are not actually needed, just “in case”. And spend time working on that UI, really thinking about what visual elements you are implementing.

-
-
-

3.2 Working as a team

-

Big Shiny Apps usually means that several peoples will work on the application. For example, at ThinkR, 3 to 4 people usually work on the application. So, how do we organize that?

-
-

3.2.1 From the tools point of view:

-
    -
  • Use version control (not sure I have to expand on that topic ;) )
  • -
  • Think of your shiny app as a tree, and divide it as much as possible into little pieces. Then, create one Shiny module by piece. This allows you to split the work, and also to have smaller files — it’s easier to work on 20 files of 200 lines than on one big app.R file.
  • -
-
-
-

3.2.2 From the organisational point of view

-
    -
  • Define one person in charge of having the big picture of the app. This person will kick off the project, and write the skeleton of the app, with the good modules and files structure. This person will also be in charge of accepting new merge requests from other developers, and to orchestrate the master and dev branches.
  • -
  • List the tasks, and open one issue for each task on your version control system. Each issue will be solved in a separate branch.
  • -
  • Finally, assign one module to one developer — if it seems that working on one module is a two-person job, divide again into two other submodules. This is a relatively complex task, as the output of one module influences the input of another, so be sure to assign them well.
  • -
-
-
-

3.2.3 Making the app production ready

-

This includes two things: scaling and maintaining. As said in the disclaimer, I won’t expand on the topic of scaling, as many have written about that, but here is one piece of advice: make the R process running the app do as less as possible, and in particular prevent it from doing what it’s not supposed to do. Which includes: use JavaScript so that the client browser renders things (instead of making R do the work — basic JS is easy to learn), use parallelization and async, and if possible, make the heavy lifting be done outside the R session running the app.

-

Maintainance, on the other, is something to think about from the beginning. It includes being able to ensure that the application will work on the long run, and that new features can be easily implemented.

-
    -
  • Working on the long run: separate the code with “business logic” (aka the data manipulation and the algorithm, that can work outside the context of the app) from the code building the application. That way, you can write regression tests for these functions to ensure they are stable.
  • -
  • Implement new elements: as we are working with modules, it’s easy to insert new elements inside the global application.
  • -
- -
-
-
-
- -
-
-
- - -
-
- - - - - - - - - - - - diff --git a/docs/golem.html b/docs/golem.html deleted file mode 100644 index b6683298..00000000 --- a/docs/golem.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - Chapter 5 Using Golem | Building Big Shiny Apps - A Workflow - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - -
-
- -
-
-

Chapter 5 Using Golem

-

Ok, that’s a lot of things to process. Is there a tool that can help us simplify this workflow? Of course there is, and it’s called {golem}.

-

It can be found at https://github.com/ThinkR-open/golem

-

{golem} is an R package that implements an opinionated framework for building production-ready Shiny apps. It all starts with an RStudio project, which contains a predefined setup for building your app. The idea is that with {golem}, you don’t have to focus on the foundation of your app, and can spend your time thinking about what you want to do, not about how to do it. It’s built on top of the working process we’ve developed at ThinkR, and tries to gather in one place the functions and tools we’ve created for building applications designed for production.

-

When you open a golem project, you’ll start with a dev-history file, which contains a series of functions that will guide you through the whole process of starting, building, and deploying your app. The newly created package also contains an app_ui.R and app_server.R waiting to be filled, and a run_app() function that will launch your application. Any new module can be added with golem::add_module(), a function that creates a new file with the required skeleton for a shiny module. As I said, you don’t need to think about the technical things. -You can also find a series of UI, server, and prod-related tools, functions for creating deployment scripts, and other cool stuffs. Check the README for more information.

- -
-
- -
-
-
- - -
-
- - - - - - - - - - - - diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 6532e32b..00000000 --- a/docs/index.html +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - Building Big Shiny Apps - A Workflow - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - -
-
- -
- -
-

Chapter 1 Motivation

-

The idea behind this book is not to talk about how to deploy and scale, but about the process of building the app. Why? Lots of blog posts and books talk about putting apps in production. Very few talks about working on building these apps. This is why I choose to talk about the process, workflow, and tools we use at ThinkR when building big Shiny Apps.

-

So, to sum up, we’ll not talk about what to do when the app is ready, we’ll talk about how to make it ready.

- -
- - - -
- -
-
-
- - -
-
- - - - - - - - - - - - diff --git a/docs/libs/gitbook-2.6.7/css/fontawesome/fontawesome-webfont.ttf b/docs/libs/gitbook-2.6.7/css/fontawesome/fontawesome-webfont.ttf deleted file mode 100644 index 35acda2f..00000000 Binary files a/docs/libs/gitbook-2.6.7/css/fontawesome/fontawesome-webfont.ttf and /dev/null differ diff --git a/docs/libs/gitbook-2.6.7/css/plugin-bookdown.css b/docs/libs/gitbook-2.6.7/css/plugin-bookdown.css deleted file mode 100644 index 8e5bb8a3..00000000 --- a/docs/libs/gitbook-2.6.7/css/plugin-bookdown.css +++ /dev/null @@ -1,99 +0,0 @@ -.book .book-header h1 { - padding-left: 20px; - padding-right: 20px; -} -.book .book-header.fixed { - position: fixed; - right: 0; - top: 0; - left: 0; - border-bottom: 1px solid rgba(0,0,0,.07); -} -span.search-highlight { - background-color: #ffff88; -} -@media (min-width: 600px) { - .book.with-summary .book-header.fixed { - left: 300px; - } -} -@media (max-width: 1240px) { - .book .book-body.fixed { - top: 50px; - } - .book .book-body.fixed .body-inner { - top: auto; - } -} -@media (max-width: 600px) { - .book.with-summary .book-header.fixed { - left: calc(100% - 60px); - min-width: 300px; - } - .book.with-summary .book-body { - transform: none; - left: calc(100% - 60px); - min-width: 300px; - } - .book .book-body.fixed { - top: 0; - } -} - -.book .book-body.fixed .body-inner { - top: 50px; -} -.book .book-body .page-wrapper .page-inner section.normal sub, .book .book-body .page-wrapper .page-inner section.normal sup { - font-size: 85%; -} - -@media print { - .book .book-summary, .book .book-body .book-header, .fa { - display: none !important; - } - .book .book-body.fixed { - left: 0px; - } - .book .book-body,.book .book-body .body-inner, .book.with-summary { - overflow: visible !important; - } -} -.kable_wrapper { - border-spacing: 20px 0; - border-collapse: separate; - border: none; - margin: auto; -} -.kable_wrapper > tbody > tr > td { - vertical-align: top; -} -.book .book-body .page-wrapper .page-inner section.normal table tr.header { - border-top-width: 2px; -} -.book .book-body .page-wrapper .page-inner section.normal table tr:last-child td { - border-bottom-width: 2px; -} -.book .book-body .page-wrapper .page-inner section.normal table td, .book .book-body .page-wrapper .page-inner section.normal table th { - border-left: none; - border-right: none; -} -.book .book-body .page-wrapper .page-inner section.normal table.kable_wrapper > tbody > tr, .book .book-body .page-wrapper .page-inner section.normal table.kable_wrapper > tbody > tr > td { - border-top: none; -} -.book .book-body .page-wrapper .page-inner section.normal table.kable_wrapper > tbody > tr:last-child > td { - border-bottom: none; -} - -div.theorem, div.lemma, div.corollary, div.proposition, div.conjecture { - font-style: italic; -} -span.theorem, span.lemma, span.corollary, span.proposition, span.conjecture { - font-style: normal; -} -div.proof:after { - content: "\25a2"; - float: right; -} -.header-section-number { - padding-right: .5em; -} diff --git a/docs/libs/gitbook-2.6.7/css/plugin-fontsettings.css b/docs/libs/gitbook-2.6.7/css/plugin-fontsettings.css deleted file mode 100644 index 87236b4c..00000000 --- a/docs/libs/gitbook-2.6.7/css/plugin-fontsettings.css +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Theme 1 - */ -.color-theme-1 .dropdown-menu { - background-color: #111111; - border-color: #7e888b; -} -.color-theme-1 .dropdown-menu .dropdown-caret .caret-inner { - border-bottom: 9px solid #111111; -} -.color-theme-1 .dropdown-menu .buttons { - border-color: #7e888b; -} -.color-theme-1 .dropdown-menu .button { - color: #afa790; -} -.color-theme-1 .dropdown-menu .button:hover { - color: #73553c; -} -/* - * Theme 2 - */ -.color-theme-2 .dropdown-menu { - background-color: #2d3143; - border-color: #272a3a; -} -.color-theme-2 .dropdown-menu .dropdown-caret .caret-inner { - border-bottom: 9px solid #2d3143; -} -.color-theme-2 .dropdown-menu .buttons { - border-color: #272a3a; -} -.color-theme-2 .dropdown-menu .button { - color: #62677f; -} -.color-theme-2 .dropdown-menu .button:hover { - color: #f4f4f5; -} -.book .book-header .font-settings .font-enlarge { - line-height: 30px; - font-size: 1.4em; -} -.book .book-header .font-settings .font-reduce { - line-height: 30px; - font-size: 1em; -} -.book.color-theme-1 .book-body { - color: #704214; - background: #f3eacb; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section { - background: #f3eacb; -} -.book.color-theme-2 .book-body { - color: #bdcadb; - background: #1c1f2b; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section { - background: #1c1f2b; -} -.book.font-size-0 .book-body .page-inner section { - font-size: 1.2rem; -} -.book.font-size-1 .book-body .page-inner section { - font-size: 1.4rem; -} -.book.font-size-2 .book-body .page-inner section { - font-size: 1.6rem; -} -.book.font-size-3 .book-body .page-inner section { - font-size: 2.2rem; -} -.book.font-size-4 .book-body .page-inner section { - font-size: 4rem; -} -.book.font-family-0 { - font-family: Georgia, serif; -} -.book.font-family-1 { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal { - color: #704214; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal a { - color: inherit; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h1, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h2, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h3, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h4, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h5, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h6 { - color: inherit; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h1, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h2 { - border-color: inherit; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal h6 { - color: inherit; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal hr { - background-color: inherit; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal blockquote { - border-color: #c4b29f; - opacity: 0.9; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code { - background: #fdf6e3; - color: #657b83; - border-color: #f8df9c; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal .highlight { - background-color: inherit; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal table th, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal table td { - border-color: #f5d06c; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal table tr { - color: inherit; - background-color: #fdf6e3; - border-color: #444444; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal table tr:nth-child(2n) { - background-color: #fbeecb; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal { - color: #bdcadb; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal a { - color: #3eb1d0; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h1, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h2, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h3, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h4, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h5, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h6 { - color: #fffffa; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h1, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h2 { - border-color: #373b4e; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal h6 { - color: #373b4e; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal hr { - background-color: #373b4e; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal blockquote { - border-color: #373b4e; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code { - color: #9dbed8; - background: #2d3143; - border-color: #2d3143; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal .highlight { - background-color: #282a39; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal table th, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal table td { - border-color: #3b3f54; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal table tr { - color: #b6c2d2; - background-color: #2d3143; - border-color: #3b3f54; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal table tr:nth-child(2n) { - background-color: #35394b; -} -.book.color-theme-1 .book-header { - color: #afa790; - background: transparent; -} -.book.color-theme-1 .book-header .btn { - color: #afa790; -} -.book.color-theme-1 .book-header .btn:hover { - color: #73553c; - background: none; -} -.book.color-theme-1 .book-header h1 { - color: #704214; -} -.book.color-theme-2 .book-header { - color: #7e888b; - background: transparent; -} -.book.color-theme-2 .book-header .btn { - color: #3b3f54; -} -.book.color-theme-2 .book-header .btn:hover { - color: #fffff5; - background: none; -} -.book.color-theme-2 .book-header h1 { - color: #bdcadb; -} -.book.color-theme-1 .book-body .navigation { - color: #afa790; -} -.book.color-theme-1 .book-body .navigation:hover { - color: #73553c; -} -.book.color-theme-2 .book-body .navigation { - color: #383f52; -} -.book.color-theme-2 .book-body .navigation:hover { - color: #fffff5; -} -/* - * Theme 1 - */ -.book.color-theme-1 .book-summary { - color: #afa790; - background: #111111; - border-right: 1px solid rgba(0, 0, 0, 0.07); -} -.book.color-theme-1 .book-summary .book-search { - background: transparent; -} -.book.color-theme-1 .book-summary .book-search input, -.book.color-theme-1 .book-summary .book-search input:focus { - border: 1px solid transparent; -} -.book.color-theme-1 .book-summary ul.summary li.divider { - background: #7e888b; - box-shadow: none; -} -.book.color-theme-1 .book-summary ul.summary li i.fa-check { - color: #33cc33; -} -.book.color-theme-1 .book-summary ul.summary li.done > a { - color: #877f6a; -} -.book.color-theme-1 .book-summary ul.summary li a, -.book.color-theme-1 .book-summary ul.summary li span { - color: #877f6a; - background: transparent; - font-weight: normal; -} -.book.color-theme-1 .book-summary ul.summary li.active > a, -.book.color-theme-1 .book-summary ul.summary li a:hover { - color: #704214; - background: transparent; - font-weight: normal; -} -/* - * Theme 2 - */ -.book.color-theme-2 .book-summary { - color: #bcc1d2; - background: #2d3143; - border-right: none; -} -.book.color-theme-2 .book-summary .book-search { - background: transparent; -} -.book.color-theme-2 .book-summary .book-search input, -.book.color-theme-2 .book-summary .book-search input:focus { - border: 1px solid transparent; -} -.book.color-theme-2 .book-summary ul.summary li.divider { - background: #272a3a; - box-shadow: none; -} -.book.color-theme-2 .book-summary ul.summary li i.fa-check { - color: #33cc33; -} -.book.color-theme-2 .book-summary ul.summary li.done > a { - color: #62687f; -} -.book.color-theme-2 .book-summary ul.summary li a, -.book.color-theme-2 .book-summary ul.summary li span { - color: #c1c6d7; - background: transparent; - font-weight: 600; -} -.book.color-theme-2 .book-summary ul.summary li.active > a, -.book.color-theme-2 .book-summary ul.summary li a:hover { - color: #f4f4f5; - background: #252737; - font-weight: 600; -} diff --git a/docs/libs/gitbook-2.6.7/css/plugin-highlight.css b/docs/libs/gitbook-2.6.7/css/plugin-highlight.css deleted file mode 100644 index 2aabd3de..00000000 --- a/docs/libs/gitbook-2.6.7/css/plugin-highlight.css +++ /dev/null @@ -1,426 +0,0 @@ -.book .book-body .page-wrapper .page-inner section.normal pre, -.book .book-body .page-wrapper .page-inner section.normal code { - /* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ - /* Tomorrow Comment */ - /* Tomorrow Red */ - /* Tomorrow Orange */ - /* Tomorrow Yellow */ - /* Tomorrow Green */ - /* Tomorrow Aqua */ - /* Tomorrow Blue */ - /* Tomorrow Purple */ -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-comment, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-comment, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-title { - color: #8e908c; -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-variable, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-variable, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-attribute, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-attribute, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-tag, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-tag, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-regexp, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-regexp, -.book .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-constant, -.book .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-constant, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-tag .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .xml .hljs-tag .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-pi, -.book .book-body .page-wrapper .page-inner section.normal code .xml .hljs-pi, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-doctype, -.book .book-body .page-wrapper .page-inner section.normal code .xml .hljs-doctype, -.book .book-body .page-wrapper .page-inner section.normal pre .html .hljs-doctype, -.book .book-body .page-wrapper .page-inner section.normal code .html .hljs-doctype, -.book .book-body .page-wrapper .page-inner section.normal pre .css .hljs-id, -.book .book-body .page-wrapper .page-inner section.normal code .css .hljs-id, -.book .book-body .page-wrapper .page-inner section.normal pre .css .hljs-class, -.book .book-body .page-wrapper .page-inner section.normal code .css .hljs-class, -.book .book-body .page-wrapper .page-inner section.normal pre .css .hljs-pseudo, -.book .book-body .page-wrapper .page-inner section.normal code .css .hljs-pseudo { - color: #c82829; -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-number, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-number, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-preprocessor, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-preprocessor, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-pragma, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-pragma, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-built_in, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-built_in, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-literal, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-literal, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-params, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-params, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-constant, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-constant { - color: #f5871f; -} -.book .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-class .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-class .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal pre .css .hljs-rules .hljs-attribute, -.book .book-body .page-wrapper .page-inner section.normal code .css .hljs-rules .hljs-attribute { - color: #eab700; -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-string, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-string, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-value, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-value, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-inheritance, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-inheritance, -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-header, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-header, -.book .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-symbol, -.book .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-symbol, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-cdata, -.book .book-body .page-wrapper .page-inner section.normal code .xml .hljs-cdata { - color: #718c00; -} -.book .book-body .page-wrapper .page-inner section.normal pre .css .hljs-hexcolor, -.book .book-body .page-wrapper .page-inner section.normal code .css .hljs-hexcolor { - color: #3e999f; -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-function, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-function, -.book .book-body .page-wrapper .page-inner section.normal pre .python .hljs-decorator, -.book .book-body .page-wrapper .page-inner section.normal code .python .hljs-decorator, -.book .book-body .page-wrapper .page-inner section.normal pre .python .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .python .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-function .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-function .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-title .hljs-keyword, -.book .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-title .hljs-keyword, -.book .book-body .page-wrapper .page-inner section.normal pre .perl .hljs-sub, -.book .book-body .page-wrapper .page-inner section.normal code .perl .hljs-sub, -.book .book-body .page-wrapper .page-inner section.normal pre .javascript .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .javascript .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal pre .coffeescript .hljs-title, -.book .book-body .page-wrapper .page-inner section.normal code .coffeescript .hljs-title { - color: #4271ae; -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs-keyword, -.book .book-body .page-wrapper .page-inner section.normal code .hljs-keyword, -.book .book-body .page-wrapper .page-inner section.normal pre .javascript .hljs-function, -.book .book-body .page-wrapper .page-inner section.normal code .javascript .hljs-function { - color: #8959a8; -} -.book .book-body .page-wrapper .page-inner section.normal pre .hljs, -.book .book-body .page-wrapper .page-inner section.normal code .hljs { - display: block; - background: white; - color: #4d4d4c; - padding: 0.5em; -} -.book .book-body .page-wrapper .page-inner section.normal pre .coffeescript .javascript, -.book .book-body .page-wrapper .page-inner section.normal code .coffeescript .javascript, -.book .book-body .page-wrapper .page-inner section.normal pre .javascript .xml, -.book .book-body .page-wrapper .page-inner section.normal code .javascript .xml, -.book .book-body .page-wrapper .page-inner section.normal pre .tex .hljs-formula, -.book .book-body .page-wrapper .page-inner section.normal code .tex .hljs-formula, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .javascript, -.book .book-body .page-wrapper .page-inner section.normal code .xml .javascript, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .vbscript, -.book .book-body .page-wrapper .page-inner section.normal code .xml .vbscript, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .css, -.book .book-body .page-wrapper .page-inner section.normal code .xml .css, -.book .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-cdata, -.book .book-body .page-wrapper .page-inner section.normal code .xml .hljs-cdata { - opacity: 0.5; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code { - /* - -Orginal Style from ethanschoonover.com/solarized (c) Jeremy Hull - -*/ - /* Solarized Green */ - /* Solarized Cyan */ - /* Solarized Blue */ - /* Solarized Yellow */ - /* Solarized Orange */ - /* Solarized Red */ - /* Solarized Violet */ -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs { - display: block; - padding: 0.5em; - background: #fdf6e3; - color: #657b83; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-comment, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-comment, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-template_comment, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-template_comment, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .diff .hljs-header, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .diff .hljs-header, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-doctype, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-doctype, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-pi, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-pi, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .lisp .hljs-string, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .lisp .hljs-string, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-javadoc, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-javadoc { - color: #93a1a1; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-keyword, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-keyword, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-winutils, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-winutils, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .method, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .method, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-addition, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-addition, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-tag, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .css .hljs-tag, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-request, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-request, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-status, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-status, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .nginx .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .nginx .hljs-title { - color: #859900; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-number, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-number, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-command, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-command, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-string, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-string, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-tag .hljs-value, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-tag .hljs-value, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-rules .hljs-value, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-rules .hljs-value, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-phpdoc, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-phpdoc, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .tex .hljs-formula, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .tex .hljs-formula, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-regexp, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-regexp, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-hexcolor, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-hexcolor, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-link_url, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-link_url { - color: #2aa198; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-localvars, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-localvars, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-chunk, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-chunk, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-decorator, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-decorator, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-built_in, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-built_in, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-identifier, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-identifier, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .vhdl .hljs-literal, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .vhdl .hljs-literal, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-id, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-id, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-function, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .css .hljs-function { - color: #268bd2; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-attribute, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-attribute, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-variable, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-variable, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .lisp .hljs-body, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .lisp .hljs-body, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .smalltalk .hljs-number, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .smalltalk .hljs-number, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-constant, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-constant, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-class .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-class .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-parent, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-parent, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .haskell .hljs-type, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .haskell .hljs-type, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-link_reference, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-link_reference { - color: #b58900; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-preprocessor, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-preprocessor, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-preprocessor .hljs-keyword, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-preprocessor .hljs-keyword, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-pragma, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-pragma, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-shebang, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-shebang, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-symbol, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-symbol, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-symbol .hljs-string, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-symbol .hljs-string, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .diff .hljs-change, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .diff .hljs-change, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-special, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-special, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-attr_selector, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-attr_selector, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-subst, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-subst, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-cdata, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-cdata, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .clojure .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .clojure .hljs-title, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-pseudo, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .css .hljs-pseudo, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-header, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-header { - color: #cb4b16; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-deletion, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-deletion, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-important, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-important { - color: #dc322f; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .hljs-link_label, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .hljs-link_label { - color: #6c71c4; -} -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal pre .tex .hljs-formula, -.book.color-theme-1 .book-body .page-wrapper .page-inner section.normal code .tex .hljs-formula { - background: #eee8d5; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code { - /* Tomorrow Night Bright Theme */ - /* Original theme - https://github.com/chriskempson/tomorrow-theme */ - /* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ - /* Tomorrow Comment */ - /* Tomorrow Red */ - /* Tomorrow Orange */ - /* Tomorrow Yellow */ - /* Tomorrow Green */ - /* Tomorrow Aqua */ - /* Tomorrow Blue */ - /* Tomorrow Purple */ -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-comment, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-comment, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-title { - color: #969896; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-variable, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-variable, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-attribute, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-attribute, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-tag, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-tag, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-regexp, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-regexp, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-constant, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-constant, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-tag .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .hljs-tag .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-pi, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .hljs-pi, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-doctype, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .hljs-doctype, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .html .hljs-doctype, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .html .hljs-doctype, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-id, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .css .hljs-id, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-class, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .css .hljs-class, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-pseudo, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .css .hljs-pseudo { - color: #d54e53; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-number, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-number, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-preprocessor, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-preprocessor, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-pragma, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-pragma, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-built_in, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-built_in, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-literal, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-literal, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-params, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-params, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-constant, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-constant { - color: #e78c45; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-class .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-class .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-rules .hljs-attribute, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .css .hljs-rules .hljs-attribute { - color: #e7c547; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-string, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-string, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-value, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-value, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-inheritance, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-inheritance, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-header, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-header, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-symbol, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-symbol, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-cdata, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .hljs-cdata { - color: #b9ca4a; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .css .hljs-hexcolor, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .css .hljs-hexcolor { - color: #70c0b1; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-function, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-function, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .python .hljs-decorator, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .python .hljs-decorator, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .python .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .python .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-function .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-function .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .ruby .hljs-title .hljs-keyword, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .ruby .hljs-title .hljs-keyword, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .perl .hljs-sub, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .perl .hljs-sub, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .javascript .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .javascript .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .coffeescript .hljs-title, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .coffeescript .hljs-title { - color: #7aa6da; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs-keyword, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs-keyword, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .javascript .hljs-function, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .javascript .hljs-function { - color: #c397d8; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .hljs, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .hljs { - display: block; - background: black; - color: #eaeaea; - padding: 0.5em; -} -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .coffeescript .javascript, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .coffeescript .javascript, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .javascript .xml, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .javascript .xml, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .tex .hljs-formula, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .tex .hljs-formula, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .javascript, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .javascript, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .vbscript, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .vbscript, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .css, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .css, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal pre .xml .hljs-cdata, -.book.color-theme-2 .book-body .page-wrapper .page-inner section.normal code .xml .hljs-cdata { - opacity: 0.5; -} diff --git a/docs/libs/gitbook-2.6.7/css/plugin-search.css b/docs/libs/gitbook-2.6.7/css/plugin-search.css deleted file mode 100644 index d7ff2d99..00000000 --- a/docs/libs/gitbook-2.6.7/css/plugin-search.css +++ /dev/null @@ -1,28 +0,0 @@ -.book .book-summary .book-search { - padding: 6px; - background: transparent; - position: absolute; - top: -50px; - left: 0px; - right: 0px; - transition: top 0.5s ease; -} -.book .book-summary .book-search input, -.book .book-summary .book-search input:focus, -.book .book-summary .book-search input:hover { - width: 100%; - background: transparent; - border: 1px solid #ccc; - box-shadow: none; - outline: none; - line-height: 22px; - padding: 7px 4px; - color: inherit; - box-sizing: border-box; -} -.book.with-search .book-summary .book-search { - top: 0px; -} -.book.with-search .book-summary ul.summary { - top: 50px; -} diff --git a/docs/libs/gitbook-2.6.7/css/plugin-table.css b/docs/libs/gitbook-2.6.7/css/plugin-table.css deleted file mode 100644 index 7fba1b9f..00000000 --- a/docs/libs/gitbook-2.6.7/css/plugin-table.css +++ /dev/null @@ -1 +0,0 @@ -.book .book-body .page-wrapper .page-inner section.normal table{display:table;width:100%;border-collapse:collapse;border-spacing:0;overflow:auto}.book .book-body .page-wrapper .page-inner section.normal table td,.book .book-body .page-wrapper .page-inner section.normal table th{padding:6px 13px;border:1px solid #ddd}.book .book-body .page-wrapper .page-inner section.normal table tr{background-color:#fff;border-top:1px solid #ccc}.book .book-body .page-wrapper .page-inner section.normal table tr:nth-child(2n){background-color:#f8f8f8}.book .book-body .page-wrapper .page-inner section.normal table th{font-weight:700} diff --git a/docs/libs/gitbook-2.6.7/css/style.css b/docs/libs/gitbook-2.6.7/css/style.css deleted file mode 100644 index b8968920..00000000 --- a/docs/libs/gitbook-2.6.7/css/style.css +++ /dev/null @@ -1,10 +0,0 @@ -/*! normalize.css v2.1.0 | MIT License | git.io/normalize */img,legend{border:0}*,.fa{-webkit-font-smoothing:antialiased}.fa-ul>li,sub,sup{position:relative}.book .book-body .page-wrapper .page-inner section.normal hr:after,.book-langs-index .inner .languages:after,.buttons:after,.dropdown-menu .buttons:after{clear:both}body,html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block}.hidden,[hidden]{display:none}audio:not([controls]){display:none;height:0}html{font-family:sans-serif}body,figure{margin:0}a:focus{outline:dotted thin}a:active,a:hover{outline:0}h1{font-size:2em;margin:.67em 0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}mark{background:#ff0;color:#000}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}small{font-size:80%}sub,sup{font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}svg:not(:root){overflow:hidden}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{padding:0}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0}button,input{line-height:normal}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button{margin-right:10px;}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0}/*! - * Preboot v2 - * - * Open sourced under MIT license by @mdo. - * Some variables and mixins from Bootstrap (Apache 2 license). - */.link-inherit,.link-inherit:focus,.link-inherit:hover{color:inherit}.fa,.fa-stack{display:inline-block}/*! - * Font Awesome 4.1.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */@font-face{font-family:FontAwesome;src:url(./fontawesome/fontawesome-webfont.ttf?v=4.1.0) format('truetype');font-weight:400;font-style:normal}.fa{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1;-moz-osx-font-smoothing:grayscale}.book .book-header,.book .book-summary{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:spin 2s infinite linear;-moz-animation:spin 2s infinite linear;-o-animation:spin 2s infinite linear;animation:spin 2s infinite linear}@-moz-keyframes spin{0%{-moz-transform:rotate(0)}100%{-moz-transform:rotate(359deg)}}@-webkit-keyframes spin{0%{-webkit-transform:rotate(0)}100%{-webkit-transform:rotate(359deg)}}@-o-keyframes spin{0%{-o-transform:rotate(0)}100%{-o-transform:rotate(359deg)}}@keyframes spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1,1);-moz-transform:scale(-1,1);-ms-transform:scale(-1,1);-o-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1,-1);-moz-transform:scale(1,-1);-ms-transform:scale(1,-1);-o-transform:scale(1,-1);transform:scale(1,-1)}.fa-stack{position:relative;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-repeat:before,.fa-rotate-right:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-exclamation-triangle:before,.fa-warning:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-floppy-o:before,.fa-save:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-bolt:before,.fa-flash:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-chain-broken:before,.fa-unlink:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:"\f150"}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:"\f151"}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:"\f152"}.fa-eur:before,.fa-euro:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-inr:before,.fa-rupee:before{content:"\f156"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:"\f158"}.fa-krw:before,.fa-won:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-try:before,.fa-turkish-lira:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-bank:before,.fa-institution:before,.fa-university:before{content:"\f19c"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-square:before,.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:"\f1c5"}.fa-file-archive-o:before,.fa-file-zip-o:before{content:"\f1c6"}.fa-file-audio-o:before,.fa-file-sound-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-empire:before,.fa-ge:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-paper-plane:before,.fa-send:before{content:"\f1d8"}.fa-paper-plane-o:before,.fa-send-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.book-langs-index{width:100%;height:100%;padding:40px 0;margin:0;overflow:auto}@media (max-width:600px){.book-langs-index{padding:0}}.book-langs-index .inner{max-width:600px;width:100%;margin:0 auto;padding:30px;background:#fff;border-radius:3px}.book-langs-index .inner h3{margin:0}.book-langs-index .inner .languages{list-style:none;padding:20px 30px;margin-top:20px;border-top:1px solid #eee}.book-langs-index .inner .languages:after,.book-langs-index .inner .languages:before{content:" ";display:table;line-height:0}.book-langs-index .inner .languages li{width:50%;float:left;padding:10px 5px;font-size:16px}@media (max-width:600px){.book-langs-index .inner .languages li{width:100%;max-width:100%}}.book .book-header{overflow:visible;height:50px;padding:0 8px;z-index:2;font-size:.85em;color:#7e888b;background:0 0}.book .book-header .btn{display:block;height:50px;padding:0 15px;border-bottom:none;color:#ccc;text-transform:uppercase;line-height:50px;-webkit-box-shadow:none!important;box-shadow:none!important;position:relative;font-size:14px}.book .book-header .btn:hover{position:relative;text-decoration:none;color:#444;background:0 0}.book .book-header h1{margin:0;font-size:20px;font-weight:200;text-align:center;line-height:50px;opacity:0;padding-left:200px;padding-right:200px;-webkit-transition:opacity .2s ease;-moz-transition:opacity .2s ease;-o-transition:opacity .2s ease;transition:opacity .2s ease;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.book .book-header h1 a,.book .book-header h1 a:hover{color:inherit;text-decoration:none}@media (max-width:1000px){.book .book-header h1{display:none}}.book .book-header h1 i{display:none}.book .book-header:hover h1{opacity:1}.book.is-loading .book-header h1 i{display:inline-block}.book.is-loading .book-header h1 a{display:none}.dropdown{position:relative}.dropdown-menu{position:absolute;top:100%;left:0;z-index:100;display:none;float:left;min-width:160px;padding:0;margin:2px 0 0;list-style:none;font-size:14px;background-color:#fafafa;border:1px solid rgba(0,0,0,.07);border-radius:1px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175);background-clip:padding-box}.dropdown-menu.open{display:block}.dropdown-menu.dropdown-left{left:auto;right:4%}.dropdown-menu.dropdown-left .dropdown-caret{right:14px;left:auto}.dropdown-menu .dropdown-caret{position:absolute;top:-8px;left:14px;width:18px;height:10px;float:left;overflow:hidden}.dropdown-menu .dropdown-caret .caret-inner,.dropdown-menu .dropdown-caret .caret-outer{display:inline-block;top:0;border-left:9px solid transparent;border-right:9px solid transparent;position:absolute}.dropdown-menu .dropdown-caret .caret-outer{border-bottom:9px solid rgba(0,0,0,.1);height:auto;left:0;width:auto;margin-left:-1px}.dropdown-menu .dropdown-caret .caret-inner{margin-top:-1px;top:1px;border-bottom:9px solid #fafafa}.dropdown-menu .buttons{border-bottom:1px solid rgba(0,0,0,.07)}.dropdown-menu .buttons:after,.dropdown-menu .buttons:before{content:" ";display:table;line-height:0}.dropdown-menu .buttons:last-child{border-bottom:none}.dropdown-menu .buttons .button{border:0;background-color:transparent;color:#a6a6a6;width:100%;text-align:center;float:left;line-height:1.42857143;padding:8px 4px}.alert,.dropdown-menu .buttons .button:hover{color:#444}.dropdown-menu .buttons .button:focus,.dropdown-menu .buttons .button:hover{outline:0}.dropdown-menu .buttons .button.size-2{width:50%}.dropdown-menu .buttons .button.size-3{width:33%}.alert{padding:15px;margin-bottom:20px;background:#eee;border-bottom:5px solid #ddd}.alert-success{background:#dff0d8;border-color:#d6e9c6;color:#3c763d}.alert-info{background:#d9edf7;border-color:#bce8f1;color:#31708f}.alert-danger{background:#f2dede;border-color:#ebccd1;color:#a94442}.alert-warning{background:#fcf8e3;border-color:#faebcc;color:#8a6d3b}.book .book-summary{position:absolute;top:0;left:-300px;bottom:0;z-index:1;width:300px;color:#364149;background:#fafafa;border-right:1px solid rgba(0,0,0,.07);-webkit-transition:left 250ms ease;-moz-transition:left 250ms ease;-o-transition:left 250ms ease;transition:left 250ms ease}.book .book-summary ul.summary{position:absolute;top:0;left:0;right:0;bottom:0;overflow-y:auto;list-style:none;margin:0;padding:0;-webkit-transition:top .5s ease;-moz-transition:top .5s ease;-o-transition:top .5s ease;transition:top .5s ease}.book .book-summary ul.summary li{list-style:none}.book .book-summary ul.summary li.divider{height:1px;margin:7px 0;overflow:hidden;background:rgba(0,0,0,.07)}.book .book-summary ul.summary li i.fa-check{display:none;position:absolute;right:9px;top:16px;font-size:9px;color:#3c3}.book .book-summary ul.summary li.done>a{color:#364149;font-weight:400}.book .book-summary ul.summary li.done>a i{display:inline}.book .book-summary ul.summary li a,.book .book-summary ul.summary li span{display:block;padding:10px 15px;border-bottom:none;color:#364149;background:0 0;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;position:relative}.book .book-summary ul.summary li span{cursor:not-allowed;opacity:.3;filter:alpha(opacity=30)}.book .book-summary ul.summary li a:hover,.book .book-summary ul.summary li.active>a{color:#008cff;background:0 0;text-decoration:none}.book .book-summary ul.summary li ul{padding-left:20px}@media (max-width:600px){.book .book-summary{width:calc(100% - 60px);bottom:0;left:-100%}}.book.with-summary .book-summary{left:0}.book.without-animation .book-summary{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;transition:none!important}.book{position:relative;width:100%;height:100%}.book .book-body,.book .book-body .body-inner{position:absolute;top:0;left:0;overflow-y:auto;bottom:0;right:0}.book .book-body{color:#000;background:#fff;-webkit-transition:left 250ms ease;-moz-transition:left 250ms ease;-o-transition:left 250ms ease;transition:left 250ms ease}.book .book-body .page-wrapper{position:relative;outline:0}.book .book-body .page-wrapper .page-inner{max-width:800px;margin:0 auto;padding:20px 0 40px}.book .book-body .page-wrapper .page-inner section{margin:0;padding:5px 15px;background:#fff;border-radius:2px;line-height:1.7;font-size:1.6rem}.book .book-body .page-wrapper .page-inner .btn-group .btn{border-radius:0;background:#eee;border:0}@media (max-width:1240px){.book .book-body{-webkit-transition:-webkit-transform 250ms ease;-moz-transition:-moz-transform 250ms ease;-o-transition:-o-transform 250ms ease;transition:transform 250ms ease;padding-bottom:20px}.book .book-body .body-inner{position:static;min-height:calc(100% - 50px)}}@media (min-width:600px){.book.with-summary .book-body{left:300px}}@media (max-width:600px){.book.with-summary{overflow:hidden}.book.with-summary .book-body{-webkit-transform:translate(calc(100% - 60px),0);-moz-transform:translate(calc(100% - 60px),0);-ms-transform:translate(calc(100% - 60px),0);-o-transform:translate(calc(100% - 60px),0);transform:translate(calc(100% - 60px),0)}}.book.without-animation .book-body{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;transition:none!important}.buttons:after,.buttons:before{content:" ";display:table;line-height:0}.button{border:0;background:#eee;color:#666;width:100%;text-align:center;float:left;line-height:1.42857143;padding:8px 4px}.button:hover{color:#444}.button:focus,.button:hover{outline:0}.button.size-2{width:50%}.button.size-3{width:33%}.book .book-body .page-wrapper .page-inner section{display:none}.book .book-body .page-wrapper .page-inner section.normal{display:block;word-wrap:break-word;overflow:hidden;color:#333;line-height:1.7;text-size-adjust:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%}.book .book-body .page-wrapper .page-inner section.normal *{box-sizing:border-box;-webkit-box-sizing:border-box;}.book .book-body .page-wrapper .page-inner section.normal>:first-child{margin-top:0!important}.book .book-body .page-wrapper .page-inner section.normal>:last-child{margin-bottom:0!important}.book .book-body .page-wrapper .page-inner section.normal blockquote,.book .book-body .page-wrapper .page-inner section.normal code,.book .book-body .page-wrapper .page-inner section.normal figure,.book .book-body .page-wrapper .page-inner section.normal img,.book .book-body .page-wrapper .page-inner section.normal pre,.book .book-body .page-wrapper .page-inner section.normal table,.book .book-body .page-wrapper .page-inner section.normal tr{page-break-inside:avoid}.book .book-body .page-wrapper .page-inner section.normal h2,.book .book-body .page-wrapper .page-inner section.normal h3,.book .book-body .page-wrapper .page-inner section.normal h4,.book .book-body .page-wrapper .page-inner section.normal h5,.book .book-body .page-wrapper .page-inner section.normal p{orphans:3;widows:3}.book .book-body .page-wrapper .page-inner section.normal h1,.book .book-body .page-wrapper .page-inner section.normal h2,.book .book-body .page-wrapper .page-inner section.normal h3,.book .book-body .page-wrapper .page-inner section.normal h4,.book .book-body .page-wrapper .page-inner section.normal h5{page-break-after:avoid}.book .book-body .page-wrapper .page-inner section.normal b,.book .book-body .page-wrapper .page-inner section.normal strong{font-weight:700}.book .book-body .page-wrapper .page-inner section.normal em{font-style:italic}.book .book-body .page-wrapper .page-inner section.normal blockquote,.book .book-body .page-wrapper .page-inner section.normal dl,.book .book-body .page-wrapper .page-inner section.normal ol,.book .book-body .page-wrapper .page-inner section.normal p,.book .book-body .page-wrapper .page-inner section.normal table,.book .book-body .page-wrapper .page-inner section.normal ul{margin-top:0;margin-bottom:.85em}.book .book-body .page-wrapper .page-inner section.normal a{color:#4183c4;text-decoration:none;background:0 0}.book .book-body .page-wrapper .page-inner section.normal a:active,.book .book-body .page-wrapper .page-inner section.normal a:focus,.book .book-body .page-wrapper .page-inner section.normal a:hover{outline:0;text-decoration:underline}.book .book-body .page-wrapper .page-inner section.normal img{border:0;max-width:100%}.book .book-body .page-wrapper .page-inner section.normal hr{height:4px;padding:0;margin:1.7em 0;overflow:hidden;background-color:#e7e7e7;border:none}.book .book-body .page-wrapper .page-inner section.normal hr:after,.book .book-body .page-wrapper .page-inner section.normal hr:before{display:table;content:" "}.book .book-body .page-wrapper .page-inner section.normal h1,.book .book-body .page-wrapper .page-inner section.normal h2,.book .book-body .page-wrapper .page-inner section.normal h3,.book .book-body .page-wrapper .page-inner section.normal h4,.book .book-body .page-wrapper .page-inner section.normal h5,.book .book-body .page-wrapper .page-inner section.normal h6{margin-top:1.275em;margin-bottom:.85em;}.book .book-body .page-wrapper .page-inner section.normal h1{font-size:2em}.book .book-body .page-wrapper .page-inner section.normal h2{font-size:1.75em}.book .book-body .page-wrapper .page-inner section.normal h3{font-size:1.5em}.book .book-body .page-wrapper .page-inner section.normal h4{font-size:1.25em}.book .book-body .page-wrapper .page-inner section.normal h5{font-size:1em}.book .book-body .page-wrapper .page-inner section.normal h6{font-size:1em;color:#777}.book .book-body .page-wrapper .page-inner section.normal code,.book .book-body .page-wrapper .page-inner section.normal pre{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;direction:ltr;border:none;color:inherit}.book .book-body .page-wrapper .page-inner section.normal pre{overflow:auto;word-wrap:normal;margin:0 0 1.275em;padding:.85em 1em;background:#f7f7f7}.book .book-body .page-wrapper .page-inner section.normal pre>code{display:inline;max-width:initial;padding:0;margin:0;overflow:initial;line-height:inherit;font-size:.85em;white-space:pre;background:0 0}.book .book-body .page-wrapper .page-inner section.normal pre>code:after,.book .book-body .page-wrapper .page-inner section.normal pre>code:before{content:normal}.book .book-body .page-wrapper .page-inner section.normal code{padding:.2em;margin:0;font-size:.85em;background-color:#f7f7f7}.book .book-body .page-wrapper .page-inner section.normal code:after,.book .book-body .page-wrapper .page-inner section.normal code:before{letter-spacing:-.2em;content:"\00a0"}.book .book-body .page-wrapper .page-inner section.normal ol,.book .book-body .page-wrapper .page-inner section.normal ul{padding:0 0 0 2em;margin:0 0 .85em}.book .book-body .page-wrapper .page-inner section.normal ol ol,.book .book-body .page-wrapper .page-inner section.normal ol ul,.book .book-body .page-wrapper .page-inner section.normal ul ol,.book .book-body .page-wrapper .page-inner section.normal ul ul{margin-top:0;margin-bottom:0}.book .book-body .page-wrapper .page-inner section.normal ol ol{list-style-type:lower-roman}.book .book-body .page-wrapper .page-inner section.normal blockquote{margin:0 0 .85em;padding:0 15px;opacity:0.75;border-left:4px solid #dcdcdc}.book .book-body .page-wrapper .page-inner section.normal blockquote:first-child{margin-top:0}.book .book-body .page-wrapper .page-inner section.normal blockquote:last-child{margin-bottom:0}.book .book-body .page-wrapper .page-inner section.normal dl{padding:0}.book .book-body .page-wrapper .page-inner section.normal dl dt{padding:0;margin-top:.85em;font-style:italic;font-weight:700}.book .book-body .page-wrapper .page-inner section.normal dl dd{padding:0 .85em;margin-bottom:.85em}.book .book-body .page-wrapper .page-inner section.normal dd{margin-left:0}.book .book-body .page-wrapper .page-inner section.normal .glossary-term{cursor:help;text-decoration:underline}.book .book-body .navigation{position:absolute;top:50px;bottom:0;margin:0;max-width:150px;min-width:90px;display:flex;justify-content:center;align-content:center;flex-direction:column;font-size:40px;color:#ccc;text-align:center;-webkit-transition:all 350ms ease;-moz-transition:all 350ms ease;-o-transition:all 350ms ease;transition:all 350ms ease}.book .book-body .navigation:hover{text-decoration:none;color:#444}.book .book-body .navigation.navigation-next{right:0}.book .book-body .navigation.navigation-prev{left:0}@media (max-width:1240px){.book .book-body .navigation{position:static;top:auto;max-width:50%;width:50%;display:inline-block;float:left}.book .book-body .navigation.navigation-unique{max-width:100%;width:100%}}.book .book-body .page-wrapper .page-inner section.glossary{margin-bottom:40px}.book .book-body .page-wrapper .page-inner section.glossary h2 a,.book .book-body .page-wrapper .page-inner section.glossary h2 a:hover{color:inherit;text-decoration:none}.book .book-body .page-wrapper .page-inner section.glossary .glossary-index{list-style:none;margin:0;padding:0}.book .book-body .page-wrapper .page-inner section.glossary .glossary-index li{display:inline;margin:0 8px;white-space:nowrap}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-overflow-scrolling:touch;-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:none;-webkit-touch-callout:none}a{text-decoration:none}body,html{height:100%}html{font-size:62.5%}body{text-rendering:optimizeLegibility;font-smoothing:antialiased;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;letter-spacing:.2px;text-size-adjust:100%} -.book .book-summary ul.summary li a span {display:inline;padding:initial;overflow:visible;cursor:auto;opacity:1;} diff --git a/docs/libs/gitbook-2.6.7/js/app.min.js b/docs/libs/gitbook-2.6.7/js/app.min.js deleted file mode 100644 index 9ace197e..00000000 --- a/docs/libs/gitbook-2.6.7/js/app.min.js +++ /dev/null @@ -1,6 +0,0 @@ -(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o"'`]/g,reHasEscapedHtml=RegExp(reEscapedHtml.source),reHasUnescapedHtml=RegExp(reUnescapedHtml.source);var reEscape=/<%-([\s\S]+?)%>/g,reEvaluate=/<%([\s\S]+?)%>/g,reInterpolate=/<%=([\s\S]+?)%>/g;var reIsDeepProp=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\n\\]|\\.)*?\1)\]/,reIsPlainProp=/^\w*$/,rePropName=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\n\\]|\\.)*?)\2)\]/g;var reRegExpChars=/^[:!,]|[\\^$.*+?()[\]{}|\/]|(^[0-9a-fA-Fnrtuvx])|([\n\r\u2028\u2029])/g,reHasRegExpChars=RegExp(reRegExpChars.source);var reComboMark=/[\u0300-\u036f\ufe20-\ufe23]/g;var reEscapeChar=/\\(\\)?/g;var reEsTemplate=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g;var reFlags=/\w*$/;var reHasHexPrefix=/^0[xX]/;var reIsHostCtor=/^\[object .+?Constructor\]$/;var reIsUint=/^\d+$/;var reLatin1=/[\xc0-\xd6\xd8-\xde\xdf-\xf6\xf8-\xff]/g;var reNoMatch=/($^)/;var reUnescapedString=/['\n\r\u2028\u2029\\]/g;var reWords=function(){var upper="[A-Z\\xc0-\\xd6\\xd8-\\xde]",lower="[a-z\\xdf-\\xf6\\xf8-\\xff]+";return RegExp(upper+"+(?="+upper+lower+")|"+upper+"?"+lower+"|"+upper+"+|[0-9]+","g")}();var contextProps=["Array","ArrayBuffer","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Math","Number","Object","RegExp","Set","String","_","clearTimeout","isFinite","parseFloat","parseInt","setTimeout","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap"];var templateCounter=-1;var typedArrayTags={};typedArrayTags[float32Tag]=typedArrayTags[float64Tag]=typedArrayTags[int8Tag]=typedArrayTags[int16Tag]=typedArrayTags[int32Tag]=typedArrayTags[uint8Tag]=typedArrayTags[uint8ClampedTag]=typedArrayTags[uint16Tag]=typedArrayTags[uint32Tag]=true;typedArrayTags[argsTag]=typedArrayTags[arrayTag]=typedArrayTags[arrayBufferTag]=typedArrayTags[boolTag]=typedArrayTags[dateTag]=typedArrayTags[errorTag]=typedArrayTags[funcTag]=typedArrayTags[mapTag]=typedArrayTags[numberTag]=typedArrayTags[objectTag]=typedArrayTags[regexpTag]=typedArrayTags[setTag]=typedArrayTags[stringTag]=typedArrayTags[weakMapTag]=false;var cloneableTags={};cloneableTags[argsTag]=cloneableTags[arrayTag]=cloneableTags[arrayBufferTag]=cloneableTags[boolTag]=cloneableTags[dateTag]=cloneableTags[float32Tag]=cloneableTags[float64Tag]=cloneableTags[int8Tag]=cloneableTags[int16Tag]=cloneableTags[int32Tag]=cloneableTags[numberTag]=cloneableTags[objectTag]=cloneableTags[regexpTag]=cloneableTags[stringTag]=cloneableTags[uint8Tag]=cloneableTags[uint8ClampedTag]=cloneableTags[uint16Tag]=cloneableTags[uint32Tag]=true;cloneableTags[errorTag]=cloneableTags[funcTag]=cloneableTags[mapTag]=cloneableTags[setTag]=cloneableTags[weakMapTag]=false;var deburredLetters={"À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","Ç":"C","ç":"c","Ð":"D","ð":"d","È":"E","É":"E","Ê":"E","Ë":"E","è":"e","é":"e","ê":"e","ë":"e","Ì":"I","Í":"I","Î":"I","Ï":"I","ì":"i","í":"i","î":"i","ï":"i","Ñ":"N","ñ":"n","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","Ù":"U","Ú":"U","Û":"U","Ü":"U","ù":"u","ú":"u","û":"u","ü":"u","Ý":"Y","ý":"y","ÿ":"y","Æ":"Ae","æ":"ae","Þ":"Th","þ":"th","ß":"ss"};var htmlEscapes={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"};var htmlUnescapes={"&":"&","<":"<",">":">",""":'"',"'":"'","`":"`"};var objectTypes={"function":true,object:true};var regexpEscapes={0:"x30",1:"x31",2:"x32",3:"x33",4:"x34",5:"x35",6:"x36",7:"x37",8:"x38",9:"x39",A:"x41",B:"x42",C:"x43",D:"x44",E:"x45",F:"x46",a:"x61",b:"x62",c:"x63",d:"x64",e:"x65",f:"x66",n:"x6e",r:"x72",t:"x74",u:"x75",v:"x76",x:"x78"};var stringEscapes={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"};var freeExports=objectTypes[typeof exports]&&exports&&!exports.nodeType&&exports;var freeModule=objectTypes[typeof module]&&module&&!module.nodeType&&module;var freeGlobal=freeExports&&freeModule&&typeof global=="object"&&global&&global.Object&&global;var freeSelf=objectTypes[typeof self]&&self&&self.Object&&self;var freeWindow=objectTypes[typeof window]&&window&&window.Object&&window;var moduleExports=freeModule&&freeModule.exports===freeExports&&freeExports;var root=freeGlobal||freeWindow!==(this&&this.window)&&freeWindow||freeSelf||this;function baseCompareAscending(value,other){if(value!==other){var valIsNull=value===null,valIsUndef=value===undefined,valIsReflexive=value===value;var othIsNull=other===null,othIsUndef=other===undefined,othIsReflexive=other===other;if(value>other&&!othIsNull||!valIsReflexive||valIsNull&&!othIsUndef&&othIsReflexive||valIsUndef&&othIsReflexive){return 1}if(value-1){}return index}function charsRightIndex(string,chars){var index=string.length;while(index--&&chars.indexOf(string.charAt(index))>-1){}return index}function compareAscending(object,other){return baseCompareAscending(object.criteria,other.criteria)||object.index-other.index}function compareMultiple(object,other,orders){var index=-1,objCriteria=object.criteria,othCriteria=other.criteria,length=objCriteria.length,ordersLength=orders.length;while(++index=ordersLength){return result}var order=orders[index];return result*(order==="asc"||order===true?1:-1)}}return object.index-other.index}function deburrLetter(letter){return deburredLetters[letter]}function escapeHtmlChar(chr){return htmlEscapes[chr]}function escapeRegExpChar(chr,leadingChar,whitespaceChar){if(leadingChar){chr=regexpEscapes[chr]}else if(whitespaceChar){chr=stringEscapes[chr]}return"\\"+chr}function escapeStringChar(chr){return"\\"+stringEscapes[chr]}function indexOfNaN(array,fromIndex,fromRight){var length=array.length,index=fromIndex+(fromRight?0:-1);while(fromRight?index--:++index=9&&charCode<=13)||charCode==32||charCode==160||charCode==5760||charCode==6158||charCode>=8192&&(charCode<=8202||charCode==8232||charCode==8233||charCode==8239||charCode==8287||charCode==12288||charCode==65279)}function replaceHolders(array,placeholder){var index=-1,length=array.length,resIndex=-1,result=[];while(++index>>1;var MAX_SAFE_INTEGER=9007199254740991;var metaMap=WeakMap&&new WeakMap;var realNames={};function lodash(value){if(isObjectLike(value)&&!isArray(value)&&!(value instanceof LazyWrapper)){if(value instanceof LodashWrapper){return value}if(hasOwnProperty.call(value,"__chain__")&&hasOwnProperty.call(value,"__wrapped__")){return wrapperClone(value)}}return new LodashWrapper(value)}function baseLodash(){}function LodashWrapper(value,chainAll,actions){this.__wrapped__=value;this.__actions__=actions||[];this.__chain__=!!chainAll}var support=lodash.support={};lodash.templateSettings={escape:reEscape,evaluate:reEvaluate,interpolate:reInterpolate,variable:"",imports:{_:lodash}};function LazyWrapper(value){this.__wrapped__=value;this.__actions__=[];this.__dir__=1;this.__filtered__=false;this.__iteratees__=[];this.__takeCount__=POSITIVE_INFINITY;this.__views__=[]}function lazyClone(){var result=new LazyWrapper(this.__wrapped__);result.__actions__=arrayCopy(this.__actions__);result.__dir__=this.__dir__;result.__filtered__=this.__filtered__;result.__iteratees__=arrayCopy(this.__iteratees__);result.__takeCount__=this.__takeCount__;result.__views__=arrayCopy(this.__views__);return result}function lazyReverse(){if(this.__filtered__){var result=new LazyWrapper(this);result.__dir__=-1;result.__filtered__=true}else{result=this.clone();result.__dir__*=-1}return result}function lazyValue(){var array=this.__wrapped__.value(),dir=this.__dir__,isArr=isArray(array),isRight=dir<0,arrLength=isArr?array.length:0,view=getView(0,arrLength,this.__views__),start=view.start,end=view.end,length=end-start,index=isRight?end:start-1,iteratees=this.__iteratees__,iterLength=iteratees.length,resIndex=0,takeCount=nativeMin(length,this.__takeCount__);if(!isArr||arrLength=LARGE_ARRAY_SIZE?createCache(values):null,valuesLength=values.length;if(cache){indexOf=cacheIndexOf;isCommon=false;values=cache}outer:while(++indexlength?0:length+start}end=end===undefined||end>length?length:+end||0;if(end<0){end+=length}length=start>end?0:end>>>0;start>>>=0;while(startlength?0:length+start}end=end===undefined||end>length?length:+end||0;if(end<0){end+=length}length=start>end?0:end-start>>>0;start>>>=0;var result=Array(length);while(++index=LARGE_ARRAY_SIZE,seen=isLarge?createCache():null,result=[];if(seen){indexOf=cacheIndexOf;isCommon=false}else{isLarge=false;seen=iteratee?[]:result}outer:while(++index>>1,computed=array[mid];if((retHighest?computed<=value:computed2?sources[length-2]:undefined,guard=length>2?sources[2]:undefined,thisArg=length>1?sources[length-1]:undefined;if(typeof customizer=="function"){customizer=bindCallback(customizer,thisArg,5);length-=2}else{customizer=typeof thisArg=="function"?thisArg:undefined;length-=customizer?1:0}if(guard&&isIterateeCall(sources[0],sources[1],guard)){customizer=length<3?undefined:customizer;length=1}while(++index-1?collection[index]:undefined}return baseFind(collection,predicate,eachFunc)}}function createFindIndex(fromRight){return function(array,predicate,thisArg){if(!(array&&array.length)){return-1}predicate=getCallback(predicate,thisArg,3);return baseFindIndex(array,predicate,fromRight)}}function createFindKey(objectFunc){return function(object,predicate,thisArg){predicate=getCallback(predicate,thisArg,3);return baseFind(object,predicate,objectFunc,true)}}function createFlow(fromRight){return function(){var wrapper,length=arguments.length,index=fromRight?length:-1,leftIndex=0,funcs=Array(length);while(fromRight?index--:++index=LARGE_ARRAY_SIZE){return wrapper.plant(value).value()}var index=0,result=length?funcs[index].apply(this,args):value;while(++index=length||!nativeIsFinite(length)){return""}var padLength=length-strLength;chars=chars==null?" ":chars+"";return repeat(chars,nativeCeil(padLength/chars.length)).slice(0,padLength)}function createPartialWrapper(func,bitmask,thisArg,partials){var isBind=bitmask&BIND_FLAG,Ctor=createCtorWrapper(func);function wrapper(){var argsIndex=-1,argsLength=arguments.length,leftIndex=-1,leftLength=partials.length,args=Array(leftLength+argsLength);while(++leftIndexarrLength)){return false}while(++index-1&&value%1==0&&value-1&&value%1==0&&value<=MAX_SAFE_INTEGER}function isStrictComparable(value){return value===value&&!isObject(value)}function mergeData(data,source){var bitmask=data[1],srcBitmask=source[1],newBitmask=bitmask|srcBitmask,isCommon=newBitmask0){if(++count>=HOT_COUNT){return key}}else{count=0}return baseSetData(key,value)}}();function shimKeys(object){var props=keysIn(object),propsLength=props.length,length=propsLength&&object.length;var allowIndexes=!!length&&isLength(length)&&(isArray(object)||isArguments(object));var index=-1,result=[];while(++index=120?createCache(othIndex&&value):null}var array=arrays[0],index=-1,length=array?array.length:0,seen=caches[0];outer:while(++index-1){splice.call(array,fromIndex,1)}}return array}var pullAt=restParam(function(array,indexes){indexes=baseFlatten(indexes);var result=baseAt(array,indexes);basePullAt(array,indexes.sort(baseCompareAscending));return result});function remove(array,predicate,thisArg){var result=[];if(!(array&&array.length)){return result}var index=-1,indexes=[],length=array.length;predicate=getCallback(predicate,thisArg,3);while(++index2?arrays[length-2]:undefined,thisArg=length>1?arrays[length-1]:undefined;if(length>2&&typeof iteratee=="function"){length-=2}else{iteratee=length>1&&typeof thisArg=="function"?(--length,thisArg):undefined;thisArg=undefined}arrays.length=length;return unzipWith(arrays,iteratee,thisArg)});function chain(value){var result=lodash(value);result.__chain__=true;return result}function tap(value,interceptor,thisArg){interceptor.call(thisArg,value);return value}function thru(value,interceptor,thisArg){return interceptor.call(thisArg,value)}function wrapperChain(){return chain(this)}function wrapperCommit(){return new LodashWrapper(this.value(),this.__chain__)}var wrapperConcat=restParam(function(values){values=baseFlatten(values);return this.thru(function(array){return arrayConcat(isArray(array)?array:[toObject(array)],values)})});function wrapperPlant(value){var result,parent=this;while(parent instanceof baseLodash){var clone=wrapperClone(parent);if(result){previous.__wrapped__=clone}else{result=clone}var previous=clone;parent=parent.__wrapped__}previous.__wrapped__=value;return result}function wrapperReverse(){var value=this.__wrapped__;var interceptor=function(value){return wrapped&&wrapped.__dir__<0?value:value.reverse()};if(value instanceof LazyWrapper){var wrapped=value;if(this.__actions__.length){wrapped=new LazyWrapper(this)}wrapped=wrapped.reverse();wrapped.__actions__.push({func:thru,args:[interceptor],thisArg:undefined});return new LodashWrapper(wrapped,this.__chain__)}return this.thru(interceptor)}function wrapperToString(){return this.value()+""}function wrapperValue(){return baseWrapperValue(this.__wrapped__,this.__actions__)}var at=restParam(function(collection,props){return baseAt(collection,baseFlatten(props))});var countBy=createAggregator(function(result,value,key){hasOwnProperty.call(result,key)?++result[key]:result[key]=1});function every(collection,predicate,thisArg){var func=isArray(collection)?arrayEvery:baseEvery;if(thisArg&&isIterateeCall(collection,predicate,thisArg)){predicate=undefined}if(typeof predicate!="function"||thisArg!==undefined){predicate=getCallback(predicate,thisArg,3)}return func(collection,predicate)}function filter(collection,predicate,thisArg){var func=isArray(collection)?arrayFilter:baseFilter;predicate=getCallback(predicate,thisArg,3);return func(collection,predicate)}var find=createFind(baseEach);var findLast=createFind(baseEachRight,true);function findWhere(collection,source){return find(collection,baseMatches(source))}var forEach=createForEach(arrayEach,baseEach);var forEachRight=createForEach(arrayEachRight,baseEachRight); -var groupBy=createAggregator(function(result,value,key){if(hasOwnProperty.call(result,key)){result[key].push(value)}else{result[key]=[value]}});function includes(collection,target,fromIndex,guard){var length=collection?getLength(collection):0;if(!isLength(length)){collection=values(collection);length=collection.length}if(typeof fromIndex!="number"||guard&&isIterateeCall(target,fromIndex,guard)){fromIndex=0}else{fromIndex=fromIndex<0?nativeMax(length+fromIndex,0):fromIndex||0}return typeof collection=="string"||!isArray(collection)&&isString(collection)?fromIndex<=length&&collection.indexOf(target,fromIndex)>-1:!!length&&getIndexOf(collection,target,fromIndex)>-1}var indexBy=createAggregator(function(result,value,key){result[key]=value});var invoke=restParam(function(collection,path,args){var index=-1,isFunc=typeof path=="function",isProp=isKey(path),result=isArrayLike(collection)?Array(collection.length):[];baseEach(collection,function(value){var func=isFunc?path:isProp&&value!=null?value[path]:undefined;result[++index]=func?func.apply(value,args):invokePath(value,path,args)});return result});function map(collection,iteratee,thisArg){var func=isArray(collection)?arrayMap:baseMap;iteratee=getCallback(iteratee,thisArg,3);return func(collection,iteratee)}var partition=createAggregator(function(result,value,key){result[key?0:1].push(value)},function(){return[[],[]]});function pluck(collection,path){return map(collection,property(path))}var reduce=createReduce(arrayReduce,baseEach);var reduceRight=createReduce(arrayReduceRight,baseEachRight);function reject(collection,predicate,thisArg){var func=isArray(collection)?arrayFilter:baseFilter;predicate=getCallback(predicate,thisArg,3);return func(collection,function(value,index,collection){return!predicate(value,index,collection)})}function sample(collection,n,guard){if(guard?isIterateeCall(collection,n,guard):n==null){collection=toIterable(collection);var length=collection.length;return length>0?collection[baseRandom(0,length-1)]:undefined}var index=-1,result=toArray(collection),length=result.length,lastIndex=length-1;n=nativeMin(n<0?0:+n||0,length);while(++index0){result=func.apply(this,arguments)}if(n<=1){func=undefined}return result}}var bind=restParam(function(func,thisArg,partials){var bitmask=BIND_FLAG;if(partials.length){var holders=replaceHolders(partials,bind.placeholder);bitmask|=PARTIAL_FLAG}return createWrapper(func,bitmask,thisArg,partials,holders)});var bindAll=restParam(function(object,methodNames){methodNames=methodNames.length?baseFlatten(methodNames):functions(object);var index=-1,length=methodNames.length;while(++indexwait){complete(trailingCall,maxTimeoutId)}else{timeoutId=setTimeout(delayed,remaining)}}function maxDelayed(){complete(trailing,timeoutId)}function debounced(){args=arguments;stamp=now();thisArg=this;trailingCall=trailing&&(timeoutId||!leading);if(maxWait===false){var leadingCall=leading&&!timeoutId}else{if(!maxTimeoutId&&!leading){lastCalled=stamp}var remaining=maxWait-(stamp-lastCalled),isCalled=remaining<=0||remaining>maxWait;if(isCalled){if(maxTimeoutId){maxTimeoutId=clearTimeout(maxTimeoutId)}lastCalled=stamp;result=func.apply(thisArg,args)}else if(!maxTimeoutId){maxTimeoutId=setTimeout(maxDelayed,remaining)}}if(isCalled&&timeoutId){timeoutId=clearTimeout(timeoutId)}else if(!timeoutId&&wait!==maxWait){timeoutId=setTimeout(delayed,wait)}if(leadingCall){isCalled=true;result=func.apply(thisArg,args)}if(isCalled&&!timeoutId&&!maxTimeoutId){args=thisArg=undefined}return result}debounced.cancel=cancel;return debounced}var defer=restParam(function(func,args){return baseDelay(func,1,args)});var delay=restParam(function(func,wait,args){return baseDelay(func,wait,args)});var flow=createFlow();var flowRight=createFlow(true);function memoize(func,resolver){if(typeof func!="function"||resolver&&typeof resolver!="function"){throw new TypeError(FUNC_ERROR_TEXT)}var memoized=function(){var args=arguments,key=resolver?resolver.apply(this,args):args[0],cache=memoized.cache;if(cache.has(key)){return cache.get(key)}var result=func.apply(this,args);memoized.cache=cache.set(key,result);return result};memoized.cache=new memoize.Cache;return memoized}var modArgs=restParam(function(func,transforms){transforms=baseFlatten(transforms);if(typeof func!="function"||!arrayEvery(transforms,baseIsFunction)){throw new TypeError(FUNC_ERROR_TEXT)}var length=transforms.length;return restParam(function(args){var index=nativeMin(args.length,length);while(index--){args[index]=transforms[index](args[index])}return func.apply(this,args)})});function negate(predicate){if(typeof predicate!="function"){throw new TypeError(FUNC_ERROR_TEXT)}return function(){return!predicate.apply(this,arguments)}}function once(func){return before(2,func)}var partial=createPartial(PARTIAL_FLAG);var partialRight=createPartial(PARTIAL_RIGHT_FLAG);var rearg=restParam(function(func,indexes){return createWrapper(func,REARG_FLAG,undefined,undefined,undefined,baseFlatten(indexes))});function restParam(func,start){if(typeof func!="function"){throw new TypeError(FUNC_ERROR_TEXT)}start=nativeMax(start===undefined?func.length-1:+start||0,0);return function(){var args=arguments,index=-1,length=nativeMax(args.length-start,0),rest=Array(length);while(++indexother}function gte(value,other){return value>=other}function isArguments(value){return isObjectLike(value)&&isArrayLike(value)&&hasOwnProperty.call(value,"callee")&&!propertyIsEnumerable.call(value,"callee")}var isArray=nativeIsArray||function(value){return isObjectLike(value)&&isLength(value.length)&&objToString.call(value)==arrayTag};function isBoolean(value){return value===true||value===false||isObjectLike(value)&&objToString.call(value)==boolTag}function isDate(value){return isObjectLike(value)&&objToString.call(value)==dateTag}function isElement(value){return!!value&&value.nodeType===1&&isObjectLike(value)&&!isPlainObject(value)}function isEmpty(value){if(value==null){return true}if(isArrayLike(value)&&(isArray(value)||isString(value)||isArguments(value)||isObjectLike(value)&&isFunction(value.splice))){return!value.length}return!keys(value).length}function isEqual(value,other,customizer,thisArg){customizer=typeof customizer=="function"?bindCallback(customizer,thisArg,3):undefined;var result=customizer?customizer(value,other):undefined;return result===undefined?baseIsEqual(value,other,customizer):!!result}function isError(value){return isObjectLike(value)&&typeof value.message=="string"&&objToString.call(value)==errorTag}function isFinite(value){return typeof value=="number"&&nativeIsFinite(value)}function isFunction(value){return isObject(value)&&objToString.call(value)==funcTag}function isObject(value){var type=typeof value;return!!value&&(type=="object"||type=="function")}function isMatch(object,source,customizer,thisArg){customizer=typeof customizer=="function"?bindCallback(customizer,thisArg,3):undefined;return baseIsMatch(object,getMatchData(source),customizer)}function isNaN(value){return isNumber(value)&&value!=+value}function isNative(value){if(value==null){return false}if(isFunction(value)){return reIsNative.test(fnToString.call(value))}return isObjectLike(value)&&reIsHostCtor.test(value)}function isNull(value){return value===null}function isNumber(value){return typeof value=="number"||isObjectLike(value)&&objToString.call(value)==numberTag}function isPlainObject(value){var Ctor;if(!(isObjectLike(value)&&objToString.call(value)==objectTag&&!isArguments(value))||!hasOwnProperty.call(value,"constructor")&&(Ctor=value.constructor,typeof Ctor=="function"&&!(Ctor instanceof Ctor))){return false}var result;baseForIn(value,function(subValue,key){result=key});return result===undefined||hasOwnProperty.call(value,result)}function isRegExp(value){return isObject(value)&&objToString.call(value)==regexpTag}function isString(value){return typeof value=="string"||isObjectLike(value)&&objToString.call(value)==stringTag}function isTypedArray(value){return isObjectLike(value)&&isLength(value.length)&&!!typedArrayTags[objToString.call(value)]}function isUndefined(value){return value===undefined}function lt(value,other){return value0;while(++index=nativeMin(start,end)&&value=0&&string.indexOf(target,position)==position}function escape(string){string=baseToString(string);return string&&reHasUnescapedHtml.test(string)?string.replace(reUnescapedHtml,escapeHtmlChar):string}function escapeRegExp(string){string=baseToString(string);return string&&reHasRegExpChars.test(string)?string.replace(reRegExpChars,escapeRegExpChar):string||"(?:)"}var kebabCase=createCompounder(function(result,word,index){return result+(index?"-":"")+word.toLowerCase()});function pad(string,length,chars){string=baseToString(string);length=+length;var strLength=string.length;if(strLength>=length||!nativeIsFinite(length)){return string}var mid=(length-strLength)/2,leftLength=nativeFloor(mid),rightLength=nativeCeil(mid);chars=createPadding("",rightLength,chars);return chars.slice(0,leftLength)+string+chars}var padLeft=createPadDir();var padRight=createPadDir(true);function parseInt(string,radix,guard){if(guard?isIterateeCall(string,radix,guard):radix==null){radix=0}else if(radix){radix=+radix}string=trim(string);return nativeParseInt(string,radix||(reHasHexPrefix.test(string)?16:10))}function repeat(string,n){var result="";string=baseToString(string);n=+n;if(n<1||!string||!nativeIsFinite(n)){return result}do{if(n%2){result+=string}n=nativeFloor(n/2);string+=string}while(n);return result}var snakeCase=createCompounder(function(result,word,index){return result+(index?"_":"")+word.toLowerCase()});var startCase=createCompounder(function(result,word,index){return result+(index?" ":"")+(word.charAt(0).toUpperCase()+word.slice(1))});function startsWith(string,target,position){string=baseToString(string);position=position==null?0:nativeMin(position<0?0:+position||0,string.length);return string.lastIndexOf(target,position)==position}function template(string,options,otherOptions){var settings=lodash.templateSettings;if(otherOptions&&isIterateeCall(string,options,otherOptions)){options=otherOptions=undefined}string=baseToString(string);options=assignWith(baseAssign({},otherOptions||options),settings,assignOwnDefaults);var imports=assignWith(baseAssign({},options.imports),settings.imports,assignOwnDefaults),importsKeys=keys(imports),importsValues=baseValues(imports,importsKeys);var isEscaping,isEvaluating,index=0,interpolate=options.interpolate||reNoMatch,source="__p += '";var reDelimiters=RegExp((options.escape||reNoMatch).source+"|"+interpolate.source+"|"+(interpolate===reInterpolate?reEsTemplate:reNoMatch).source+"|"+(options.evaluate||reNoMatch).source+"|$","g");var sourceURL="//# sourceURL="+("sourceURL"in options?options.sourceURL:"lodash.templateSources["+ ++templateCounter+"]")+"\n";string.replace(reDelimiters,function(match,escapeValue,interpolateValue,esTemplateValue,evaluateValue,offset){interpolateValue||(interpolateValue=esTemplateValue);source+=string.slice(index,offset).replace(reUnescapedString,escapeStringChar);if(escapeValue){isEscaping=true;source+="' +\n__e("+escapeValue+") +\n'"}if(evaluateValue){isEvaluating=true;source+="';\n"+evaluateValue+";\n__p += '"}if(interpolateValue){source+="' +\n((__t = ("+interpolateValue+")) == null ? '' : __t) +\n'"}index=offset+match.length;return match});source+="';\n";var variable=options.variable;if(!variable){source="with (obj) {\n"+source+"\n}\n"}source=(isEvaluating?source.replace(reEmptyStringLeading,""):source).replace(reEmptyStringMiddle,"$1").replace(reEmptyStringTrailing,"$1;");source="function("+(variable||"obj")+") {\n"+(variable?"":"obj || (obj = {});\n")+"var __t, __p = ''"+(isEscaping?", __e = _.escape":"")+(isEvaluating?", __j = Array.prototype.join;\n"+"function print() { __p += __j.call(arguments, '') }\n":";\n")+source+"return __p\n}";var result=attempt(function(){return Function(importsKeys,sourceURL+"return "+source).apply(undefined,importsValues)});result.source=source;if(isError(result)){throw result}return result}function trim(string,chars,guard){var value=string;string=baseToString(string);if(!string){return string}if(guard?isIterateeCall(value,chars,guard):chars==null){return string.slice(trimmedLeftIndex(string),trimmedRightIndex(string)+1)}chars=chars+"";return string.slice(charsLeftIndex(string,chars),charsRightIndex(string,chars)+1)}function trimLeft(string,chars,guard){var value=string;string=baseToString(string);if(!string){return string}if(guard?isIterateeCall(value,chars,guard):chars==null){return string.slice(trimmedLeftIndex(string))}return string.slice(charsLeftIndex(string,chars+""))}function trimRight(string,chars,guard){var value=string;string=baseToString(string);if(!string){return string}if(guard?isIterateeCall(value,chars,guard):chars==null){return string.slice(0,trimmedRightIndex(string)+1)}return string.slice(0,charsRightIndex(string,chars+"")+1)}function trunc(string,options,guard){if(guard&&isIterateeCall(string,options,guard)){options=undefined}var length=DEFAULT_TRUNC_LENGTH,omission=DEFAULT_TRUNC_OMISSION;if(options!=null){if(isObject(options)){var separator="separator"in options?options.separator:separator;length="length"in options?+options.length||0:length;omission="omission"in options?baseToString(options.omission):omission}else{length=+options||0}}string=baseToString(string);if(length>=string.length){return string}var end=length-omission.length;if(end<1){return omission}var result=string.slice(0,end);if(separator==null){return result+omission}if(isRegExp(separator)){if(string.slice(end).search(separator)){var match,newEnd,substring=string.slice(0,end);if(!separator.global){separator=RegExp(separator.source,(reFlags.exec(separator)||"")+"g")}separator.lastIndex=0;while(match=separator.exec(substring)){newEnd=match.index}result=result.slice(0,newEnd==null?end:newEnd)}}else if(string.indexOf(separator,end)!=end){var index=result.lastIndexOf(separator);if(index>-1){result=result.slice(0,index)}}return result+omission}function unescape(string){string=baseToString(string);return string&&reHasEscapedHtml.test(string)?string.replace(reEscapedHtml,unescapeHtmlChar):string}function words(string,pattern,guard){if(guard&&isIterateeCall(string,pattern,guard)){pattern=undefined}string=baseToString(string);return string.match(pattern||reWords)||[]}var attempt=restParam(function(func,args){try{return func.apply(undefined,args)}catch(e){return isError(e)?e:new Error(e)}});function callback(func,thisArg,guard){if(guard&&isIterateeCall(func,thisArg,guard)){thisArg=undefined}return isObjectLike(func)?matches(func):baseCallback(func,thisArg)}function constant(value){return function(){return value}}function identity(value){return value}function matches(source){return baseMatches(baseClone(source,true))}function matchesProperty(path,srcValue){return baseMatchesProperty(path,baseClone(srcValue,true))}var method=restParam(function(path,args){return function(object){return invokePath(object,path,args)}});var methodOf=restParam(function(object,args){return function(path){return invokePath(object,path,args)}});function mixin(object,source,options){if(options==null){var isObj=isObject(source),props=isObj?keys(source):undefined,methodNames=props&&props.length?baseFunctions(source,props):undefined;if(!(methodNames?methodNames.length:isObj)){methodNames=false;options=source;source=object;object=this}}if(!methodNames){methodNames=baseFunctions(source,keys(source))}var chain=true,index=-1,isFunc=isFunction(object),length=methodNames.length;if(options===false){chain=false}else if(isObject(options)&&"chain"in options){chain=options.chain}while(++index0||end<0)){return new LazyWrapper(result)}if(start<0){result=result.takeRight(-start)}else if(start){result=result.drop(start)}if(end!==undefined){end=+end||0;result=end<0?result.dropRight(-end):result.take(end-start)}return result};LazyWrapper.prototype.takeRightWhile=function(predicate,thisArg){return this.reverse().takeWhile(predicate,thisArg).reverse()};LazyWrapper.prototype.toArray=function(){return this.take(POSITIVE_INFINITY)};baseForOwn(LazyWrapper.prototype,function(func,methodName){var checkIteratee=/^(?:filter|map|reject)|While$/.test(methodName),retUnwrapped=/^(?:first|last)$/.test(methodName),lodashFunc=lodash[retUnwrapped?"take"+(methodName=="last"?"Right":""):methodName];if(!lodashFunc){return}lodash.prototype[methodName]=function(){var args=retUnwrapped?[1]:arguments,chainAll=this.__chain__,value=this.__wrapped__,isHybrid=!!this.__actions__.length,isLazy=value instanceof LazyWrapper,iteratee=args[0],useLazy=isLazy||isArray(value);if(useLazy&&checkIteratee&&typeof iteratee=="function"&&iteratee.length!=1){isLazy=useLazy=false}var interceptor=function(value){return retUnwrapped&&chainAll?lodashFunc(value,1)[0]:lodashFunc.apply(undefined,arrayPush([value],args))};var action={func:thru,args:[interceptor],thisArg:undefined},onlyLazy=isLazy&&!isHybrid;if(retUnwrapped&&!chainAll){if(onlyLazy){value=value.clone();value.__actions__.push(action);return func.call(value)}return lodashFunc.call(undefined,this.value())[0]}if(!retUnwrapped&&useLazy){value=onlyLazy?value:new LazyWrapper(this);var result=func.apply(value,args);result.__actions__.push(action);return new LodashWrapper(result,chainAll)}return this.thru(interceptor)}});arrayEach(["join","pop","push","replace","shift","sort","splice","split","unshift"],function(methodName){var func=(/^(?:replace|split)$/.test(methodName)?stringProto:arrayProto)[methodName],chainName=/^(?:push|sort|unshift)$/.test(methodName)?"tap":"thru",retUnwrapped=/^(?:join|pop|replace|shift)$/.test(methodName);lodash.prototype[methodName]=function(){var args=arguments;if(retUnwrapped&&!this.__chain__){return func.apply(this.value(),args)}return this[chainName](function(value){return func.apply(value,args)})}});baseForOwn(LazyWrapper.prototype,function(func,methodName){var lodashFunc=lodash[methodName];if(lodashFunc){var key=lodashFunc.name,names=realNames[key]||(realNames[key]=[]);names.push({name:methodName,func:lodashFunc})}});realNames[createHybridWrapper(undefined,BIND_KEY_FLAG).name]=[{name:"wrapper",func:undefined}];LazyWrapper.prototype.clone=lazyClone;LazyWrapper.prototype.reverse=lazyReverse;LazyWrapper.prototype.value=lazyValue;lodash.prototype.chain=wrapperChain;lodash.prototype.commit=wrapperCommit;lodash.prototype.concat=wrapperConcat;lodash.prototype.plant=wrapperPlant;lodash.prototype.reverse=wrapperReverse;lodash.prototype.toString=wrapperToString;lodash.prototype.run=lodash.prototype.toJSON=lodash.prototype.valueOf=lodash.prototype.value=wrapperValue;lodash.prototype.collect=lodash.prototype.map;lodash.prototype.head=lodash.prototype.first;lodash.prototype.select=lodash.prototype.filter;lodash.prototype.tail=lodash.prototype.rest;return lodash}var _=runInContext();if(typeof define=="function"&&typeof define.amd=="object"&&define.amd){root._=_;define(function(){return _})}else if(freeExports&&freeModule){if(moduleExports){(freeModule.exports=_)._=_}else{freeExports._=_}}else{root._=_}}).call(this)}).call(this,typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{})},{}],3:[function(require,module,exports){(function(window,document,undefined){var _MAP={8:"backspace",9:"tab",13:"enter",16:"shift",17:"ctrl",18:"alt",20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"ins",46:"del",91:"meta",93:"meta",224:"meta"};var _KEYCODE_MAP={106:"*",107:"+",109:"-",110:".",111:"/",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"};var _SHIFT_MAP={"~":"`","!":"1","@":"2","#":"3",$:"4","%":"5","^":"6","&":"7","*":"8","(":"9",")":"0",_:"-","+":"=",":":";",'"':"'","<":",",">":".","?":"/","|":"\\"};var _SPECIAL_ALIASES={option:"alt",command:"meta","return":"enter",escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"};var _REVERSE_MAP;for(var i=1;i<20;++i){_MAP[111+i]="f"+i}for(i=0;i<=9;++i){_MAP[i+96]=i}function _addEvent(object,type,callback){if(object.addEventListener){object.addEventListener(type,callback,false);return}object.attachEvent("on"+type,callback)}function _characterFromEvent(e){if(e.type=="keypress"){var character=String.fromCharCode(e.which);if(!e.shiftKey){character=character.toLowerCase()}return character}if(_MAP[e.which]){return _MAP[e.which]}if(_KEYCODE_MAP[e.which]){return _KEYCODE_MAP[e.which]}return String.fromCharCode(e.which).toLowerCase()}function _modifiersMatch(modifiers1,modifiers2){return modifiers1.sort().join(",")===modifiers2.sort().join(",")}function _eventModifiers(e){var modifiers=[];if(e.shiftKey){modifiers.push("shift")}if(e.altKey){modifiers.push("alt")}if(e.ctrlKey){modifiers.push("ctrl")}if(e.metaKey){modifiers.push("meta")}return modifiers}function _preventDefault(e){if(e.preventDefault){e.preventDefault();return}e.returnValue=false}function _stopPropagation(e){if(e.stopPropagation){e.stopPropagation();return}e.cancelBubble=true}function _isModifier(key){return key=="shift"||key=="ctrl"||key=="alt"||key=="meta"}function _getReverseMap(){if(!_REVERSE_MAP){_REVERSE_MAP={};for(var key in _MAP){if(key>95&&key<112){continue}if(_MAP.hasOwnProperty(key)){_REVERSE_MAP[_MAP[key]]=key}}}return _REVERSE_MAP}function _pickBestAction(key,modifiers,action){if(!action){action=_getReverseMap()[key]?"keydown":"keypress"}if(action=="keypress"&&modifiers.length){action="keydown"}return action}function _keysFromString(combination){if(combination==="+"){return["+"]}combination=combination.replace(/\+{2}/g,"+plus");return combination.split("+")}function _getKeyInfo(combination,action){var keys;var key;var i;var modifiers=[];keys=_keysFromString(combination);for(i=0;i1){_bindSequence(combination,sequence,callback,action);return}info=_getKeyInfo(combination,action);self._callbacks[info.key]=self._callbacks[info.key]||[];_getMatches(info.key,info.modifiers,{type:info.action},sequenceName,combination,level);self._callbacks[info.key][sequenceName?"unshift":"push"]({callback:callback,modifiers:info.modifiers,action:info.action,seq:sequenceName,level:level,combo:combination})}self._bindMultiple=function(combinations,callback,action){for(var i=0;i-1){return false}if(_belongsTo(element,self.target)){return false}return element.tagName=="INPUT"||element.tagName=="SELECT"||element.tagName=="TEXTAREA"||element.isContentEditable};Mousetrap.prototype.handleKey=function(){var self=this;return self._handleKey.apply(self,arguments)};Mousetrap.init=function(){var documentMousetrap=Mousetrap(document);for(var method in documentMousetrap){if(method.charAt(0)!=="_"){Mousetrap[method]=function(method){return function(){return documentMousetrap[method].apply(documentMousetrap,arguments)}}(method)}}};Mousetrap.init();window.Mousetrap=Mousetrap;if(typeof module!=="undefined"&&module.exports){module.exports=Mousetrap}if(typeof define==="function"&&define.amd){define(function(){return Mousetrap})}})(window,document)},{}],4:[function(require,module,exports){(function(process){function normalizeArray(parts,allowAboveRoot){var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up--;up){parts.unshift("..")}}return parts}var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;var splitPath=function(filename){return splitPathRe.exec(filename).slice(1)};exports.resolve=function(){var resolvedPath="",resolvedAbsolute=false;for(var i=arguments.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?arguments[i]:process.cwd();if(typeof path!=="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){continue}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=path.charAt(0)==="/"}resolvedPath=normalizeArray(filter(resolvedPath.split("/"),function(p){return!!p}),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."};exports.normalize=function(path){var isAbsolute=exports.isAbsolute(path),trailingSlash=substr(path,-1)==="/";path=normalizeArray(filter(path.split("/"),function(p){return!!p}),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path};exports.isAbsolute=function(path){return path.charAt(0)==="/"};exports.join=function(){var paths=Array.prototype.slice.call(arguments,0);return exports.normalize(filter(paths,function(p,index){if(typeof p!=="string"){throw new TypeError("Arguments to path.join must be strings")}return p}).join("/"))};exports.relative=function(from,to){from=exports.resolve(from).substr(1);to=exports.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i1){for(var i=1;i= 0x80 (not a basic code point)","invalid-input":"Invalid input"},baseMinusTMin=base-tMin,floor=Math.floor,stringFromCharCode=String.fromCharCode,key;function error(type){throw RangeError(errors[type])}function map(array,fn){var length=array.length;var result=[];while(length--){result[length]=fn(array[length])}return result}function mapDomain(string,fn){var parts=string.split("@");var result="";if(parts.length>1){result=parts[0]+"@";string=parts[1]}string=string.replace(regexSeparators,".");var labels=string.split(".");var encoded=map(labels,fn).join(".");return result+encoded}function ucs2decode(string){var output=[],counter=0,length=string.length,value,extra;while(counter=55296&&value<=56319&&counter65535){value-=65536;output+=stringFromCharCode(value>>>10&1023|55296);value=56320|value&1023}output+=stringFromCharCode(value);return output}).join("")}function basicToDigit(codePoint){if(codePoint-48<10){return codePoint-22}if(codePoint-65<26){return codePoint-65}if(codePoint-97<26){return codePoint-97}return base}function digitToBasic(digit,flag){return digit+22+75*(digit<26)-((flag!=0)<<5)}function adapt(delta,numPoints,firstTime){var k=0;delta=firstTime?floor(delta/damp):delta>>1;delta+=floor(delta/numPoints);for(;delta>baseMinusTMin*tMax>>1;k+=base){delta=floor(delta/baseMinusTMin)}return floor(k+(baseMinusTMin+1)*delta/(delta+skew))}function decode(input){var output=[],inputLength=input.length,out,i=0,n=initialN,bias=initialBias,basic,j,index,oldi,w,k,digit,t,baseMinusT;basic=input.lastIndexOf(delimiter);if(basic<0){basic=0}for(j=0;j=128){error("not-basic")}output.push(input.charCodeAt(j))}for(index=basic>0?basic+1:0;index=inputLength){error("invalid-input")}digit=basicToDigit(input.charCodeAt(index++));if(digit>=base||digit>floor((maxInt-i)/w)){error("overflow")}i+=digit*w;t=k<=bias?tMin:k>=bias+tMax?tMax:k-bias;if(digitfloor(maxInt/baseMinusT)){error("overflow")}w*=baseMinusT}out=output.length+1;bias=adapt(i-oldi,out,oldi==0);if(floor(i/out)>maxInt-n){error("overflow")}n+=floor(i/out);i%=out;output.splice(i++,0,n)}return ucs2encode(output)}function encode(input){var n,delta,handledCPCount,basicLength,bias,j,m,q,k,t,currentValue,output=[],inputLength,handledCPCountPlusOne,baseMinusT,qMinusT;input=ucs2decode(input);inputLength=input.length;n=initialN;delta=0;bias=initialBias;for(j=0;j=n&¤tValuefloor((maxInt-delta)/handledCPCountPlusOne)){error("overflow")}delta+=(m-n)*handledCPCountPlusOne;n=m;for(j=0;jmaxInt){error("overflow")}if(currentValue==n){for(q=delta,k=base;;k+=base){t=k<=bias?tMin:k>=bias+tMax?tMax:k-bias;if(q0&&len>maxKeys){len=maxKeys}for(var i=0;i=0){kstr=x.substr(0,idx);vstr=x.substr(idx+1)}else{kstr=x;vstr=""}k=decodeURIComponent(kstr);v=decodeURIComponent(vstr);if(!hasOwnProperty(obj,k)){obj[k]=v}else if(isArray(obj[k])){obj[k].push(v)}else{obj[k]=[obj[k],v]}}return obj};var isArray=Array.isArray||function(xs){return Object.prototype.toString.call(xs)==="[object Array]"}},{}],8:[function(require,module,exports){"use strict";var stringifyPrimitive=function(v){switch(typeof v){case"string":return v;case"boolean":return v?"true":"false";case"number":return isFinite(v)?v:"";default:return""}};module.exports=function(obj,sep,eq,name){sep=sep||"&";eq=eq||"=";if(obj===null){obj=undefined}if(typeof obj==="object"){return map(objectKeys(obj),function(k){var ks=encodeURIComponent(stringifyPrimitive(k))+eq;if(isArray(obj[k])){return map(obj[k],function(v){return ks+encodeURIComponent(stringifyPrimitive(v))}).join(sep)}else{return ks+encodeURIComponent(stringifyPrimitive(obj[k]))}}).join(sep)}if(!name)return"";return encodeURIComponent(stringifyPrimitive(name))+eq+encodeURIComponent(stringifyPrimitive(obj))};var isArray=Array.isArray||function(xs){return Object.prototype.toString.call(xs)==="[object Array]"};function map(xs,f){if(xs.map)return xs.map(f);var res=[];for(var i=0;i",'"',"`"," ","\r","\n"," "],unwise=["{","}","|","\\","^","`"].concat(delims),autoEscape=["'"].concat(unwise),nonHostChars=["%","/","?",";","#"].concat(autoEscape),hostEndingChars=["/","?","#"],hostnameMaxLen=255,hostnamePartPattern=/^[a-z0-9A-Z_-]{0,63}$/,hostnamePartStart=/^([a-z0-9A-Z_-]{0,63})(.*)$/,unsafeProtocol={javascript:true,"javascript:":true},hostlessProtocol={javascript:true,"javascript:":true},slashedProtocol={http:true,https:true,ftp:true,gopher:true,file:true,"http:":true,"https:":true,"ftp:":true,"gopher:":true,"file:":true},querystring=require("querystring");function urlParse(url,parseQueryString,slashesDenoteHost){if(url&&isObject(url)&&url instanceof Url)return url;var u=new Url;u.parse(url,parseQueryString,slashesDenoteHost);return u}Url.prototype.parse=function(url,parseQueryString,slashesDenoteHost){if(!isString(url)){throw new TypeError("Parameter 'url' must be a string, not "+typeof url)}var rest=url;rest=rest.trim();var proto=protocolPattern.exec(rest);if(proto){proto=proto[0];var lowerProto=proto.toLowerCase();this.protocol=lowerProto;rest=rest.substr(proto.length)}if(slashesDenoteHost||proto||rest.match(/^\/\/[^@\/]+@[^@\/]+/)){var slashes=rest.substr(0,2)==="//";if(slashes&&!(proto&&hostlessProtocol[proto])){rest=rest.substr(2);this.slashes=true}}if(!hostlessProtocol[proto]&&(slashes||proto&&!slashedProtocol[proto])){var hostEnd=-1;for(var i=0;i127){newpart+="x"}else{newpart+=part[j]}}if(!newpart.match(hostnamePartPattern)){var validParts=hostparts.slice(0,i);var notHost=hostparts.slice(i+1);var bit=part.match(hostnamePartStart);if(bit){validParts.push(bit[1]);notHost.unshift(bit[2])}if(notHost.length){rest="/"+notHost.join(".")+rest}this.hostname=validParts.join(".");break}}}}if(this.hostname.length>hostnameMaxLen){this.hostname=""}else{this.hostname=this.hostname.toLowerCase()}if(!ipv6Hostname){var domainArray=this.hostname.split(".");var newOut=[];for(var i=0;i0?result.host.split("@"):false;if(authInHost){result.auth=authInHost.shift();result.host=result.hostname=authInHost.shift()}}result.search=relative.search;result.query=relative.query;if(!isNull(result.pathname)||!isNull(result.search)){result.path=(result.pathname?result.pathname:"")+(result.search?result.search:"")}result.href=result.format();return result}if(!srcPath.length){result.pathname=null;if(result.search){result.path="/"+result.search}else{result.path=null}result.href=result.format();return result}var last=srcPath.slice(-1)[0];var hasTrailingSlash=(result.host||relative.host)&&(last==="."||last==="..")||last==="";var up=0;for(var i=srcPath.length;i>=0;i--){last=srcPath[i];if(last=="."){srcPath.splice(i,1)}else if(last===".."){srcPath.splice(i,1);up++}else if(up){srcPath.splice(i,1);up--}}if(!mustEndAbs&&!removeAllDots){for(;up--;up){srcPath.unshift("..")}}if(mustEndAbs&&srcPath[0]!==""&&(!srcPath[0]||srcPath[0].charAt(0)!=="/")){srcPath.unshift("")}if(hasTrailingSlash&&srcPath.join("/").substr(-1)!=="/"){srcPath.push("")}var isAbsolute=srcPath[0]===""||srcPath[0]&&srcPath[0].charAt(0)==="/";if(psychotic){result.hostname=result.host=isAbsolute?"":srcPath.length?srcPath.shift():"";var authInHost=result.host&&result.host.indexOf("@")>0?result.host.split("@"):false;if(authInHost){result.auth=authInHost.shift();result.host=result.hostname=authInHost.shift()}}mustEndAbs=mustEndAbs||result.host&&srcPath.length;if(mustEndAbs&&!isAbsolute){srcPath.unshift("")}if(!srcPath.length){result.pathname=null;result.path=null}else{result.pathname=srcPath.join("/")}if(!isNull(result.pathname)||!isNull(result.search)){result.path=(result.pathname?result.pathname:"")+(result.search?result.search:"")}result.auth=relative.auth||result.auth;result.slashes=result.slashes||relative.slashes;result.href=result.format();return result};Url.prototype.parseHost=function(){var host=this.host;var port=portPattern.exec(host);if(port){port=port[0];if(port!==":"){this.port=port.substr(1)}host=host.substr(0,host.length-port.length)}if(host)this.hostname=host};function isString(arg){return typeof arg==="string"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isNull(arg){return arg===null}function isNullOrUndefined(arg){return arg==null}},{punycode:6,querystring:9}],11:[function(require,module,exports){var $=require("jquery");function toggleDropdown(e){var $dropdown=$(e.currentTarget).parent().find(".dropdown-menu");$dropdown.toggleClass("open");e.stopPropagation();e.preventDefault()}function closeDropdown(e){$(".dropdown-menu").removeClass("open")}function init(){$(document).on("click",".toggle-dropdown",toggleDropdown);$(document).on("click",".dropdown-menu",function(e){e.stopPropagation()});$(document).on("click",closeDropdown)}module.exports={init:init}},{jquery:1}],12:[function(require,module,exports){var $=require("jquery");module.exports=$({})},{jquery:1}],13:[function(require,module,exports){var $=require("jquery");var _=require("lodash");var storage=require("./storage");var dropdown=require("./dropdown");var events=require("./events");var state=require("./state");var keyboard=require("./keyboard");var navigation=require("./navigation");var sidebar=require("./sidebar");var toolbar=require("./toolbar");function start(config){sidebar.init();keyboard.init();dropdown.init();navigation.init();toolbar.createButton({index:0,icon:"fa fa-align-justify",onClick:function(e){e.preventDefault();sidebar.toggle()}});events.trigger("start",config);navigation.notify()}var gitbook={start:start,events:events,state:state,toolbar:toolbar,sidebar:sidebar,storage:storage,keyboard:keyboard};var MODULES={gitbook:gitbook,jquery:$,lodash:_};window.gitbook=gitbook;window.$=$;window.jQuery=$;gitbook.require=function(mods,fn){mods=_.map(mods,function(mod){mod=mod.toLowerCase();if(!MODULES[mod]){throw new Error("GitBook module "+mod+" doesn't exist")}return MODULES[mod]});fn.apply(null,mods)};module.exports={}},{"./dropdown":11,"./events":12,"./keyboard":14,"./navigation":16,"./sidebar":18,"./state":19,"./storage":20,"./toolbar":21,jquery:1,lodash:2}],14:[function(require,module,exports){var Mousetrap=require("mousetrap");var navigation=require("./navigation");var sidebar=require("./sidebar");function bindShortcut(keys,fn){Mousetrap.bind(keys,function(e){fn();return false})}function init(){bindShortcut(["right"],function(e){navigation.goNext()});bindShortcut(["left"],function(e){navigation.goPrev()});bindShortcut(["s"],function(e){sidebar.toggle()})}module.exports={init:init,bind:bindShortcut}},{"./navigation":16,"./sidebar":18,mousetrap:3}],15:[function(require,module,exports){var state=require("./state");function showLoading(p){state.$book.addClass("is-loading");p.always(function(){state.$book.removeClass("is-loading")});return p}module.exports={show:showLoading}},{"./state":19}],16:[function(require,module,exports){var $=require("jquery");var url=require("url");var events=require("./events");var state=require("./state");var loading=require("./loading");var usePushState=typeof history.pushState!=="undefined";function handleNavigation(relativeUrl,push){var uri=url.resolve(window.location.pathname,relativeUrl);notifyPageChange();location.href=relativeUrl;return;return loading.show($.get(uri).done(function(html){if(push)history.pushState({path:uri},null,uri);html=html.replace(/<(\/?)(html|head|body)([^>]*)>/gi,function(a,b,c,d){return"<"+b+"div"+(b?"":' data-element="'+c+'"')+d+">"});var $page=$(html);var $pageHead=$page.find("[data-element=head]");var $pageBody=$page.find(".book");document.title=$pageHead.find("title").text();var $head=$("head");$head.find("link[rel=prev]").remove();$head.find("link[rel=next]").remove();$head.append($pageHead.find("link[rel=prev]"));$head.append($pageHead.find("link[rel=next]"));var bodyClass=$(".book").attr("class");var scrollPosition=$(".book-summary .summary").scrollTop();$pageBody.toggleClass("with-summary",$(".book").hasClass("with-summary"));$(".book").replaceWith($pageBody);$(".book").attr("class",bodyClass);$(".book-summary .summary").scrollTop(scrollPosition);state.update($("html"));preparePage()}).fail(function(e){location.href=relativeUrl}))}function updateNavigationPosition(){var bodyInnerWidth,pageWrapperWidth;bodyInnerWidth=parseInt($(".body-inner").css("width"),10);pageWrapperWidth=parseInt($(".page-wrapper").css("width"),10);$(".navigation-next").css("margin-right",bodyInnerWidth-pageWrapperWidth+"px")}function notifyPageChange(){events.trigger("page.change")}function preparePage(notify){var $bookBody=$(".book-body");var $bookInner=$bookBody.find(".body-inner");var $pageWrapper=$bookInner.find(".page-wrapper");updateNavigationPosition();$bookInner.scrollTop(0);$bookBody.scrollTop(0);if(notify!==false)notifyPageChange()}function isLeftClickEvent(e){return e.button===0}function isModifiedEvent(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function handlePagination(e){if(isModifiedEvent(e)||!isLeftClickEvent(e)){return}e.stopPropagation();e.preventDefault();var url=$(this).attr("href");if(url)handleNavigation(url,true)}function goNext(){var url=$(".navigation-next").attr("href");if(url)handleNavigation(url,true)}function goPrev(){var url=$(".navigation-prev").attr("href");if(url)handleNavigation(url,true)}function init(){$.ajaxSetup({});if(location.protocol!=="file:"){history.replaceState({path:window.location.href},"")}window.onpopstate=function(event){if(event.state===null){return}return handleNavigation(event.state.path,false)};$(document).on("click",".navigation-prev",handlePagination);$(document).on("click",".navigation-next",handlePagination);$(document).on("click",".summary [data-path] a",handlePagination);$(window).resize(updateNavigationPosition);preparePage(false)}module.exports={init:init,goNext:goNext,goPrev:goPrev,notify:notifyPageChange}},{"./events":12,"./loading":15,"./state":19,jquery:1,url:10}],17:[function(require,module,exports){module.exports={isMobile:function(){return document.body.clientWidth<=600}}},{}],18:[function(require,module,exports){var $=require("jquery");var _=require("lodash");var storage=require("./storage");var platform=require("./platform");var state=require("./state");function toggleSidebar(_state,animation){if(state!=null&&isOpen()==_state)return;if(animation==null)animation=true;state.$book.toggleClass("without-animation",!animation);state.$book.toggleClass("with-summary",_state);storage.set("sidebar",isOpen())}function isOpen(){return state.$book.hasClass("with-summary")}function init(){if(platform.isMobile()){toggleSidebar(false,false)}else{toggleSidebar(storage.get("sidebar",true),false)}$(document).on("click",".book-summary li.chapter a",function(e){if(platform.isMobile())toggleSidebar(false,false)})}function filterSummary(paths){var $summary=$(".book-summary");$summary.find("li").each(function(){var path=$(this).data("path");var st=paths==null||_.contains(paths,path);$(this).toggle(st);if(st)$(this).parents("li").show()})}module.exports={init:init,isOpen:isOpen,toggle:toggleSidebar,filter:filterSummary}},{"./platform":17,"./state":19,"./storage":20,jquery:1,lodash:2}],19:[function(require,module,exports){var $=require("jquery");var url=require("url");var path=require("path");var state={};state.update=function(dom){var $book=$(dom.find(".book"));state.$book=$book;state.level=$book.data("level");state.basePath=$book.data("basepath");state.innerLanguage=$book.data("innerlanguage");state.revision=$book.data("revision");state.filepath=$book.data("filepath");state.chapterTitle=$book.data("chapter-title");state.root=url.resolve(location.protocol+"//"+location.host,path.dirname(path.resolve(location.pathname.replace(/\/$/,"/index.html"),state.basePath))).replace(/\/?$/,"/");state.bookRoot=state.innerLanguage?url.resolve(state.root,".."):state.root};state.update($);module.exports=state},{jquery:1,path:4,url:10}],20:[function(require,module,exports){var baseKey="";module.exports={setBaseKey:function(key){baseKey=key},set:function(key,value){key=baseKey+":"+key;try{localStorage[key]=JSON.stringify(value)}catch(e){}},get:function(key,def){key=baseKey+":"+key;if(localStorage[key]===undefined)return def;try{var v=JSON.parse(localStorage[key]);return v==null?def:v}catch(err){return localStorage[key]||def}},remove:function(key){key=baseKey+":"+key;localStorage.removeItem(key)}}},{}],21:[function(require,module,exports){var $=require("jquery");var _=require("lodash");var events=require("./events");var buttons=[];function insertAt(parent,selector,index,element){var lastIndex=parent.children(selector).size();if(index<0){index=Math.max(0,lastIndex+1+index)}parent.append(element);if(index",{"class":"dropdown-menu",html:''});if(_.isString(dropdown)){$menu.append(dropdown)}else{var groups=_.map(dropdown,function(group){if(_.isArray(group))return group;else return[group]});_.each(groups,function(group){var $group=$("
",{"class":"buttons"});var sizeClass="size-"+group.length;_.each(group,function(btn){btn=_.defaults(btn||{},{text:"",className:"",onClick:defaultOnClick});var $btn=$("