First commit: this starts off on a pretty solid foot because we iterated on it a lot already.

This commit is contained in:
Tom MacWright
2016-03-02 17:40:09 -08:00
commit e4f117bf7d
21 changed files with 5375 additions and 0 deletions

7
.babelrc Normal file
View File

@ -0,0 +1,7 @@
{
"presets": [
"stage-0",
"es2015",
"react"
]
}

48
.eslintrc Normal file
View File

@ -0,0 +1,48 @@
{
"parser": "babel-eslint",
"env": {
"browser": true,
"node": true
},
"plugins": [
"react",
"babel"
],
"globals": {
},
"extends": "eslint:recommended",
"rules": {
"comma-dangle": [2, "never"],
"jsx-quotes": [2, "prefer-single"],
"key-spacing": [0],
"max-len": [1, 360],
"no-bitwise": [2],
"no-console": [0],
"no-redeclare": [2],
"no-trailing-spaces": [2],
"no-undef": [2],
"no-underscore-dangle": [0],
"no-unused-vars": [2],
"no-use-before-define": [0],
"quotes": [2, "single"],
"react/display-name": [2, { "acceptTranspilerName": true }],
"react/jsx-no-bind": 2,
"react/jsx-no-duplicate-props": 2,
"react/jsx-no-undef": 2,
"react/jsx-uses-react": 2,
"react/react-in-jsx-scope": 2,
"react/no-unknown-property": [2],
"react/prop-types": 2,
"react/require-extension": [2, { "extensions": [".js", ".jsx"] }],
"react/self-closing-comp": [2],
"react/wrap-multilines": [2],
"semi": [2, "always"],
"space-after-keywords": [2, "always"],
"space-before-blocks": 2,
"space-before-function-paren": [2, "never"],
"space-before-keywords": [2, "always"],
"space-in-parens": [2, "never"],
"strict": [0],
"valid-typeof": 2
}
}

