From 5369ebee11492631e21cd9222de1e5e8ed969830 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 24 Nov 2017 09:57:49 -0800 Subject: [PATCH] Embed animated examples in Markdown --- content/docs/addons-animation.md | 139 +---------------- content/docs/hello-world.md | 7 +- examples/animation/animation.css | 17 +++ examples/animation/custom-classes.js | 21 +++ examples/animation/image-carousel.js | 18 +++ examples/animation/initial-mounting.js | 15 ++ .../animation/invalid-unmounted-example.js | 27 ++++ examples/animation/react-transition-group.js | 58 +++++++ gatsby-config.js | 8 + plugins/gatsby-remark-embed-snippet/README.md | 120 +++++++++++++++ plugins/gatsby-remark-embed-snippet/index.js | 141 ++++++++++++++++++ .../gatsby-remark-embed-snippet/package.json | 29 ++++ 12 files changed, 461 insertions(+), 139 deletions(-) create mode 100644 examples/animation/animation.css create mode 100644 examples/animation/custom-classes.js create mode 100644 examples/animation/image-carousel.js create mode 100644 examples/animation/initial-mounting.js create mode 100644 examples/animation/invalid-unmounted-example.js create mode 100644 examples/animation/react-transition-group.js create mode 100644 plugins/gatsby-remark-embed-snippet/README.md create mode 100644 plugins/gatsby-remark-embed-snippet/index.js create mode 100644 plugins/gatsby-remark-embed-snippet/package.json diff --git a/content/docs/addons-animation.md b/content/docs/addons-animation.md index 40c110ac020..989a91ff427 100644 --- a/content/docs/addons-animation.md +++ b/content/docs/addons-animation.md @@ -27,48 +27,7 @@ import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; // ES6 var ReactCSSTransitionGroup = require('react-addons-css-transition-group'); // ES5 with npm ``` -```javascript{31-36} -class TodoList extends React.Component { - constructor(props) { - super(props); - this.state = {items: ['hello', 'world', 'click', 'me']}; - this.handleAdd = this.handleAdd.bind(this); - } - - handleAdd() { - const newItems = this.state.items.concat([ - prompt('Enter some text') - ]); - this.setState({items: newItems}); - } - - handleRemove(i) { - let newItems = this.state.items.slice(); - newItems.splice(i, 1); - this.setState({items: newItems}); - } - - render() { - const items = this.state.items.map((item, i) => ( -
this.handleRemove(i)}> - {item} -
- )); - - return ( -
- - - {items} - -
- ); - } -} -``` +`embed:animation/react-transition-group.js` > Note: > @@ -78,25 +37,7 @@ In this component, when a new item is added to `ReactCSSTransitionGroup` it will You can use these classes to trigger a CSS animation or transition. For example, try adding this CSS and adding a new list item: -```css -.example-enter { - opacity: 0.01; -} - -.example-enter.example-enter-active { - opacity: 1; - transition: opacity 500ms ease-in; -} - -.example-leave { - opacity: 1; -} - -.example-leave.example-leave-active { - opacity: 0.01; - transition: opacity 300ms ease-in; -} -``` +`embed:animation/animation.css` You'll notice that animation durations need to be specified in both the CSS and the render method; this tells React when to remove the animation classes from the element and -- if it's leaving -- when to remove the element from the DOM. @@ -104,20 +45,7 @@ You'll notice that animation durations need to be specified in both the CSS and `ReactCSSTransitionGroup` provides the optional prop `transitionAppear`, to add an extra transition phase at the initial mount of the component. There is generally no transition phase at the initial mount as the default value of `transitionAppear` is `false`. The following is an example which passes the prop `transitionAppear` with the value `true`. -```javascript{5-6} -render() { - return ( - -

Fading at Initial Mount

-
- ); -} -``` +`embed:animation/initial-mounting.js` During the initial mount `ReactCSSTransitionGroup` will get the `example-appear` CSS class and the `example-appear-active` CSS class added in the next tick. @@ -144,30 +72,7 @@ At the initial mount, all children of the `ReactCSSTransitionGroup` will `appear It is also possible to use custom class names for each of the steps in your transitions. Instead of passing a string into transitionName you can pass an object containing either the `enter` and `leave` class names, or an object containing the `enter`, `enter-active`, `leave-active`, and `leave` class names. If only the enter and leave classes are provided, the enter-active and leave-active classes will be determined by appending '-active' to the end of the class name. Here are two examples using custom classes: -```javascript -// ... - - {item} - - - - {item2} - -// ... -``` +`embed:animation/custom-classes.js` ### Animation Group Must Be Mounted To Work @@ -175,45 +80,13 @@ In order for it to apply transitions to its children, the `ReactCSSTransitionGro The example below would **not** work, because the `ReactCSSTransitionGroup` is being mounted along with the new item, instead of the new item being mounted within it. Compare this to the [Getting Started](#getting-started) section above to see the difference. -```javascript{4,6,13} -render() { - const items = this.state.items.map((item, i) => ( -
this.handleRemove(i)}> - - {item} - -
- )); - - return ( -
- - {items} -
- ); -} -``` +`embed:animation/invalid-unmounted-example.js` ### Animating One or Zero Items In the example above, we rendered a list of items into `ReactCSSTransitionGroup`. However, the children of `ReactCSSTransitionGroup` can also be one or zero items. This makes it possible to animate a single element entering or leaving. Similarly, you can animate a new element replacing the current element. For example, we can implement a simple image carousel like this: -```javascript{10} -import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; - -function ImageCarousel(props) { - return ( -
- - - -
- ); -} -``` +`embed:animation/image-carousel.js` ### Disabling Animations diff --git a/content/docs/hello-world.md b/content/docs/hello-world.md index be30b5b9b0f..fabdcf2fb33 100644 --- a/content/docs/hello-world.md +++ b/content/docs/hello-world.md @@ -16,12 +16,7 @@ The easiest way to get started with React is to use [this Hello World example co The smallest React example looks like this: -```js -ReactDOM.render( -

Hello, world!

, - document.getElementById('root') -); -``` +```embed:hello-world.js``` It renders a header saying "Hello, world!" on the page. diff --git a/examples/animation/animation.css b/examples/animation/animation.css new file mode 100644 index 00000000000..002137d5667 --- /dev/null +++ b/examples/animation/animation.css @@ -0,0 +1,17 @@ +.example-enter { + opacity: 0.01; +} + +.example-enter.example-enter-active { + opacity: 1; + transition: opacity 500ms ease-in; +} + +.example-leave { + opacity: 1; +} + +.example-leave.example-leave-active { + opacity: 0.01; + transition: opacity 300ms ease-in; +} \ No newline at end of file diff --git a/examples/animation/custom-classes.js b/examples/animation/custom-classes.js new file mode 100644 index 00000000000..698e4de685b --- /dev/null +++ b/examples/animation/custom-classes.js @@ -0,0 +1,21 @@ +
+ + {item} + + + {item2} + +
; diff --git a/examples/animation/image-carousel.js b/examples/animation/image-carousel.js new file mode 100644 index 00000000000..d37da66e31d --- /dev/null +++ b/examples/animation/image-carousel.js @@ -0,0 +1,18 @@ +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; + +function ImageCarousel(props) { + return ( +
+ + {/* highlight-range{1-4} */} + + +
+ ); +} diff --git a/examples/animation/initial-mounting.js b/examples/animation/initial-mounting.js new file mode 100644 index 00000000000..82505e63293 --- /dev/null +++ b/examples/animation/initial-mounting.js @@ -0,0 +1,15 @@ +class MyComponent extends Component { + render() { + return ( + // highlight-range{3,4} + +

Fading at Initial Mount

+
+ ); + } +} diff --git a/examples/animation/invalid-unmounted-example.js b/examples/animation/invalid-unmounted-example.js new file mode 100644 index 00000000000..01fc4befd98 --- /dev/null +++ b/examples/animation/invalid-unmounted-example.js @@ -0,0 +1,27 @@ +class MyComponent extends Component { + render() { + const items = this.state.items.map( + (item, i) => ( +
+ this.handleRemove(i)}> + {/* highlight-range{1,3} */} + + {item} + +
+ ) + ); + + return ( +
+ + {items /* highlight-line */} +
+ ); + } +} diff --git a/examples/animation/react-transition-group.js b/examples/animation/react-transition-group.js new file mode 100644 index 00000000000..7c35cfb0d09 --- /dev/null +++ b/examples/animation/react-transition-group.js @@ -0,0 +1,58 @@ +class TodoList extends React.Component { + constructor(props) { + super(props); + this.state = { + items: [ + 'hello', + 'world', + 'click', + 'me', + ], + }; + this.handleAdd = this.handleAdd.bind( + this + ); + } + + handleAdd() { + const newItems = this.state.items.concat( + [prompt('Enter some text')] + ); + this.setState({items: newItems}); + } + + handleRemove(i) { + let newItems = this.state.items.slice(); + newItems.splice(i, 1); + this.setState({items: newItems}); + } + + render() { + const items = this.state.items.map( + (item, i) => ( +
+ this.handleRemove(i)}> + {item} +
+ ) + ); + + return ( +
+ + {/* highlight-range{1-6} */} + + {items} + +
+ ); + } +} diff --git a/gatsby-config.js b/gatsby-config.js index 6c65bdf2958..148375593e5 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -56,10 +56,18 @@ module.exports = { }, }, 'gatsby-remark-autolink-headers', + { + resolve: 'gatsby-remark-embed-snippet', + options: { + classPrefix: 'gatsby-code-', + directory: `${__dirname}/examples/`, + }, + }, { resolve: 'gatsby-remark-code-repls', options: { defaultText: 'Try it on CodePen', + dependencies: ['react', 'react-dom'], directory: `${__dirname}/examples/`, externals: [ `//unpkg.com/react/umd/react.development.js`, diff --git a/plugins/gatsby-remark-embed-snippet/README.md b/plugins/gatsby-remark-embed-snippet/README.md new file mode 100644 index 00000000000..fb5c82ff597 --- /dev/null +++ b/plugins/gatsby-remark-embed-snippet/README.md @@ -0,0 +1,120 @@ +# gatsby-remark-embed-snippet + +Embeds the contents of specified files within Prism-formatted HTML blocks. + +## Overview + +### Embedding Files + +For example, given the following project directory structure: +``` +./examples/ +├── sample-javascript-file.js +├── sample-html-file.html +``` + +The following markdown syntax could be used to embed the contents of these files: +```md +# Sample JavaScript +`embed:sample-javascript-file.js` + +# Sample HTML +`embed:sample-html-file.html` +``` + +The resulting HTML for the above markdown would look something like this: +```html +

