56 | )
57 | }
58 | }
59 |
60 | Visualiser.propTypes = {
61 | data: React.PropTypes.any,
62 | indent: React.PropTypes.number,
63 | useHljs: React.PropTypes.string,
64 | name: React.PropTypes.string,
65 | path: React.PropTypes.string,
66 | click: React.PropTypes.func
67 | }
68 |
--------------------------------------------------------------------------------
/src/js/config/development.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | kayeroHomepage: '/',
3 | gistUrl: 'https://gist.githubusercontent.com/anonymous/',
4 | gistApi: 'https://api.github.com/gists',
5 | cssUrl: 'dist/main.css',
6 | scriptUrl: 'dist/bundle.js'
7 | }
8 |
--------------------------------------------------------------------------------
/src/js/config/index.js:
--------------------------------------------------------------------------------
1 | var env = process.env.NODE_ENV || 'development'
2 |
3 | var config = {
4 | test: require('./development.config'),
5 | development: require('./development.config'),
6 | production: require('./production.config')
7 | }
8 |
9 | module.exports = config[env]
10 |
--------------------------------------------------------------------------------
/src/js/config/production.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | kayeroHomepage: 'http://www.joelotter.com/kayero/',
3 | gistUrl: 'https://gist.githubusercontent.com/anonymous/',
4 | gistApi: 'https://api.github.com/gists',
5 | cssUrl: 'http://www.joelotter.com/kayero/dist/main.css',
6 | scriptUrl: 'http://www.joelotter.com/kayero/dist/bundle.js'
7 | }
8 |
--------------------------------------------------------------------------------
/src/js/markdown.js:
--------------------------------------------------------------------------------
1 | import MarkdownIt from 'markdown-it'
2 | import latex from 'markdown-it-katex'
3 | import fm from 'front-matter'
4 | import Immutable from 'immutable'
5 |
6 | import { codeToText } from './util'
7 |
8 | const markdownIt = new MarkdownIt()
9 | markdownIt.use(latex)
10 |
11 | /*
12 | * Extracts a code block (Immutable Map) from the
13 | * block-parsed Markdown.
14 | */
15 | function extractCodeBlock (token) {
16 | const info = token.info.split(';').map(s => s.trim())
17 | const language = info[0] || undefined
18 | const option = info[1] || undefined
19 | if (['runnable', 'auto', 'hidden'].indexOf(option) < 0) {
20 | // If not an executable block, we just want to represent as Markdown.
21 | return null
22 | }
23 | return Immutable.fromJS({
24 | type: 'code',
25 | content: token.content.trim(),
26 | language,
27 | option
28 | })
29 | }
30 |
31 | function flushTextBlock (counter, blocks, blockOrder, text) {
32 | if (!text.match(/\S+/)) {
33 | return
34 | }
35 | const id = String(counter)
36 | blockOrder.push(id)
37 | blocks[id] = Immutable.fromJS({
38 | type: 'text',
39 | id: id,
40 | content: text.trim()
41 | })
42 | }
43 |
44 | function extractBlocks (md) {
45 | const rgx = /(```\w+;\s*?(?:runnable|auto|hidden)\s*?[\n\r]+[\s\S]*?^\s*?```\s*?$)/gm
46 | const parts = md.split(rgx)
47 |
48 | let blockCounter = 0
49 | let currentString = ''
50 | const blockOrder = []
51 | const blocks = {}
52 |
53 | for (let i = 0; i < parts.length; i++) {
54 | const part = parts[i]
55 | const tokens = markdownIt.parse(parts[i])
56 | if (tokens.length === 1 && tokens[0].type === 'fence') {
57 | const block = extractCodeBlock(tokens[0])
58 | // If it's an executable block
59 | if (block) {
60 | // Flush the current text to a text block
61 | flushTextBlock(blockCounter, blocks, blockOrder, currentString)
62 | currentString = ''
63 | blockCounter++
64 |
65 | // Then add the code block
66 | const id = String(blockCounter)
67 | blockOrder.push(id)
68 | blocks[id] = block.set('id', id)
69 | blockCounter++
70 | continue
71 | }
72 | }
73 | // If it isn't an executable code block, just add
74 | // to the current text block;
75 | currentString += part
76 | }
77 | flushTextBlock(blockCounter, blocks, blockOrder, currentString)
78 |
79 | return {
80 | content: blockOrder,
81 | blocks
82 | }
83 | }
84 |
85 | export function parse (md, filename) {
86 | // Separate front-matter and body
87 | const doc = fm(md)
88 | const {content, blocks} = extractBlocks(doc.body)
89 |
90 | return Immutable.fromJS({
91 | metadata: {
92 | title: doc.attributes.title,
93 | author: doc.attributes.author,
94 | datasources: doc.attributes.datasources || {},
95 | original: doc.attributes.original,
96 | showFooter: doc.attributes.show_footer !== false,
97 | path: filename
98 | },
99 | content,
100 | blocks
101 | })
102 | }
103 |
104 | /*
105 | * Functions for rendering blocks back into Markdown
106 | */
107 |
108 | function renderDatasources (datasources) {
109 | let rendered = 'datasources:\n'
110 | datasources.map((url, name) => {
111 | rendered += ' ' + name + ': "' + url + '"\n'
112 | })
113 | return rendered
114 | }
115 |
116 | function renderMetadata (metadata) {
117 | let rendered = '---\n'
118 | if (metadata.get('title') !== undefined) {
119 | rendered += 'title: "' + metadata.get('title') + '"\n'
120 | }
121 | if (metadata.get('author') !== undefined) {
122 | rendered += 'author: "' + metadata.get('author') + '"\n'
123 | }
124 | const datasources = metadata.get('datasources')
125 | if (datasources && datasources.size > 0) {
126 | rendered += renderDatasources(datasources)
127 | }
128 | const original = metadata.get('original')
129 | if (original && original.get('title') && original.get('url')) {
130 | rendered += 'original:\n'
131 | rendered += ' title: "' + original.get('title') + '"\n'
132 | rendered += ' url: "' + original.get('url') + '"\n'
133 | }
134 | if (metadata.get('showFooter') !== undefined) {
135 | rendered += 'show_footer: ' + metadata.get('showFooter') + '\n'
136 | }
137 | return rendered + '---\n\n'
138 | }
139 |
140 | function renderBlock (block) {
141 | if (block.get('type') === 'text') {
142 | return block.get('content')
143 | }
144 | return codeToText(block, true)
145 | }
146 |
147 | function renderBody (blocks, blockOrder) {
148 | return blockOrder
149 | .map((id) => blocks.get(id))
150 | .map(renderBlock)
151 | .join('\n\n') + '\n'
152 | }
153 |
154 | export function render (notebook) {
155 | let rendered = ''
156 | rendered += renderMetadata(notebook.get('metadata'))
157 | rendered += renderBody(notebook.get('blocks'), notebook.get('content'))
158 | return rendered
159 | }
160 |
--------------------------------------------------------------------------------
/src/js/reducers/editorReducer.js:
--------------------------------------------------------------------------------
1 | import Immutable from 'immutable'
2 | import {
3 | TOGGLE_EDIT, TOGGLE_SAVE, EDIT_BLOCK,
4 | FILE_SAVED, LOAD_MARKDOWN,
5 | UPDATE_BLOCK, UPDATE_META, ADD_BLOCK, DELETE_BLOCK, MOVE_BLOCK, DELETE_DATASOURCE, UPDATE_DATASOURCE, CHANGE_CODE_BLOCK_OPTION
6 | } from '../actions'
7 |
8 | /*
9 | * This reducer simply keeps track of the state of the editor.
10 | */
11 | const defaultEditor = Immutable.Map({
12 | editable: false,
13 | saving: false,
14 | activeBlock: null,
15 | unsavedChanges: false
16 | })
17 |
18 | export default function editor (state = defaultEditor, action = {}) {
19 | switch (action.type) {
20 | case TOGGLE_EDIT:
21 | return state.set('editable', !state.get('editable'))
22 | case TOGGLE_SAVE:
23 | return state.set('saving', !state.get('saving'))
24 | case EDIT_BLOCK:
25 | return state.set('activeBlock', action.id)
26 | case FILE_SAVED:
27 | case LOAD_MARKDOWN:
28 | return state.set('unsavedChanges', false)
29 | case UPDATE_BLOCK:
30 | case UPDATE_META:
31 | case ADD_BLOCK:
32 | case DELETE_BLOCK:
33 | case MOVE_BLOCK:
34 | case DELETE_DATASOURCE:
35 | case UPDATE_DATASOURCE:
36 | case CHANGE_CODE_BLOCK_OPTION:
37 | return state.set('unsavedChanges', true)
38 | default:
39 | return state
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/js/reducers/executionReducer.js:
--------------------------------------------------------------------------------
1 | import Immutable from 'immutable'
2 | import {
3 | RECEIVED_DATA,
4 | CODE_RUNNING,
5 | CODE_EXECUTED,
6 | CODE_ERROR,
7 | UPDATE_BLOCK,
8 | DELETE_BLOCK,
9 | DELETE_DATASOURCE,
10 | UPDATE_DATASOURCE,
11 | LOAD_MARKDOWN
12 | } from '../actions'
13 |
14 | /*
15 | * This reducer handles the state of execution of code blocks -
16 | * retaining results, carrying context around, and making note
17 | * of which blocks have and haven't been executed. It's also
18 | * where the obtained data is stored.
19 | */
20 | export const initialState = Immutable.Map({
21 | executionContext: Immutable.Map(),
22 | data: Immutable.Map(),
23 | results: Immutable.Map(),
24 | blocksExecuted: Immutable.Set(),
25 | blocksRunning: Immutable.Set()
26 | })
27 |
28 | export default function execution (state = initialState, action = {}) {
29 | const { id, name, data, context } = action
30 | switch (action.type) {
31 | case LOAD_MARKDOWN:
32 | return initialState
33 | case CODE_RUNNING:
34 | return state.set('blocksRunning', state.get('blocksRunning').add(id))
35 | case CODE_EXECUTED:
36 | return state
37 | .setIn(['results', id], data)
38 | .set('blocksExecuted', state.get('blocksExecuted').add(id))
39 | .set('blocksRunning', state.get('blocksRunning').delete(id))
40 | .set('executionContext', context)
41 | case CODE_ERROR:
42 | return state
43 | .setIn(['results', id], data)
44 | .set('blocksRunning', state.get('blocksRunning').delete(id))
45 | .set('blocksExecuted', state.get('blocksExecuted').add(id))
46 | case RECEIVED_DATA:
47 | return state.setIn(['data', name], Immutable.fromJS(data))
48 | case UPDATE_BLOCK:
49 | case DELETE_BLOCK:
50 | return state
51 | .set('blocksRunning', state.get('blocksRunning').delete(id))
52 | .set('blocksExecuted', state.get('blocksExecuted').remove(id))
53 | .removeIn(['results', id])
54 | case UPDATE_DATASOURCE:
55 | case DELETE_DATASOURCE:
56 | return state.deleteIn(['data', id])
57 | default:
58 | return state
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/js/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 |
3 | import notebook from './notebookReducer'
4 | import execution from './executionReducer'
5 | import editor from './editorReducer'
6 |
7 | export default combineReducers({
8 | notebook,
9 | execution,
10 | editor
11 | })
12 |
13 |
--------------------------------------------------------------------------------
/src/js/reducers/notebookReducer.js:
--------------------------------------------------------------------------------
1 | import Immutable from 'immutable'
2 | import Jutsu from 'jutsu'
3 | import { parse } from '../markdown'
4 | import { kayeroHomepage } from '../config' // eslint-disable-line
5 | import {
6 | LOAD_MARKDOWN,
7 | FILE_SAVED,
8 | UPDATE_BLOCK,
9 | UPDATE_META,
10 | TOGGLE_META,
11 | ADD_BLOCK,
12 | DELETE_BLOCK,
13 | MOVE_BLOCK,
14 | DELETE_DATASOURCE,
15 | UPDATE_DATASOURCE,
16 | GIST_CREATED,
17 | UNDO,
18 | CHANGE_CODE_BLOCK_OPTION,
19 | UPDATE_GRAPH_BLOCK_PROPERTY,
20 | UPDATE_GRAPH_BLOCK_HINT,
21 | UPDATE_GRAPH_BLOCK_LABEL,
22 | CLEAR_GRAPH_BLOCK_DATA
23 | } from '../actions'
24 |
25 | /*
26 | * This reducer handles the state of the notebook's actual content,
27 | * obtained by parsing Markdown. This is kept separate from the execution
28 | * state to help with implementing 'undo' in the editor.
29 | */
30 | export const initialState = Immutable.Map({
31 | metadata: Immutable.fromJS({
32 | datasources: {}
33 | }),
34 | content: Immutable.List(),
35 | blocks: Immutable.Map(),
36 | undoStack: Immutable.List()
37 | })
38 |
39 | export default function notebook (state = initialState, action = {}) {
40 | const { id, text, field, blockType, nextIndex, option } = action
41 | const content = state.get('content')
42 | let newState
43 | switch (action.type) {
44 | case LOAD_MARKDOWN:
45 | return parse(action.markdown, action.filename).set('undoStack', state.get('undoStack'))
46 | case FILE_SAVED:
47 | return state.setIn(['metadata', 'path'], action.filename)
48 | case UPDATE_BLOCK:
49 | return handleChange(
50 | state, state.setIn(['blocks', id, 'content'], text)
51 | )
52 | case UPDATE_META:
53 | return handleChange(
54 | state, state.setIn(['metadata', field], text)
55 | )
56 | case TOGGLE_META:
57 | return handleChange(
58 | state, state.setIn(['metadata', field], !state.getIn(['metadata', field]))
59 | )
60 | case ADD_BLOCK:
61 | const newId = getNewId(content)
62 | let newBlock = {type: blockType, id: newId}
63 | if (blockType === 'code') {
64 | newBlock.content = '// New code block'
65 | newBlock.language = 'javascript'
66 | newBlock.option = 'runnable'
67 | } else if (blockType === 'graph') {
68 | newBlock.language = 'javascript'
69 | newBlock.option = 'runnable'
70 | newBlock.content = 'return graphs.pieChart(data);'
71 | newBlock.graphType = 'pieChart'
72 | newBlock.dataPath = 'data'
73 | newBlock.hints = Immutable.fromJS({
74 | label: '',
75 | value: '',
76 | x: '',
77 | y: ''
78 | })
79 | newBlock.labels = Immutable.fromJS({
80 | x: '',
81 | y: ''
82 | })
83 | } else {
84 | newBlock.content = 'New text block'
85 | }
86 | newState = handleChange(
87 | state, state.setIn(['blocks', newId], Immutable.fromJS(newBlock))
88 | )
89 | if (id === undefined) {
90 | return newState.set('content', content.push(newId))
91 | }
92 | return newState.set('content', content.insert(content.indexOf(id), newId))
93 | case DELETE_BLOCK:
94 | return handleChange(
95 | state,
96 | state.deleteIn(['blocks', id]).set(
97 | 'content', content.delete(content.indexOf(id))
98 | )
99 | )
100 | case MOVE_BLOCK:
101 | const index = content.indexOf(id)
102 | if (index === nextIndex || typeof nextIndex === 'undefined') {
103 | return state
104 | }
105 | if (index > nextIndex) { // going up
106 | return handleChange(
107 | state, state.set('content', content.slice(0, Math.max(nextIndex, 0))
108 | .push(id)
109 | .concat(content.slice(nextIndex, index))
110 | .concat(content.slice(index + 1)))
111 | )
112 | } else { // going down
113 | return handleChange(
114 | state, state.set('content', content.slice(0, Math.max(index, 0))
115 | .concat(content.slice(index + 1, nextIndex + 1))
116 | .push(id)
117 | .concat(content.slice(nextIndex + 1)))
118 | )
119 | }
120 | case DELETE_DATASOURCE:
121 | return handleChange(
122 | state, state.deleteIn(['metadata', 'datasources', id])
123 | )
124 | case UPDATE_DATASOURCE:
125 | return handleChange(
126 | state, state.setIn(['metadata', 'datasources', id], text)
127 | )
128 | case GIST_CREATED:
129 | return state.setIn(['metadata', 'gistUrl'], kayeroHomepage + '?id=' + id)
130 | case UNDO:
131 | return undo(state)
132 | case CHANGE_CODE_BLOCK_OPTION:
133 | return handleChange(state, state.setIn(
134 | ['blocks', id, 'option'],
135 | option || getNewOption(state.getIn(['blocks', id, 'option']))
136 | ))
137 | case UPDATE_GRAPH_BLOCK_PROPERTY:
138 | newState = state.setIn(
139 | ['blocks', id, action.property], action.value
140 | )
141 | return handleChange(state, newState.setIn(
142 | ['blocks', id, 'content'],
143 | generateCode(newState.getIn(['blocks', id]))
144 | ))
145 | case UPDATE_GRAPH_BLOCK_HINT:
146 | newState = state.setIn(
147 | ['blocks', id, 'hints', action.hint], action.value
148 | )
149 | return handleChange(state, newState.setIn(
150 | ['blocks', id, 'content'],
151 | generateCode(newState.getIn(['blocks', id]))
152 | ))
153 | case UPDATE_GRAPH_BLOCK_LABEL:
154 | newState = state.setIn(
155 | ['blocks', id, 'labels', action.label], action.value
156 | )
157 | return handleChange(state, newState.setIn(
158 | ['blocks', id, 'content'],
159 | generateCode(newState.getIn(['blocks', id]))
160 | ))
161 | case CLEAR_GRAPH_BLOCK_DATA:
162 | return state.setIn(
163 | ['blocks', id],
164 | state.getIn(['blocks', id])
165 | .remove('hints')
166 | .remove('graphType').remove('labels')
167 | .remove('dataPath')
168 | )
169 | default:
170 | return state
171 | }
172 | }
173 |
174 | function generateCode (block) {
175 | return 'return graphs.' + block.get('graphType') +
176 | '(' + block.get('dataPath') + getLabels(block) +
177 | getHints(block) + ');'
178 | }
179 |
180 | function getHints (block) {
181 | const hints = block.get('hints')
182 | const schema = Jutsu().__SMOLDER_SCHEMA[block.get('graphType')].data[0]
183 | const result = []
184 | const keys = Object.keys(schema).sort()
185 | for (let i = 0; i < keys.length; i++) {
186 | const hint = keys[i]
187 | const value = hints.get(hint)
188 | if (value) {
189 | result.push(hint + ": '" + value + "'")
190 | }
191 | }
192 | if (result.length === 0) {
193 | return ''
194 | }
195 | return ', {' + result.join(', ') + '}'
196 | }
197 |
198 | function getLabels (block) {
199 | if (block.get('graphType') === 'pieChart') {
200 | return ''
201 | }
202 | const labels = block.get('labels')
203 | return ', ' +
204 | [labels.get('x'), labels.get('y')]
205 | .map((label) => "'" + label + "'")
206 | .join(', ')
207 | }
208 |
209 | function getNewId (content) {
210 | var id = 0
211 | while (content.contains(String(id))) {
212 | id++
213 | }
214 | return String(id)
215 | }
216 |
217 | function getNewOption (option) {
218 | const options = ['runnable', 'auto', 'hidden']
219 | const i = options.indexOf(option)
220 | return options[(i + 1) % options.length]
221 | }
222 |
223 | /*
224 | * Handles changes, if they exist, by pushing to the undo stack.
225 | */
226 | function handleChange (currentState, newState) {
227 | if (currentState.equals(newState)) {
228 | return newState
229 | }
230 | let result = newState.set(
231 | 'undoStack',
232 | newState.get('undoStack').push(currentState.remove('undoStack'))
233 | ).deleteIn(
234 | ['metadata', 'gistUrl']
235 | )
236 |
237 | // If it's the first change, update the parent link.
238 | if (currentState.get('undoStack').size === 0) {
239 | result = result.setIn(['metadata', 'original'], Immutable.fromJS({
240 | title: currentState.getIn(['metadata', 'title']),
241 | url: window.location.href
242 | }))
243 | }
244 | return result
245 | }
246 |
247 | function undo (state) {
248 | if (state.get('undoStack').size === 0) {
249 | return state
250 | }
251 | return state.get('undoStack').last()
252 | .set('undoStack', state.get('undoStack').pop())
253 | }
254 |
--------------------------------------------------------------------------------
/src/js/selectors.js:
--------------------------------------------------------------------------------
1 | export const metadataSelector = state => {
2 | return {
3 | metadata: state.notebook.get('metadata'),
4 | undoSize: state.notebook.get('undoStack').size
5 | }
6 | }
7 |
8 | export const contentSelector = state => {
9 | return {
10 | content: state.notebook.get('content').map(
11 | num => state.notebook.getIn(['blocks', num])
12 | ),
13 | results: state.execution.get('results'),
14 | blocksExecuted: state.execution.get('blocksExecuted'),
15 | blocksRunning: state.execution.get('blocksRunning')
16 | }
17 | }
18 |
19 | export const editorSelector = state => {
20 | return state.editor.toJS()
21 | }
22 |
23 | export const saveSelector = state => {
24 | return {notebook: state.notebook}
25 | }
26 |
27 | export const dataSelector = state => {
28 | return {data: state.execution.get('data').toJS()}
29 | }
30 |
--------------------------------------------------------------------------------
/src/js/util.js:
--------------------------------------------------------------------------------
1 | import hljs from 'highlight.js'
2 | import config from './config' // eslint-disable-line
3 |
4 | export function codeToText (codeBlock, includeOption) {
5 | let result = '```'
6 | result += codeBlock.get('language')
7 | const option = codeBlock.get('option')
8 | if (includeOption && option) {
9 | result += '; ' + option
10 | }
11 | result += '\n'
12 | result += codeBlock.get('content')
13 | result += '\n```'
14 | return result
15 | }
16 |
17 | export function highlight (str, lang) {
18 | if (lang && hljs.getLanguage(lang)) {
19 | try {
20 | return hljs.highlight(lang, str).value
21 | } catch (__) {}
22 | }
23 | return '' // use external default escaping
24 | }
25 |
26 | export function renderHTML (markdown) {
27 | let result = '\n\n \n'
28 | result += ' \n'
29 | result += ' \n'
30 | result += ' \n'
31 | result += ' \n \n \n'
39 | result += ' \n'
40 | result += ' \n'
41 | result += ' \n\n'
42 | return result
43 | }
44 |
45 | export function arrayToCSV (data) {
46 | return new Promise((resolve, reject) => {
47 | let CSV = ''
48 | let header = ''
49 | Object.keys(data[0]).forEach(colName => {
50 | header += colName + ','
51 | })
52 | header = header.slice(0, -1)
53 | CSV += header + '\r\n'
54 | data.forEach((rowData) => {
55 | let row = ''
56 | Object.keys(rowData).forEach(colName => {
57 | row += '"' + rowData[colName] + '",'
58 | })
59 | row.slice(0, -1)
60 | CSV += row + '\r\n'
61 | })
62 |
63 | resolve(CSV)
64 | })
65 | }
66 |
--------------------------------------------------------------------------------
/src/scss/_base.scss:
--------------------------------------------------------------------------------
1 | $background-col: #fff;
2 | $text-black: #444;
3 | $text-grey: #646464;
4 | $text-light: #999;
5 | $background-grey: #eee;
6 | $background-dark-grey: #ddd;
7 |
8 | $size-sm: 35.5em;
9 | $size-md: 48em;
10 | $size-lg: 64em;
11 | $size-xl: 80em;
12 |
13 | @font-face {
14 | font-family: "Andada";
15 | src: local('Andada'), url("../fonts/andada.otf") format('opentype');
16 | }
17 |
18 | @font-face {
19 | font-family: "Amaranth";
20 | src: local('Amaranth'), url("../fonts/Amaranth-Regular.otf") format('opentype');
21 | }
22 |
23 | @font-face {
24 | font-family: "Source Code Pro";
25 | src: local('Source Code Pro'), url("../fonts/SourceCodePro-Regular.otf") format('opentype');
26 | }
27 |
28 | @mixin respond-to($size) {
29 | @media only screen and (min-width: $size) { @content; }
30 | }
31 |
32 | @mixin block-border {
33 | border: 1px dashed $text-light;
34 | }
35 |
--------------------------------------------------------------------------------
/src/scss/_editor.scss:
--------------------------------------------------------------------------------
1 | @import 'base';
2 |
3 | .editable {
4 | input {
5 | @include block-border;
6 | display: inherit;
7 | color: inherit;
8 | font-family: inherit;
9 | font-size: inherit;
10 | font-weight: inherit;
11 | width: 100%;
12 | outline: none;
13 | }
14 |
15 | .dragging {
16 | opacity: 0;
17 | }
18 |
19 | .clickable {
20 | cursor: pointer;
21 | }
22 |
23 | .edit-box {
24 | @include block-border;
25 | margin: 1em 0;
26 | padding: 0.5em 0.5em 0.5em 0;
27 |
28 | .CodeMirror {
29 | font-family: 'Source Code Pro', monospace;
30 | font-size: 0.8em;
31 | }
32 | }
33 |
34 | .text-block {
35 | @include block-border;
36 | padding: 0.5em;
37 | .text-block-content {
38 | *:first-child {
39 | margin-top: 0;
40 | }
41 | *:last-child {
42 | margin-bottom: 0;
43 | }
44 | }
45 | margin: 1em 0;
46 | }
47 |
48 | .metadata {
49 |
50 | .metadata-row {
51 | margin: 0.5em 0;
52 | span {
53 | margin-left: 15px;
54 | }
55 | input {
56 | margin-left: 15px;
57 | padding: 0.25em;
58 | box-sizing: border-box;
59 | display: inline-block;
60 | width: calc(100% - 100px);
61 | }
62 | }
63 | .datasource {
64 | margin: 0.5em 0;
65 | .fa {
66 | text-align: center;
67 | margin-top: 0.5em;
68 | cursor: pointer;
69 |
70 | @include respond-to($size-md) {
71 | margin-top: 0.25em;
72 | }
73 | }
74 | input {
75 | width: 100%;
76 | padding: 0.25em;
77 | margin: 0.25em 0;
78 | box-sizing: border-box;
79 |
80 | @include respond-to($size-md) {
81 | margin: 0;
82 | }
83 | }
84 | .source-name input {
85 | @include respond-to($size-md) {
86 | width: 95%;
87 | }
88 | }
89 | p {
90 | margin: 0.5em 0.25em;
91 |
92 | @include respond-to($size-md) {
93 | margin: 0.25em;
94 | }
95 | }
96 | }
97 | }
98 |
99 | .add-controls {
100 | overflow: hidden;
101 | i {
102 | margin-left: 0.5em;
103 | float: right;
104 | }
105 | }
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/src/scss/_graphui.scss:
--------------------------------------------------------------------------------
1 | @import 'base';
2 |
3 | .graph-creator {
4 | @include block-border;
5 | padding: 0.5em;
6 | margin: 1em 0;
7 | font-family: 'Amaranth', sans-serif;
8 |
9 | .editor-buttons {
10 | margin-top: 0;
11 | }
12 |
13 | .graph-type {
14 | @include block-border;
15 | padding: 0.25em;
16 | cursor: pointer;
17 | display: inline-block;
18 | margin-bottom: 0.5em;
19 | margin-right: 0.5em;
20 | color: $text-light;
21 |
22 | &.selected {
23 | @include block-border;
24 | font-weight: bold;
25 | color: $text-black;
26 | border: 1px dashed $text-black;
27 | cursor: default;
28 | }
29 | }
30 |
31 | p {
32 | margin: 0.5em 0.25em;
33 |
34 | @include respond-to($size-md) {
35 | margin: 0.25em;
36 | }
37 | }
38 |
39 | .hint {
40 | margin: 0.5em 0;
41 |
42 | .fa {
43 | text-align: center;
44 | margin-top: 0.5em;
45 |
46 | @include respond-to($size-md) {
47 | margin-top: 0.25em;
48 | }
49 | }
50 |
51 | input {
52 | width: 100%;
53 | padding: 0.25em;
54 | margin: 0.25em 0;
55 | box-sizing: border-box;
56 |
57 | @include respond-to($size-md) {
58 | margin: 0;
59 | }
60 | }
61 |
62 | .visualiser {
63 | margin-top: 0.5em;
64 | }
65 | }
66 |
67 | .visualiser {
68 | background-color: $background-grey;
69 | padding: 0.5em;
70 | margin-bottom: 0.5em;
71 |
72 | .visualiser-key {
73 | cursor: pointer;
74 | }
75 | }
76 |
77 | pre {
78 | font-family: 'Source Code Pro', monospace;
79 | font-size: 0.8em;
80 | }
81 |
82 | .graph-preview svg {
83 | width: 100%;
84 | &.nvd3-svg {
85 | height: inherit;
86 | }
87 | text {
88 | fill: $text-black;
89 | }
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/src/scss/_save.scss:
--------------------------------------------------------------------------------
1 | @import 'base';
2 |
3 | .save-dialog {
4 |
5 | .close-button {
6 | float: right;
7 | margin-top: 0.75em;
8 | margin-right: 0.25em;
9 | cursor: pointer;
10 | }
11 |
12 | .export-option,
13 | .clipboard-button {
14 | @include block-border;
15 | font-family: 'Amaranth', sans-serif;
16 | padding: 0.25em;
17 | cursor: pointer;
18 | display: inline-block;
19 |
20 | &.selected {
21 | @include block-border;
22 | font-weight: bold;
23 | color: $text-black;
24 | border: 1px dashed $text-black;
25 | cursor: default;
26 | }
27 | }
28 |
29 | textarea {
30 | height: 400px;
31 | box-sizing: border-box;
32 | }
33 |
34 | .export-option {
35 | margin-top: 0.5em;
36 | margin-right: 0.5em;
37 | color: $text-light;
38 | }
39 |
40 | .clipboard-button {
41 | font-weight: bold;
42 | float: right;
43 | }
44 |
45 | input {
46 | @include block-border;
47 | width: 100%;
48 | box-sizing: border-box;
49 | padding: 0.25em;
50 | margin-top: 0.25em;
51 | margin-bottom: 0.5em;
52 | display: inherit;
53 | color: inherit;
54 | font-family: inherit;
55 | font-size: inherit;
56 | font-weight: inherit;
57 | width: 100%;
58 | outline: none;
59 | cursor: text;
60 | }
61 |
62 | .pure-g {
63 | margin-top: 0.75em;
64 | p {
65 | font-family: 'Amaranth', sans-serif;
66 | }
67 | }
68 |
69 | p {
70 | clear: both;
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/src/scss/_shell.scss:
--------------------------------------------------------------------------------
1 | @import 'base';
2 |
3 | body {
4 | background: $background-col;
5 | color: $text-black;
6 | font-family: "Andada", serif;
7 |
8 | @include respond-to($size-md) {
9 | font-size: 1.2em;
10 | }
11 | }
12 |
13 | .metadata {
14 | font-size: 1em;
15 | font-family: "Amaranth", sans-serif;
16 |
17 | .metadata-sep {
18 | display: none;
19 |
20 | @include respond-to($size-md) {
21 | display: inline;
22 | }
23 | }
24 |
25 | .metadata-item {
26 | display: block;
27 | margin: 0.5em 0;
28 |
29 | @include respond-to($size-md) {
30 | display: inline;
31 | }
32 | }
33 | }
34 |
35 | .controls {
36 | float: right;
37 | margin: 0 0.5em;
38 | cursor: pointer;
39 | i {
40 | margin-left: 0.5em;
41 | }
42 | }
43 |
44 | .editor-buttons {
45 | float: right;
46 | margin-bottom: 1px; // Fixes overlap bug on Safari
47 | margin-top: -0.2em; // Account for font size difference
48 | i {
49 | margin-left: 0.25em;
50 | cursor: pointer;
51 | }
52 | }
53 |
54 | code {
55 | font-family: "Source Code Pro", monospace;
56 | font-size: 0.8em;
57 | }
58 |
59 | .offset-col {
60 | display: none;
61 |
62 | @include respond-to($size-md) {
63 | display: inline-block;
64 | }
65 | }
66 |
67 | hr {
68 | display: block;
69 | height: 1px;
70 | border: 0;
71 | border-top: 2px dashed $text-light;
72 | margin: 1.5em 0;
73 | padding: 0;
74 | }
75 |
76 | .top-sep {
77 | border-top: 2px dashed $text-black;
78 | margin: 1.5em 0;
79 | }
80 |
81 | h1 {
82 | font-size: 2.5em;
83 | }
84 |
85 | a {
86 | text-decoration: none;
87 | border-bottom: 1px dashed $text-light;
88 | font-weight: bold;
89 | color: $text-black;
90 | }
91 |
92 | a:visited {
93 | color: inherit;
94 | }
95 |
96 | img {
97 | max-width: 100%;
98 | }
99 |
100 | pre,
101 | .codeBlock,
102 | .resultBlock,
103 | .graphBlock {
104 | @include block-border;
105 | overflow: auto;
106 | padding: 0.5em;
107 | }
108 |
109 | .codeContainer {
110 | margin: 1em 0;
111 | pre {
112 | padding: 0;
113 | border: none;
114 | overflow: auto;
115 | margin: 0;
116 | }
117 |
118 | .graphBlock,
119 | .resultBlock {
120 | border-top: none;
121 | }
122 |
123 | .graphBlock > * {
124 | margin: 0 auto;
125 | }
126 |
127 | .graphBlock:empty {
128 | display: none;
129 | }
130 |
131 | .graphBlock svg {
132 | width: 100%;
133 | &.nvd3-svg {
134 | height: inherit;
135 | }
136 | text {
137 | fill: $text-black;
138 | }
139 | }
140 |
141 | .resultBlock {
142 | background-color: $background-grey;
143 | }
144 |
145 | }
146 |
147 | .hiddenCode {
148 | .codeBlock {
149 | display: none;
150 | }
151 |
152 | .resultBlock,
153 | .graphBlock {
154 | border-top: 1px dashed $text-light;
155 | }
156 |
157 | .graphBlock {
158 | border-bottom: none;
159 | }
160 | }
161 |
162 | textarea {
163 | @include block-border;
164 | width: 100%;
165 | color: $text-black;
166 | padding: 0.5em;
167 | outline: none;
168 | height: 300px;
169 | font-family: 'Source Code Pro', monospace;
170 | font-size: 0.8em;
171 | resize: none;
172 | margin: 1em 0;
173 | }
174 |
175 | .footer {
176 | padding-bottom: 1.5em;
177 | .footer-row {
178 | display: block;
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/src/scss/_visualiser.scss:
--------------------------------------------------------------------------------
1 | .visualiser {
2 | font-family: 'Source Code Pro', monospace;
3 | font-size: 0.8em;
4 | white-space: pre;
5 | overflow: auto;
6 |
7 | .visualiser-spacing {
8 | cursor: default;
9 | }
10 |
11 | .visualiser-arrow {
12 | cursor: pointer;
13 | }
14 |
15 | .visualiser-row {
16 | display: inline-block;
17 | }
18 |
19 | .export-to-csv {
20 | float: right;
21 | cursor: pointer;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/scss/base16-tomorrow-light.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Made with colours from Highlight.js.
3 | Who in turn got them from Base16.
4 | https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow.css
5 | */
6 |
7 | .cm-s-base16-tomorrow-light {
8 | *.CodeMirror {
9 | background: #fff;
10 | color: #444;
11 | }
12 |
13 | div.CodeMirror-selected { background: #e0e0e0 !important; }
14 |
15 | .CodeMirror-gutters {
16 | background: #fff;
17 | border-right: 0;
18 | }
19 |
20 | .CodeMirror-linenumber { color: #b4b7b4; }
21 |
22 | .CodeMirror-cursor { border-left: 1px solid #969896 !important; }
23 |
24 | span.cm-comment { color: #8e908c; }
25 | span.cm-atom { color: #b294bb; }
26 | span.cm-number { color: #f5871f; }
27 |
28 | span.cm-property,
29 | span.cm-attribute { color: #444; }
30 | span.cm-keyword { color: #8959a8; }
31 | span.cm-string { color: #718c00; }
32 |
33 | span.cm-variable { color: #444; }
34 | span.cm-variable-2 { color: #444; }
35 | span.cm-def { color: #4271ae; }
36 | span.cm-error {
37 | background: #c66;
38 | color: #969896;
39 | }
40 | span.cm-bracket { color: #282a2e; }
41 | span.cm-tag { color: #c66; }
42 | span.cm-link { color: #b294bb; }
43 |
44 | .CodeMirror-matchingbracket {
45 | text-decoration: underline;
46 | color: white !important;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/scss/linter.scss:
--------------------------------------------------------------------------------
1 | /* The lint marker gutter */
2 | .CodeMirror-lint-markers {
3 | width: 16px;
4 | }
5 |
6 | .CodeMirror-lint-tooltip {
7 | color: #282c34;
8 | border-radius: 4.5px;
9 | border-top-left-radius: 0;
10 | background: #bdc5d4;
11 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
12 | overflow: hidden;
13 | padding: 2px 5px;
14 | position: fixed;
15 | white-space: pre-wrap;
16 | z-index: 100;
17 | max-width: 200px;
18 | max-width: 600px;
19 | opacity: 0;
20 | transition: opacity 0.4s;
21 | font-size: 12px;
22 | line-height: 22px;
23 | }
24 |
25 | .CodeMirror-lint-mark-error,
26 | .CodeMirror-lint-mark-warning {
27 | background-position: left bottom;
28 | background-repeat: repeat-x;
29 | }
30 |
31 | .CodeMirror-lint-mark-error {
32 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==");
33 | }
34 |
35 | .CodeMirror-lint-mark-warning {
36 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=");
37 | }
38 |
39 | .CodeMirror-lint-marker-error,
40 | .CodeMirror-lint-marker-warning {
41 | height: 8px;
42 | width: 8px;
43 | border-radius: 50%;
44 | display: inline-block;
45 | cursor: pointer;
46 | margin-left: 12px;
47 | position: relative;
48 | top: -2px;
49 | }
50 |
51 | .CodeMirror-lint-message-error::before,
52 | .CodeMirror-lint-message-warning::before {
53 | display: inline-block;
54 | border-radius: 3px;
55 | overflow: hidden;
56 | text-overflow: ellipsis;
57 | font-size: 0.8em;
58 | min-width: 1.6em;
59 | padding: 0.4em 0.6em;
60 | position: relative;
61 | margin-right: 5px;
62 | top: 2px;
63 | line-height: 1em;
64 | }
65 |
66 | .CodeMirror-lint-marker-error,
67 | .CodeMirror-lint-message-error::before {
68 | background: #d92626;
69 | }
70 |
71 | .CodeMirror-lint-message-error::before {
72 | content: 'Error';
73 | color: white;
74 | }
75 |
76 | .CodeMirror-lint-marker-warning,
77 | .CodeMirror-lint-message-warning::before {
78 | background: #cc8533;
79 | }
80 |
81 | .CodeMirror-lint-message-warning::before {
82 | content: 'Warning';
83 | color: white;
84 | }
85 |
--------------------------------------------------------------------------------
/src/scss/main.scss:
--------------------------------------------------------------------------------
1 | @import '../../node_modules/font-awesome/scss/font-awesome';
2 | @import '../../node_modules/highlight.js/styles/tomorrow';
3 | @import '../../node_modules/nvd3/build/nv.d3';
4 | @import '../../node_modules/codemirror/lib/codemirror';
5 | @import '../../node_modules/katex/dist/katex.min.css';
6 | @import 'base16-tomorrow-light';
7 | @import 'grids';
8 | @import 'base';
9 | @import 'shell';
10 | @import 'visualiser';
11 | @import 'editor';
12 | @import 'save';
13 | @import 'graphui';
14 | @import 'linter';
15 |
--------------------------------------------------------------------------------
/src/templates/blank.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Blank Kayero notebook"
3 | author: "Mathieu DUtour"
4 | show_footer: false
5 | ---
6 |
7 | ## I'm a blank notebook.
8 |
9 | This is a Kayero notebook, but there's nothing in it yet. Click the edit button on the top-right to get started!
10 |
--------------------------------------------------------------------------------
/src/templates/example.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Kayero"
3 | author: "Joel Auterson"
4 | datasources:
5 | joelotter: "https://api.github.com/users/joelotter/repos"
6 | popular: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc"
7 | original:
8 | title: "Blank Kayero notebook"
9 | url: "http://www.joelotter.com/kayero/blank"
10 | show_footer: true
11 | ---
12 |
13 | [](https://www.npmjs.com/package/kayero) [](https://raw.githubusercontent.com/mathieudutour/kayero/master/LICENSE) [](https://github.com/mathieudutour/kayero/stargazers) [](https://gitter.im/mathieudutour/kayero?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
14 |
15 | **Kayero** is designed to make it really easy for anyone to create good-looking, responsive, interactive documents.
16 |
17 | All Kayero notebooks are editable in-line - including the one you're reading right now. Just click the pencil icon in the top-right to get started.
18 |
19 | This notebook is an interactive run-through of Kayero's features. If you'd rather just grab the source code, [it's on GitHub](https://github.com/mathieudutour/kayero).
20 |
21 | ## It's just Markdown
22 |
23 | If you view the page source for this notebook, you'll notice that it's really just a Markdown document with a script attached. This is because the notebooks are fully rendered in-browser - there's no backend required, so you can host them on GitHub pages or wherever you like.
24 |
25 | Markdown is great for writing documents, and this helps create very readable notebooks.
26 |
27 | - You
28 | - Can
29 | - Write
30 | - Lists
31 |
32 | You can write code samples too!
33 |
34 | ```go
35 | package main
36 |
37 | import "fmt"
38 |
39 | func main() {
40 | fmt.Println("This is some Go.")
41 | }
42 | ```
43 |
44 | The Kayero package on [npm](https://www.npmjs.com/package/kayero) contains some command-line tools to create notebooks out of Markdown files. However, you might not want to create a notebook from scratch, so...
45 |
46 | ## Every notebook is editable
47 |
48 | Clicking the pencil icon in the top-right puts Kayero into edit mode. Every notebook can be edited - you can move text and code blocks around, add new data sources, change the title and author, and add your own content.
49 |
50 | Once you've made your changes, clicking the save icon will allow you to export your new notebook as Markdown or HTML.
51 |
52 | Notebooks can also be exported as Gists. This saves the generated Markdown on GitHub's [Gist](https://gist.github.com/) service, and provides a unique URL which can be used to access the saved notebook. This means you don't need to host the notebook yourself.
53 |
54 | A notebook's footer will contain a link to the parent notebook - the one the notebook was forked from. This footer can be turned off in the editor if you don't want it.
55 |
56 | ## Interactive code samples
57 |
58 | JavaScript code samples in Kayero notebooks can be executed by clicking the play button.
59 |
60 | ```javascript; runnable
61 | return 1 + 2;
62 | ```
63 |
64 | Code blocks can be set to run automatically, when the notebook is loaded.
65 |
66 | ```javascript; auto
67 | return "Like this one.";
68 | ```
69 |
70 | They can also be set to 'hidden' - only the result will be visible, the code itself will be hidden (though is still viewable and editable in the editor).
71 |
72 | ```javascript; hidden
73 | return "This is a hidden code block."
74 | ```
75 |
76 | It's possible to pass data between different code blocks by using **this**. For example, in the following blocks, the first defines a variable and the second does something with it.
77 |
78 | ```javascript; auto
79 | this.number = 100;
80 | return this.number;
81 | ```
82 |
83 | ```javascript; auto
84 | return this.number + 100;
85 | ```
86 |
87 | Want to run something asynchronous? No problem - return a Promise and it'll be resolved.
88 |
89 | ```javascript; runnable
90 | return new Promise(function (resolve) {
91 | setTimeout(function() {
92 | resolve("This took three seconds.");
93 | }, 3000);
94 | });
95 | ```
96 |
97 | Kayero includes a built-in data visualiser, similar to Chrome's object inspector. This means your code blocks can return whatever data you need, not just primitives.
98 |
99 | For example, here's an array:
100 |
101 | ```javascript; auto
102 | var toSplit = "This is a string. We're going to split it into an array.";
103 | return toSplit.split(' ');
104 | ```
105 |
106 | And an object:
107 |
108 | ```javascript; auto
109 | var person = {
110 | name: 'Joel',
111 | age: 22,
112 | projects: {
113 | termloop: 'https://github.com/JoelOtter/termloop',
114 | kayero: 'https://github.com/mathieudutour/kayero'
115 | }
116 | };
117 |
118 | return person;
119 | ```
120 |
121 | ## Working with data sources
122 |
123 | Kayero allows users to define JSON data sources in the editor. These are fetched when the notebook is first loaded, and made available in code blocks via the **data** object.
124 |
125 | ```javascript; auto
126 | return data;
127 | ```
128 |
129 | In this notebook, the data source **joelotter** retrieves my repository data via the GitHub API. We can write code blocks to work with this data.
130 |
131 | ```javascript; runnable
132 | return data.joelotter.map(
133 | function (repo) {
134 | return repo.name;
135 | }
136 | );
137 | ```
138 |
139 | ### Reshaper
140 |
141 | Kayero also includes [Reshaper](http://www.joelotter.com/reshaper), a library which can automatically reshape data to match a provided schema.
142 |
143 | You can read more about Reshaper by following the link above. Here are a couple of quick examples, using the GitHub data.
144 |
145 | ```javascript; runnable
146 | var schema = ['String'];
147 | return reshaper(data.joelotter, schema);
148 | ```
149 |
150 | Reshaper has correctly produced an array of strings, which is what we asked for. However, we didn't tell it exactly what data we're interested in, so it's used the first string it could find.
151 |
152 | We can give Reshaper a **hint** as to which part of the data we care about. Let's get an array of the repo names.
153 |
154 | ```javascript; runnable
155 | var schema = ['String'];
156 | return reshaper(data.joelotter, schema, 'name');
157 | ```
158 |
159 | We can provide a more complex schema for a more useful resulting data structure. Let's get the name and number of stargazers for each repo.
160 |
161 | ```javascript; runnable
162 | // Reshaper can use object keys as hints
163 | var schema = [{
164 | name: 'String',
165 | stargazers_count: 'Number'
166 | }];
167 |
168 | return reshaper(data.joelotter, schema);
169 | ```
170 |
171 | Check the [Reshaper documentation](http://www.joelotter.com/reshaper) for more examples.
172 |
173 | ## Graphs
174 |
175 | When working with data sources, a very common task would be to use the data to produce graphs. Kayero provides a few options for this.
176 |
177 | ### D3
178 |
179 | [D3](http://d3js.org) is the web's favourite library for creating interactive graphics. It's available for use in code blocks.
180 |
181 | Every code block has a DOM node called **graphElement**. Users can draw to this with D3.
182 |
183 | ```javascript; runnable
184 | // Remove any old SVGs for re-running
185 | d3.select(graphElement).selectAll('*').remove();
186 |
187 | var sampleSVG = d3.select(graphElement)
188 | .append("svg")
189 | .attr("width", 100)
190 | .attr("height", 100);
191 |
192 | sampleSVG.append("circle")
193 | .style("stroke", "gray")
194 | .style("fill", "white")
195 | .attr("r", 40)
196 | .attr("cx", 50)
197 | .attr("cy", 50)
198 | .on("mouseover", function(){d3.select(this).style("fill", "aliceblue");})
199 | .on("mouseout", function(){d3.select(this).style("fill", "white");})
200 | .on("mousedown", animateFirstStep);
201 |
202 | function animateFirstStep(){
203 | d3.select(this)
204 | .transition()
205 | .delay(0)
206 | .duration(1000)
207 | .attr("r", 10)
208 | .each("end", animateSecondStep);
209 | };
210 |
211 | function animateSecondStep(){
212 | d3.select(this)
213 | .transition()
214 | .duration(1000)
215 | .attr("r", 40);
216 | };
217 |
218 | return "Try clicking the circle!";
219 | ```
220 |
221 | ### NVD3
222 |
223 | D3 is incredibly powerful, but creating complex drawings can be daunting. [NVD3](http://nvd3.org/) provides some nice pre-built graphs, and is also included in Kayero.
224 |
225 | The following code will generate and draw a random scatter plot.
226 |
227 | ```javascript; runnable
228 | d3.select(graphElement).selectAll('*').remove();
229 | d3.select(graphElement).append('svg').attr("width", "100%");
230 |
231 | nv.addGraph(function() {
232 | var chart = nv.models.scatter()
233 | .margin({top: 20, right: 20, bottom: 20, left: 20})
234 | .pointSize(function(d) { return d.z })
235 | .useVoronoi(false);
236 | d3.select(graphElement).selectAll("svg")
237 | .datum(randomData())
238 | .transition().duration(500)
239 | .call(chart);
240 | nv.utils.windowResize(chart.update);
241 | return chart;
242 | });
243 |
244 | function randomData() {
245 | var data = [];
246 | for (i = 0; i < 2; i++) {
247 | data.push({
248 | key: 'Group ' + i,
249 | values: []
250 | });
251 | for (j = 0; j < 100; j++) {
252 | data[i].values.push({x: Math.random(), y: Math.random(), z: Math.random()});
253 | }
254 | }
255 | return data;
256 | }
257 | return "Try clicking the rerun button!";
258 | ```
259 |
260 | ### Jutsu
261 |
262 | Even NVD3 may be too difficult to use for those with little coding experience. This is why Kayero includes [Jutsu](http://www.joelotter.com/jutsu), a very simple graphing library.
263 |
264 | Jutsu uses Reshaper internally, via a library wrapper called [Smolder](https://github.com/JoelOtter/smolder). This means you can throw whatever data you like at it, and Jutsu will attempt to make a graph with that data. You can also provide hints.
265 |
266 | Additionally, the Kayero editor includes a GUI for graph creation. This makes it possible to create graphs from data without writing any code at all. Try it out!
267 |
268 | For our examples, let's look at the data provided by the **popular** data source. This contains data on the 30 most popular GitHub repos of 2016, by number of stargazers.
269 |
270 | ```javascript; runnable
271 | return data.popular;
272 | ```
273 |
274 | Let's create a pie chart using this data.
275 |
276 | ```javascript; auto
277 | return graphs.pieChart(data.popular);
278 | ```
279 |
280 | It's made a pie chart - but, like in the first Reshaper example, we didn't tell it what data we're interested in. Let's use the repo name as a label, and plot a pie chart of the number of open issues each repo has.
281 |
282 | As well as using strings as before, we can provide hints in the form of arrays or objects.
283 |
284 | ```javascript; auto
285 | return graphs.pieChart(data.popular, {label: 'name', value: 'open_issues'});
286 | ```
287 |
288 | Jutsu functions will return the reshaped data used in the graph.
289 |
290 | The pie chart is a little hard to read. Let's do a bar chart instead. As well as hints, we can provide axis labels:
291 |
292 | ```javascript; auto
293 | return graphs.barChart(
294 | data.popular, 'Repo', 'Number of open issues',
295 | {label: 'name', value: 'open_issues'}
296 | );
297 | ```
298 |
299 | Line graphs can be used to investigate trends. Is there any correlation between the number of stargazers a repo has, and the number of open issues?
300 |
301 | ```javascript; auto
302 | return graphs.lineChart(
303 | data.popular, 'Stargazers', 'Open issues',
304 | {label: 'name', x: 'stargazers_count', y: 'open_issues'}
305 | );
306 | ```
307 |
308 | It's a little hard to tell. Let's use a scatter plot instead.
309 |
310 | ```javascript; auto
311 | return graphs.scatterPlot(
312 | data.popular, 'Stargazers', 'Open issues',
313 | {label: 'name', x: 'stargazers_count', y: 'open_issues'}
314 | );
315 | ```
316 |
317 | There doesn't seem to be any correlation!
318 |
319 | ## Questions? Feedback?
320 |
321 | Please feel free to file an [issue](https://github.com/mathieudutour/kayero/issues) with any you may have. If you're having a problem with the way Jutsu or Reshaper restructures your data, it's probably better to file an issue in [Reshaper](https://github.com/JoelOtter/reshaper) itself.
322 |
323 | Kayero is a part of my Master's project at Imperial College London, and as part of my evaluation I'd really love to hear any feedback you might have. Feel free to [shoot me an email](mailto:joel.auterson@gmail.com).
324 |
325 | I hope you find Kayero useful!
326 |
--------------------------------------------------------------------------------
/tests/actions.js:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 |
3 | import Immutable from 'immutable'
4 | import configureMockStore from 'redux-mock-store'
5 | import thunk from 'redux-thunk'
6 | import fetchMock from 'fetch-mock'
7 | import requireMock from 'mock-require'
8 |
9 | requireMock('electron', {
10 | remote: {
11 | dialog: {
12 | showOpenDialog (_, callback) {
13 | callback()
14 | }
15 | },
16 | app: {
17 | addRecentDocument () {}
18 | },
19 | BrowserWindow: {
20 | getFocusedWindow () {
21 | return {
22 | setRepresentedFilename () {},
23 | setDocumentEdited () {}
24 | }
25 | }
26 | }
27 | }
28 | })
29 |
30 | import { gistUrl, gistApi } from '../src/js/config' // eslint-disable-line
31 | const actions = require('../src/js/actions')
32 |
33 | const middlewares = [thunk]
34 | const mockStore = configureMockStore(middlewares)
35 |
36 | // Mock stuff for execution
37 | global.document = require('jsdom').jsdom('')
38 | global.window = document.defaultView
39 | global.nv = {}
40 |
41 | test.afterEach.always((t) => {
42 | fetchMock.restore()
43 | })
44 |
45 | test('should create RECEIVED_DATA, trigger auto exec when data is received with URLs', (t) => {
46 | fetchMock.mock('http://example.com/data1', {body: {thing: 'data1'}})
47 | .mock('http://example.com/data2', {body: {thing: 'data2'}})
48 |
49 | const store = mockStore({
50 | notebook: Immutable.fromJS({
51 | metadata: {
52 | datasources: {
53 | one: 'http://example.com/data1',
54 | two: 'http://example.com/data2'
55 | }
56 | },
57 | blocks: {
58 | '12': {
59 | option: 'auto'
60 | }
61 | },
62 | content: ['12']
63 | }),
64 | execution: Immutable.fromJS({
65 | data: {},
66 | executionContext: {}
67 | })
68 | })
69 |
70 | const expecteds = [
71 | {type: actions.RECEIVED_DATA, name: 'one', data: {thing: 'data1'}},
72 | {type: actions.RECEIVED_DATA, name: 'two', data: {thing: 'data2'}}
73 | ]
74 |
75 | return store.dispatch(actions.fetchData())
76 | .then(() => {
77 | t.deepEqual(store.getActions().slice(0, 2), expecteds)
78 | t.is(store.getActions().length, 4)
79 | t.is(store.getActions()[2].type, actions.CODE_RUNNING)
80 | t.is(store.getActions()[3].type, actions.CODE_EXECUTED)
81 | })
82 | })
83 |
84 | test('should create RECEIVED_DATA, trigger auto exec when data is received with mongo connection', (t) => {
85 | const store = mockStore({
86 | notebook: Immutable.fromJS({
87 | metadata: {
88 | datasources: {
89 | one: 'mongodb://localhost',
90 | two: 'mongodb-secure://./secret.json'
91 | }
92 | },
93 | blocks: {
94 | '12': {
95 | option: 'auto'
96 | }
97 | },
98 | content: ['12']
99 | }),
100 | execution: Immutable.fromJS({
101 | data: {},
102 | executionContext: {}
103 | })
104 | })
105 |
106 | const expecteds = [
107 | {type: actions.RECEIVED_DATA, name: 'one', data: {
108 | __type: 'mongodb',
109 | __secure: false,
110 | url: 'mongodb://localhost'
111 | }},
112 | {type: actions.RECEIVED_DATA, name: 'two', data: {
113 | __type: 'mongodb',
114 | __secure: true,
115 | url: 'mongodb-secure://./secret.json'
116 | }}
117 | ]
118 |
119 | return store.dispatch(actions.fetchData())
120 | .then(() => {
121 | t.deepEqual(store.getActions().slice(0, 2), expecteds)
122 | t.is(store.getActions().length, 4)
123 | t.is(store.getActions()[2].type, actions.CODE_RUNNING)
124 | t.is(store.getActions()[3].type, actions.CODE_EXECUTED)
125 | })
126 | })
127 |
128 | test('should not fetch data unless necessary', (t) => {
129 | fetchMock.mock('http://example.com/data1', {body: {thing: 'data1'}})
130 | .mock('http://example.com/data2', {body: {thing: 'data2'}})
131 |
132 | const store = mockStore({
133 | notebook: Immutable.fromJS({
134 | metadata: {
135 | datasources: {
136 | one: 'http://example.com/data1',
137 | two: 'http://example.com/data2'
138 | }
139 | },
140 | blocks: {},
141 | content: []
142 | }),
143 | execution: Immutable.fromJS({
144 | data: {one: 'hooray'}
145 | })
146 | })
147 |
148 | const expected = [
149 | {type: actions.RECEIVED_DATA, name: 'two', data: {thing: 'data2'}}
150 | ]
151 |
152 | return store.dispatch(actions.fetchData())
153 | .then(() => {
154 | t.deepEqual(store.getActions(), expected)
155 | })
156 | })
157 |
158 | test('should save a gist and return a GIST_CREATED action', (t) => {
159 | fetchMock.mock(gistApi, 'POST', {
160 | id: 'test_gist_id'
161 | })
162 |
163 | const store = mockStore({})
164 | const expected = [{type: actions.GIST_CREATED, id: 'test_gist_id'}]
165 |
166 | return store.dispatch(actions.saveGist('title', '## markdown'))
167 | .then(() => {
168 | t.deepEqual(store.getActions(), expected)
169 | })
170 | })
171 |
172 | test('should create CODE_EXECUTED on successful block execution', (t) => {
173 | const store = mockStore({
174 | notebook: Immutable.fromJS({
175 | blocks: {
176 | '0': {
177 | type: 'code',
178 | language: 'javascript',
179 | option: 'runnable',
180 | content: 'return 1 + 2;'
181 | }
182 | }
183 | }),
184 | execution: Immutable.fromJS({
185 | data: {},
186 | executionContext: {}
187 | })
188 | })
189 |
190 | const expected = [{
191 | type: actions.CODE_RUNNING,
192 | id: '0'
193 | }, {
194 | type: actions.CODE_EXECUTED,
195 | id: '0',
196 | data: 3,
197 | context: Immutable.fromJS({})
198 | }]
199 |
200 | return store.dispatch(actions.executeCodeBlock('0'))
201 | .then(() => {
202 | t.deepEqual(store.getActions(), expected)
203 | })
204 | })
205 |
206 | test('should create CODE_ERROR on error in block execution', (t) => {
207 | const store = mockStore({
208 | notebook: Immutable.fromJS({
209 | blocks: {
210 | '0': {
211 | type: 'code',
212 | language: 'javascript',
213 | option: 'runnable',
214 | content: 'some bullshit;'
215 | }
216 | }
217 | }),
218 | execution: Immutable.fromJS({
219 | data: {},
220 | executionContext: {}
221 | })
222 | })
223 |
224 | const expected = [{
225 | type: actions.CODE_RUNNING,
226 | id: '0'
227 | }, {
228 | type: actions.CODE_ERROR,
229 | id: '0',
230 | data: Error('SyntaxError: Unexpected identifier')
231 | }]
232 |
233 | return store.dispatch(actions.executeCodeBlock('0'))
234 | .then(() => {
235 | t.deepEqual(store.getActions(), expected)
236 | })
237 | })
238 |
239 | test('should pass context along for use with "this"', (t) => {
240 | const store = mockStore({
241 | notebook: Immutable.fromJS({
242 | blocks: {
243 | '0': {
244 | type: 'code',
245 | language: 'javascript',
246 | option: 'runnable',
247 | content: 'this.number = 100; return 5;'
248 | }
249 | }
250 | }),
251 | execution: Immutable.fromJS({
252 | data: {},
253 | executionContext: {}
254 | })
255 | })
256 |
257 | const expected = [{
258 | type: actions.CODE_RUNNING,
259 | id: '0'
260 | }, {
261 | type: actions.CODE_EXECUTED,
262 | id: '0',
263 | context: Immutable.Map({number: 100}),
264 | data: 5
265 | }]
266 |
267 | return store.dispatch(actions.executeCodeBlock('0'))
268 | .then(() => {
269 | t.deepEqual(store.getActions(), expected)
270 | })
271 | })
272 |
273 | test('should make context contents available in code blocks', (t) => {
274 | const store = mockStore({
275 | notebook: Immutable.fromJS({
276 | blocks: {
277 | '0': {
278 | type: 'code',
279 | language: 'javascript',
280 | option: 'runnable',
281 | content: 'return this.number;'
282 | }
283 | }
284 | }),
285 | execution: Immutable.fromJS({
286 | data: {},
287 | executionContext: {number: 100}
288 | })
289 | })
290 |
291 | const expected = [{
292 | type: actions.CODE_RUNNING,
293 | id: '0'
294 | }, {
295 | type: actions.CODE_EXECUTED,
296 | id: '0',
297 | context: Immutable.Map({number: 100}),
298 | data: 100
299 | }]
300 |
301 | return store.dispatch(actions.executeCodeBlock('0'))
302 | .then(() => {
303 | t.deepEqual(store.getActions(), expected)
304 | })
305 | })
306 |
307 | test('should resolve returned promises', (t) => {
308 | const store = mockStore({
309 | notebook: Immutable.fromJS({
310 | blocks: {
311 | '0': {
312 | type: 'code',
313 | language: 'javascript',
314 | option: 'runnable',
315 | content: 'return Promise.resolve(5);'
316 | }
317 | }
318 | }),
319 | execution: Immutable.fromJS({
320 | data: {},
321 | executionContext: {}
322 | })
323 | })
324 |
325 | const expected = [{
326 | type: actions.CODE_RUNNING,
327 | id: '0'
328 | }, {
329 | type: actions.CODE_EXECUTED,
330 | id: '0',
331 | context: Immutable.Map(),
332 | data: 5
333 | }]
334 |
335 | return store.dispatch(actions.executeCodeBlock('0'))
336 | .then(() => {
337 | t.deepEqual(store.getActions(), expected)
338 | })
339 | })
340 |
341 | test('should auto execute auto and hidden code blocks', (t) => {
342 | const store = mockStore({
343 | notebook: Immutable.fromJS({
344 | blocks: {
345 | '0': {
346 | type: 'code',
347 | language: 'javascript',
348 | option: 'auto',
349 | content: 'return Promise.resolve(5);'
350 | },
351 | '1': {
352 | type: 'code',
353 | language: 'javascript',
354 | option: 'runnable',
355 | content: 'return 10;'
356 | },
357 | '2': {
358 | type: 'code',
359 | language: 'javascript',
360 | option: 'hidden',
361 | content: 'return 15;'
362 | }
363 | },
364 | content: ['0', '1', '2']
365 | }),
366 | execution: Immutable.fromJS({
367 | data: {},
368 | executionContext: {}
369 | })
370 | })
371 |
372 | const expected = [{
373 | type: actions.CODE_RUNNING,
374 | id: '0'
375 | }, {
376 | type: actions.CODE_EXECUTED,
377 | id: '0',
378 | context: Immutable.Map(),
379 | data: 5
380 | }, {
381 | type: actions.CODE_RUNNING,
382 | id: '2'
383 | }, {
384 | type: actions.CODE_EXECUTED,
385 | id: '2',
386 | context: Immutable.Map(),
387 | data: 15
388 | }]
389 |
390 | return store.dispatch(actions.executeAuto())
391 | .then(() => {
392 | t.deepEqual(store.getActions(), expected)
393 | })
394 | })
395 |
396 | test('should create an action for toggling the editor', (t) => {
397 | const expected = {
398 | type: actions.TOGGLE_EDIT
399 | }
400 | t.deepEqual(actions.toggleEdit(), expected)
401 | })
402 |
403 | test('should create an action for updating a block', (t) => {
404 | const id = '12'
405 | const text = '## some markdown'
406 | const expected = {
407 | type: actions.UPDATE_BLOCK,
408 | id,
409 | text
410 | }
411 | t.deepEqual(actions.updateBlock(id, text), expected)
412 | })
413 |
414 | test('should create an action for adding a new text block', (t) => {
415 | const id = '12'
416 | const expected = {
417 | type: actions.ADD_BLOCK,
418 | blockType: 'text',
419 | id
420 | }
421 | t.deepEqual(actions.addTextBlock(id), expected)
422 | })
423 |
424 | test('should create an action for adding a new code block', (t) => {
425 | const id = '12'
426 | const expected = {
427 | type: actions.ADD_BLOCK,
428 | blockType: 'code',
429 | id
430 | }
431 | t.deepEqual(actions.addCodeBlock(id), expected)
432 | })
433 |
434 | test('should create an action for deleting a block', (t) => {
435 | const id = '12'
436 | const expected = {
437 | type: actions.DELETE_BLOCK,
438 | id
439 | }
440 | t.deepEqual(actions.deleteBlock(id), expected)
441 | })
442 |
443 | test('should create an action for updating the title', (t) => {
444 | const text = 'New title'
445 | const expected = {
446 | type: actions.UPDATE_META,
447 | field: 'title',
448 | text
449 | }
450 | t.deepEqual(actions.updateTitle(text), expected)
451 | })
452 |
453 | test('should create an action for updating the author', (t) => {
454 | const text = 'New author'
455 | const expected = {
456 | type: actions.UPDATE_META,
457 | field: 'author',
458 | text
459 | }
460 | t.deepEqual(actions.updateAuthor(text), expected)
461 | })
462 |
463 | test('should create an action for toggling the footer', (t) => {
464 | const expected = {
465 | type: actions.TOGGLE_META,
466 | field: 'showFooter'
467 | }
468 | t.deepEqual(actions.toggleFooter(), expected)
469 | })
470 |
471 | test('should create an action for moving a block', (t) => {
472 | const id = '12'
473 | const nextIndex = 3
474 | const expected = {
475 | type: actions.MOVE_BLOCK,
476 | id,
477 | nextIndex
478 | }
479 | t.deepEqual(actions.moveBlock(id, nextIndex), expected)
480 | })
481 |
482 | test('should create an action for updating a datasource', (t) => {
483 | const name = 'github'
484 | const url = 'http://github.com'
485 | const expected = {
486 | type: actions.UPDATE_DATASOURCE,
487 | id: name,
488 | text: url
489 | }
490 | t.deepEqual(actions.updateDatasource(name, url), expected)
491 | })
492 |
493 | test('should create an action for deleting a datasource', (t) => {
494 | const name = 'github'
495 | const expected = {
496 | type: actions.DELETE_DATASOURCE,
497 | id: name
498 | }
499 | t.deepEqual(actions.deleteDatasource(name), expected)
500 | })
501 |
502 | test('should create an action to toggle the save form', (t) => {
503 | const expected = {
504 | type: actions.TOGGLE_SAVE
505 | }
506 | t.deepEqual(actions.toggleSave(), expected)
507 | })
508 |
509 | test('should create an action for undo', (t) => {
510 | const expected = {
511 | type: actions.UNDO
512 | }
513 | t.deepEqual(actions.undo(), expected)
514 | })
515 |
516 | test('should create an action for changing code block option', (t) => {
517 | const expected = {
518 | type: actions.CHANGE_CODE_BLOCK_OPTION,
519 | id: 'testId',
520 | option: undefined
521 | }
522 | t.deepEqual(actions.changeCodeBlockOption('testId'), expected)
523 | })
524 |
525 | test('should create an action for changing code block option with a specified option', (t) => {
526 | const expected = {
527 | type: actions.CHANGE_CODE_BLOCK_OPTION,
528 | id: 'testId',
529 | option: 'runnable'
530 | }
531 | t.deepEqual(actions.changeCodeBlockOption('testId', 'runnable'), expected)
532 | })
533 |
534 | test('should create an action for creating a graph block', (t) => {
535 | const expd = {
536 | type: actions.ADD_BLOCK,
537 | blockType: 'graph',
538 | id: '12'
539 | }
540 | t.deepEqual(actions.addGraphBlock('12'), expd)
541 | })
542 |
543 | test('should create an action for changing graph type', (t) => {
544 | const expd = {
545 | type: actions.UPDATE_GRAPH_BLOCK_PROPERTY,
546 | property: 'graphType',
547 | value: 'pieChart',
548 | id: '12'
549 | }
550 | t.deepEqual(actions.updateGraphType('12', 'pieChart'), expd)
551 | })
552 |
553 | test('should create an action for updating graph block data path', (t) => {
554 | const expd = {
555 | type: actions.UPDATE_GRAPH_BLOCK_PROPERTY,
556 | property: 'dataPath',
557 | value: 'data.popular',
558 | id: '12'
559 | }
560 | t.deepEqual(actions.updateGraphDataPath('12', 'data.popular'), expd)
561 | })
562 |
563 | test('should create an action for updating graph block hint', (t) => {
564 | const expd = {
565 | type: actions.UPDATE_GRAPH_BLOCK_HINT,
566 | hint: 'label',
567 | value: 'name',
568 | id: '12'
569 | }
570 | t.deepEqual(actions.updateGraphHint('12', 'label', 'name'), expd)
571 | })
572 |
573 | test('should create an action for updating graph block label', (t) => {
574 | const expd = {
575 | type: actions.UPDATE_GRAPH_BLOCK_LABEL,
576 | label: 'x',
577 | value: 'Repos',
578 | id: '12'
579 | }
580 | t.deepEqual(actions.updateGraphLabel('12', 'x', 'Repos'), expd)
581 | })
582 |
583 | test('should create an action for saving graph block to code', (t) => {
584 | const expd = {
585 | type: actions.UPDATE_GRAPH_BLOCK_PROPERTY,
586 | property: 'type',
587 | value: 'code',
588 | id: '12'
589 | }
590 | t.deepEqual(actions.compileGraphBlock('12'), expd)
591 | })
592 |
593 | test('should create an action for clearing graph data', (t) => {
594 | const expd = {
595 | type: actions.CLEAR_GRAPH_BLOCK_DATA,
596 | id: '12'
597 | }
598 | t.deepEqual(actions.clearGraphData('12'), expd)
599 | })
600 |
601 | test('should create an action for editing a block', (t) => {
602 | const expd = {
603 | type: actions.EDIT_BLOCK,
604 | id: '12'
605 | }
606 | t.deepEqual(actions.editBlock('12'), expd)
607 | })
608 |
--------------------------------------------------------------------------------
/tests/editorReducer.js:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 |
3 | import Immutable from 'immutable'
4 | import requireMock from 'mock-require'
5 |
6 | requireMock('electron', {
7 | remote: {
8 | dialog: {
9 | showOpenDialog (_, callback) {
10 | callback()
11 | }
12 | }
13 | }
14 | })
15 |
16 | const reducer = require('../src/js/reducers/editorReducer').default
17 | const actions = require('../src/js/actions')
18 |
19 | test('should do nothing for an unhandled action type', (t) => {
20 | t.deepEqual(reducer(Immutable.Map({editable: false}), {type: 'FAKE_ACTION'}),
21 | Immutable.Map({editable: false}))
22 | })
23 |
24 | test('should toggle editor state for TOGGLE_EDIT', (t) => {
25 | t.deepEqual(
26 | reducer(Immutable.Map({editable: false}), {type: actions.TOGGLE_EDIT}).toJS(),
27 | {editable: true})
28 | t.deepEqual(
29 | reducer(Immutable.Map({editable: true}), {type: actions.TOGGLE_EDIT}).toJS(),
30 | {editable: false})
31 | })
32 |
33 | test('should return the inital state', (t) => {
34 | t.deepEqual(reducer(), Immutable.Map({
35 | editable: false,
36 | saving: false,
37 | activeBlock: null,
38 | unsavedChanges: false
39 | }))
40 | })
41 |
42 | test('should toggle save state for TOGGLE_SAVE', (t) => {
43 | t.deepEqual(reducer(Immutable.Map({saving: false}), {type: actions.TOGGLE_SAVE}).toJS(),
44 | {saving: true})
45 | t.deepEqual(reducer(Immutable.Map({saving: true}), {type: actions.TOGGLE_SAVE}).toJS(),
46 | {saving: false})
47 | })
48 |
49 | test('should set the editing block on EDIT_BLOCK', (t) => {
50 | t.deepEqual(reducer(Immutable.Map({activeBlock: null}), {type: actions.EDIT_BLOCK, id: '12'}).toJS(),
51 | {activeBlock: '12'})
52 | })
53 |
--------------------------------------------------------------------------------
/tests/executionReducer.js:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 |
3 | import Immutable from 'immutable'
4 | import requireMock from 'mock-require'
5 |
6 | requireMock('electron', {
7 | remote: {
8 | dialog: {
9 | showOpenDialog (_, callback) {
10 | callback()
11 | }
12 | }
13 | }
14 | })
15 |
16 | const reducer = require('../src/js/reducers/executionReducer').default
17 | const initialState = require('../src/js/reducers/executionReducer').initialState
18 | const actions = require('../src/js/actions')
19 |
20 | test('should return the initial state', (t) => {
21 | t.is(reducer(), initialState)
22 | })
23 |
24 | test('should reset the execution state when loading another file', (t) => {
25 | const beforeState = initialState
26 | .setIn(['results', '12'], 120)
27 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12'))
28 | t.is(reducer(beforeState, {type: actions.LOAD_MARKDOWN}), initialState)
29 | })
30 |
31 | test('should update the data on received data', (t) => {
32 | const action = {
33 | type: actions.RECEIVED_DATA,
34 | name: 'github',
35 | data: {repos: 12}
36 | }
37 | const newState = initialState.setIn(['data', 'github', 'repos'], 12)
38 | t.deepEqual(reducer(initialState, action).toJS(), newState.toJS())
39 | })
40 |
41 | test('should clear block results and executed state on update', (t) => {
42 | const action = {
43 | type: actions.UPDATE_BLOCK,
44 | id: '12'
45 | }
46 | const beforeState = initialState
47 | .setIn(['results', '12'], 120)
48 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12'))
49 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS())
50 | })
51 |
52 | test('should clear block results and executed state on update', (t) => {
53 | const action = {
54 | type: actions.DELETE_BLOCK,
55 | id: '12'
56 | }
57 | const beforeState = initialState
58 | .setIn(['results', '12'], 120)
59 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12'))
60 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS())
61 | })
62 |
63 | test('should clear datasource data when the datasource is deleted', (t) => {
64 | const action = {
65 | type: actions.DELETE_DATASOURCE,
66 | id: 'github'
67 | }
68 | const beforeState = initialState.setIn(['data', 'github', 'repos'], 12)
69 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS())
70 | })
71 |
72 | test('should clear datasource data when the datasource is updated', (t) => {
73 | const action = {
74 | type: actions.UPDATE_DATASOURCE,
75 | id: 'github'
76 | }
77 | const beforeState = initialState.setIn(['data', 'github', 'repos'], 12)
78 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS())
79 | })
80 |
81 | test('should update result, executed and context on CODE_EXECUTED', (t) => {
82 | const action = {
83 | type: actions.CODE_EXECUTED,
84 | id: '99',
85 | data: 3,
86 | context: Immutable.Map({number: 10})
87 | }
88 | const expected = initialState.setIn(['results', '99'], 3)
89 | .set('blocksExecuted', initialState.get('blocksExecuted').add('99'))
90 | .set('executionContext', Immutable.Map({number: 10}))
91 | t.deepEqual(reducer(initialState, action).toJS(), expected.toJS())
92 | })
93 |
94 | test('should update result and executed on CODE_ERROR', (t) => {
95 | const action = {
96 | type: actions.CODE_ERROR,
97 | id: '99',
98 | data: 'Some error'
99 | }
100 | const expected = initialState.setIn(['results', '99'], 'Some error')
101 | .set('blocksExecuted', initialState.get('blocksExecuted').add('99'))
102 | t.deepEqual(reducer(initialState, action).toJS(), expected.toJS())
103 | })
104 |
--------------------------------------------------------------------------------
/tests/fixtures/extractCodeBlocks.md:
--------------------------------------------------------------------------------
1 | ### Some markdown
2 |
3 | The below is a code sample
4 |
5 | ```javascript;hidden
6 | console.log("Hello!");
7 | ```
8 |
9 | This is a non-js sample.
10 |
11 | ```
12 | print "Non-js block"
13 | ```
14 |
15 | This is a JS sample with no attrs
16 |
17 | ```javascript
18 | return 1 + 1;
19 | ```
20 |
21 | Done!
22 |
--------------------------------------------------------------------------------
/tests/fixtures/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
299 |
300 |
301 |
302 |
303 |
--------------------------------------------------------------------------------
/tests/fixtures/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Sample Kayero notebook"
3 | author: "Joel Auterson"
4 | datasources:
5 | joelotter: "https://api.github.com/users/joelotter/repos"
6 | popular: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc"
7 | extra: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc&per_page=100"
8 | original:
9 | title: "Blank Kayero notebook"
10 | url: "http://www.joelotter.com/kayero/blank"
11 | show_footer: true
12 | ---
13 |
14 | This is an example of a notebook written in **Kayero**. Hopefully it'll give you a bit of insight into what Kayero is, and what you might be able to use it for!
15 |
16 | Kayero is designed to make it really easy for anyone to create good-looking, responsive, data-rich documents. They're totally viewable in a web browser - no backend necessary beyond what's needed to host the page - and can contain interactive code samples, written in JavaScript.
17 |
18 | They've also got some nice graphing and data visualisation capabilities - we'll look at that in a bit.
19 |
20 | (If you were wondering, _'kayero'_ is the Esperanto word for _'notebook'_.)
21 |
22 | Let's have a look at some features.
23 |
24 | ## It's just Markdown
25 |
26 | Go ahead and take a look at the [page source](view-source:http://www.joelotter.com/kayero). You'll notice that the notebook is really just a Markdown document with some bookending HTML tags - it's the Kayero script that does all the work of rendering. You've got all the usability of Markdown to play with.
27 |
28 | - You
29 | - can
30 | - make
31 | - lists!
32 |
33 | \[ Escapes work \]
34 |
35 | There is also inline code, like `console.log('thing')`.
36 |
37 | HTML is allowed, as per the Markdown spec.
38 |
39 |
print "You can use inline HTML to get styling, which is neat.
40 |
41 | ```python
42 | print "You can have code, with syntax highlighting."
43 | print "This is Python - it can't be run in the browser."
44 | ```
45 |
46 | ```javascript; runnable
47 | return "Javascript can be, though. Click play!";
48 | ```
49 |
50 | Because it's just Markdown, and all the work is done by a script in the browser, it's really easy for users to create new notebooks from scratch. But you might not want to create from scratch, so...
51 |
52 | ## Every notebook is editable
53 |
54 | You might have noticed the little pencil icon in the top-right. Give it a poke! You'll find yourself in the editor interface. Every single Kayero notebook is fully editable right in the browser. Once you've made your changes, you can export it as a new HTML or Markdown document.
55 |
56 | The notebooks also contain a link to their parent page. It's in the footer of this page if you want to have a look! If users don't want this footer, it can be turned off in the editor.
57 |
58 | This is all very well, but the notebooks are supposed to be _interactive_.
59 |
60 | ## Running code
61 |
62 | Authors need to be able to put code samples in their documents. If these samples are in JavaScript, they can be run by the users. Here's a very simple example, which squares and sums the numbers up to 10.
63 |
64 | ```javascript; runnable
65 | var result = 0;
66 | for (var i = 1; i <= 10; i++) {
67 | result += i * i;
68 | }
69 | return result;
70 | ```
71 |
72 | Code samples are written (and run) as functions, and the function's returned value is displayed to the user in the box below the code. What if we want to share information between code samples, though?
73 |
74 | In Kayero, the keyword **this** refers to the global context, which is passed around between samples. We can assign something onto the context, and then access it in another sample.
75 |
76 | ```javascript; runnable
77 | this.number = 100;
78 | return this.number;
79 | ```
80 |
81 | We can now sum the squares of numbers up to the number we defined in the previous code block.
82 |
83 | ```javascript; runnable
84 | var result = 0;
85 | for (var i = 10; i <= this.number; i++) {
86 | result += i * i;
87 | }
88 | return result;
89 | ```
90 |
91 | ```javascript; runnable
92 | this.number *= 2;
93 | return this.number;
94 | ```
95 |
96 | Try playing around with running these code samples in different orders, to see how the results change.
97 |
98 | ## Working with data
99 |
100 | If you had a look in the editor, you'll have noticed that users can define _data sources_ - these are URLs of public JSON data. This data is automatically fetched, and put into the **data** object, which is made available in code samples.
101 |
102 | The **joelotter** data source is my GitHub repository information. Let's get the names of my repositories.
103 |
104 | ```javascript; runnable
105 | return data.joelotter.map(function(repo) {
106 | return repo.name;
107 | });
108 | ```
109 |
110 | You'll notice that Kayero can visualise whatever data you throw at it - it's not just strings and numbers! Here's the whole of my repository data to demonstrate.
111 |
112 | ```javascript; runnable
113 | return data.joelotter;
114 | ```
115 |
116 | This isn't necessarily the most attractive or user-friendly way to look at data, though.
117 |
118 | ## Graphs
119 |
120 | Kayero gives users access to [d3](https://d3js.org/), the web's favourite graphing library.
121 |
122 | ```javascript; runnable
123 | // Remove any old SVGs for re-running
124 | d3.select(graphElement).selectAll('*').remove();
125 |
126 | var sampleSVG = d3.select(graphElement)
127 | .append("svg")
128 | .attr("width", 100)
129 | .attr("height", 100);
130 |
131 | sampleSVG.append("circle")
132 | .style("stroke", "gray")
133 | .style("fill", "white")
134 | .attr("r", 40)
135 | .attr("cx", 50)
136 | .attr("cy", 50)
137 | .on("mouseover", function(){d3.select(this).style("fill", "aliceblue");})
138 | .on("mouseout", function(){d3.select(this).style("fill", "white");})
139 | .on("mousedown", animateFirstStep);
140 |
141 | function animateFirstStep(){
142 | d3.select(this)
143 | .transition()
144 | .delay(0)
145 | .duration(1000)
146 | .attr("r", 10)
147 | .each("end", animateSecondStep);
148 | };
149 |
150 | function animateSecondStep(){
151 | d3.select(this)
152 | .transition()
153 | .duration(1000)
154 | .attr("r", 40);
155 | };
156 |
157 | return "Try clicking the circle!";
158 | ```
159 |
160 | Users get access to **d3**, which is the library itself, and **graphElement**, which is a reference to the element where the graph is drawn.
161 |
162 | d3 is incredibly powerful, but may be too complex for many users. To help out with this, Kayero also includes [NVD3](http://nvd3.org/), which provides some nice pre-built graphs for d3. The code below generates a random scatter graph - try it!
163 |
164 | ```javascript; runnable
165 | d3.select(graphElement).selectAll('*').remove();
166 | d3.select(graphElement).append('svg').attr("width", "100%");
167 |
168 | nv.addGraph(function() {
169 | var chart = nv.models.scatter()
170 | .margin({top: 20, right: 20, bottom: 20, left: 20})
171 | .pointSize(function(d) { return d.z })
172 | .useVoronoi(false);
173 | d3.select(graphElement).selectAll("svg")
174 | .datum(randomData())
175 | .transition().duration(500)
176 | .call(chart);
177 | nv.utils.windowResize(chart.update);
178 | return chart;
179 | });
180 |
181 | function randomData() {
182 | var data = [];
183 | for (i = 0; i < 2; i++) {
184 | data.push({
185 | key: 'Group ' + i,
186 | values: []
187 | });
188 | for (j = 0; j < 100; j++) {
189 | data[i].values.push({x: Math.random(), y: Math.random(), z: Math.random()});
190 | }
191 | }
192 | return data;
193 | }
194 | return "Try clicking the rerun button!";
195 | ```
196 |
197 | This is useful too, but what about those users with little-to-no coding experience?
198 |
199 | ## Jutsu
200 |
201 | Kayero includes Jutsu, a very simple graphing library built with support for [Smolder](https://www.github.com/JoelOtter/smolder).
202 |
203 | Smolder is a 'type system' (not really, but I'm not sure what to call it) for JavaScript, which will attempt to automatically restructure arbitrary data to fit a provided schema for a function. The actual reshaping is done by a library called, predictably, [Reshaper](https://www.github.com/JoelOtter/reshaper).
204 |
205 | From a user's perspective, the details don't really matter. Let's use Jutsu (available in Kayero code samples as **graphs**) to create a pie chart, based on the most popular GitHub repositories of 2016.
206 |
207 | ```javascript; runnable
208 | // Here's what the 'popular' data looks like before it's reshaped.
209 | return data.popular;
210 | ```
211 |
212 | ```javascript; runnable
213 | // The graph functions return the reshaped data, so we can see
214 | // what's going on.
215 | return graphs.pieChart(data.popular);
216 | ```
217 |
218 | It's worked! Smolder knows that a pie chart needs labels and numerical values, so it's reshaped the data to get these.
219 |
220 | However, it's picked the first number it could find for the value, which in this case looks to be the repo IDs. This isn't really useful for a pie chart! We'd rather look at something like the number of stargazers. We can pass in a 'hint', to tell Jutsu which value we care about.
221 |
222 | ```javascript; runnable
223 | return graphs.pieChart(data.popular, 'stargazers_count');
224 | ```
225 |
226 | We can give multiple hints. Let's say we want to use the name of the repository.
227 |
228 | ```javascript; runnable
229 | return graphs.pieChart(data.popular, ['name', 'stargazers_count']);
230 | ```
231 |
232 | Good, that's a bit more readable.
233 |
234 | It's kind of hard to compare the stargazers counts in a pie chart - they're all relatively similar. Let's try a bar chart instead.
235 |
236 | ```javascript; runnable
237 | return graphs.barChart(data.popular, 'Repo', 'Stargazers', ['name', 'stargazers_count']);
238 | ```
239 |
240 | This is a bit more useful. We can put labels on the axes too, to make sure the graph is easy to understand.
241 |
242 | The idea is that it should be possible to use Kayero to investigate and write about trends in data. Let's conduct a toy investigation of our own - is there any relation between a repository's star count and the number of open issues it has?
243 |
244 | Let's try a line graph.
245 |
246 | ```javascript; runnable
247 | return graphs.lineChart(
248 | data.popular.items, 'Open Issues', 'Stargazers',
249 | ['open_issues', 'stargazers_count', 'name']
250 | );
251 | ```
252 |
253 | The extra hint, _name_, is used to provide labels for the data points. All the graphs are interactive - try mousing over them.
254 |
255 | It's pretty easy to see which repository has the most open issues (for me it's chakra-core; it might have changed by the time you read this!) and which has the most stargazers. However, it's hard to see a trend here.
256 |
257 | A much better graph for investigating correlation is a scatter plot.
258 |
259 | ```javascript; runnable
260 | return graphs.scatterPlot(
261 | data.popular.items, 'Open Issues', 'Stargazers',
262 | ['open_issues', 'stargazers_count', 'name']
263 | );
264 | ```
265 |
266 | There might be a trend there, but it's hard to see. Maybe we need more data.
267 |
268 | The GitHub API lets us request up to 100 results per page, with a default of 30. While the **popular** data source just uses the default, I've also included **extra**, which has 100. Let's try our scatter plot with 100 data points!
269 |
270 | ```javascript; runnable
271 | return graphs.scatterPlot(
272 | data.extra.items, 'Open Issues', 'Stargazers',
273 | ['open_issues', 'stargazers_count', 'name']
274 | );
275 | ```
276 |
277 | This is a little better. We can see there might be a slight positive correlation, though there are a lot of outliers.
278 |
279 | ## What's next?
280 |
281 | Hopefully this notebook has given you a decent explanation of what Kayero is for. Here are the next things needing done:
282 |
283 | - Exporting the notebook
284 | - Making Reshaper smarter
285 | - More graphs
286 | - Exporting to Gist (if there's time!)
287 |
288 | Why not try making your own notebook? This one is forked from a [blank notebook](http://www.joelotter.com/kayero/blank) - have a play with the editor!
289 |
--------------------------------------------------------------------------------
/tests/fixtures/sampleNotebook.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "A sample notebook"
3 | author: "Joel Auterson"
4 | show_footer: true
5 | ---
6 |
7 | ## This is a sample Notebook
8 |
9 | It _should_ get correctly parsed.
10 |
11 | [This is a link](http://github.com)
12 |
13 | 
14 | 
15 |
16 | ```python
17 | print "Non-runnable code sample"
18 | ```
19 |
20 | And finally a runnable one...
21 |
22 | ```javascript; runnable
23 | console.log("Runnable");
24 | ```
25 |
26 | ```
27 | Isolated non-runnable
28 | ```
29 |
--------------------------------------------------------------------------------
/tests/markdown.js:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 |
3 | import fs from 'fs'
4 | import Immutable from 'immutable'
5 | import { parse, render } from '../src/js/markdown'
6 |
7 | function loadMarkdown (filename) {
8 | return fs.readFileSync('./fixtures/' + filename + '.md').toString()
9 | }
10 |
11 | // Mock stuff for execution
12 | global.document = require('jsdom').jsdom('')
13 | global.window = document.defaultView
14 | global.nv = {}
15 |
16 | const sampleNotebook = Immutable.fromJS({
17 | metadata: {
18 | title: 'A sample notebook',
19 | author: 'Joel Auterson',
20 | datasources: {},
21 | original: undefined,
22 | showFooter: true,
23 | path: undefined
24 | },
25 | content: [ '0', '1', '2' ],
26 | blocks: {
27 | '0': {
28 | type: 'text',
29 | id: '0',
30 | content: '## This is a sample Notebook\n\nIt _should_ get correctly parsed.\n\n[This is a link](http://github.com)\n\n\n\n\n```python\nprint "Non-runnable code sample"\n```\n\nAnd finally a runnable one...'
31 | }, '1': {
32 | type: 'code',
33 | content: 'console.log("Runnable");',
34 | language: 'javascript',
35 | option: 'runnable',
36 | id: '1'
37 | }, '2': {
38 | type: 'text',
39 | id: '2',
40 | content: '```\nIsolated non-runnable\n```'
41 | }
42 | }
43 | })
44 |
45 | test('correctly parses sample markdown', (t) => {
46 | const sampleMd = loadMarkdown('sampleNotebook')
47 | t.deepEqual(parse(sampleMd).toJS(), sampleNotebook.toJS())
48 | })
49 |
50 | test('uses placeholders for a blank document', (t) => {
51 | const expected = Immutable.fromJS({
52 | metadata: {
53 | title: undefined,
54 | author: undefined,
55 | showFooter: true,
56 | original: undefined,
57 | datasources: {},
58 | path: undefined
59 | },
60 | blocks: {},
61 | content: []
62 | })
63 | t.deepEqual(parse('').toJS(), expected.toJS())
64 | })
65 |
66 | test('should correctly render a sample notebook', (t) => {
67 | const sampleMd = loadMarkdown('sampleNotebook')
68 | t.deepEqual(render(sampleNotebook), sampleMd)
69 | })
70 |
71 | test('should correctly render an empty notebook', (t) => {
72 | const nb = Immutable.fromJS({
73 | metadata: {},
74 | blocks: {},
75 | content: []
76 | })
77 | const expected = '---\n---\n\n\n'
78 | t.deepEqual(render(nb), expected)
79 | })
80 |
81 | test('should render a parsed notebook to the original markdown', (t) => {
82 | const sampleMd = loadMarkdown('index')
83 | t.deepEqual(render(parse(sampleMd)), sampleMd)
84 | })
85 |
--------------------------------------------------------------------------------
/tests/util.js:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 |
3 | import fs from 'fs'
4 | import Immutable from 'immutable'
5 | import * as util from '../src/js/util'
6 |
7 | // Mock stuff for execution
8 | global.document = require('jsdom').jsdom('')
9 | global.window = document.defaultView
10 | global.nv = {}
11 |
12 | test('should correctly transform a code block to text', (t) => {
13 | const codeBlock = Immutable.fromJS({
14 | type: 'code',
15 | language: 'javascript',
16 | option: 'hidden',
17 | content: 'return 1 + 2;'
18 | })
19 | const expected = '```javascript\nreturn 1 + 2;\n```'
20 | t.is(util.codeToText(codeBlock), expected)
21 | })
22 |
23 | test('should include option if includeOption is true ', (t) => {
24 | const codeBlock = Immutable.fromJS({
25 | type: 'code',
26 | language: 'javascript',
27 | option: 'hidden',
28 | content: 'return 1 + 2;'
29 | })
30 | const expected = '```javascript; hidden\nreturn 1 + 2;\n```'
31 | t.is(util.codeToText(codeBlock, true), expected)
32 | })
33 |
34 | test('correctly highlights code', (t) => {
35 | const expected = 'console' +
36 | '.log("hello");'
37 | t.is(util.highlight('console.log("hello");', 'javascript'), expected)
38 | })
39 |
40 | test('returns nothing for an unsupported language', (t) => {
41 | t.is(util.highlight('rubbish', 'dfhjf'), '')
42 | })
43 |
44 | test('should correctly render the index.html from its markdown', (t) => {
45 | const indexMd = fs.readFileSync('./fixtures/index.md').toString()
46 | const indexHTML = fs.readFileSync('./fixtures/index.html').toString()
47 | t.is(util.renderHTML(indexMd), indexHTML)
48 | })
49 |
50 | test.todo('arrayToCSV')
51 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 | const ExternalsPlugin = webpack.ExternalsPlugin
4 | const WriteFilePlugin = require('write-file-webpack-plugin')
5 |
6 | module.exports = {
7 | devtool: 'eval-source-map',
8 | entry: [
9 | 'webpack-dev-server/client?http://localhost:3002',
10 | 'webpack/hot/only-dev-server',
11 | './src/js/app'
12 | ],
13 | output: {
14 | path: path.join(__dirname, 'dist'),
15 | filename: 'bundle.js',
16 | publicPath: 'http://localhost:3002/dist/'
17 | },
18 | plugins: [
19 | new webpack.HotModuleReplacementPlugin(),
20 | new ExternalsPlugin('commonjs', [
21 | 'monk'
22 | ]),
23 | new WriteFilePlugin()
24 | ],
25 | module: {
26 | loaders: [
27 | { test: /\.css$/, loader: 'style-loader!css-loader' },
28 | {
29 | test: /\.scss$/,
30 | loaders: ['style', 'css', 'sass']
31 | },
32 | { test: /\.png$/, loader: 'url-loader?limit=100000' },
33 | { test: /\.jpg$/, loader: 'file-loader' },
34 | { test: /\.json$/, loader: 'json-loader' },
35 | {
36 | test: /\.(ttf|eot|svg|otf|woff(2)?)(\?[a-z0-9=&.]+)?$/, // font files
37 | loader: 'file-loader'
38 | },
39 | {
40 | test: /\.js$/,
41 | loaders: ['react-hot', 'babel'],
42 | include: path.join(__dirname, 'src')
43 | }
44 | ]
45 | },
46 | target: 'electron-renderer'
47 | }
48 |
--------------------------------------------------------------------------------