82
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,82 @@
## Topics
Topics contain information that applies to web services in general (not specific to APIs).
## API endpoints
Each individual API should have its own markdown file in the /content directory. Use snake case for filenames.
Each API file should have:
- H2 naming the API (i.e. "Wobbles")
- description of the API
- H3 naming the object or resource that is retrieved/created/deleted by the API
- description of the method
- list of parameters
- example requests and responses
Make sure the API is also included in the nav by adding it to `content.js`.
### Description
A one- or two-sentence description explaining what the API does (not how to use it).
### Object
Each API should have a description of the primary resources returned and manipulated
using the API.
- H3 naming the object (i.e. "The wobble object")
- One sentence explaining what the object is.
- Two-column table to describe the object:
- property name / type / required
- property description
- If the object is severely nested, use a nested list instead of a table
### Methods
List all methods for interacting with the API.
Each method:
- H3 naming the method (i.e. "Retrieve a font")
- Endpoint (h4?)
- do not include base URL in endpoint
- endpoints should use [three backtick markdown format with syntax highlighting](https://help.github.com/articles/github-flavored-markdown/#syntax-highlighting) with `url` as the language
- A description of what the method does. (NOT how to use it.)
- (how do we define scopes?)
- If necessary, a description of accepted values/filetypes and limits/restrictions
- Two-column table to describe parameters
- parameter name
- parameter description and accepted values
### Examples
Each method should have H4 headers for examples:
- Example request
- Example request body (if applicable)
- Example response
There should be four examples under each header, one for each library. Use
[three backtick markdown format with syntax highlighting](https://help.github.com/articles/github-flavored-markdown/#syntax-highlighting):
## Style conventions
- Always JSON, never `JSON` or json.
- Do **not** include access tokens in example URLs.
- h2 and h3 will be included in side nav
- code blocks/h4/blockquotes will be pushed to the right
## Lingo
- the parts of a JSON object are called **properties**
- querystring parameters are called **parameters**
## Formatting JSON
We need to show JSON examples, but we want to make the documentation readable
on a wide range of monitors: so it needs to be somewhat narrow and compact.
Usually stringifying JSON with indentation of 2 spaces does the trick:
if that isn't enough, use [json-pretty-compact-cli](https://github.com/tmcw/json-pretty-compact-cli)
or another more tasteful formatter.

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# Docbox
**REST API Documentation powered by Markdown**
Docbox is an open source version of Mapbox's REST API documentation system. It takes structured Markdown files and generates a friendly two-column layout with navigation, permalinks, and examples. The documentation source files that Docbox uses are friendly for documentation authors and free of presentational code: it's just Markdown.
_Docbox is a [Mapbox](http://mapbox.com/) community open source project. We built an awesome system for our REST API documentation and wanted to share it with you. Not a Mapbox product, so there's no guaranteed support and may have some rough edges._
Docbox is a JavaScript application written with React. The core magic is thanks to the [remark](http://remark.js.org/) Markdown parser, which enables the layout: after parsing a file into an [Abstract Syntax Tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree), we can move examples to the right, prose to the left, and build the navigation system.
It also has a supercharged **test suite**. Our tests check for everything from broken links to invalid examples and structure problems: this way, the application is only concerned with output and you can proactively enforce consistency and correctness. We even extract JavaScript examples from documentation and test them with [eslint](http://eslint.org/)
When you're ready to ship, Docbox's `build` task minifies JavaScript and uses React's server rendering code to make documentation indexable for search engines and viewable without JavaScript.
## Writing Documentation
Documentation is written as Markdown files in the `content` directory, and is organized by the `src/content.js` file - that file requires each documentation page and puts them in order. This demo has a little bit of content - [content/example.md](content/example.md) and [content/introduction.md](content/introduction.md), so that there's an example to follow.
## Development
We care about the ease of writing documentation. Docbox comes with batteries included: after you `npm install` the project, you can run `npm start` and its development server, [budo](https://github.com/mattdesl/budo), will serve the website locally and update automatically.
To run the site locally:
1. Clone this repository
2. `git clone https://github.com/mapbox/docbox.git`
2. `npm install`
3. `npm start`
4. Open http://localhost:9966/
## Tests
Tests cover both the source code of Docbox as well as the content in the `content/` directory.
To run tests:
1. Clone this repository
2. `git clone https://github.com/mapbox/docbox.git`
2. `npm install`
3. `npm test`
## Deployment
The `npm run build` command builds a `bundle.js` file that contains all of the JavaScript code and content needed to show the site, and creates an `index.html` file that already contains the site content. Note that this _replaces_ the existing `index.html` file, so it's best to run this only when deploying the site and to undo changes to `index.html` if you want to keep working on content.
1. Clone this repository
2. `git clone https://github.com/mapbox/docbox.git`
2. `npm install`
3. `npm run build`

431
content/example.md Normal file
View File

@ -0,0 +1,431 @@
## Wobble
This is our high-quality wobbles API. You can use this API to request
and remove different wobbles at a low wibble price.
### List wobbles
Lists all wobbles for a particular account.
```endpoint
GET /wobbles/v1/{username} wobbles:read
```
#### Example request
```curl
$ curl https://wobble.biz/wobbles/v1/{username}
```
```bash
$ wbl wobbles list
```
```javascript
client.listWobbles(function(err, wobbles) {
console.log(wobbles);
});
```
```python
wobbles.list()
```
#### Example response
```json
[
{
"owner": "{username}",
"id": "{wobble_id}",
"created": "{timestamp}",
"modified": "{timestamp}"
},
{
"owner": "{username}",
"id": "{wobble_id}",
"created": "{timestamp}",
"modified": "{timestamp}"
}
]
```
### Create wobble
Creates a new, empty wobble.
```endpoint
POST /wobbles/v1/{username} wobbles:write
```
#### Example request
```curl
curl -X POST https://wobble.biz/wobbles/v1/{username}
```
```bash
$ wbl wobbles create
```
```javascript
client.createWobble({
name: 'example',
description: 'An example wobble'
}, function(err, wobble) {
console.log(wobble);
});
```
```python
response = wobbles.create(
name='example', description='An example wobble')
```
#### Example request body
```json
{
"name": "foo",
"description": "bar"
}
```
Property | Description
---|---
`name` | (optional) the name of the wobble
`description` | (optional) a description of the wobble
#### Example response
```json
{
"owner": "{username}",
"id": "{wobble_id}",
"name": null,
"description": null,
"created": "{timestamp}",
"modified": "{timestamp}"
}
```
### Retrieve a wobble
Returns a single wobble.
```endpoint
GET /wobbles/v1/{username}/{wobble_id} wobbles:read
```
Retrieve information about an existing wobble.
#### Example request
```curl
curl https://wobble.biz/wobbles/v1/{username}/{wobble_id}
```
```bash
$ wbl wobble read-wobble wobble-id
```
```python
attrs = wobbles.read_wobble(wobble_id).json()
```
```javascript
client.readWobble('wobble-id',
function(err, wobble) {
console.log(wobble);
});
```
#### Example response
```json
{
"owner": "{username}",
"id": "{wobble_id}",
"created": "{timestamp}",
"modified": "{timestamp}"
}
```
### Update a wobble
Updates the properties of a particular wobble.
```endpoint
PATCH /wobbles/v1/{username}/{wobble_id} wobbles:write
```
#### Example request
```curl
curl --request PATCH https://wobble.biz/wobbles/v1/{username}/{wobble_id} \
-d @data.json
```
```python
resp = wobbles.update_wobble(
wobble_id,
name='updated example',
description='An updated example wobble'
).json()
```
```bash
$ wbl wobble update-wobble wobble-id
```
```javascript
var options = { name: 'foo' };
client.updateWobble('wobble-id', options, function(err, wobble) {
console.log(wobble);
});
```
#### Example request body
```json
{
"name": "foo",
"description": "bar"
}
```
Property | Description
---|---
`name` | (optional) the name of the wobble
`description` | (optional) a description of the wobble
#### Example response
```json
{
"owner": "{username}",
"id": "{wobble_id}",
"name": "foo",
"description": "bar",
"created": "{timestamp}",
"modified": "{timestamp}"
}
```
### Delete a wobble
Deletes a wobble, including all wibbles it contains.
```endpoint
DELETE /wobbles/v1/{username}/{wobble_id} wobbles:write
```
#### Example request
```curl
curl -X DELETE https://wobble.biz/wobbles/v1/{username}/{wobble_id}
```
```bash
$ wbl wobble delete-wobble wobble-id
```
```python
resp = wobbles.delete_wobble(wobble_id)
```
```javascript
client.deleteWobble('wobble-id', function(err) {
if (!err) console.log('deleted!');
});
```
#### Example response
> HTTP 204
### List wibbles
List all the wibbles in a wobble. The response body will be a
WobbleCollection.
```endpoint
GET /wobbles/v1/{username}/{wobble_id}/wibbles wobbles:read
```
#### Example request
```curl
curl https://wobble.biz/wobbles/v1/{username}/{wobble_id}/wibbles
```
```bash
$ wbl wobble list-wibbles wobble-id
```
```python
collection = wobbles.list_wibbles(wobble_id).json()
```
```javascript
client.listWobbles('wobble-id', {}, function(err, collection) {
console.log(collection);
});
```
#### Example response
```json
{
"type": "Wobble",
"wibbles": [
{
"id": "{wibble_id}",
"type": "Wobble",
"properties": {
"prop0": "value0"
}
},
{
"id": "{wibble_id}",
"type": "Wobble",
"properties": {
"prop0": "value0"
}
}
]
}
```
### Insert or update a wibble
Inserts or updates a wibble in a wobble. If there's already a wibble
with the given ID in the wobble, it will be replaced. If there isn't
a wibble with that ID, a new wibble is created.
```endpoint
PUT /wobbles/v1/{username}/{wobble_id}/wibbles/{wibble_id} wobbles:write
```
#### Example request
```curl
curl https://wobble.biz/wobbles/v1/{username}/{wobble_id}/wibbles/{wibble_id} \
-X PUT \
-d @file.geojson
```
```bash
$ wbl wobble put-wibble wobble-id wibble-id 'geojson-wibble'
```
```javascript
var wibble = {
"type": "Wobble",
"properties": { "name": "Null Island" }
};
client.insertWobble(wibble, 'wobble-id', function(err, wibble) {
console.log(wibble);
});
```
#### Example request body
```json
{
"id": "{wibble_id}",
"type": "Wobble",
"properties": {
"prop0": "value0"
}
}
```
Property | Description
--- | ---
`id` | the id of an existing wibble in the wobble
#### Example response
```json
{
"id": "{wibble_id}",
"type": "Wobble",
"properties": {
"prop0": "value0"
}
}
```
### Retrieve a wibble
Retrieves a wibble in a wobble.
```endpoint
GET /wobbles/v1/{username}/{wobble_id}/wibbles/{wibble_id} wobbles:read
```
#### Example request
```curl
curl https://wobble.biz/wobbles/v1/{username}/{wobble_id}/wibbles/{wibble_id}
```
```bash
$ wbl wobble read-wibble wobble-id wibble-id
```
```javascript
client.readWobble('wibble-id', 'wobble-id',
function(err, wibble) {
console.log(wibble);
});
```
```python
wibble = wobbles.read_wibble(wobble_id, '2').json()
```
#### Example response
```json
{
"id": "{wibble_id}",
"type": "Wobble",
"properties": {
"prop0": "value0"
}
}
```
### Delete a wibble
Removes a wibble from a wobble.
```endpoint
DELETE /wobbles/v1/{username}/{wobble_id}/wibbles/{wibble_id} wobbles:write
```
#### Example request
```javascript
client.deleteWobble('wibble-id', 'wobble-id', function(err, wibble) {
if (!err) console.log('deleted!');
});
```
```curl
curl -X DELETE https://wobble.biz/wobbles/v1/{username}/{wobble_id}/wibbles/{wibble_id}
```
```python
resp = wobbles.delete_wibble(wobble_id, wibble_id)
```
```bash
$ wbl wobble delete-wibble wobble-id wibble-id
```
#### Example response
> HTTP 204

3
content/introduction.md Normal file
View File

@ -0,0 +1,3 @@
## Our API
Welcome to coolcorp biz! This is our API documentation.

3758
css/base.css Normal file

File diff suppressed because it is too large Load Diff

100
css/railscasts.css Normal file
View File

@ -0,0 +1,100 @@
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: #232323;
color: #e6e1dc;
}
.hljs-comment,
.hljs-quote {
color: #bc9458;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag {
color: #c26230;
}
.hljs-string,
.hljs-number,
.hljs-regexp,
.hljs-variable,
.hljs-template-variable {
color: #a5c261;
}
.hljs-subst {
color: #519f50;
}
.hljs-tag,
.hljs-name {
color: #e8bf6a;
}
.hljs-type {
color: #da4939;
}
.hljs-symbol,
.hljs-bullet,
.hljs-built_in,
.hljs-builtin-name,
.hljs-attr,
.hljs-link {
color: #6d9cbe;
}
.hljs-params {
color: #d0d0ff;
}
.hljs-attribute {
color: #cda869;
}
.hljs-meta {
color: #9b859d;
}
.hljs-title,
.hljs-section {
color: #ffc66d;
}
.hljs-addition {
background-color: #144212;
color: #e6e1dc;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #600;
color: #e6e1dc;
display: inline-block;
width: 100%;
}
.hljs-selector-class {
color: #9b703f;
}
.hljs-selector-id {
color: #8b98ab;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}

151
css/style.css Normal file
View File

@ -0,0 +1,151 @@
/* BASE OVERRIDES */
/* Add buffer for nicer anchor links */
.prose h2:first-child {
padding-top: 20px;
}
.prose h3:first-child {
padding-top: 20px;
}
/* Header gets big */
@media only screen and (max-width:960px) {
.prose h2:first-child {
padding-top: 60px;
}
.prose h3:first-child {
padding-top: 60px;
}
}
/* Header gets bigger */
@media only screen and (max-width:640px) {
.prose h2:first-child {
padding-top: 100px;
}
.prose h3:first-child {
padding-top: 100px;
}
}
/* tables are too giant */
.prose table,
.prose table code {
margin-bottom: 20px;
font-size: 12px;
line-height: 1.5;
}
.prose table th, .prose table td {
padding: 5px;
}
.scroll-styled::-webkit-scrollbar {
width: 6px;
height: 6px;
background: transparent;
}
.scroll-styled::-webkit-scrollbar:hover {
background: transparent;
}
.scroll-styled::-webkit-scrollbar-track {
background:none;
}
.scroll-styled::-webkit-scrollbar-thumb {
background: rgba(0,0,0,.18);
width: 6px;
border:none;
border-radius: 3px;
}
.scroll-styled::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,.25);
}
.scroll-styled::-webkit-scrollbar-track:hover {
background: transparent;
}
.dark.keyline-top,
.dark.keyline-bottom {
border-color: #313131;
}
html, body, #app, .container {
height: 100%;
}
body pre {
padding: 0;
}
/* BASE ADDONS */
.fill-dark2 { background-color: #313131; }
.fill-dark2 .rounded-toggle input[type=radio]:checked + label,
.fill-dark2 .rounded-toggle .active { background-color: #313131; }
.space-top3 { margin-top: 30px;}
.space-top5 { margin-top: 50px;}
.line-height15 { line-height: 15px; }
.pad00y { padding-top: 2px; padding-bottom: 2px; }
.space-bottom00 { margin-bottom: 3px;}
.endpoint {
width: 100%;
display: flex;
}
.endpoint-method {
}
.endpoint-url {
flex-grow: 1;
word-wrap: break-word;
}
.endpoint-url a {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 2px;
font-weight:bold;
padding: 2px;
color: #fff;
}
.endpoint-url a:hover {
background-color: rgba(255, 255, 255, 0.15);
}
.endpoint-token {
}
a.hljs-linked {
border-radius: 2px;
color: #a5c261;
background: #383C2F;
padding: 2px;
}
body .prose blockquote {
padding: 5px 10px;
background: #232323;
}
.preview {
background-image: -webkit-linear-gradient(#F1F075, #F1F075);
background-size: 10px 10px;
background-repeat: repeat-y;
}
.preview h2::after,
.preview h3::after {
content: 'PREVIEW';
background-color: #F1F075;
border-radius: 2px;
font-size: 9px;
font-weight: normal;
padding: 2px 5px;
color: rgba(0, 0, 0, 0.5);
margin-left: 10px;
vertical-align: middle;
}

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<meta http-equiv='X-UA-Compatible' content='IE=11' />
<title>Mapbox</title>
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<link href='css/base.css' rel='stylesheet' />
<link rel='shortcut icon' href='/img/favicon.ico' type='image/x-icon' />
<link href='css/style.css' rel='stylesheet' />
<link href='css/railscasts.css' rel='stylesheet' />
</head>
<body>
<div id='app'>APP</div>
<script src='bundle.js'></script>
</body>
</html>

64
package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "docbox",
"version": "1.0.0",
"description": "an api documentation website",
"main": "index.js",
"scripts": {
"start": "budo src/index.js --serve=bundle.js --live",
"test": "npm run test-unit && eslint src && npm run prerender -- /dev/null && rm -rf lib",
"test-unit": "mocha --compilers js:babel-register test",
"prerender": "babel src --out-dir lib && node lib/render.js",
"build": "NODE_ENV=production browserify src/index.js | uglifyjs -c -m > bundle.js && npm run prerender -- index.html"
},
"browserify": {
"transform": [
"babelify",
"brfs"
]
},
"keywords": [
"api",
"documentation"
],
"author": "Tom MacWright",
"license": "ISC",
"dependencies": {
"babel-cli": "^6.4.0",
"babel-eslint": "^4.1.6",
"babel-polyfill": "^6.3.14",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babel-preset-stage-0": "^6.3.13",
"babelify": "^7.2.0",
"brfs": "^1.4.2",
"browserify": "^13.0.0",
"cssnano": "^3.4.0",
"es6-promise": "^3.0.2",
"eslint": "^1.10.3",
"eslint-plugin-babel": "^3.0.0",
"eslint-plugin-react": "^3.14.0",
"github-slugger": "^1.0.1",
"isomorphic-fetch": "^2.2.0",
"lodash.debounce": "^4.0.3",
"minifyify": "^7.1.0",
"react": "^0.14.6",
"react-dom": "^0.14.6",
"react-pure-render": "^1.0.2",
"react-visibility-sensor": "^3.0.0",
"react-waypoint": "^1.2.0",
"remark": "^3.2.0",
"remark-highlight.js": "^2.0.0",
"remark-html": "^2.0.2",
"remark-slug": "^3.0.1",
"unist-util-select": "^1.3.0",
"unist-util-visit": "^1.0.0"
},
"devDependencies": {
"babel-register": "^6.3.13",
"expect": "^1.13.4",
"mocha": "^2.3.4",
"to-vfile": "^1.0.0",
"budo": "^7.1.0",
"uglifyjs": "^2.4.10"
}
}

171
src/components/app.js Normal file
View File

@ -0,0 +1,171 @@
import React from 'react';
import Navigation from './navigation';
import Content from './content';
import RoundedToggle from './rounded_toggle';
import PureRenderMixin from 'react-pure-render/mixin';
import GithubSlugger from 'github-slugger';
import debounce from 'lodash.debounce';
let slugger = new GithubSlugger();
let slug = title => { slugger.reset(); return slugger.slug(title); };
let languageOptions = ['curl', 'cli', 'python', 'javascript'];
let debouncedReplaceState = debounce(hash => {
window.history.replaceState('', '', hash);
}, 100);
var App = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
content: React.PropTypes.string.isRequired,
ast: React.PropTypes.object.isRequired
},
getInitialState() {
var active = 'Introduction';
if (process.browser) {
let hash = window.location.hash.split('#').pop();
let mqls = {
'desktop': window.matchMedia('(min-width: 961px)'),
'tablet': window.matchMedia('(max-width: 960px)'),
'mobile': window.matchMedia('(max-width: 640px)')
};
Object.keys(mqls).forEach(key => {
mqls[key].addListener(this.mediaQueryChanged);
});
if (hash) {
let headingForHash = this.props.ast.children
.filter(child => child.type === 'heading')
.find(heading => heading.data.id === hash);
if (headingForHash) {
active = headingForHash.children[0].value;
}
}
return {
mqls: mqls,
queries: {},
language: 'curl',
activeSection: active,
showNav: false
};
} else {
return {
mqls: {
desktop: true
},
queries: {
desktop: true
},
language: 'curl',
activeSection: '',
showNav: false
};
}
},
toggleNav() {
this.setState({ showNav: !this.state.showNav });
},
componentDidMount() {
this.mediaQueryChanged();
},
mediaQueryChanged() {
var queries = {
mobile: this.state.mqls.mobile.matches,
tablet: this.state.mqls.tablet.matches,
desktop: this.state.mqls.desktop.matches
};
this.setState({ queries });
},
componentWillUnmount() {
Object.keys(this.state.mqls).forEach(key =>
this.state.mqls[key].removeListener(this.mediaQueryChanged));
},
onChangeLanguage(language) {
this.setState({ language });
},
onSpy(activeSection) {
this.setState({ activeSection });
},
componentDidUpdate(_, prevState) {
if (prevState.activeSection !== this.state.activeSection) {
// when the section changes, replace the hash
debouncedReplaceState(`#${slug(this.state.activeSection)}`);
} else if (prevState.language !== this.state.language) {
// when the language changes, use the hash to set scroll
window.location = window.location;
}
},
handleClick(activeSection) {
setTimeout(() => {
this.setState({ activeSection });
}, 10);
if (!this.state.queries.desktop) {
this.toggleNav();
}
},
render() {
let { ast } = this.props;
let { activeSection, queries, showNav } = this.state;
return (<div className='container unlimiter'>
{/* Content background */ }
{!queries.mobile && <div className={`fixed-top fixed-right ${queries.desktop && 'space-left16'}`}>
<div className='fill-dark col6 pin-right'></div>
</div>}
{/* Desktop nav */ }
{queries.desktop && <div className='space-top5 scroll-styled pad1 width16 sidebar fixed-left fill-light'>
<Navigation
handleClick={this.handleClick}
activeSection={activeSection}
ast={ast} />
</div>}
{/* Content */ }
<div className={`${queries.desktop && 'space-left16'}`}>
<Content
ast={ast}
queries={queries}
onSpy={this.onSpy}
language={this.state.language}/>
</div>
{/* Language toggle */ }
<div className={`fixed-top dark ${queries.desktop && 'space-left16'}`}>
<div className={`events fill-dark2 pad1 col6 pin-topright ${queries.mobile && 'space-top5 fixed-topright'}`}>
<RoundedToggle
options={languageOptions}
onChange={this.onChangeLanguage}
active={this.state.language} />
</div>
</div>
{/* Header */ }
<div className={`fill-gray fixed-top ${queries.tablet ? 'pad1y pad2x col6' : 'pad0 width16'}`}>
<a href='/' className='active space-top1 space-left1 pin-topleft icon round fill-red dark pad0'></a>
<div className={`strong small pad0 ${queries.mobile && 'space-left3'} ${queries.tablet ? 'space-left2' : 'space-left4 line-height15' }`}>
{queries.desktop ? 'Example API Documentation' :
queries.mobile ? 'API Docs' : 'Example API Docs'}
</div>
{queries.tablet && <div>
<button
onClick={this.toggleNav}
className={`short quiet pin-topright micro button rcon ${showNav ? 'caret-up' : 'caret-down'} space-right1 space-top1`}>
{activeSection}
</button>
{showNav && <div
className='fixed-left fill-light pin-left col6 pad2 scroll-styled space-top5'>
<Navigation
handleClick={this.handleClick}
activeSection={activeSection}
ast={ast} />
</div>}
</div>}
</div>
</div>);
}
});
module.exports = App;

116
src/components/content.js Normal file
View File

@ -0,0 +1,116 @@
import React from 'react';
import Section from './section';
import PureRenderMixin from 'react-pure-render/mixin';
function highlightTokens(str) {
return str.replace(/{[\w_]+}/g,
(str) => '<span class="strong">' + str + '</span>')
.replace(
/{@2x}/g,
`<a title='Retina support: adding @2x to this URL will produce 2x scale images' href='#retina'>{@2x}</a>`);
}
function transformURL(node) {
let { value } = node;
let parts = value.split(/\s+/);
if (parts.length === 3) {
return {
type: 'html',
value: `<div class='endpoint'>
<div class='round-left pad0y pad1x fill-lighten0 code micro endpoint-method'>${parts[0]}</div>
<div class='fill-darken1 pad0 code micro endpoint-url'>${highlightTokens(parts[1])}</div>
<div class='endpoint-token contain fill-lighten0 pad0x round-topright'>
<span class='pad0 micro code'>${parts[2]}</span>
<a href='#access-tokens' class='center endpoint-scope space-top3 micro pad1x pin-top fill-lighten1 round-bottom'>
Token scope
</a>
</div>
</div>`
};
} else {
return {
type: 'html',
value: `<div class='endpoint'>
<div class='round-left pad0y pad1x fill-lighten0 code small endpoint-method'>${parts[0]}</div>
<div class='fill-darken1 pad0 code small endpoint-url'>${highlightTokens(parts[1])}</div>
</div>`
};
}
}
function chunkifyAST(ast, language) {
var preview = false;
return ast.children.reduce((chunks, node) => {
if (node.type === 'heading' && node.depth === 1) {
return chunks;
} else if (node.type === 'heading' && node.depth < 4) {
chunks.push([node]);
} else {
chunks[chunks.length - 1].push(node);
}
return chunks;
}, [[]]).filter(chunk => chunk.length)
.map(chunk => {
var left = [], right = [], title;
if (language === 'cli') {
language = 'bash';
}
if (chunk[0].depth < 3) {
preview = false;
}
chunk.forEach(node => {
if (node.type === 'code') {
if (node.lang === 'json' || node.lang === 'http' || node.lang === 'html') {
right.push(node);
} else if (node.lang === language) {
if (language === 'curl') {
right.push({ ...node, lang: 'bash' });
} else {
right.push(node);
}
} else if (node.lang === 'endpoint') {
right.push(transformURL(node));
}
} else if (node.type === 'heading' && node.depth >= 4) {
right.push(node);
} else if (node.type === 'blockquote') {
right.push(node);
} else if (node.type === 'heading' && node.depth < 4 && !title) {
title = node.children[0].value;
left.push(node);
} else if (node.type === 'html') {
if (node.value.indexOf('<!--') === 0) {
var content = node.value
.replace(/^<!--/, '')
.replace(/-->$/, '')
.trim();
if (content === 'preview') {
preview = true;
}
}
} else {
left.push(node);
}
});
return { left, right, title, preview };
});
}
var Content = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
ast: React.PropTypes.object.isRequired,
language: React.PropTypes.string.isRequired,
onSpy: React.PropTypes.func.isRequired
},
render() {
return (<div className='clearfix'>
{chunkifyAST(this.props.ast, this.props.language).map((chunk, i) => <Section
onSpy={this.props.onSpy}
chunk={chunk}
key={i} />)}
</div>);
}
});
module.exports = Content;