Sample JavaScript

+
+
+    
+      
+    
+  
+
+ +

Sample HTML

+
+
+    
+      
+    
+  
+
+``` + +### Highlighting Lines + +You can also specify specific lines for Prism to highlight using `highlight-line` and `highlight-next-line` comments. + +#### JavaScript example +```js +import React from 'react'; +import ReactDOM from 'react-dom'; + +// highlight-next-line +ReactDOM.render( +

Hello, world!

, + document.getElementById('root') +); // highlight-line +``` + +#### CSS example +```css +html { + /* highlight-next-line */ + height: 100%; +} + +* { + box-sizing: border-box; /* highlight-line */ +} +``` + +#### HTML example +```html + + +

highlight me

+

+ + And me +

+ + +``` + +#### YAML example +```yaml +foo: 1 # highlight-line +bar: 2 +# highlight-next-line +baz: 3 +``` + +## Installation + +`npm install --save gatsby-remark-embed-snippet` + +## Usage + +```javascript +// In your gatsby-config.js +{ + resolve: 'gatsby-remark-embed-snippet', + options: { + // Class prefix for
 tags containing syntax highlighting;
+    // defaults to 'language-' (eg 
).
+    // If your site loads Prism into the browser at runtime,
+    // (eg for use with libraries like react-live),
+    // you may use this to prevent Prism from re-processing syntax.
+    // This is an uncommon use-case though;
+    // If you're unsure, it's best to use the default value.
+    classPrefix: 'language-',
+
+    // Example code links are relative to this dir.
+    // eg examples/path/to/file.js
+    directory: `${__dirname}/examples/`,
+  },
+},
+```
\ No newline at end of file
diff --git a/plugins/gatsby-remark-embed-snippet/index.js b/plugins/gatsby-remark-embed-snippet/index.js
new file mode 100644
index 00000000000..d31b0492da6
--- /dev/null
+++ b/plugins/gatsby-remark-embed-snippet/index.js
@@ -0,0 +1,141 @@
+'use strict';
+
+const fs = require(`fs`);
+const normalizePath = require(`normalize-path`);
+const rangeParser = require(`parse-numeric-range`);
+const visit = require(`unist-util-visit`);
+
+// HACK: It would be nice to find a better way to share this utility code.
+const highlightCode = require(`gatsby-remark-prismjs/highlight-code`);
+
+// Language defaults to extension.toLowerCase();
+// This map tracks languages that don't match their extension.
+const FILE_EXTENSION_TO_LANGUAGE_MAP = {
+  js: `jsx`,
+  md: `markup`,
+  sh: `bash`,
+};
+
+const HIGHLIGHT_LINE_REGEX = /\s+(\{\/\*|\/\*|\/\/|)*/;
+
+const getLanguage = file => {
+  if (!file.includes(`.`)) {
+    return `none`;
+  }
+
+  const extension = file.split(`.`).pop();
+
+  return FILE_EXTENSION_TO_LANGUAGE_MAP.hasOwnProperty(extension)
+    ? FILE_EXTENSION_TO_LANGUAGE_MAP[extension]
+    : extension.toLowerCase();
+};
+
+module.exports = (
+  {markdownAST},
+  {classPrefix = `language-`, directory} = {},
+) => {
+  if (!directory) {
+    throw Error(`Required option "directory" not specified`);
+  } else if (!fs.existsSync(directory)) {
+    throw Error(`Invalid directory specified "${directory}"`);
+  } else if (!directory.endsWith(`/`)) {
+    directory += `/`;
+  }
+
+  visit(markdownAST, `inlineCode`, node => {
+    const {value} = node;
+
+    if (value.startsWith(`embed:`)) {
+      const file = value.substr(6);
+      const path = normalizePath(`${directory}${file}`);
+
+      if (!fs.existsSync(path)) {
+        throw Error(`Invalid snippet specified; no such file "${path}"`);
+      }
+
+      // Parse file contents and extract highlight markers:
+      // highlight-line, highlight-next-line, highlight-range
+      // We support JS, JSX, HTML, CSS, and YAML style comments.
+      // Turn them into an Array format as expected by highlightCode().
+      // The order if these operations is important!
+      // Filtering next-line comments impacts line-numbers for same-line comments.
+      const highlightLines = [];
+      const code = fs
+        .readFileSync(path, `utf8`)
+        .split(`\n`)
+        .filter((line, index) => {
+          if (line.includes(`highlight-next-line`)) {
+            // Although we're highlighting the next line,
+            // We can use the current index since we also filter this lines.
+            // (Highlight line numbers are 1-based).
+            highlightLines.push(index + 1);
+
+            // Strip lines that contain highlight-next-line comments.
+            return false;
+          } else if (line.includes(`highlight-range`)) {
+            const match = line.match(/highlight-range{([^}]+)}/);
+            if (!match) {
+              console.warn(`Invalid match specified: "${line.trim()}"`);
+              return false;
+            }
+            const range = match[1];
+
+            // Highlight line numbers are 1-based but so are offsets.
+            // Remember that the current line (index) will be removed.
+            rangeParser.parse(range).forEach(offset => {
+              highlightLines.push(index + offset);
+            });
+
+            // Strip lines that contain highlight-range comments.
+            return false;
+          }
+
+          return true;
+        })
+        .map((line, index) => {
+          if (line.includes(`highlight-line`)) {
+            // Mark this line for highlighting.
+            // (Highlight line numbers are 1-based).
+            highlightLines.push(index + 1);
+
+            // Strip the highlight comment itself.
+            return line.replace(HIGHLIGHT_LINE_REGEX, ``);
+          }
+
+          return line;
+        })
+        .join(`\n`)
+        .trim();
+
+      // PrismJS's theme styles are targeting pre[class*="language-"]
+      // to apply its styles. We do the same here so that users
+      // can apply a PrismJS theme and get the expected, ready-to-use
+      // outcome without any additional CSS.
+      //
+      // @see https://github.com/PrismJS/prism/blob/1d5047df37aacc900f8270b1c6215028f6988eb1/themes/prism.css#L49-L54
+      const language = getLanguage(file);
+
+      // Allow users to specify a custom class prefix to avoid breaking
+      // line highlights if Prism is required by any other code.
+      // This supports custom user styling without causing Prism to
+      // re-process our already-highlighted markup.
+      // @see https://github.com/gatsbyjs/gatsby/issues/1486
+      const className = language
+        .split(` `)
+        .map(token => `${classPrefix}${token}`)
+        .join(` `);
+
+      // Replace the node with the markup we need to make 100% width highlighted code lines work
+      node.type = `html`;
+      node.value = `
+
${highlightCode(
+        language,
+        code,
+        highlightLines,
+      )}
+
`; + } + }); + + return markdownAST; +}; diff --git a/plugins/gatsby-remark-embed-snippet/package.json b/plugins/gatsby-remark-embed-snippet/package.json new file mode 100644 index 00000000000..35935b93bcf --- /dev/null +++ b/plugins/gatsby-remark-embed-snippet/package.json @@ -0,0 +1,29 @@ +{ + "name": "gatsby-remark-embed-snippet", + "description": "Gatsby plugin to embed formatted code snippets within markdown", + "version": "1.0.0", + "author": "Brian Vaughn ", + "dependencies": { + "babel-runtime": "^6.26.0", + "gatsby-remark-prismjs": "^1.2.9", + "normalize-path": "^2.1.1", + "unist-util-map": "^1.0.3" + }, + "devDependencies": { + "babel-cli": "^6.26.0", + "cross-env": "^5.0.5" + }, + "keywords": [ + "gatsby", + "gatsby-plugin", + "remark", + "prism" + ], + "license": "MIT", + "main": "index.js", + "scripts": { + "build": "babel src --out-dir . --ignore __tests__", + "watch": "babel -w src --out-dir . --ignore __tests__", + "prepublish": "cross-env NODE_ENV=production npm run build" + } +} \ No newline at end of file