Repository: ines/course-starter-r Branch: master Commit: 34f27959e509 Files: 46 Total size: 148.7 KB Directory structure: gitextract_z0saycqm/ ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── binder/ │ ├── install.R │ └── runtime.txt ├── chapters/ │ ├── chapter1.md │ └── chapter2.md ├── exercises/ │ ├── exc_01_03.R │ ├── solution_01_03.R │ └── test_01_03.R ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── meta.json ├── package.json ├── slides/ │ └── chapter1_01_introduction.md ├── src/ │ ├── components/ │ │ ├── button.js │ │ ├── choice.js │ │ ├── code.js │ │ ├── exercise.js │ │ ├── hint.js │ │ ├── juniper.js │ │ ├── layout.js │ │ ├── link.js │ │ ├── seo.js │ │ ├── slides.js │ │ └── typography.js │ ├── context.js │ ├── markdown.js │ ├── pages/ │ │ └── index.js │ ├── styles/ │ │ ├── button.module.sass │ │ ├── chapter.module.sass │ │ ├── choice.module.sass │ │ ├── code.module.sass │ │ ├── exercise.module.sass │ │ ├── hint.module.sass │ │ ├── index.module.sass │ │ ├── index.sass │ │ ├── layout.module.sass │ │ ├── link.module.sass │ │ ├── reveal.css │ │ ├── slides.module.sass │ │ └── typography.module.sass │ └── templates/ │ └── chapter.js └── theme.sass ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .vscode # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # dotenv environment variables file .env # gatsby files .cache/ public # Mac files .DS_Store # Yarn yarn-error.log .pnp/ .pnp.js # Yarn Integrity file .yarn-integrity ================================================ FILE: .prettierrc ================================================ { "semi": false, "singleQuote": true, "trailingComma": "es5", "tabWidth": 4, "printWidth": 100, "overrides": [ { "files": "*.sass", "options": { "printWidth": 999 } }, { "files": "*.md", "options": { "tabWidth": 2, "printWidth": 80, "proseWrap": "always", "htmlWhitespaceSensitivity": "strict" } } ] } ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (C) 2019 Ines Montani 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. ================================================ FILE: README.md ================================================ # Online course starter: R This is a starter repo based on the [course framework](https://github.com/ines/spacy-course) I developed for my [spaCy course](https://course.spacy.io). The front-end is powered by [Gatsby](http://gatsbyjs.org/) and [Reveal.js](https://revealjs.com) and the back-end code execution uses [Binder](https://mybinder.org) 💖 _This repo could use some better code examples. Also, if you have experience with R, feel free to suggest improvements to the [test logic and template](#adding-tests). It all works as expected, but there might be ways to make it more elegant._ [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/ines/courser-starter-r) [![](https://user-images.githubusercontent.com/13643239/56341448-68fe9380-61b5-11e9-816f-5c71ae71b94f.png)](https://course-starter-r.netlify.com) ## ✅ Quickstart 1. [Import](https://github.com/new/import) this repo, install it and make sure the app is running locally. 2. Customize the [`meta.json`](meta.json) and [`binder/install.R`](binder/install.R). 3. Build a [Binder](https://mybinder.org) from the `binder` branch of this repo. 4. Add content (chapters, exercises and slides) and optionally add separate content license. 5. Customize the UI theme in [`theme.sass`](theme.sass) and update images in [`static`](static) as needed. 6. Deploy the app, e.g. to [Netlify](https://netlify.com). ### Running the app To start the local development server, install [Gatsby](https://gatsbyjs.org) and then all other dependencies. This should serve up the app on `localhost:8000`. ```bash npm install -g gatsby-cli # Install Gatsby globally npm install # Install dependencies npm run dev # Run the development server ``` ## 💡Introduction > This section was contributed by [@laderast](https://github.com/laderast). > Thanks! ✨ ### How does this repo work? The course repository works with two components: Gatsby (front-end), and Binder (back-end). We'll go over both of these to understand how it works as a whole. ### What is Gatsby? [Gatsby](https://www.gatsbyjs.org/) is a JavaScript/react.js based web page building framework, like Hugo, or Jekyll. The nice thing about it being JavaScript is that JavaScript widgets that you build are tightly integrated. You can think of Gatsby as being the client side of the lesson framework. All code, solutions, tests, and chapter.md files are handled by Gatsby. ### What is Binder? [Binder](https://gke.mybinder.org/) is a way of building Docker containers from repositories that can be launched on a remote server/cluster (such as mybinder.org). This Docker container can be based on a Dockerfile, or R image. The thing about Binder is that the containers are ephemeral - if they're not used, they're deleted off the Binder servers. The main applications of Binder are: 1. Reproducible Research (shareable notebooks) and 2. Education (shareable notebooks) You can think of Binder as being the server side of the lesson framework. It needs the instructions on how to build the docker container (which is in the `binder/` folder), and the data you want to use for the lessons (in the `data/` folder). The only thing you need to get started with R and Binder is a repo that has a `runtime.txt` file, or a Dockerfile. The rest, such as datafiles, are optional, but are usually contained in a Binder repository. ### How does Gatsby work with Binder? ![code-execution](https://user-images.githubusercontent.com/3315629/60834090-b49d5980-a174-11e9-9d69-966084ba97b9.png) Ines was super clever and designed a JavaScript plugin for Gatsby called [Juniper](https://github.com/ines/juniper) to handle communication to and from the Binder container using Jupyter kernels. That's how code gets executed on the Binder container, and how code output (such as terminal messages, images, etc) are received from the Binder container. ## Branches of this repo ![Course-repo](https://user-images.githubusercontent.com/3315629/60834054-a18a8980-a174-11e9-930d-c61df5faba7b.png) There are two branches of this repo, which are used for different tasks: - **master** - this is what the course is served out of via netlify: http://r-bootcamp.netlify.com - any changes to exercises in this branch will show up on the netlify page. The netlify page uses a JavaScript framework called Gatsby to build the pages. Gatsby submits code to binder and receives the output. It also handles the code checking. The parts of the repo that are handled by Gatsby include: 1. exercises/solutions/tests 2. chapter.md files 3. slides (using reveal.js) - **binder** - this is what the Binder image is built from. The reason they're different is that binder forces a Docker container rebuild when a branch is updated. So, if we served our container out of master, it would rebuild everytime we modified a `chapter.md` or an exercise. If you need to add packages, you will add them to the `binder/install.R` for this branch, and if you need to add datasets, you can add them to the `data/` folder. The parts of the repo handled by Binder include: 1. datasets in data/ folder (the container needs access to these to load data from submitted code) 2. installation instructions in the binder/ folder for installing dependencies I would say that the easiest thing to do is to occasionally merge `master` into `binder` when you need to update the data: Note that rebuilding the binder container can take a little bit of time (usually on the order of 5 or 10 minutes or so), since it is installing/compiling `tidyverse` for the container. You can always check the build status of the container by clicking the badge below and looking at the log. You can view the binder container here: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ines/course-starter-r/binder) or at: https://mybinder.org/v2/gh/ines/course-starter-r/binder - you can launch an Rstudio instance to test the container by using the "new" tab in the top right corner, and selecting 'Rstudio'. This is super helpful if you want to test if code will work in the binder container. ### Adding Packages If you need to add packages, add the appropriate `install.packages()` statement into `binder/install.R`. When you do, check that the container was built properly by clicking the binder link above. Currently, `tidyverse` is installed in the binder container. ### `data/` folder If you want to access datasets in the data folder, you can always refer to this folder as `data/`. For example, to use `data/pets.csv`: ``` pets <- read.csv("data/pets.csv") ``` Remember, if you need to add a dataset to the repo, it needs to be done in the `binder` branch into the `data/` folder. ### Using `decampr` to transfer your DataCamp repository If you would like to transfer your courses from DataCamp, there is a package made for that: [`decampr`](http://github.com/laderast/decampr). It will scan your repository and attempt to extract exercise instructions, quizzes, exercise code, and solutions and write them to the appropriate directory for your project. For more info, please check out the `decampr` repo: http://github.com/laderast/decampr ## 🎨 Customization The app separates its source and content – so you usually shouldn't have to dig into the JavaScript source to change things. The following points of customization are available: | Location | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `meta.json` | General config settings, title, description etc. | | `theme.sass` | Color theme. | | `binder/install.R` | Packages to install. | | `binder/runtime.txt` | YYYY-MM-DD snapshot at MRAN that will be used for installing libraries. [See here](https://github.com/binder-examples/r) for details. | | `chapters` | The chapters, one Markdown file per chapter. | | `slides` | The slides, one Markdown file per slide deck. | | `static` | Static assets like images, will be copied to the root. | ### `meta.json` The following meta settings are available. **Note that you have to re-start Gatsby to see the changes if you're editing it while the server is running.** | Setting | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `courseId` | Unique ID of the course. Will be used when saving completed exercises to the browser's local storage. | | `title` | The title of the course. | | `slogan` | Course slogan, displayed in the page title on the front page. | | `description` | Course description. Used for site meta and in footer. | | `bio` | Author bio. Used in the footer. | | `siteUrl` | URL of the deployed site (without trailing slash). | | `twitter` | Author twitter handle, used in Twitter cards meta. | | `fonts` | [Google Fonts](https://fonts.google.com) to load. Should be the font part of the URL in the embed string, e.g. `Lato:400,400i,700,700i`. | | `testTemplate` | Template used to validate the answers. `${solution}` will be replaced with the user code and `${test}` with the contents of the test file. | | `juniper.repo` | Repo to build on Binder in `user/repo` format. Usually the same as this repo. | | `juniper.branch` | Branch to build. Ideally not `master`, so the image is not rebuilt every time you push. | | `juniper.lang` | Code language for syntax highlighting. | | `juniper.kernelType` | The name of the kernel to use. | | `juniper.debug` | Logs additional debugging info to the console. | | `showProfileImage` | Whether to show the profile image in the footer. If `true`, a file `static/profile.jpg` needs to be available. | | `footerLinks` | List of objects with `"text"` and `"url"` to display as links in the footer. | | `theme` | Currently only used for the progressive web app, e.g. as the theme color on mobile. For the UI theme, edit `theme.sass`. | ### Static assets All files added to `/static` will become available at the root of the deployed site. So `/static/image.jpg` can be referenced in your course as `/image.jpg`. The following assets need to be available and can be customized: | File | Description | | ----------------- | -------------------------------------------------------- | | `icon.png` | Custom [favicon](https://en.wikipedia.org/wiki/Favicon). | | `logo.svg` | The course logo. | | `profile.jpg` | Photo or profile image. | | `social.jpg` | Social image, displayed in Twitter and Facebook cards. | | `icon_check.svg` | "Check" icon displayed on "Mark as completed" button. | | `icon_slides.svg` | Icon displayed in the corner of a slides exercise. | ## ✏️ Content ### File formats #### Chapters Chapters are placed in [`/chapters`](/chapters) and are Markdown files consisting of `` components. They'll be turned into pages, e.g. `/chapter1`. In their frontmatter block at the top of the file, they need to specify `type: chapter`, as well as the following meta: ```yaml --- title: The chapter title description: The chapter description prev: /chapter1 # exact path to previous chapter or null to not show a link next: /chapter3 # exact path to next chapter or null to not show a link id: 2 # unique identifier for chapter type: chapter # important: this creates a standalone page from the chapter --- ``` #### Slides Slides are placed in [`/slides`](/slides) and are markdown files consisting of slide content, separated by `---`. They need to specify the following frontmatter block at the top of the file: ```yaml --- type: slides --- ``` The **first and last slide** use a special layout and will display the headline in the center of the slide. **Speaker notes** (in this case, the script) can be added at the end of a slide, prefixed by `Notes:`. They'll then be shown on the right next to the slides. Here's an example slides file: ```markdown --- type: slides --- # Processing pipelines Notes: This is a slide deck about processing pipelines. --- # Next slide - Some bullet points here - And another bullet point An image located in /static ``` ### Custom Elements When using custom elements, make sure to place a newline between the opening/closing tags and the children. Otherwise, Markdown content may not render correctly. #### `` Container of a single exercise. | Argument | Type | Description | | ------------ | --------------- | -------------------------------------------------------------- | | `id` | number / string | Unique exercise ID within chapter. | | `title` | string | Exercise title. | | `type` | string | Optional type. `"slides"` makes container wider and adds icon. | | **children** | - | The contents of the exercise. | ```markdown Content goes here... ``` #### `` | Argument | Type | Description | | ------------ | --------------- | -------------------------------------------------------------------------------------------- | | `id` | number / string | Unique identifier of the code exercise. | | `source` | string | Name of the source file (without file extension). Defaults to `exc_${id}` if not set. | | `solution` | string | Name of the solution file (without file extension). Defaults to `solution_${id}` if not set. | | `test` | string | Name of the test file (without file extension). Defaults to `test_${id}` if not set. | | **children** | string | Optional hints displayed when the user clicks "Show hints". | ```markdown This is a hint! ``` #### `` Container to display slides interactively using Reveal.js and a Markdown file. | Argument | Type | Description | | -------- | ------ | --------------------------------------------- | | `source` | string | Name of slides file (without file extension). | ```markdown ``` #### `` Container for multiple-choice question. | Argument | Type | Description | | ------------ | --------------- | -------------------------------------------------------------------------------------------- | | `id` | string / number | Optional unique ID. Can be used if more than one choice question is present in one exercise. | | **children** | nodes | Only `` components for the options. | ```markdown You have selected option one! This is not good. Yay! ``` #### `` A multiple-choice option. | Argument | Type | Description | | ------------ | ------ | ---------------------------------------------------------------------------------------------- | | `text` | string | The option text to be displayed. Supports inline HTML. | | `correct` | string | `"true"` if the option is the correct answer. | | **children** | string | The text to be displayed if the option is selected (explaining why it's correct or incorrect). | ### Setting up Binder The [`install.R`](binder/install.R) in the repository defines the packages that are installed when building it with Binder. You can specify the binder settings like repo, branch and kernel type in the `"juniper"` section of the `meta.json`. I'd recommend running the very first build via the interface on the [Binder website](https://mybinder.org), as this gives you a detailed build log and feedback on whether everything worked as expected. Enter your repository URL, click "launch" and wait for it to install the dependencies and build the image. ![Binder](https://user-images.githubusercontent.com/13643239/39412757-a518d416-4c21-11e8-9dad-8b4cc14737bc.png) ### Adding tests To validate the code when the user hits "Submit", we're currently using a slightly hacky trick. Since the R code is sent back to the kernel as a string, we can manipulate it and add tests – for example, exercise `exc_01_02_01.R` will be validated using `test_01_02_01.R` (if available). The user code and test are combined using a string template. At the moment, the `testTemplate` in the `meta.json` looks like this: ```r success <- function(text) { cat(paste("\033[32m", text, "\033[0m", sep = "")) } .solution <- "${solutionEscaped}" ${solution} ${test} tryCatch({ test() }, error = function(e) { cat(paste("\033[31m", e[1], "\033[0m", sep = "")) }) ``` If present, `${solution}` will be replaced with the string value of the submitted user code, and `${solutionEscaped}` with the code but with all `"` replaced by `\"`, so we can assign it to a variable as a string and check whether the submission includes something. We also insert the regular solution, so we can actually run it and check the objects it creates. `${test}` is replaced by the contents of the test file. The template also defines a `success` function, which prints a formatted green message and can be used in the tests. Finally, the `tryCatch` expression checks if the test function raises a `stop` and if so, it outputs the formatted error message. This also hides the full error traceback (which can easily leak the correct answers). A test file could then look like this: ```r test <- function() { if (some_var != length(mtcars)) { stop("Are you getting the correct length?") } if (!grepl("print(mtcars$gear)", .solution, fixed = TRUE)) { stop("Are you printing the correct variable?") } success("Well done!") } ``` The string answer is available as `.solution`, and the test also has access to the solution code. --- For more details on how it all works behind the scenes, see [the original course repo](https://github.com/ines/spacy-course). ================================================ FILE: binder/install.R ================================================ install.packages("tidyverse") install.packages("rjson") ================================================ FILE: binder/runtime.txt ================================================ r-2019-04-10 ================================================ FILE: chapters/chapter1.md ================================================ --- title: 'Chapter 1: Getting started' description: 'This chapter will teach you about many cool things and introduce you to the most important concepts of the course.' prev: null next: /chapter2 type: chapter id: 1 --- Let's ask some questions about the slides. Whats the correct answer? This is not the correct answer. Good job! This is not correct either. This is a code exercise. The content can be formatted in simple Markdown – so you can have **bold text**, `code` or [links](https://spacy.io) or lists, like the one for the instructions below. - These are instructions and they can have bullet points. - The code block below will look for the files `exc_01_03`, `solution_01_03` and `test_01_03` in `/exercises`. This is a hint. ================================================ FILE: chapters/chapter2.md ================================================ --- title: 'Chapter 2: More stuff' description: 'This chapter will teach you even more stuff and help you learn some new concepts.' prev: /chapter1 next: null type: chapter id: 2 --- ================================================ FILE: exercises/exc_01_03.R ================================================ library(ggplot2) mtcars$gear <- factor(mtcars$gear,levels=c(3,4,5), labels=c("3gears","4gears","5gears")) mtcars$am <- factor(mtcars$am,levels=c(0,1), labels=c("Automatic","Manual")) mtcars$cyl <- factor(mtcars$cyl,levels=c(4,6,8), labels=c("4cyl","6cyl","8cyl")) # Print the gear variable of mtcars print(____) # Assign the length of mtcars to some_var some_var <- ____ # Uncomment this to see the plot # print(qplot(mpg, data=mtcars, geom="density", fill=gear, alpha=I(.5))) ================================================ FILE: exercises/solution_01_03.R ================================================ library(ggplot2) mtcars$gear <- factor(mtcars$gear,levels=c(3,4,5), labels=c("3gears","4gears","5gears")) mtcars$am <- factor(mtcars$am,levels=c(0,1), labels=c("Automatic","Manual")) mtcars$cyl <- factor(mtcars$cyl,levels=c(4,6,8), labels=c("4cyl","6cyl","8cyl")) # Print the gear variable of mtcars print(mtcars$gear) # Assign the length of mtcars to some_var some_var <- length(mtcars) # Uncomment this to see the plot # print(qplot(mpg, data=mtcars, geom="density", fill=gear, alpha=I(.5))) ================================================ FILE: exercises/test_01_03.R ================================================ test <- function() { # Here we can either check objects created in the solution code, or the # string value of the solution, available as .solution. See the testTemplate # in the meta.json for details. if (some_var != length(mtcars)) { stop("Are you getting the correct length?") } if (!grepl("print(mtcars$gear)", .solution, fixed = TRUE)) { stop("Are you printing the correct variable?") } # This function is defined in the testTemplate success("Well done!") } ================================================ FILE: gatsby-browser.js ================================================ import python from 'codemirror/mode/r/r' // eslint-disable-line no-unused-vars // This doesn't have to be here – but if we do import Juniper here, it's already // preloaded and cached when we dynamically import it in code.js. import Juniper from './src/components/juniper' // eslint-disable-line no-unused-vars ================================================ FILE: gatsby-config.js ================================================ const meta = require('./meta.json') const autoprefixer = require('autoprefixer') module.exports = { siteMetadata: meta, plugins: [ { resolve: `gatsby-plugin-sass`, options: { indentedSyntax: true, postCssPlugins: [autoprefixer()], cssLoaderOptions: { localIdentName: process.env.NODE_ENV == 'development' ? '[name]-[local]-[hash:8]' : '[hash:8]', }, }, }, `gatsby-plugin-react-helmet`, { resolve: `gatsby-source-filesystem`, options: { name: `chapters`, path: `${__dirname}/chapters`, }, }, { resolve: `gatsby-source-filesystem`, options: { name: `slides`, path: `${__dirname}/slides`, }, }, { resolve: `gatsby-source-filesystem`, options: { name: `exercises`, path: `${__dirname}/exercises`, }, }, { resolve: 'gatsby-plugin-react-svg', options: { rule: { include: /static/, }, }, }, { resolve: `gatsby-transformer-remark`, options: { plugins: [ `gatsby-remark-copy-linked-files`, { resolve: `gatsby-remark-prismjs`, options: { noInlineHighlight: true, }, }, { resolve: `gatsby-remark-smartypants`, options: { dashes: 'oldschool', }, }, { resolve: `gatsby-remark-images`, options: { maxWidth: 790, linkImagesToOriginal: true, sizeByPixelDensity: false, showCaptions: true, quality: 80, withWebp: { quality: 80 }, }, }, `gatsby-remark-unwrap-images`, ], }, }, `gatsby-transformer-sharp`, `gatsby-plugin-sharp`, `gatsby-plugin-sitemap`, { resolve: `gatsby-plugin-manifest`, options: { name: meta.title, short_name: meta.title, start_url: `/`, background_color: meta.theme, theme_color: meta.theme, display: `minimal-ui`, icon: `static/icon.png`, }, }, `gatsby-plugin-offline`, ], } ================================================ FILE: gatsby-node.js ================================================ const path = require('path') const { createFilePath } = require('gatsby-source-filesystem') const chapterTemplate = path.resolve('src/templates/chapter.js') function replacePath(pagePath) { return pagePath === `/` ? pagePath : pagePath.replace(/\/$/, ``) } async function onCreateNode({ node, actions, getNode, loadNodeContent, createNodeId, createContentDigest, }) { const { createNodeField, createNode, createParentChildLink } = actions if (node.internal.type === 'MarkdownRemark') { const slug = createFilePath({ node, getNode, basePath: 'chapters', trailingSlash: false }) createNodeField({ name: 'slug', node, value: slug }) } else if (node.extension === 'r') { // Load the contents of the Python file and make it available via GraphQL // https://www.gatsbyjs.org/docs/creating-a-transformer-plugin/ const content = await loadNodeContent(node) const contentDigest = createContentDigest(content) const id = createNodeId(`${node.id}-code`) const internal = { type: 'Code', contentDigest } const codeNode = { id, parent: node.id, children: [], code: content, name: node.name, internal, } createNode(codeNode) createParentChildLink({ parent: node, child: codeNode }) } } exports.onCreateNode = onCreateNode exports.createPages = ({ actions, graphql }) => { const { createPage } = actions return graphql(` { allMarkdownRemark { edges { node { frontmatter { title type } fields { slug } } } } } `).then(result => { if (result.errors) { return Promise.reject(result.errors) } const posts = result.data.allMarkdownRemark.edges.filter( ({ node }) => node.frontmatter.type == 'chapter' ) posts.forEach(({ node }) => { createPage({ path: replacePath(node.fields.slug), component: chapterTemplate, context: { slug: node.fields.slug }, }) }) }) } ================================================ FILE: meta.json ================================================ { "courseId": "course-starter-r", "title": "My cool online course", "slogan": "A free online course", "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam tristique libero at est congue, sed vestibulum tortor laoreet. Aenean egestas massa non commodo consequat. Curabitur faucibus, sapien vitae euismod imperdiet, arcu erat semper urna, in accumsan sapien dui ac mi. Pellentesque felis lorem, semper nec velit nec, consectetur placerat enim.", "bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam tristique libero at est congue, sed vestibulum tortor laoreet. Aenean egestas massa non commodo consequat. Curabitur faucibus, sapien vitae euismod imperdiet, arcu erat semper urna.", "siteUrl": "https://course-starter-r.netlify.com", "twitter": "spacy_io", "fonts": "IBM+Plex+Mono:500|IBM+Plex+Sans:700|Lato:400,400i,700,700i", "testTemplate": "success <- function(text) {\n cat(paste(\"\\033[32m\", text, \"\\033[0m\", sep = \"\"))\n}\n\n.solution <- \"${solutionEscaped}\"\n\n${solution}\n\n${test}\ntryCatch({\n test()\n}, error = function(e) {\n cat(paste(\"\\033[31m\", e[1], \"\\033[0m\", sep = \"\"))\n})", "juniper": { "repo": "ines/course-starter-r", "branch": "binder", "lang": "r", "kernelType": "ir", "debug": false }, "showProfileImage": true, "footerLinks": [ { "text": "Website", "url": "https://spacy.io" }, { "text": "Source", "url": "https://github.com/ines/course-starter-r" }, { "text": "Built with ♥", "url": "https://github.com/ines/course-starter-r" } ], "theme": "#de7878" } ================================================ FILE: package.json ================================================ { "name": "course-starter-r", "private": true, "description": "Starter package to build interactive R courses", "version": "0.0.1", "author": "Ines Montani ", "dependencies": { "@illinois/react-use-local-storage": "^1.1.0", "@jupyterlab/outputarea": "^0.19.1", "@jupyterlab/rendermime": "^0.19.1", "@phosphor/widgets": "^1.6.0", "autoprefixer": "^9.4.7", "classnames": "^2.2.6", "codemirror": "^5.43.0", "gatsby": "^2.1.4", "gatsby-image": "^2.0.29", "gatsby-plugin-manifest": "^2.0.17", "gatsby-plugin-offline": "^2.0.23", "gatsby-plugin-react-helmet": "^3.0.6", "gatsby-plugin-react-svg": "^2.1.1", "gatsby-plugin-sass": "^2.0.10", "gatsby-plugin-sharp": "^2.0.29", "gatsby-plugin-sitemap": "^2.0.5", "gatsby-remark-copy-linked-files": "^2.0.9", "gatsby-remark-images": "^3.0.4", "gatsby-remark-prismjs": "^3.2.4", "gatsby-remark-smartypants": "^2.0.8", "gatsby-remark-unwrap-images": "^1.0.1", "gatsby-source-filesystem": "^2.0.20", "gatsby-transformer-remark": "^2.2.5", "gatsby-transformer-sharp": "^2.1.17", "juniper-js": "^0.1.0", "node-sass": "^4.11.0", "prismjs": "^1.15.0", "react": "^16.8.2", "react-dom": "^16.8.2", "react-helmet": "^5.2.0", "rehype-react": "^3.1.0", "remark-react": "^5.0.1", "reveal.js": "^3.8.0" }, "scripts": { "build": "gatsby build", "dev": "gatsby develop", "lint": "eslint **", "clear": "rm -rf .cache", "test": "echo \"Write tests! -> https://gatsby.app/unit-testing\"" }, "devDependencies": { "browser-monads": "^1.0.0", "prettier": "^1.16.4" }, "repository": { "type": "git", "url": "https://github.com/ines/course-starter-python" } } ================================================ FILE: slides/chapter1_01_introduction.md ================================================ --- type: slides --- # Introduction Notes: Text at the end of a slide prefixed like this will be displayed as speaker notes on the side. Slides can be separated with a divider: ---. --- # This is a slide ```r # Print something print("Hello world", quote = FALSE) ``` ```out Hello world ``` - Slides can have code, bullet points, tables and pretty much all other Markdown elements. - This is another bullet point. This image is in /static Notes: Some more notes go here --- # Let's practice! Notes: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam tristique libero at est congue, sed vestibulum tortor laoreet. Aenean egestas massa non commodo consequat. Curabitur faucibus, sapien vitae euismod imperdiet, arcu erat semper urna, in accumsan sapien dui ac mi. Pellentesque felis lorem, semper nec velit nec, consectetur placerat enim. ================================================ FILE: src/components/button.js ================================================ import React from 'react' import classNames from 'classnames' import IconCheck from '../../static/icon_check.svg' import classes from '../styles/button.module.sass' export const Button = ({ Component = 'button', children, onClick, variant, small, className }) => { const buttonClassNames = classNames(classes.root, className, { [classes.primary]: variant === 'primary', [classes.secondary]: variant === 'secondary', [classes.small]: !!small, }) return ( {children} ) } export const CompleteButton = ({ completed, toggleComplete, small = true }) => { const buttonClassNames = classNames({ [classes.completeInactive]: !completed, [classes.completeActive]: completed, }) return ( ) } ================================================ FILE: src/components/choice.js ================================================ import React, { useState, useCallback } from 'react' import classNames from 'classnames' import { Button } from './button' import classes from '../styles/choice.module.sass' const Choice = ({ id = '0', children = [] }) => { const [selected, setSelected] = useState(null) const [answer, setAnswer] = useState(null) const handleAnswer = useCallback(() => setAnswer(selected), [selected]) const options = children.filter(child => child !== '\n') return ( <> {options.map(({ key, props }, i) => (