View File

@ -0,0 +1,94 @@
import React from 'react';
import PureRenderMixin from 'react-pure-render/mixin';
import NavigationItem from './navigation_item';
function getAllInSectionFromChild(headings, idx) {
for (var i = idx; i > 0; i--) {
if (headings[i].depth === 2) {
return getAllInSection(headings, i);
}
}
}
function getAllInSection(headings, idx) {
var activeHeadings = [];
for (var i = idx + 1; i < headings.length; i++) {
if (headings[i].depth === 3) {
activeHeadings.push(headings[i].children[0].value);
} else if (headings[i].depth === 2) {
break;
}
}
return activeHeadings;
}
var Navigation = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
ast: React.PropTypes.object.isRequired,
activeSection: React.PropTypes.string,
handleClick: React.PropTypes.func.isRequired
},
render() {
var activeHeadings = [];
let headings = this.props.ast.children
.filter(child => child.type === 'heading');
if (this.props.activeSection) {
let activeHeadingIdx = headings.findIndex(heading =>
heading.children[0].value === this.props.activeSection);
let activeHeading = headings[activeHeadingIdx];
if (activeHeading.depth === 3) {
activeHeadings = [this.props.activeSection]
.concat(getAllInSectionFromChild(headings, activeHeadingIdx));
}
// this could potentially have children, try to find them
if (activeHeading.depth === 2) {
activeHeadings = [this.props.activeSection]
.concat(getAllInSection(headings, activeHeadingIdx));
}
}
activeHeadings = activeHeadings.reduce((memo, heading) => {
memo[heading] = true;
return memo;
}, {});
return (<div className='pad0x small'>
{headings
.map((child, i) => {
let sectionName = child.children[0].value;
var active = sectionName === this.props.activeSection;
if (child.depth === 1) {
return (<div key={i}
onClick={this.handleClick}
className='small pad0x strong space-top1'>{sectionName}</div>);
} else if (child.depth === 2) {
return (<NavigationItem
key={i}
href={`#${child.data.id}`}
handleClick={this.props.handleClick}
active={active}
sectionName={sectionName} />);
} else if (child.depth === 3) {
if (activeHeadings.hasOwnProperty(sectionName)) {
return (<div
key={i}
className='space-left1'>
<NavigationItem
href={`#${child.data.id}`}
handleClick={this.props.handleClick}
active={active}
sectionName={sectionName} />
</div>);
}
}
})}
</div>);
}
});
module.exports = Navigation;

