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

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} />)));