setSelected(i)} />

))} {options.map(({ key, props }, i) => { const isCorrect = !!props.correct return answer === i ? (
{isCorrect ? "That's correct! " : 'Incorrect. '} {props.children}
) : null })} ) } export const Option = ({ children }) => { return children } export default Choice ================================================ FILE: src/components/code.js ================================================ import React from 'react' import { StaticQuery, graphql } from 'gatsby' import { Hint } from './hint' import { Button } from './button' import classes from '../styles/code.module.sass' function getFiles({ allCode }) { return Object.assign( {}, ...allCode.edges.map(({ node }) => ({ [node.name]: node.code, })) ) } function makeTest(template, testFile, solution) { // Escape quotation marks in the solution code, for cases where we // can only place the solution in regular quotes. const solutionEscaped = solution.replace(/"/g, '\\"') return template .replace(/\${solutionEscaped}/g, solutionEscaped) .replace(/\${solution}/g, solution) .replace(/\${test}/g, testFile) } class CodeBlock extends React.Component { state = { Juniper: null, showSolution: false, key: 0 } handleShowSolution() { this.setState({ showSolution: true }) } handleReset() { // Using the key as a hack to force component to rerender this.setState({ showSolution: false, key: this.state.key + 1 }) } updateJuniper() { // This type of stuff only really works in class components. I'm not // sure why, but I've tried with function components and hooks lots of // times and couldn't get it to work. So class component it is. if (!this.state.Juniper) { // We need a dynamic import here for SSR. Juniper's dependencies // include references to the global window object and I haven't // managed to fix this using webpack yet. If we imported Juniper // at the top level, Gatsby won't build. import('./juniper').then(Juniper => { this.setState({ Juniper: Juniper.default }) }) } } componentDidMount() { this.updateJuniper() } componentDidUpdate() { this.updateJuniper() } render() { const { Juniper, showSolution } = this.state const { id, source, solution, test, children } = this.props const sourceId = source || `exc_${id}` const solutionId = solution || `solution_${id}` const testId = test || `test_${id}` const juniperClassNames = { cell: classes.cell, input: classes.input, button: classes.button, output: classes.output, } const hintActions = [ { text: 'Show solution', onClick: () => this.handleShowSolution() }, { text: 'Reset', onClick: () => this.handleReset() }, ] return ( { const { testTemplate } = data.site.siteMetadata const { repo, branch, kernelType, debug, lang } = data.site.siteMetadata.juniper const files = getFiles(data) const sourceFile = files[sourceId] const solutionFile = files[solutionId] const testFile = files[testId] return (
{Juniper && ( ( <> {testFile && ( )} )} > {showSolution ? solutionFile : sourceFile} )} {children}
) }} /> ) } } export default CodeBlock ================================================ FILE: src/components/exercise.js ================================================ import React, { useRef, useCallback, useContext, useEffect } from 'react' import classNames from 'classnames' import { Button, CompleteButton } from './button' import { ChapterContext } from '../context' import IconSlides from '../../static/icon_slides.svg' import classes from '../styles/exercise.module.sass' const Exercise = ({ id, title, type, children }) => { const excRef = useRef() const excId = parseInt(id) const { activeExc, setActiveExc, completed, setCompleted } = useContext(ChapterContext) const isExpanded = activeExc === excId const isCompleted = completed.includes(excId) useEffect(() => { if (isExpanded && excRef.current) { excRef.current.scrollIntoView() } }, [isExpanded]) const handleExpand = useCallback(() => setActiveExc(isExpanded ? null : excId), [ isExpanded, excId, ]) const handleNext = useCallback(() => setActiveExc(excId + 1)) const handleSetCompleted = useCallback(() => { const newCompleted = isCompleted ? completed.filter(v => v !== excId) : [...completed, excId] setCompleted(newCompleted) }, [isCompleted, completed, excId]) const rootClassNames = classNames(classes.root, { [classes.expanded]: isExpanded, [classes.wide]: isExpanded && type === 'slides', [classes.completed]: !isExpanded && isCompleted, }) const titleClassNames = classNames(classes.title, { [classes.titleExpanded]: isExpanded, }) return (

{excId} {title} {type === 'slides' && }

{isExpanded && (
{children}
)}
) } export default Exercise ================================================ FILE: src/components/hint.js ================================================ import React, { useState, useCallback } from 'react' import classes from '../styles/hint.module.sass' export const Hint = ({ expanded = false, actions = [], children }) => { const [isExpanded, setIsExpanded] = useState(expanded) const handleExpand = useCallback(() => setIsExpanded(!isExpanded), [isExpanded]) return ( ) } ================================================ FILE: src/components/juniper.js ================================================ import React from 'react' import PropTypes from 'prop-types' import CodeMirror from 'codemirror' import { Widget } from '@phosphor/widgets' import { Kernel, ServerConnection } from '@jupyterlab/services' import { OutputArea, OutputAreaModel } from '@jupyterlab/outputarea' import { RenderMimeRegistry, standardRendererFactories } from '@jupyterlab/rendermime' import { window } from 'browser-monads' class Juniper extends React.Component { outputRef = null inputRef = null state = { content: null, cm: null, kernel: null, renderers: null, fromStorage: null } static defaultProps = { children: '', branch: 'master', url: 'https://mybinder.org', serverSettings: {}, kernelType: 'python3', lang: 'python', theme: 'default', isolateCells: true, useBinder: true, storageKey: 'juniper', useStorage: true, storageExpire: 60, debug: true, msgButton: 'run', msgLoading: 'Loading...', msgError: 'Connecting failed. Please reload and try again.', classNames: { cell: 'juniper-cell', input: 'juniper-input', button: 'juniper-button', output: 'juniper-output', }, } static propTypes = { children: PropTypes.string, repo: PropTypes.string.isRequired, branch: PropTypes.string, url: PropTypes.string, serverSettings: PropTypes.object, kernelType: PropTypes.string, lang: PropTypes.string, theme: PropTypes.string, isolateCells: PropTypes.bool, useBinder: PropTypes.bool, useStorage: PropTypes.bool, storageExpire: PropTypes.number, msgButton: PropTypes.string, msgLoading: PropTypes.string, msgError: PropTypes.string, classNames: PropTypes.shape({ cell: PropTypes.string, input: PropTypes.string, button: PropTypes.string, output: PropTypes.string, }), actions: PropTypes.func, } componentDidMount() { this.setState({ content: this.props.children }) const renderers = standardRendererFactories.filter(factory => factory.mimeTypes.includes('text/latex') ? window.MathJax : true ) const outputArea = new OutputArea({ model: new OutputAreaModel({ trusted: true }), rendermime: new RenderMimeRegistry({ initialFactories: renderers }), }) const cm = new CodeMirror(this.inputRef, { value: this.props.children.trim(), mode: this.props.lang, theme: this.props.theme, }) this.setState({ cm }) const runCode = wrapper => { const value = cm.getValue() this.execute(outputArea, wrapper ? wrapper(value) : value) } const setValue = value => cm.setValue(value) cm.setOption('extraKeys', { 'Shift-Enter': runCode }) Widget.attach(outputArea, this.outputRef) this.setState({ runCode, setValue }) } log(logFunction) { if (this.props.debug) { logFunction() } } componentWillReceiveProps({ children }) { if (children !== this.state.content && this.state.cm) { this.state.cm.setValue(children.trim()) } } /** * Request a binder, e.g. from mybinder.org * @param {string} repo - Repository name in the format 'user/repo'. * @param {string} branch - The repository branch, e.g. 'master'. * @param {string} url - The binder reployment URL, including 'http(s)'. * @returns {Promise} - Resolved with Binder settings, rejected with Error. */ requestBinder(repo, branch, url) { const binderUrl = `${url}/build/gh/${repo}/${branch}` this.log(() => console.info('building', { binderUrl })) return new Promise((resolve, reject) => { const es = new EventSource(binderUrl) es.onerror = err => { es.close() this.log(() => console.error('failed')) reject(new Error(err)) } let phase = null es.onmessage = ({ data }) => { const msg = JSON.parse(data) if (msg.phase && msg.phase !== phase) { phase = msg.phase.toLowerCase() this.log(() => console.info(phase === 'ready' ? 'server-ready' : phase)) } if (msg.phase === 'failed') { es.close() reject(new Error(msg)) } else if (msg.phase === 'ready') { es.close() const settings = { baseUrl: msg.url, wsUrl: `ws${msg.url.slice(4)}`, token: msg.token, } resolve(settings) } } }) } /** * Request kernel and estabish a server connection via the JupyerLab service * @param {object} settings - The server settings. * @returns {Promise} - A promise that's resolved with the kernel. */ requestKernel(settings) { if (this.props.useStorage) { const timestamp = new Date().getTime() + this.props.storageExpire * 60 * 1000 const json = JSON.stringify({ settings, timestamp }) window.localStorage.setItem(this.props.storageKey, json) } const serverSettings = ServerConnection.makeSettings(settings) return Kernel.startNew({ type: this.props.kernelType, name: this.props.kernelType, serverSettings, }).then(kernel => { this.log(() => console.info('ready')) return kernel }) } /** * Get a kernel by requesting a binder or from localStorage / user settings * @returns {Promise} */ getKernel() { if (this.props.useStorage) { const stored = window.localStorage.getItem(this.props.storageKey) if (stored) { this.setState({ fromStorage: true }) const { settings, timestamp } = JSON.parse(stored) if (timestamp && new Date().getTime() < timestamp) { return this.requestKernel(settings) } window.localStorage.removeItem(this.props.storageKey) } } if (this.props.useBinder) { return this.requestBinder(this.props.repo, this.props.branch, this.props.url).then( settings => this.requestKernel(settings) ) } return this.requestKernel(this.props.serverSettings) } /** * Render the kernel response in a JupyterLab output area * @param {OutputArea} outputArea - The cell's output area. * @param {string} code - The code to execute. */ renderResponse(outputArea, code) { outputArea.future = this.state.kernel.requestExecute({ code }) outputArea.model.add({ output_type: 'stream', name: 'loading', text: this.props.msgLoading, }) outputArea.model.clear(true) } /** * Process request to execute the code * @param {OutputArea} - outputArea - The cell's output area. * @param {string} code - The code to execute. */ execute(outputArea, code) { this.log(() => console.info('executing')) if (this.state.kernel) { if (this.props.isolateCells) { this.state.kernel .restart() .then(() => this.renderResponse(outputArea, code)) .catch(() => { this.log(() => console.error('failed')) this.setState({ kernel: null }) outputArea.model.clear() outputArea.model.add({ output_type: 'stream', name: 'failure', text: this.props.msgError, }) }) return } this.renderResponse(outputArea, code) return } this.log(() => console.info('requesting kernel')) const url = this.props.url.split('//')[1] const action = !this.state.fromStorage ? 'Launching' : 'Reconnecting to' outputArea.model.clear() outputArea.model.add({ output_type: 'stream', name: 'stdout', text: `${action} Docker container on ${url}...`, }) new Promise((resolve, reject) => this.getKernel() .then(resolve) .catch(reject) ) .then(kernel => { this.setState({ kernel }) this.renderResponse(outputArea, code) }) .catch(() => { this.log(() => console.error('failed')) this.setState({ kernel: null }) if (this.props.useStorage) { this.setState({ fromStorage: false }) window.localStorage.removeItem(this.props.storageKey) } outputArea.model.clear() outputArea.model.add({ output_type: 'stream', name: 'failure', text: this.props.msgError, }) }) } render() { return (
{ this.inputRef = x }} /> {this.props.msgButton && ( )} {this.props.actions && this.props.actions(this.state)}
{ this.outputRef = x }} className={this.props.classNames.output} />
) } } export default Juniper ================================================ FILE: src/components/layout.js ================================================ import React from 'react' import { StaticQuery, graphql } from 'gatsby' import SEO from './seo' import { Link } from './link' import { H3 } from './typography' import Logo from '../../static/logo.svg' import '../styles/index.sass' import classes from '../styles/layout.module.sass' const Layout = ({ isHome, title, description, children }) => { return ( { const meta = data.site.siteMetadata return ( <>
{!isHome && (

)}
{(title || description) && (
{title &&

{title}

} {description && (

{description}

)}
)} {children}

About this course

{meta.description}

About me

{meta.showProfileImage && ( )}

{meta.bio}

{meta.footerLinks && (
    {meta.footerLinks.map(({ text, url }, i) => (
  • {text}
  • ))}
)}
) }} /> ) } export default Layout ================================================ FILE: src/components/link.js ================================================ import React from 'react' import PropTypes from 'prop-types' import { Link as GatsbyLink } from 'gatsby' import classNames from 'classnames' import classes from '../styles/link.module.sass' export const Link = ({ children, to, href, onClick, variant, hidden, className, ...other }) => { const dest = to || href const external = /(http(s?)):\/\//gi.test(dest) const linkClassNames = classNames(classes.root, className, { [classes.hidden]: hidden, [classes.secondary]: variant === 'secondary', }) if (!external) { if ((dest && /^#/.test(dest)) || onClick) { return ( {children} ) } return ( {children} ) } return ( {children} ) } Link.propTypes = { children: PropTypes.node.isRequired, to: PropTypes.string, href: PropTypes.string, onClick: PropTypes.func, variant: PropTypes.oneOf(['secondary', null]), hidden: PropTypes.bool, className: PropTypes.string, } ================================================ FILE: src/components/seo.js ================================================ import React from 'react' import Helmet from 'react-helmet' import { StaticQuery, graphql } from 'gatsby' const SEO = ({ title, description }) => ( { const lang = 'en' const siteMetadata = data.site.siteMetadata const pageTitle = title ? `${title} · ${siteMetadata.title}` : `${siteMetadata.title} · ${siteMetadata.slogan}` const pageDesc = description || siteMetadata.description const image = `${siteMetadata.siteUrl}/social.jpg` const meta = [ { name: 'description', content: pageDesc, }, { property: 'og:title', content: pageTitle, }, { property: 'og:description', content: pageDesc, }, { property: 'og:type', content: `website`, }, { property: 'og:site_name', content: siteMetadata.title, }, { property: 'og:image', content: image, }, { name: 'twitter:card', content: 'summary_large_image', }, { name: 'twitter:image', content: image, }, { name: 'twitter:creator', content: `@${siteMetadata.twitter}`, }, { name: 'twitter:site', content: `@${siteMetadata.twitter}`, }, { name: 'twitter:title', content: pageTitle, }, { name: 'twitter:description', content: pageDesc, }, ] return ( {siteMetadata.fonts && ( )} ) }} /> ) export default SEO const query = graphql` query DefaultSEOQuery { site { siteMetadata { title description slogan siteUrl twitter fonts } } } ` ================================================ FILE: src/components/slides.js ================================================ import React from 'react' import { StaticQuery, graphql } from 'gatsby' import Marked from 'reveal.js/plugin/markdown/marked.js' import classNames from 'classnames' import '../styles/reveal.css' import classes from '../styles/slides.module.sass' function getFiles({ allMarkdownRemark }) { return Object.assign( {}, ...allMarkdownRemark.edges.map(({ node }) => ({ [node.fields.slug.replace('/', '')]: node.rawMarkdownBody, })) ) } function getSlideContent(data, source) { const files = getFiles(data) const file = files[source] || '' return file.split('\n---\n').map(f => f.trim()) } class Slides extends React.Component { componentDidMount() { import('reveal.js').then(({ default: Reveal }) => { window.Reveal = Reveal window.marked = Marked import('reveal.js/plugin/markdown/markdown.js').then(({ RevealMarkdown }) => { RevealMarkdown.init() Reveal.initialize({ center: false, progress: false, showNotes: true, controls: true, width: '100%', height: 600, minScale: 0.75, maxScale: 1, }) }) }) } componentWillUnmount() { // Work around default reveal.js behaviour that doesn't allow // re-initialization and clashes with React delete window.Reveal delete window.marked delete require.cache[require.resolve('reveal.js')] delete require.cache[require.resolve('reveal.js/plugin/markdown/markdown.js')] } render() { const { source } = this.props const revealClassNames = classNames('reveal', 'show-notes', classes.reveal) const slideClassNames = classNames('slides', classes.slides) return (
{ const content = getSlideContent(data, source) return (
{content.map((markdown, i) => (