View File

@ -0,0 +1,26 @@
import React from 'react';
import PureRenderMixin from 'react-pure-render/mixin';
var NavigationItem = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
sectionName: React.PropTypes.string.isRequired,
active: React.PropTypes.bool.isRequired,
handleClick: React.PropTypes.func.isRequired,
href: React.PropTypes.string.isRequired
},
onClick() {
this.props.handleClick(this.props.sectionName);
},
render() {
var {sectionName, href, active} = this.props;
return (<a
href={href}
onClick={this.onClick}
className={`line-height15 pad0x pad00y block ${active ? 'fill-darken0 quiet active round' : ''}`}>
{sectionName}
</a>);
}
});
module.exports = NavigationItem;

View File

@ -0,0 +1,42 @@
import React from 'react';
import PureRenderMixin from 'react-pure-render/mixin';
var RoundedToggle = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
options: React.PropTypes.array.isRequired,
active: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func.isRequired
},
render() {
let { options, active } = this.props;
return (<div className='rounded-toggle inline short'>
{options.map(option =>
<RoundedToggleOption
key={option}
option={option}
onClick={this.props.onChange}
className={'strong ' + (option === active ? 'active': '')} />)}
</div>);
}
});
var RoundedToggleOption = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
option: React.PropTypes.string.isRequired,
className: React.PropTypes.string.isRequired,
onClick: React.PropTypes.func.isRequired
},
onClick() {
this.props.onClick(this.props.option);
},
render() {
let { className, option } = this.props;
return (<a
onClick={this.onClick}
className={className}>{option}</a>);
}
});
module.exports = RoundedToggle;

56
src/components/section.js Normal file
View File

@ -0,0 +1,56 @@
import React from 'react';
import remark from 'remark';
import remarkHTML from 'remark-html';
import remarkHighlight from 'remark-highlight.js';
import Waypoint from 'react-waypoint';
import PureRenderMixin from 'react-pure-render/mixin';
function renderHighlighted(nodes) {
return {
__html: remark()
.use(remarkHTML)
.stringify(remark().use(remarkHighlight).run({
type: 'root',
children: nodes
}))
.replace(
/<span class="hljs-string">"{timestamp}"<\/span>/g,
`<span class="hljs-string">"</span><a class='hljs-linked' href='#dates'>{timestamp}</a><span class="hljs-string">"</span>`)
};
}
var Section = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
chunk: React.PropTypes.object.isRequired,
onSpy: React.PropTypes.func.isRequired
},
waypointEnterFromAbove(e, direction) {
if (direction === 'above') {
this.props.onSpy(this.props.chunk.title);
}
},
waypointEnterFromBelow(e, direction) {
if (direction === 'below') {
this.props.onSpy(this.props.chunk.title);
}
},
render() {
let { chunk } = this.props;
let { left, right, preview } = chunk;
return (<div className={`contain clearfix ${preview ? 'preview' : ''}`}>
<Waypoint onEnter={this.waypointEnterFromBelow} />
<div
className='col6 pad2x prose clip'
dangerouslySetInnerHTML={renderHighlighted(left)} />
{(right.length > 0) && <div
className='col6 pad2 prose dark space-top5 clip keyline-top fill-dark'
dangerouslySetInnerHTML={renderHighlighted(right)} />}
<div className='pin-bottom'>
<Waypoint onEnter={this.waypointEnterFromAbove} />
</div>
</div>);
}
});
module.exports = Section;

7
src/content.js Normal file
View File

@ -0,0 +1,7 @@
var fs = require('fs');
module.exports =
'# Introduction\n' +
fs.readFileSync('./content/introduction.md', 'utf8') + '\n' +
'# Example\n' +
fs.readFileSync('./content/example.md', 'utf8') + '\n';

13
src/index.js Normal file
View File

@ -0,0 +1,13 @@
import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/app';
import remark from 'remark';
import slug from 'remark-slug';
import content from './content';
var ast = remark().use(slug).run(remark().parse(content));
ReactDOM.render(
<App ast={ast} content={content} />,
document.getElementById('app'));

18
src/render.js Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './components/app';
import remark from 'remark';
import slug from 'remark-slug';
import content from './content';
import fs from 'fs';
var ast = remark().use(slug).run(remark().parse(content));
var template = fs.readFileSync('./index.html', 'utf8');
var target = process.argv[2];
fs.writeFileSync(target,
template.replace('APP',
ReactDOMServer.renderToString(
<App ast={ast} content={content} />)));

121
test/content.js Normal file
View File

@ -0,0 +1,121 @@
/* global it describe */
var remark = require('remark');
var expect = require('expect');
var visit = require('unist-util-visit');
var select = require('unist-util-select');
var fs = require('fs');
var GithubSlugger = require('github-slugger');
var { linter } = require('eslint');
var allPages = require('../src/content');
var slugger = new GithubSlugger();
var actionVerbs = /^(List|Retrieve|Remove|Search|Create|Delete)/;
let isSectionTitle = (title) => title.match(actionVerbs);
let getSectionTitle = chunk => chunk[0].children[0].value;
function extractSections(ast) {
return ast.children.reduce((chunks, node) => {
if (node.type === 'heading' && node.depth === 1) {
return chunks;
} else if (node.type === 'heading' && node.depth === 3) {
chunks.push([node]);
} else {
chunks[chunks.length - 1].push(node);
}
return chunks;
}, [[]])
.filter(chunk => chunk.length)
.filter(chunk => {
return isSectionTitle(getSectionTitle(chunk));
})
.map(chunk => ({ type: 'root', children: chunk }));
}
var slugs = {};
describe('global rules', () => {
var ast = remark.parse(allPages);
var seen = {};
/**
* Check that titles are unique. This is to ensure that permalinks
* are unique.
*/
visit(ast, 'heading', node => {
slugs['#' + slugger.slug(node.children[0].value)] = true;
if (node.depth > 3) return;
var { value } = node.children[0];
it('title ' + value + ' is unique', () => {
expect(seen.hasOwnProperty(value))
.toEqual(false, 'Title `' + value + '` should be unique');
seen[value] = true;
});
});
});
describe('content', () => {
fs.readdirSync('./content').forEach(function(file) {
describe(file, () => {
var content = fs.readFileSync('./content/' + file, 'utf8');
var ast = remark.parse(content);
it('links are valid', function() {
visit(ast, 'link', node => {
if (node.href && node.href[0] === '#') {
expect(slugs[node.href])
.toExist('A link to ' + node.href + ' at ' +
node.position.start.line + ' of ' + file + ' was invalid');
}
});
});
it('has h2 title', function() {
expect(ast.children[0].type).toEqual('heading');
expect(ast.children[0].depth).toEqual(2);
});
it('has API description', function() {
expect(ast.children[1].type === 'paragraph' || ast.children[1].type === 'html').toEqual(true);
});
it('has valid json', () => {
select(ast, 'code[lang=json]').forEach(node => {
expect(function() {
JSON.parse(node.value);
}).toNotThrow(null, 'a JSON code block at L:' +
node.position.start.line + ' of ' + file + ' was invalid');
});
});
it('has valid javascript', () => {
select(ast, 'code[lang=javascript]').forEach(node => {
var messages = linter.verify(node.value);
expect(messages).toEqual([], 'a JS code block at L:' +
node.position.start.line + ' of ' + file + ' was invalid');
});
});
extractSections(ast).forEach(chunk => {
describe(getSectionTitle(chunk.children), function() {
it('has an endpoint and that endpoint has a valid method', () => {
var endpoint = select(chunk, 'code[lang=endpoint]');
expect(endpoint.length).toBeGreaterThan(0);
expect(endpoint[0].value.toString()).toMatch(/^(PUT|POST|GET|DELETE|PATCH)/);
});
if (!file.match(/(style|static_gl|maps|static_classic|uploads)/)) {
it('has python example', () => {
expect(select(chunk, 'code[lang=python]').length).toBeGreaterThan(0);
});
it('has js example', () => {
expect(select(chunk, 'code[lang=javascript]').length).toBeGreaterThan(0);
});
it('has curl example', () => {
expect(select(chunk, 'code[lang=curl]').length).toBeGreaterThan(0);
});
}
});
});
});
});
});