From edfbdb8d96614c0e657e63ab2b9ceff015c8f119 Mon Sep 17 00:00:00 2001
From: Peter <pwjablonski@gmail.com>
Date: Mon, 22 Oct 2018 17:05:39 -0400
Subject: [PATCH 1/4] Element Highlighter

---
 package.json                                  |  1 +
 src/actions/index.js                          |  6 ++
 src/actions/ui.js                             | 18 +++++
 src/components/Editor.jsx                     | 21 ++++++
 src/components/EditorsColumn.jsx              |  9 +++
 src/components/Preview.jsx                    |  7 ++
 src/components/PreviewFrame.jsx               | 13 ++++
 src/containers/EditorsColumn.js               | 30 +++++++++
 src/containers/Preview.js                     |  2 +
 src/preview.js                                |  3 +
 src/previewSupport/handleElementHighlights.js | 65 +++++++++++++++++++
 src/records/UiState.js                        |  1 +
 src/reducers/ui.js                            | 11 ++++
 src/sagas/ui.js                               | 18 +++++
 src/selectors/getFocusedSelector.js           |  3 +
 src/selectors/index.js                        |  2 +
 src/util/compileProject.js                    |  9 +++
 src/util/selectorAtCursor.js                  | 31 +++++++++
 templates/highlighter.css                     | 21 ++++++
 test/unit/reducers/ui.js                      | 23 +++++++
 test/unit/sagas/ui.js                         | 18 +++++
 test/unit/util/selectorAtCursor.js            | 20 ++++++
 22 files changed, 332 insertions(+)
 create mode 100644 src/previewSupport/handleElementHighlights.js
 create mode 100644 src/selectors/getFocusedSelector.js
 create mode 100644 src/util/selectorAtCursor.js
 create mode 100644 templates/highlighter.css
 create mode 100644 test/unit/util/selectorAtCursor.js

diff --git a/package.json b/package.json
index 89817101f7..3803ceb17b 100644
--- a/package.json
+++ b/package.json
@@ -304,6 +304,7 @@
     "null-loader": "^0.1.1",
     "postcss-preset-env": "^5.3.0",
     "raw-loader": "^0.5.1",
+    "react-addons-perf": "^15.4.2",
     "redux-saga-test-plan": "^3.0.2",
     "script-ext-html-webpack-plugin": "^2.0.1",
     "sinon": "^5.0.7",
diff --git a/src/actions/index.js b/src/actions/index.js
index cceb3e0076..a3e24aa7cf 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -22,7 +22,10 @@ import {
 } from './projects';
 
 import {
+  currentCursorChanged,
   focusLine,
+  editorBlurred,
+  editorFocused,
   editorFocusedRequestedLine,
   startDragColumnDivider,
   stopDragColumnDivider,
@@ -93,6 +96,8 @@ export {
   unhideComponent,
   toggleComponent,
   focusLine,
+  editorBlurred,
+  editorFocused,
   editorFocusedRequestedLine,
   previousConsoleHistory,
   nextConsoleHistory,
@@ -100,6 +105,7 @@ export {
   stopDragColumnDivider,
   notificationTriggered,
   userDismissedNotification,
+  currentCursorChanged,
   updateNotificationMetadata,
   exportProject,
   projectExportDisplayed,
diff --git a/src/actions/ui.js b/src/actions/ui.js
index a68835a03f..039b9fc28b 100644
--- a/src/actions/ui.js
+++ b/src/actions/ui.js
@@ -12,6 +12,15 @@ export const editorFocusedRequestedLine = createAction(
   'EDITOR_FOCUSED_REQUESTED_LINE',
 );
 
+export const editorFocused = createAction(
+  'EDITOR_FOCUSED',
+  (source, cursor, language) => ({source, cursor, language}),
+);
+
+export const editorBlurred = createAction(
+  'EDITOR_BLURRED',
+);
+
 export const startDragColumnDivider = createAction(
   'START_DRAG_COLUMN_DIVIDER',
 );
@@ -34,6 +43,15 @@ export const userDismissedNotification = createAction(
   type => ({type}),
 );
 
+export const currentCursorChanged = createAction(
+  'CURRENT_CURSOR_CHANGED',
+  (source, cursor, language) => ({source, cursor, language}),
+);
+
+export const currentFocusedSelectorChanged = createAction(
+  'CURRENT_FOCUSED_SELECTOR_CHANGED',
+);
+
 export const updateNotificationMetadata = createAction(
   'UPDATE_NOTIFICATION_METADATA',
   (type, metadata) => ({type, metadata}),
diff --git a/src/components/Editor.jsx b/src/components/Editor.jsx
index bc0b1eb74a..43d0b132c2 100644
--- a/src/components/Editor.jsx
+++ b/src/components/Editor.jsx
@@ -129,9 +129,27 @@ class Editor extends React.Component {
 
   _startNewSession(source) {
     const session = createAceSessionWithoutWorker(this.props.language, source);
+    const cursor = session.selection.lead;
     session.on('change', () => {
       this.props.onInput(this._editor.getValue());
     });
+    session.selection.on('changeCursor', () => {
+      this.props.onCursorChange(
+        this._editor.getValue(),
+        cursor,
+        this.props.language,
+      );
+    });
+    this._editor.on('blur', () => {
+      this.props.onEditorBlurred();
+    });
+    this._editor.on('focus', () => {
+      this.props.onEditorFocused(
+        this._editor.getValue(),
+        cursor,
+        this.props.language,
+      );
+    });
     session.setAnnotations(this.props.errors);
     this._editor.setSession(session);
     this._editor.moveCursorTo(0, 0);
@@ -147,6 +165,9 @@ Editor.propTypes = {
   requestedFocusedLine: PropTypes.instanceOf(EditorLocation),
   source: PropTypes.string.isRequired,
   textSizeIsLarge: PropTypes.bool.isRequired,
+  onCursorChange: PropTypes.func.isRequired,
+  onEditorBlurred: PropTypes.func.isRequired,
+  onEditorFocused: PropTypes.func.isRequired,
   onInput: PropTypes.func.isRequired,
   onRequestedLineFocused: PropTypes.func.isRequired,
 };
diff --git a/src/components/EditorsColumn.jsx b/src/components/EditorsColumn.jsx
index ea87fe1a99..1ac667f1c1 100644
--- a/src/components/EditorsColumn.jsx
+++ b/src/components/EditorsColumn.jsx
@@ -29,6 +29,9 @@ export default function EditorsColumn({
   requestedFocusedLine,
   onComponentHide,
   onComponentUnhide,
+  onEditorBlurred,
+  onEditorCursorChange,
+  onEditorFocused,
   onEditorInput,
   onRef,
   onRequestedLineFocused,
@@ -70,6 +73,9 @@ export default function EditorsColumn({
           requestedFocusedLine={requestedFocusedLine}
           source={currentProject.sources[language]}
           textSizeIsLarge={isTextSizeLarge}
+          onCursorChange={onEditorCursorChange}
+          onEditorBlurred={onEditorBlurred}
+          onEditorFocused={onEditorFocused}
           onInput={partial(
             onEditorInput,
             currentProject.projectKey,
@@ -144,6 +150,9 @@ EditorsColumn.propTypes = {
   style: PropTypes.object.isRequired,
   onComponentHide: PropTypes.func.isRequired,
   onComponentUnhide: PropTypes.func.isRequired,
+  onEditorBlurred: PropTypes.func.isRequired,
+  onEditorCursorChange: PropTypes.func.isRequired,
+  onEditorFocused: PropTypes.func.isRequired,
   onEditorInput: PropTypes.func.isRequired,
   onRef: PropTypes.func.isRequired,
   onRequestedLineFocused: PropTypes.func.isRequired,
diff --git a/src/components/Preview.jsx b/src/components/Preview.jsx
index aac203e0ed..c4ba779e98 100644
--- a/src/components/Preview.jsx
+++ b/src/components/Preview.jsx
@@ -13,6 +13,7 @@ import PreviewFrame from './PreviewFrame';
 export default function Preview({
   compiledProjects,
   consoleEntries,
+  focusedSelector,
   showingErrors,
   onConsoleError,
   onConsoleLog,
@@ -29,6 +30,7 @@ export default function Preview({
     <PreviewFrame
       compiledProject={compiledProject}
       consoleEntries={consoleEntries}
+      focusedSelector={focusedSelector}
       isActive={key === compiledProjects.keySeq().last()}
       key={compiledProject.compiledProjectKey}
       onConsoleError={onConsoleError}
@@ -63,6 +65,7 @@ export default function Preview({
 Preview.propTypes = {
   compiledProjects: ImmutablePropTypes.iterable.isRequired,
   consoleEntries: ImmutablePropTypes.iterable.isRequired,
+  focusedSelector: PropTypes.string,
   showingErrors: PropTypes.bool.isRequired,
   onConsoleError: PropTypes.func.isRequired,
   onConsoleLog: PropTypes.func.isRequired,
@@ -71,3 +74,7 @@ Preview.propTypes = {
   onRefreshClick: PropTypes.func.isRequired,
   onRuntimeError: PropTypes.func.isRequired,
 };
+
+Preview.defaultProps = {
+  focusedSelector: null,
+};
diff --git a/src/components/PreviewFrame.jsx b/src/components/PreviewFrame.jsx
index 2aa455b120..1335b09511 100644
--- a/src/components/PreviewFrame.jsx
+++ b/src/components/PreviewFrame.jsx
@@ -47,6 +47,7 @@ class PreviewFrame extends React.Component {
     const {consoleEntries, isActive} = this.props;
 
     if (this._channel && isActive) {
+      this._postFocusedSelectorToFrame(this.props.focusedSelector);
       for (const [key, {expression}] of consoleEntries) {
         if (!prevConsoleEntries.has(key) && expression) {
           this._evaluateConsoleExpression(key, expression);
@@ -135,6 +136,13 @@ class PreviewFrame extends React.Component {
     this.props.onConsoleLog(printedValue, compiledProjectKey);
   }
 
+  _postFocusedSelectorToFrame(selector) {
+    this._channel.notify({
+      method: 'highlightElement',
+      params: selector,
+    });
+  }
+
   _attachToFrame(frame) {
     if (!frame) {
       if (this._channel) {
@@ -168,6 +176,7 @@ class PreviewFrame extends React.Component {
 PreviewFrame.propTypes = {
   compiledProject: PropTypes.instanceOf(CompiledProjectRecord).isRequired,
   consoleEntries: ImmutablePropTypes.iterable.isRequired,
+  focusedSelector: PropTypes.string,
   isActive: PropTypes.bool.isRequired,
   onConsoleError: PropTypes.func.isRequired,
   onConsoleLog: PropTypes.func.isRequired,
@@ -175,4 +184,8 @@ PreviewFrame.propTypes = {
   onRuntimeError: PropTypes.func.isRequired,
 };
 
+PreviewFrame.defaultProps = {
+  focusedSelector: null,
+};
+
 export default PreviewFrame;
diff --git a/src/containers/EditorsColumn.js b/src/containers/EditorsColumn.js
index 4f3ebe67f4..92ef909622 100644
--- a/src/containers/EditorsColumn.js
+++ b/src/containers/EditorsColumn.js
@@ -9,10 +9,13 @@ import {
   isTextSizeLarge,
 } from '../selectors';
 import {
+  currentCursorChanged,
   editorFocusedRequestedLine,
   hideComponent,
   updateProjectSource,
   unhideComponent,
+  editorBlurred,
+  editorFocused,
 } from '../actions';
 
 function mapStateToProps(state) {
@@ -41,6 +44,33 @@ function mapDispatchToProps(dispatch) {
     onRequestedLineFocused() {
       dispatch(editorFocusedRequestedLine());
     },
+
+    onEditorCursorChange(source, cursor, language) {
+      dispatch(
+        currentCursorChanged(
+          source,
+          cursor,
+          language,
+        ),
+      );
+    },
+
+    onEditorBlurred() {
+      dispatch(
+        editorBlurred(),
+      );
+    },
+
+    onEditorFocused(source, cursor, language) {
+      dispatch(
+        editorFocused(
+          source,
+          cursor,
+          language,
+        ),
+      );
+    },
+
   };
 }
 
diff --git a/src/containers/Preview.js b/src/containers/Preview.js
index 1956aedfbd..1fe85720a0 100644
--- a/src/containers/Preview.js
+++ b/src/containers/Preview.js
@@ -14,6 +14,7 @@ import {
 import {
   getCompiledProjects,
   getConsoleHistory,
+  getFocusedSelector,
   isCurrentProjectSyntacticallyValid,
   isUserTyping,
 } from '../selectors';
@@ -22,6 +23,7 @@ function mapStateToProps(state) {
   return {
     compiledProjects: getCompiledProjects(state),
     consoleEntries: getConsoleHistory(state),
+    focusedSelector: getFocusedSelector(state),
     showingErrors: (
       !isUserTyping(state) &&
         !isCurrentProjectSyntacticallyValid(state)
diff --git a/src/preview.js b/src/preview.js
index 08ec4e543f..63d57fc71c 100644
--- a/src/preview.js
+++ b/src/preview.js
@@ -3,8 +3,11 @@ import handleConsoleExpressions
   from './previewSupport/handleConsoleExpressions';
 import handleConsoleLogs from './previewSupport/handleConsoleLogs';
 import overrideAlert from './previewSupport/overrideAlert';
+import handleElementHighlights from './previewSupport/handleElementHighlights';
 
 handleErrors();
 handleConsoleExpressions();
 handleConsoleLogs();
 overrideAlert();
+handleElementHighlights();
+
diff --git a/src/previewSupport/handleElementHighlights.js b/src/previewSupport/handleElementHighlights.js
new file mode 100644
index 0000000000..475a635732
--- /dev/null
+++ b/src/previewSupport/handleElementHighlights.js
@@ -0,0 +1,65 @@
+import throttle from 'lodash-es/throttle';
+
+import channel from './channel';
+
+const RESIZE_THROTTLE = 250;
+let highlightSelector = null;
+
+const handleWindowResize = throttle(() => {
+  updateCovers(highlightSelector);
+}, RESIZE_THROTTLE);
+
+window.addEventListener('resize', handleWindowResize);
+
+function getOffsetFromBody(element) {
+  if (element === document.body) {
+    return {top: 0, left: 0};
+  }
+  const {top, left} = getOffsetFromBody(element.offsetParent);
+  return {top: top + element.offsetTop, left: left + element.offsetLeft};
+}
+
+function removeCovers() {
+  const highlighterElements =
+    document.querySelectorAll('.__popcode-highlighter');
+  for (const highlighterElement of highlighterElements) {
+    highlighterElement.remove();
+  }
+}
+
+function addCovers(selector) {
+  const elements = document.querySelectorAll(selector);
+  for (const element of elements) {
+    const cover = document.createElement('div');
+    const rect = element.getBoundingClientRect();
+    let offset = {top: rect.top, left: rect.left};
+    if (element.offsetParent === null) {
+      cover.style.position = 'fixed';
+    } else if (element !== document.body ||
+      element !== document.documentElement) {
+      offset = getOffsetFromBody(element);
+    }
+    document.body.appendChild(cover);
+    cover.classList = '__popcode-highlighter';
+    cover.style.left = `${offset.left}px`;
+    cover.style.top = `${offset.top}px`;
+    cover.style.width = `${element.offsetWidth}px`;
+    cover.style.height = `${element.offsetHeight}px`;
+    cover.classList.add('fade');
+  }
+}
+
+function updateCovers(selector) {
+  removeCovers();
+  if (selector !== null) {
+    highlightSelector = selector;
+    addCovers(selector);
+  }
+}
+
+export default function handleElementHighlights() {
+  channel.bind(
+    'highlightElement',
+    (_trans, selector) => updateCovers(selector),
+  );
+}
diff --git a/src/records/UiState.js b/src/records/UiState.js
index fcfaddfc5a..e0da97b061 100644
--- a/src/records/UiState.js
+++ b/src/records/UiState.js
@@ -10,4 +10,5 @@ export default Record({
   openTopBarMenu: null,
   requestedFocusedLine: null,
   saveIndicatorShown: false,
+  focusedSelector: null,
 }, 'UiState');
diff --git a/src/reducers/ui.js b/src/reducers/ui.js
index ea89335059..72bc00e63f 100644
--- a/src/reducers/ui.js
+++ b/src/reducers/ui.js
@@ -66,6 +66,17 @@ export default function ui(stateIn, action) {
     case 'EDITOR_FOCUSED_REQUESTED_LINE':
       return state.set('requestedFocusedLine', null);
 
+    case 'CURRENT_FOCUSED_SELECTOR_CHANGED':
+      return state.setIn(
+        ['focusedSelector'], action.payload,
+      );
+
+    case 'EDITOR_BLURRED':
+      return state.setIn(
+        ['focusedSelector'],
+        null,
+      );
+
     case 'START_DRAG_COLUMN_DIVIDER':
       return state.set('isDraggingColumnDivider', true);
 
diff --git a/src/sagas/ui.js b/src/sagas/ui.js
index 4b9e0bb410..8f735619fd 100644
--- a/src/sagas/ui.js
+++ b/src/sagas/ui.js
@@ -5,6 +5,7 @@ import {
   userDoneTyping as userDoneTypingAction,
   showSaveIndicator,
   hideSaveIndicator,
+  currentFocusedSelectorChanged,
 } from '../actions/ui';
 import {getCurrentProject} from '../selectors';
 import {
@@ -14,7 +15,16 @@ import {
 import {openWindowWithContent} from '../util';
 import spinnerPageHtml from '../../templates/project-export.html';
 import compileProject from '../util/compileProject';
+import retryingFailedImports from '../util/retryingFailedImports';
 
+export async function importSelectorAtCursor() {
+  return retryingFailedImports(
+    () => import(
+      /* webpackChunkName: "mainAsync" */
+      '../util/selectorAtCursor',
+    ),
+  );
+}
 export function* userDoneTyping() {
   yield put(userDoneTypingAction());
 }
@@ -62,11 +72,19 @@ export function* exportProject() {
   );
 }
 
+export function* updateFocusedSelector({payload: {source, cursor, language}}) {
+  const {selectorAtCursor} = yield call(importSelectorAtCursor);
+  const selector = yield call(selectorAtCursor, source, cursor, language);
+  yield put(currentFocusedSelectorChanged(selector));
+}
+
 export default function* ui() {
   yield all([
     debounceFor('UPDATE_PROJECT_SOURCE', userDoneTyping, 1000),
     takeEvery('POP_OUT_PROJECT', popOutProject),
     takeEvery('EXPORT_PROJECT', exportProject),
     debounceFor('PROJECT_SUCCESSFULLY_SAVED', projectSuccessfullySaved, 1000),
+    takeEvery('CURRENT_CURSOR_CHANGED', updateFocusedSelector),
+    takeEvery('EDITOR_FOCUSED', updateFocusedSelector),
   ]);
 }
diff --git a/src/selectors/getFocusedSelector.js b/src/selectors/getFocusedSelector.js
new file mode 100644
index 0000000000..3d51bb47e8
--- /dev/null
+++ b/src/selectors/getFocusedSelector.js
@@ -0,0 +1,3 @@
+export default function getFocusedSelector(state) {
+  return state.getIn(['ui', 'focusedSelector']);
+}
diff --git a/src/selectors/index.js b/src/selectors/index.js
index 30db47c62e..b81de07bf7 100644
--- a/src/selectors/index.js
+++ b/src/selectors/index.js
@@ -16,6 +16,7 @@ import getCurrentValidationState from './getCurrentValidationState';
 import getEnabledLibraries from './getEnabledLibraries';
 import getErrors from './getErrors';
 import getHiddenUIComponents from './getHiddenUIComponents';
+import getFocusedSelector from './getFocusedSelector';
 import getNotifications from './getNotifications';
 import getOpenTopBarMenu from './getOpenTopBarMenu';
 import getProject from './getProject';
@@ -59,6 +60,7 @@ export {
   getEnabledLibraries,
   getErrors,
   getHiddenUIComponents,
+  getFocusedSelector,
   getNotifications,
   getOpenTopBarMenu,
   getProject,
diff --git a/src/util/compileProject.js b/src/util/compileProject.js
index be75dd672c..a0f13d284d 100644
--- a/src/util/compileProject.js
+++ b/src/util/compileProject.js
@@ -8,6 +8,8 @@ import uniq from 'lodash-es/uniq';
 
 import config from '../config';
 
+import highlighterCss from '../../templates/highlighter.css';
+
 import retryingFailedImports from './retryingFailedImports';
 
 const downloadingScript = downloadScript();
@@ -180,6 +182,12 @@ async function addJavascript(
   doc.body.appendChild(scriptTag);
 }
 
+function addHighlighterCss(doc) {
+  const styleTag = doc.createElement('style');
+  styleTag.innerHTML = highlighterCss;
+  doc.head.appendChild(styleTag);
+}
+
 export function generateTextPreview(project) {
   const {title} = constructDocument(project);
   return (title || '').trim();
@@ -201,6 +209,7 @@ export default async function compileProject(
     await addPreviewSupportScript(doc);
   }
   await addJavascript(doc, project, {breakLoops: isInlinePreview});
+  addHighlighterCss(doc);
 
   return {
     title: (doc.title || '').trim(),
diff --git a/src/util/selectorAtCursor.js b/src/util/selectorAtCursor.js
new file mode 100644
index 0000000000..e286a5add1
--- /dev/null
+++ b/src/util/selectorAtCursor.js
@@ -0,0 +1,31 @@
+import postcss from 'postcss';
+
+export function selectorAtCursor(source, cursor, language) {
+  let highlighterSelector = null;
+  if (language === 'css') {
+    try {
+      const rootNode = postcss.parse(source);
+      rootNode.walkRules((rule) => {
+        const ruleStartRow = rule.source.start.line;
+        const ruleStartCol = rule.source.start.column;
+        const ruleEndRow = rule.source.end.line;
+        const ruleEndCol = rule.source.end.column;
+        const cursorRow = cursor.row + 1;
+        const cursorCol = cursor.column + 1;
+        if (
+          (cursorRow > ruleStartRow && cursorRow < ruleEndRow) ||
+          (cursorRow === ruleStartRow && cursorCol >= ruleStartCol) ||
+          (cursorRow === ruleEndRow && cursorCol <= ruleEndCol)
+        ) {
+          highlighterSelector = rule.selector;
+          return false;
+        }
+        return null;
+      });
+      return highlighterSelector;
+    } catch (e) {
+      return null;
+    }
+  }
+  return null;
+}
diff --git a/templates/highlighter.css b/templates/highlighter.css
new file mode 100644
index 0000000000..d580cc8ea5
--- /dev/null
+++ b/templates/highlighter.css
@@ -0,0 +1,21 @@
+.__popcode-highlighter {
+  z-index: 2000000;
+  margin: -1px 0px 0px -1px;
+  padding: 0px;
+  position: absolute;
+  pointer-events: none;
+  border-radius: 0px;
+  border-style: solid;
+  border-width: 1px;
+  border-color: #00b8ff;
+  box-shadow: #ffffff 0px 0px 1px;
+  box-sizing: content-box;
+  background-color: #00b8ff;
+  opacity: .5;
+  transition-property: opacity, background-color;
+  transition-duration: 300ms, 2.3s;
+}
+
+.__popcode-highlighter.fade {
+  background-color: #ffffff00;
+}
diff --git a/test/unit/reducers/ui.js b/test/unit/reducers/ui.js
index dade5f9776..a74ecc6aba 100644
--- a/test/unit/reducers/ui.js
+++ b/test/unit/reducers/ui.js
@@ -23,6 +23,7 @@ import {
   cancelEditingInstructions,
   showSaveIndicator,
   hideSaveIndicator,
+  currentFocusedSelectorChanged,
 } from '../../../src/actions/ui';
 import {
   snapshotCreated,
@@ -334,3 +335,25 @@ test('toggleTopBarMenu', (t) => {
     initialState.set('openTopBarMenu', 'silly'),
   ));
 });
+
+test('updateSelector', (t) => {
+  t.test('focusSelector', reducerTest(
+    reducer,
+    initialState,
+    partial(currentFocusedSelectorChanged, 'h1'),
+    initialState.set(
+      'focusedSelector',
+      'h1',
+    ),
+  ));
+
+  t.test('unFocusSelector', reducerTest(
+    reducer,
+    initialState.set(
+      'focusedSelector',
+      'h1',
+    ),
+    partial(currentFocusedSelectorChanged, null),
+    initialState,
+  ));
+});
diff --git a/test/unit/sagas/ui.js b/test/unit/sagas/ui.js
index c9a3d30374..3fe88913e1 100644
--- a/test/unit/sagas/ui.js
+++ b/test/unit/sagas/ui.js
@@ -6,6 +6,8 @@ import {
   exportProject as exportProjectSaga,
   popOutProject as popOutProjectSaga,
   projectSuccessfullySaved as projectSuccessfullySavedSaga,
+  updateFocusedSelector as updateFocusedSelectorSaga,
+  importSelectorAtCursor,
 } from '../../../src/sagas/ui';
 import {getCurrentProject} from '../../../src/selectors';
 import {
@@ -13,6 +15,7 @@ import {
   popOutProject,
   showSaveIndicator,
   hideSaveIndicator,
+  currentFocusedSelectorChanged,
 } from '../../../src/actions/ui';
 import {
   projectExported,
@@ -23,6 +26,7 @@ import {
 import {openWindowWithContent} from '../../../src/util';
 import spinnerPageHtml from '../../../templates/project-export.html';
 import compileProject from '../../../src/util/compileProject';
+import {selectorAtCursor} from '../../../src/util/selectorAtCursor';
 
 test('userDoneTyping', (assert) => {
   testSaga(userDoneTypingSaga).
@@ -98,3 +102,17 @@ test('projectSuccessfullySaved', (assert) => {
     next().isDone();
   assert.end();
 });
+
+test('projectSuccessfullySaved', (assert) => {
+  const source = 'body{}';
+  const cursor = {column: 1, row: 0};
+  const language = 'css';
+  const selector = 'body';
+
+  testSaga(updateFocusedSelectorSaga, {payload: {source, cursor, language}}).
+    next().call(importSelectorAtCursor).
+    next({selectorAtCursor}).call(selectorAtCursor, source, cursor, language).
+    next(selector).put(currentFocusedSelectorChanged(selector)).
+    next().isDone();
+  assert.end();
+});
diff --git a/test/unit/util/selectorAtCursor.js b/test/unit/util/selectorAtCursor.js
new file mode 100644
index 0000000000..b1c08890f8
--- /dev/null
+++ b/test/unit/util/selectorAtCursor.js
@@ -0,0 +1,20 @@
+import test from 'tape';
+
+import {selectorAtCursor} from '../../../src/util/selectorAtCursor';
+import {css} from '../../data/acceptance.json';
+
+const [source] = css;
+
+test('cursor in css rule returns correct selector', (assert) => {
+  const cursor = {column: 1, row: 0};
+  assert.isEqual(selectorAtCursor(source, cursor, 'css'), 'body');
+  assert.end();
+});
+
+test('cursor outside css rule returns null', (assert) => {
+  const cursor = {column: 1, row: 2};
+  assert.isEqual(selectorAtCursor(source, cursor, 'css'), null);
+  assert.end();
+});
+
+

From c7dcbaf0cf0b6bed20645bebae991294fa76411e Mon Sep 17 00:00:00 2001
From: Wylie Conlon <wylieconlon@gmail.com>
Date: Mon, 22 Oct 2018 15:25:44 -0400
Subject: [PATCH 2/4] Add highlighting for some jQuery selectors

---
 package-lock.json                  | 695 +++++++++++++++++++++++++++--
 package.json                       |   4 +-
 src/util/selectorAtCursor.js       |  30 ++
 test/unit/util/selectorAtCursor.js |  44 +-
 4 files changed, 724 insertions(+), 49 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index e52b4836a7..06da5f16fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -119,9 +119,9 @@
       }
     },
     "@babel/parser": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.1.0.tgz",
-      "integrity": "sha512-SmjnXCuPAlai75AFtzv+KCBcJ3sDDWbIn+WytKw1k+wAtEy6phqI2RqKh/zAnw53i1NR8su3Ep/UoqaKcimuLg=="
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.1.3.tgz",
+      "integrity": "sha512-gqmspPZOMW3MIRb9HlrnbZHXI1/KHTOroBwN1NcLL6pWxzqzEKGvRTq0W/PxS45OtQGbaFikSQpkS5zbnsQm2w=="
     },
     "@babel/template": {
       "version": "7.1.0",
@@ -134,25 +134,37 @@
       }
     },
     "@babel/traverse": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.1.0.tgz",
-      "integrity": "sha512-bwgln0FsMoxm3pLOgrrnGaXk18sSM9JNf1/nHC/FksmNGFbYnPWY4GYCfLxyP1KRmfsxqkRpfoa6xr6VuuSxdw==",
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.1.4.tgz",
+      "integrity": "sha512-my9mdrAIGdDiSVBuMjpn/oXYpva0/EZwWL3sm3Wcy/AVWO2eXnsoZruOT9jOGNRXU8KbCIu5zsKnXcAJ6PcV6Q==",
       "requires": {
         "@babel/code-frame": "^7.0.0",
-        "@babel/generator": "^7.0.0",
+        "@babel/generator": "^7.1.3",
         "@babel/helper-function-name": "^7.1.0",
         "@babel/helper-split-export-declaration": "^7.0.0",
-        "@babel/parser": "^7.1.0",
-        "@babel/types": "^7.0.0",
+        "@babel/parser": "^7.1.3",
+        "@babel/types": "^7.1.3",
         "debug": "^3.1.0",
         "globals": "^11.1.0",
         "lodash": "^4.17.10"
       },
       "dependencies": {
+        "@babel/generator": {
+          "version": "7.1.3",
+          "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.1.3.tgz",
+          "integrity": "sha512-ZoCZGcfIJFJuZBqxcY9OjC1KW2lWK64qrX1o4UYL3yshVhwKFYgzpWZ0vvtGMNJdTlvkw0W+HR1VnYN8q3QPFQ==",
+          "requires": {
+            "@babel/types": "^7.1.3",
+            "jsesc": "^2.5.1",
+            "lodash": "^4.17.10",
+            "source-map": "^0.5.0",
+            "trim-right": "^1.0.1"
+          }
+        },
         "debug": {
-          "version": "3.2.5",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz",
-          "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==",
+          "version": "3.2.6",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+          "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
           "requires": {
             "ms": "^2.1.1"
           }
@@ -161,13 +173,18 @@
           "version": "2.1.1",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
           "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
+        },
+        "source-map": {
+          "version": "0.5.7",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+          "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
         }
       }
     },
     "@babel/types": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0.tgz",
-      "integrity": "sha512-5tPDap4bGKTLPtci2SUl/B7Gv8RnuJFuQoWx26RJobS0fFrz4reUA3JnwIM+HVHEmWE0C1mzKhDtTp8NsWY02Q==",
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.1.3.tgz",
+      "integrity": "sha512-RpPOVfK+yatXyn8n4PB1NW6k9qjinrXrRR8ugBN8fD6hCy5RXI6PSbVqpOJBO9oSaY7Nom4ohj35feb0UR9hSA==",
       "requires": {
         "esutils": "^2.0.2",
         "lodash": "^4.17.10",
@@ -2569,7 +2586,7 @@
     },
     "bl": {
       "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz",
+      "resolved": "http://registry.npmjs.org/bl/-/bl-1.1.2.tgz",
       "integrity": "sha1-/cqHGplxOqANGeO7ukHER4emU5g=",
       "dev": true,
       "optional": true,
@@ -3001,7 +3018,7 @@
         },
         "semver": {
           "version": "5.0.3",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-5.0.3.tgz",
+          "resolved": "http://registry.npmjs.org/semver/-/semver-5.0.3.tgz",
           "integrity": "sha1-d0Zt5YnNXTyV8TiqeLxWmjy10no=",
           "dev": true
         }
@@ -3622,6 +3639,7 @@
       "requires": {
         "anymatch": "^1.3.0",
         "async-each": "^1.0.0",
+        "fsevents": "^1.0.0",
         "glob-parent": "^2.0.0",
         "inherits": "^2.0.1",
         "is-binary-path": "^1.0.0",
@@ -3918,7 +3936,7 @@
     },
     "cloudflare": {
       "version": "2.4.1",
-      "resolved": "https://registry.npmjs.org/cloudflare/-/cloudflare-2.4.1.tgz",
+      "resolved": "http://registry.npmjs.org/cloudflare/-/cloudflare-2.4.1.tgz",
       "integrity": "sha512-L1KMiguQ4J1GlHwPD1iSNU+Yw6R7cuYasdXWb1d1Vkx7LftdbfLVqekoe9RaN0gnPURSckC34+fuc8cyTbDkOg==",
       "dev": true,
       "requires": {
@@ -5064,7 +5082,7 @@
         },
         "pify": {
           "version": "2.3.0",
-          "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         },
@@ -5815,7 +5833,7 @@
     },
     "es6-promisify": {
       "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
+      "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
       "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
       "dev": true,
       "requires": {
@@ -6637,7 +6655,7 @@
         },
         "finalhandler": {
           "version": "1.1.1",
-          "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
+          "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
           "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==",
           "dev": true,
           "requires": {
@@ -7391,6 +7409,601 @@
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
       "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
     },
+    "fsevents": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz",
+      "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "nan": "^2.9.2",
+        "node-pre-gyp": "^0.10.0"
+      },
+      "dependencies": {
+        "abbrev": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+          "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+          "dev": true,
+          "optional": true
+        },
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
+          "dev": true
+        },
+        "aproba": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+          "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+          "dev": true,
+          "optional": true
+        },
+        "are-we-there-yet": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz",
+          "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "delegates": "^1.0.0",
+            "readable-stream": "^2.0.6"
+          }
+        },
+        "balanced-match": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+          "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
+          "dev": true
+        },
+        "brace-expansion": {
+          "version": "1.1.11",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0",
+            "concat-map": "0.0.1"
+          }
+        },
+        "chownr": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.0.1.tgz",
+          "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
+          "dev": true,
+          "optional": true
+        },
+        "code-point-at": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+          "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
+          "dev": true
+        },
+        "concat-map": {
+          "version": "0.0.1",
+          "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+          "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+          "dev": true
+        },
+        "console-control-strings": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+          "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
+          "dev": true
+        },
+        "core-util-is": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+          "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+          "dev": true,
+          "optional": true
+        },
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "deep-extend": {
+          "version": "0.5.1",
+          "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.5.1.tgz",
+          "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==",
+          "dev": true,
+          "optional": true
+        },
+        "delegates": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+          "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
+          "dev": true,
+          "optional": true
+        },
+        "detect-libc": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+          "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
+          "dev": true,
+          "optional": true
+        },
+        "fs-minipass": {
+          "version": "1.2.5",
+          "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz",
+          "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "minipass": "^2.2.1"
+          }
+        },
+        "fs.realpath": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+          "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+          "dev": true,
+          "optional": true
+        },
+        "gauge": {
+          "version": "2.7.4",
+          "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+          "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "aproba": "^1.0.3",
+            "console-control-strings": "^1.0.0",
+            "has-unicode": "^2.0.0",
+            "object-assign": "^4.1.0",
+            "signal-exit": "^3.0.0",
+            "string-width": "^1.0.1",
+            "strip-ansi": "^3.0.1",
+            "wide-align": "^1.1.0"
+          }
+        },
+        "glob": {
+          "version": "7.1.2",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
+          "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.0.4",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "has-unicode": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+          "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
+          "dev": true,
+          "optional": true
+        },
+        "iconv-lite": {
+          "version": "0.4.21",
+          "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.21.tgz",
+          "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "safer-buffer": "^2.1.0"
+          }
+        },
+        "ignore-walk": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz",
+          "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "minimatch": "^3.0.4"
+          }
+        },
+        "inflight": {
+          "version": "1.0.6",
+          "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+          "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "once": "^1.3.0",
+            "wrappy": "1"
+          }
+        },
+        "inherits": {
+          "version": "2.0.3",
+          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+          "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
+          "dev": true
+        },
+        "ini": {
+          "version": "1.3.5",
+          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
+          "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
+          "dev": true,
+          "optional": true
+        },
+        "is-fullwidth-code-point": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+          "dev": true,
+          "requires": {
+            "number-is-nan": "^1.0.0"
+          }
+        },
+        "isarray": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
+          "dev": true,
+          "optional": true
+        },
+        "minimatch": {
+          "version": "3.0.4",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+          "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^1.1.7"
+          }
+        },
+        "minimist": {
+          "version": "0.0.8",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+          "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
+          "dev": true
+        },
+        "minipass": {
+          "version": "2.2.4",
+          "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz",
+          "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
+          "dev": true,
+          "requires": {
+            "safe-buffer": "^5.1.1",
+            "yallist": "^3.0.0"
+          }
+        },
+        "minizlib": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.1.0.tgz",
+          "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "minipass": "^2.2.1"
+          }
+        },
+        "mkdirp": {
+          "version": "0.5.1",
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+          "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+          "dev": true,
+          "requires": {
+            "minimist": "0.0.8"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+          "dev": true,
+          "optional": true
+        },
+        "needle": {
+          "version": "2.2.0",
+          "resolved": "https://registry.npmjs.org/needle/-/needle-2.2.0.tgz",
+          "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "debug": "^2.1.2",
+            "iconv-lite": "^0.4.4",
+            "sax": "^1.2.4"
+          }
+        },
+        "node-pre-gyp": {
+          "version": "0.10.0",
+          "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.0.tgz",
+          "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "detect-libc": "^1.0.2",
+            "mkdirp": "^0.5.1",
+            "needle": "^2.2.0",
+            "nopt": "^4.0.1",
+            "npm-packlist": "^1.1.6",
+            "npmlog": "^4.0.2",
+            "rc": "^1.1.7",
+            "rimraf": "^2.6.1",
+            "semver": "^5.3.0",
+            "tar": "^4"
+          }
+        },
+        "nopt": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+          "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "abbrev": "1",
+            "osenv": "^0.1.4"
+          }
+        },
+        "npm-bundled": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.3.tgz",
+          "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==",
+          "dev": true,
+          "optional": true
+        },
+        "npm-packlist": {
+          "version": "1.1.10",
+          "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.1.10.tgz",
+          "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "ignore-walk": "^3.0.1",
+            "npm-bundled": "^1.0.1"
+          }
+        },
+        "npmlog": {
+          "version": "4.1.2",
+          "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+          "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "are-we-there-yet": "~1.1.2",
+            "console-control-strings": "~1.1.0",
+            "gauge": "~2.7.3",
+            "set-blocking": "~2.0.0"
+          }
+        },
+        "number-is-nan": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+          "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
+          "dev": true
+        },
+        "object-assign": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+          "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+          "dev": true,
+          "optional": true
+        },
+        "once": {
+          "version": "1.4.0",
+          "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+          "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+          "dev": true,
+          "requires": {
+            "wrappy": "1"
+          }
+        },
+        "os-homedir": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+          "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
+          "dev": true,
+          "optional": true
+        },
+        "os-tmpdir": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+          "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
+          "dev": true,
+          "optional": true
+        },
+        "osenv": {
+          "version": "0.1.5",
+          "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+          "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "os-homedir": "^1.0.0",
+            "os-tmpdir": "^1.0.0"
+          }
+        },
+        "path-is-absolute": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+          "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+          "dev": true,
+          "optional": true
+        },
+        "process-nextick-args": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
+          "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==",
+          "dev": true,
+          "optional": true
+        },
+        "rc": {
+          "version": "1.2.7",
+          "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.7.tgz",
+          "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "deep-extend": "^0.5.1",
+            "ini": "~1.3.0",
+            "minimist": "^1.2.0",
+            "strip-json-comments": "~2.0.1"
+          },
+          "dependencies": {
+            "minimist": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+              "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
+              "dev": true,
+              "optional": true
+            }
+          }
+        },
+        "readable-stream": {
+          "version": "2.3.6",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+          "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "rimraf": {
+          "version": "2.6.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz",
+          "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "glob": "^7.0.5"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.1.1",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
+          "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
+          "dev": true
+        },
+        "safer-buffer": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+          "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+          "dev": true,
+          "optional": true
+        },
+        "sax": {
+          "version": "1.2.4",
+          "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+          "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
+          "dev": true,
+          "optional": true
+        },
+        "semver": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
+          "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
+          "dev": true,
+          "optional": true
+        },
+        "set-blocking": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+          "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
+          "dev": true,
+          "optional": true
+        },
+        "signal-exit": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+          "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=",
+          "dev": true,
+          "optional": true
+        },
+        "string-width": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+          "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+          "dev": true,
+          "requires": {
+            "code-point-at": "^1.0.0",
+            "is-fullwidth-code-point": "^1.0.0",
+            "strip-ansi": "^3.0.0"
+          }
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        },
+        "strip-json-comments": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+          "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
+          "dev": true,
+          "optional": true
+        },
+        "tar": {
+          "version": "4.4.1",
+          "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.1.tgz",
+          "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "chownr": "^1.0.1",
+            "fs-minipass": "^1.2.5",
+            "minipass": "^2.2.4",
+            "minizlib": "^1.1.0",
+            "mkdirp": "^0.5.0",
+            "safe-buffer": "^5.1.1",
+            "yallist": "^3.0.2"
+          }
+        },
+        "util-deprecate": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+          "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+          "dev": true,
+          "optional": true
+        },
+        "wide-align": {
+          "version": "1.1.2",
+          "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz",
+          "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "string-width": "^1.0.2"
+          }
+        },
+        "wrappy": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+          "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+          "dev": true
+        },
+        "yallist": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
+          "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
+          "dev": true
+        }
+      }
+    },
     "fsm-iterator": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/fsm-iterator/-/fsm-iterator-1.1.0.tgz",
@@ -9608,12 +10221,12 @@
         },
         "entities": {
           "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz",
+          "resolved": "http://registry.npmjs.org/entities/-/entities-1.0.0.tgz",
           "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY="
         },
         "htmlparser2": {
           "version": "3.8.3",
-          "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz",
+          "resolved": "http://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz",
           "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=",
           "requires": {
             "domelementtype": "1",
@@ -10019,7 +10632,7 @@
       "dependencies": {
         "iconv-lite": {
           "version": "0.4.15",
-          "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz",
+          "resolved": "http://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz",
           "integrity": "sha1-/iZaIYrGpXz+hUkn6dBMGYJe3es=",
           "dev": true
         }
@@ -10284,7 +10897,7 @@
         },
         "pify": {
           "version": "2.3.0",
-          "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         },
@@ -10668,7 +11281,7 @@
         },
         "form-data": {
           "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.0.0.tgz",
+          "resolved": "http://registry.npmjs.org/form-data/-/form-data-2.0.0.tgz",
           "integrity": "sha1-bwrrrcxdoWwT4ezBETfYX5uIOyU=",
           "dev": true,
           "optional": true,
@@ -11036,7 +11649,7 @@
         },
         "remark-parse": {
           "version": "1.1.0",
-          "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-1.1.0.tgz",
+          "resolved": "http://registry.npmjs.org/remark-parse/-/remark-parse-1.1.0.tgz",
           "integrity": "sha1-w8oQ+ajaBGFcKPCapOMEUQUm7CE=",
           "dev": true,
           "requires": {
@@ -11053,7 +11666,7 @@
         },
         "remark-stringify": {
           "version": "1.1.0",
-          "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-1.1.0.tgz",
+          "resolved": "http://registry.npmjs.org/remark-stringify/-/remark-stringify-1.1.0.tgz",
           "integrity": "sha1-pxBeJbnuK/mkm3XSxCPxGwauIJI=",
           "dev": true,
           "requires": {
@@ -11582,6 +12195,13 @@
       "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
       "dev": true
     },
+    "nan": {
+      "version": "2.11.1",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.1.tgz",
+      "integrity": "sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==",
+      "dev": true,
+      "optional": true
+    },
     "nanolru": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/nanolru/-/nanolru-1.0.0.tgz",
@@ -11914,7 +12534,7 @@
         },
         "strip-ansi": {
           "version": "0.1.1",
-          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz",
+          "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-0.1.1.tgz",
           "integrity": "sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=",
           "dev": true
         },
@@ -12021,7 +12641,7 @@
         },
         "camelcase-keys": {
           "version": "2.1.0",
-          "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+          "resolved": "http://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
           "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=",
           "dev": true,
           "requires": {
@@ -12164,7 +12784,7 @@
         },
         "meow": {
           "version": "3.7.0",
-          "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+          "resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
           "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=",
           "dev": true,
           "requires": {
@@ -12223,7 +12843,7 @@
         },
         "pify": {
           "version": "2.3.0",
-          "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         },
@@ -13293,7 +13913,7 @@
         },
         "kind-of": {
           "version": "1.1.0",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz",
+          "resolved": "http://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz",
           "integrity": "sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ=",
           "dev": true
         }
@@ -17156,7 +17776,7 @@
         },
         "htmlparser2": {
           "version": "3.3.0",
-          "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz",
           "integrity": "sha1-zHDQWln2VC5D8OaFyYLhTJJKnv4=",
           "dev": true,
           "requires": {
@@ -20280,6 +20900,7 @@
             "anymatch": "^2.0.0",
             "async-each": "^1.0.0",
             "braces": "^2.3.0",
+            "fsevents": "^1.2.2",
             "glob-parent": "^3.1.0",
             "inherits": "^2.0.1",
             "is-binary-path": "^1.0.0",
@@ -20470,7 +21091,7 @@
       "dependencies": {
         "ws": {
           "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/ws/-/ws-4.1.0.tgz",
+          "resolved": "http://registry.npmjs.org/ws/-/ws-4.1.0.tgz",
           "integrity": "sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA==",
           "dev": true,
           "requires": {
@@ -20886,7 +21507,7 @@
     },
     "yargs": {
       "version": "6.4.0",
-      "resolved": "http://registry.npmjs.org/yargs/-/yargs-6.4.0.tgz",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-6.4.0.tgz",
       "integrity": "sha1-gW4ahm1VmMzzTlWW3c4i2S2kkNQ=",
       "dev": true,
       "requires": {
@@ -21024,7 +21645,7 @@
     },
     "yargs-parser": {
       "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-4.2.1.tgz",
+      "resolved": "http://registry.npmjs.org/yargs-parser/-/yargs-parser-4.2.1.tgz",
       "integrity": "sha1-KczqwNxPA8bIe0qfIX3RjJ90hxw=",
       "dev": true,
       "requires": {
diff --git a/package.json b/package.json
index 3803ceb17b..6f63add9fa 100644
--- a/package.json
+++ b/package.json
@@ -155,6 +155,9 @@
   "bugs": "https://trello.com/b/ONaFg6wh/popcode",
   "license": "MIT",
   "dependencies": {
+    "@babel/parser": "^7.1.3",
+    "@babel/traverse": "^7.1.4",
+    "@babel/types": "^7.1.3",
     "@firebase/app": "^0.3.4",
     "@firebase/app-types": "^0.3.2",
     "@firebase/auth": "^0.7.6",
@@ -304,7 +307,6 @@
     "null-loader": "^0.1.1",
     "postcss-preset-env": "^5.3.0",
     "raw-loader": "^0.5.1",
-    "react-addons-perf": "^15.4.2",
     "redux-saga-test-plan": "^3.0.2",
     "script-ext-html-webpack-plugin": "^2.0.1",
     "sinon": "^5.0.7",
diff --git a/src/util/selectorAtCursor.js b/src/util/selectorAtCursor.js
index e286a5add1..f808e47956 100644
--- a/src/util/selectorAtCursor.js
+++ b/src/util/selectorAtCursor.js
@@ -1,3 +1,6 @@
+import {parse} from '@babel/parser';
+import traverse from '@babel/traverse';
+import {isStringLiteral} from '@babel/types';
 import postcss from 'postcss';
 
 export function selectorAtCursor(source, cursor, language) {
@@ -22,6 +25,33 @@ export function selectorAtCursor(source, cursor, language) {
         }
         return null;
       });
+      return highlighterSelector;
+    } catch (e) {
+      return null;
+    }
+  } else if (language === 'javascript') {
+    try {
+      const ast = parse(source);
+
+      const visitor = {
+        CallExpression(path) {
+          // Only matches code of the form $('selector') on the same line
+          const expressionLoc = path.node.loc;
+          const cursorLine = cursor.row + 1;
+          const [selector] = path.node.arguments;
+          if (
+            path.get('callee').toString() === '$' &&
+            isStringLiteral(selector) &&
+            expressionLoc.start.line >= cursorLine &&
+            expressionLoc.end.line <= cursorLine
+          ) {
+            highlighterSelector = selector.value;
+            path.stop();
+          }
+        },
+      };
+      traverse(ast, visitor, null);
+
       return highlighterSelector;
     } catch (e) {
       return null;
diff --git a/test/unit/util/selectorAtCursor.js b/test/unit/util/selectorAtCursor.js
index b1c08890f8..db8cb53887 100644
--- a/test/unit/util/selectorAtCursor.js
+++ b/test/unit/util/selectorAtCursor.js
@@ -1,20 +1,42 @@
 import test from 'tape';
 
 import {selectorAtCursor} from '../../../src/util/selectorAtCursor';
-import {css} from '../../data/acceptance.json';
+import {css, javascript} from '../../data/acceptance.json';
 
-const [source] = css;
+test('css rules', (t) => {
+  const [source] = css;
 
-test('cursor in css rule returns correct selector', (assert) => {
-  const cursor = {column: 1, row: 0};
-  assert.isEqual(selectorAtCursor(source, cursor, 'css'), 'body');
-  assert.end();
-});
+  t.test('cursor in css rule returns correct selector', (assert) => {
+    const cursor = {column: 1, row: 0};
+    assert.isEqual(selectorAtCursor(source, cursor, 'css'), 'body');
+    assert.end();
+  });
 
-test('cursor outside css rule returns null', (assert) => {
-  const cursor = {column: 1, row: 2};
-  assert.isEqual(selectorAtCursor(source, cursor, 'css'), null);
-  assert.end();
+  t.test('cursor outside css rule returns null', (assert) => {
+    const cursor = {column: 1, row: 2};
+    assert.isEqual(selectorAtCursor(source, cursor, 'css'), null);
+    assert.end();
+  });
 });
 
+test('jquery selectors that can be analyzed', (t) => {
+  const [source] = javascript;
+
+  t.test('cursor in jquery rule with string return selector', (assert) => {
+    const cursor = {column: 5, row: 1};
+    assert.isEqual(
+      selectorAtCursor(source, cursor, 'javascript'),
+      '#submit-name',
+    );
+    assert.end();
+  });
 
+  t.test('cursor outside jquery rule with string returns null', (assert) => {
+    const cursor = {column: 2, row: 0};
+    assert.isEqual(
+      selectorAtCursor(source, cursor, 'javascript'),
+      null,
+    );
+    assert.end();
+  });
+});

From 48e73c0ce11b8f12599167490e98b54a7be0e422 Mon Sep 17 00:00:00 2001
From: Wylie Conlon <wylieconlon@gmail.com>
Date: Wed, 24 Oct 2018 16:37:34 -0400
Subject: [PATCH 3/4] Parse and save highlight positions every 100ms

---
 src/actions/index.js                          |  5 ++
 src/actions/selectorLocations.js              |  7 ++
 src/records/SelectorLocations.js              |  6 ++
 src/reducers/index.js                         |  2 +
 src/reducers/selectorLocations.js             | 21 ++++++
 src/sagas/projects.js                         | 23 +++++++
 src/sagas/ui.js                               | 10 ++-
 .../getSelectorLocationsForLanguage.js        |  6 ++
 src/selectors/index.js                        |  3 +
 src/util/getCssSelectorLocations.js           | 19 ++++++
 src/util/getJsSelectorLocations.js            | 33 +++++++++
 src/util/selectorAtCursor.js                  | 67 +++----------------
 12 files changed, 140 insertions(+), 62 deletions(-)
 create mode 100644 src/actions/selectorLocations.js
 create mode 100644 src/records/SelectorLocations.js
 create mode 100644 src/reducers/selectorLocations.js
 create mode 100644 src/selectors/getSelectorLocationsForLanguage.js
 create mode 100644 src/util/getCssSelectorLocations.js
 create mode 100644 src/util/getJsSelectorLocations.js

diff --git a/src/actions/index.js b/src/actions/index.js
index a3e24aa7cf..3aa218a1d6 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -77,6 +77,10 @@ import {
   updateResizableFlex,
 } from './resizableFlex';
 
+import {
+  updateSelectorLocations,
+} from './selectorLocations';
+
 export {
   clearConsoleEntries,
   consoleInputChanged,
@@ -132,4 +136,5 @@ export {
   updateResizableFlex,
   startAccountMigration,
   dismissAccountMigration,
+  updateSelectorLocations,
 };
diff --git a/src/actions/selectorLocations.js b/src/actions/selectorLocations.js
new file mode 100644
index 0000000000..25f221314c
--- /dev/null
+++ b/src/actions/selectorLocations.js
@@ -0,0 +1,7 @@
+import {createAction} from 'redux-actions';
+
+export const updateSelectorLocations = createAction(
+  'UPDATE_SELECTOR_LOCATIONS',
+  selectors => ({selectors}),
+  (_selectors, timestamp = Date.now()) => ({timestamp}),
+);
diff --git a/src/records/SelectorLocations.js b/src/records/SelectorLocations.js
new file mode 100644
index 0000000000..94abb1e94d
--- /dev/null
+++ b/src/records/SelectorLocations.js
@@ -0,0 +1,6 @@
+import {Record, List} from 'immutable';
+
+export default Record({
+  javascript: new List(),
+  css: new List(),
+}, 'SelectorLocations');
diff --git a/src/reducers/index.js b/src/reducers/index.js
index 2e1d80b0fd..2145a64ceb 100644
--- a/src/reducers/index.js
+++ b/src/reducers/index.js
@@ -10,6 +10,7 @@ import ui from './ui';
 import clients from './clients';
 import compiledProjects from './compiledProjects';
 import resizableFlex from './resizableFlex';
+import selectorLocations from './selectorLocations';
 
 const reduceRoot = combineReducers({
   user,
@@ -21,6 +22,7 @@ const reduceRoot = combineReducers({
   compiledProjects,
   console,
   resizableFlex,
+  selectorLocations,
 });
 
 export default reduceReducers(
diff --git a/src/reducers/selectorLocations.js b/src/reducers/selectorLocations.js
new file mode 100644
index 0000000000..53f66b505f
--- /dev/null
+++ b/src/reducers/selectorLocations.js
@@ -0,0 +1,21 @@
+import Immutable from 'immutable';
+
+const emptyMap = new Immutable.Map();
+
+export default function reduceProjects(stateIn, action) {
+  let state;
+
+  if (stateIn === undefined) {
+    state = emptyMap;
+  } else {
+    state = stateIn;
+  }
+
+  switch (action.type) {
+    case 'UPDATE_SELECTOR_LOCATIONS':
+      return state.set('selectors', action.payload.selectors);
+
+    default:
+      return state;
+  }
+}
diff --git a/src/sagas/projects.js b/src/sagas/projects.js
index 7d2a619772..27e3811ade 100644
--- a/src/sagas/projects.js
+++ b/src/sagas/projects.js
@@ -24,7 +24,10 @@ import {
   snapshotNotFound,
   projectRestoredFromLastSession,
 } from '../actions/clients';
+import {updateSelectorLocations} from '../actions/selectorLocations';
 import {isPristineProject} from '../util/projectUtils';
+import getCssSelectorLocations from '../util/getCssSelectorLocations';
+import getJsSelectorLocations from '../util/getJsSelectorLocations';
 import {loadGistFromId} from '../clients/github';
 import {
   loadAllProjects,
@@ -55,6 +58,22 @@ export function* changeCurrentProject() {
   yield* saveCurrentProject();
 }
 
+export function* parseCurrentProjectSource() {
+  const currentProject = yield select(getCurrentProject);
+  const jsSelectorLocations = yield call(
+    getJsSelectorLocations,
+    currentProject.sources.javascript,
+  );
+  const cssSelectorLocations = yield call(
+    getCssSelectorLocations,
+    currentProject.sources.css,
+  );
+  yield put(updateSelectorLocations({
+    javascript: jsSelectorLocations,
+    css: cssSelectorLocations,
+  }));
+}
+
 export function* importSnapshot({payload: {snapshotKey}}) {
   try {
     const snapshot = yield call(loadProjectSnapshot, snapshotKey);
@@ -131,6 +150,10 @@ export default function* projects() {
       'UPDATE_PROJECT_SOURCE',
       'UPDATE_PROJECT_INSTRUCTIONS',
     ], updateProjectSource),
+    throttle(100, [
+      'UPDATE_PROJECT_SOURCE',
+      'PROJECT_RESTORED_FROM_LAST_SESSION',
+    ], parseCurrentProjectSource),
     takeEvery('USER_AUTHENTICATED', userAuthenticated),
     takeEvery('TOGGLE_LIBRARY', toggleLibrary),
   ]);
diff --git a/src/sagas/ui.js b/src/sagas/ui.js
index 8f735619fd..d25248d2a5 100644
--- a/src/sagas/ui.js
+++ b/src/sagas/ui.js
@@ -7,7 +7,10 @@ import {
   hideSaveIndicator,
   currentFocusedSelectorChanged,
 } from '../actions/ui';
-import {getCurrentProject} from '../selectors';
+import {
+  getCurrentProject,
+  getSelectorLocationsForLanguage,
+} from '../selectors';
 import {
   projectExportDisplayed,
   projectExportNotDisplayed,
@@ -72,9 +75,10 @@ export function* exportProject() {
   );
 }
 
-export function* updateFocusedSelector({payload: {source, cursor, language}}) {
+export function* updateFocusedSelector({payload: {cursor, language}}) {
   const {selectorAtCursor} = yield call(importSelectorAtCursor);
-  const selector = yield call(selectorAtCursor, source, cursor, language);
+  const selectors = yield select(getSelectorLocationsForLanguage, language);
+  const selector = yield call(selectorAtCursor, selectors, cursor);
   yield put(currentFocusedSelectorChanged(selector));
 }
 
diff --git a/src/selectors/getSelectorLocationsForLanguage.js b/src/selectors/getSelectorLocationsForLanguage.js
new file mode 100644
index 0000000000..792f43001f
--- /dev/null
+++ b/src/selectors/getSelectorLocationsForLanguage.js
@@ -0,0 +1,6 @@
+export default function getSelectorLocationsForLanguage(state, language) {
+  return state.getIn([
+    'selectorLocations',
+    'selectors',
+  ])[language];
+}
diff --git a/src/selectors/index.js b/src/selectors/index.js
index b81de07bf7..17ead268b6 100644
--- a/src/selectors/index.js
+++ b/src/selectors/index.js
@@ -21,6 +21,8 @@ import getNotifications from './getNotifications';
 import getOpenTopBarMenu from './getOpenTopBarMenu';
 import getProject from './getProject';
 import getRequestedFocusedLine from './getRequestedFocusedLine';
+import getSelectorLocationsForLanguage
+  from './getSelectorLocationsForLanguage';
 import isClassroomExportInProgress from './isClassroomExportInProgress';
 import isCurrentlyValidating from './isCurrentlyValidating';
 import isCurrentProjectSyntacticallyValid
@@ -65,6 +67,7 @@ export {
   getOpenTopBarMenu,
   getProject,
   getRequestedFocusedLine,
+  getSelectorLocationsForLanguage,
   isClassroomExportInProgress,
   isCurrentlyValidating,
   isCurrentProjectSyntacticallyValid,
diff --git a/src/util/getCssSelectorLocations.js b/src/util/getCssSelectorLocations.js
new file mode 100644
index 0000000000..c3e4660c49
--- /dev/null
+++ b/src/util/getCssSelectorLocations.js
@@ -0,0 +1,19 @@
+import postcss from 'postcss';
+
+export default function getCssSelectorLocations(source) {
+  try {
+    const rootNode = postcss.parse(source);
+    const selectors = [];
+
+    rootNode.walkRules((rule) => {
+      selectors.push({
+        selector: rule.selector,
+        loc: rule.source,
+      });
+    });
+
+    return selectors;
+  } catch (e) {
+    return null;
+  }
+}
diff --git a/src/util/getJsSelectorLocations.js b/src/util/getJsSelectorLocations.js
new file mode 100644
index 0000000000..0a8bc08234
--- /dev/null
+++ b/src/util/getJsSelectorLocations.js
@@ -0,0 +1,33 @@
+import {parse} from '@babel/parser';
+import traverse from '@babel/traverse';
+import {isStringLiteral} from '@babel/types';
+
+export default function getJsSelectorLocations(source) {
+  try {
+    const ast = parse(source);
+
+    const foundMatches = [];
+
+    const visitor = {
+      CallExpression(path) {
+        // Only matches code of the form $('selector')
+        const expressionLoc = path.node.loc;
+        const [selector] = path.node.arguments;
+        if (
+          path.get('callee').toString() === '$' &&
+          isStringLiteral(selector)
+        ) {
+          foundMatches.push({
+            loc: expressionLoc,
+            selector: selector.value,
+          });
+        }
+      },
+    };
+    traverse(ast, visitor, null);
+
+    return foundMatches;
+  } catch (e) {
+    return null;
+  }
+}
diff --git a/src/util/selectorAtCursor.js b/src/util/selectorAtCursor.js
index f808e47956..161819c2b9 100644
--- a/src/util/selectorAtCursor.js
+++ b/src/util/selectorAtCursor.js
@@ -1,61 +1,10 @@
-import {parse} from '@babel/parser';
-import traverse from '@babel/traverse';
-import {isStringLiteral} from '@babel/types';
-import postcss from 'postcss';
-
-export function selectorAtCursor(source, cursor, language) {
-  let highlighterSelector = null;
-  if (language === 'css') {
-    try {
-      const rootNode = postcss.parse(source);
-      rootNode.walkRules((rule) => {
-        const ruleStartRow = rule.source.start.line;
-        const ruleStartCol = rule.source.start.column;
-        const ruleEndRow = rule.source.end.line;
-        const ruleEndCol = rule.source.end.column;
-        const cursorRow = cursor.row + 1;
-        const cursorCol = cursor.column + 1;
-        if (
-          (cursorRow > ruleStartRow && cursorRow < ruleEndRow) ||
-          (cursorRow === ruleStartRow && cursorCol >= ruleStartCol) ||
-          (cursorRow === ruleEndRow && cursorCol <= ruleEndCol)
-        ) {
-          highlighterSelector = rule.selector;
-          return false;
-        }
-        return null;
-      });
-      return highlighterSelector;
-    } catch (e) {
-      return null;
+export function selectorAtCursor(selectors, cursor) {
+  const matchingSelector = selectors.find(({loc, selector}) => {
+    const cursorRow = cursor.row + 1;
+    if (cursorRow >= loc.start.line && cursorRow <= loc.end.line) {
+      return selector;
     }
-  } else if (language === 'javascript') {
-    try {
-      const ast = parse(source);
-
-      const visitor = {
-        CallExpression(path) {
-          // Only matches code of the form $('selector') on the same line
-          const expressionLoc = path.node.loc;
-          const cursorLine = cursor.row + 1;
-          const [selector] = path.node.arguments;
-          if (
-            path.get('callee').toString() === '$' &&
-            isStringLiteral(selector) &&
-            expressionLoc.start.line >= cursorLine &&
-            expressionLoc.end.line <= cursorLine
-          ) {
-            highlighterSelector = selector.value;
-            path.stop();
-          }
-        },
-      };
-      traverse(ast, visitor, null);
-
-      return highlighterSelector;
-    } catch (e) {
-      return null;
-    }
-  }
-  return null;
+    return false;
+  });
+  return matchingSelector ? matchingSelector.selector : null;
 }

From f08514db7d0a8b7de7778c12af7c50e99a2bb3fd Mon Sep 17 00:00:00 2001
From: Wylie Conlon <wylieconlon@gmail.com>
Date: Thu, 25 Oct 2018 15:59:04 -0400
Subject: [PATCH 4/4] Move babel import into async bundle and add tests

---
 src/records/index.js                          |  2 +
 src/reducers/selectorLocations.js             |  7 +-
 src/sagas/projects.js                         | 25 ++++++-
 src/sagas/ui.js                               | 11 +--
 .../getSelectorLocationsForLanguage.js        |  2 +-
 src/util/getJsSelectorLocations.js            |  9 ++-
 src/util/selectorAtCursor.js                  |  5 +-
 test/unit/sagas/projects.js                   | 57 +++++++++++++++
 test/unit/sagas/ui.js                         | 26 ++++---
 test/unit/util/getCssSelectorLocations.js     | 23 +++++++
 test/unit/util/getJsSelectorLocations.js      | 25 +++++++
 test/unit/util/selectorAtCursor.js            | 69 ++++++++++---------
 12 files changed, 203 insertions(+), 58 deletions(-)
 create mode 100644 test/unit/util/getCssSelectorLocations.js
 create mode 100644 test/unit/util/getJsSelectorLocations.js

diff --git a/src/records/index.js b/src/records/index.js
index e56a5aa82e..c4026c6cdd 100644
--- a/src/records/index.js
+++ b/src/records/index.js
@@ -9,6 +9,7 @@ import ErrorList from './ErrorList';
 import ErrorReport from './ErrorReport';
 import Notification from './Notification';
 import Project from './Project';
+import SelectorLocations from './SelectorLocations';
 import UiState from './UiState';
 import User from './User';
 import UserAccount from './UserAccount';
@@ -25,6 +26,7 @@ export {
   ErrorReport,
   Notification,
   Project,
+  SelectorLocations,
   User,
   UserAccount,
   UiState,
diff --git a/src/reducers/selectorLocations.js b/src/reducers/selectorLocations.js
index 53f66b505f..43515d3550 100644
--- a/src/reducers/selectorLocations.js
+++ b/src/reducers/selectorLocations.js
@@ -1,5 +1,7 @@
 import Immutable from 'immutable';
 
+import {SelectorLocations} from '../records';
+
 const emptyMap = new Immutable.Map();
 
 export default function reduceProjects(stateIn, action) {
@@ -13,7 +15,10 @@ export default function reduceProjects(stateIn, action) {
 
   switch (action.type) {
     case 'UPDATE_SELECTOR_LOCATIONS':
-      return state.set('selectors', action.payload.selectors);
+      return state.set(
+        'selectors',
+        new SelectorLocations(action.payload.selectors),
+      );
 
     default:
       return state;
diff --git a/src/sagas/projects.js b/src/sagas/projects.js
index 27e3811ade..9793003c62 100644
--- a/src/sagas/projects.js
+++ b/src/sagas/projects.js
@@ -26,8 +26,7 @@ import {
 } from '../actions/clients';
 import {updateSelectorLocations} from '../actions/selectorLocations';
 import {isPristineProject} from '../util/projectUtils';
-import getCssSelectorLocations from '../util/getCssSelectorLocations';
-import getJsSelectorLocations from '../util/getJsSelectorLocations';
+import retryingFailedImports from '../util/retryingFailedImports';
 import {loadGistFromId} from '../clients/github';
 import {
   loadAllProjects,
@@ -58,7 +57,29 @@ export function* changeCurrentProject() {
   yield* saveCurrentProject();
 }
 
+export async function importGetJsSelectorLocations() {
+  const module = await retryingFailedImports(
+    () => import(
+      /* webpackChunkName: "mainAsync" */
+      '../util/getJsSelectorLocations',
+    ),
+  );
+  return module.default;
+}
+
+export async function importGetCssSelectorLocations() {
+  const module = await retryingFailedImports(
+    () => import(
+      /* webpackChunkName: "mainAsync" */
+      '../util/getCssSelectorLocations',
+    ),
+  );
+  return module.default;
+}
+
 export function* parseCurrentProjectSource() {
+  const getJsSelectorLocations = yield call(importGetJsSelectorLocations);
+  const getCssSelectorLocations = yield call(importGetCssSelectorLocations);
   const currentProject = yield select(getCurrentProject);
   const jsSelectorLocations = yield call(
     getJsSelectorLocations,
diff --git a/src/sagas/ui.js b/src/sagas/ui.js
index d25248d2a5..f7132d177f 100644
--- a/src/sagas/ui.js
+++ b/src/sagas/ui.js
@@ -18,16 +18,8 @@ import {
 import {openWindowWithContent} from '../util';
 import spinnerPageHtml from '../../templates/project-export.html';
 import compileProject from '../util/compileProject';
-import retryingFailedImports from '../util/retryingFailedImports';
+import selectorAtCursor from '../util/selectorAtCursor';
 
-export async function importSelectorAtCursor() {
-  return retryingFailedImports(
-    () => import(
-      /* webpackChunkName: "mainAsync" */
-      '../util/selectorAtCursor',
-    ),
-  );
-}
 export function* userDoneTyping() {
   yield put(userDoneTypingAction());
 }
@@ -76,7 +68,6 @@ export function* exportProject() {
 }
 
 export function* updateFocusedSelector({payload: {cursor, language}}) {
-  const {selectorAtCursor} = yield call(importSelectorAtCursor);
   const selectors = yield select(getSelectorLocationsForLanguage, language);
   const selector = yield call(selectorAtCursor, selectors, cursor);
   yield put(currentFocusedSelectorChanged(selector));
diff --git a/src/selectors/getSelectorLocationsForLanguage.js b/src/selectors/getSelectorLocationsForLanguage.js
index 792f43001f..c1275295ff 100644
--- a/src/selectors/getSelectorLocationsForLanguage.js
+++ b/src/selectors/getSelectorLocationsForLanguage.js
@@ -2,5 +2,5 @@ export default function getSelectorLocationsForLanguage(state, language) {
   return state.getIn([
     'selectorLocations',
     'selectors',
-  ])[language];
+  ])[language] || null;
 }
diff --git a/src/util/getJsSelectorLocations.js b/src/util/getJsSelectorLocations.js
index 0a8bc08234..e68c0ad3e5 100644
--- a/src/util/getJsSelectorLocations.js
+++ b/src/util/getJsSelectorLocations.js
@@ -10,11 +10,16 @@ export default function getJsSelectorLocations(source) {
 
     const visitor = {
       CallExpression(path) {
-        // Only matches code of the form $('selector')
+        // Only matches jquery or querySelector with string literals
         const expressionLoc = path.node.loc;
         const [selector] = path.node.arguments;
+        const callee = path.get('callee').toString();
+
         if (
-          path.get('callee').toString() === '$' &&
+          (
+            callee === '$' ||
+            callee.indexOf('querySelector') !== -1
+          ) &&
           isStringLiteral(selector)
         ) {
           foundMatches.push({
diff --git a/src/util/selectorAtCursor.js b/src/util/selectorAtCursor.js
index 161819c2b9..bb2358cf64 100644
--- a/src/util/selectorAtCursor.js
+++ b/src/util/selectorAtCursor.js
@@ -1,4 +1,7 @@
-export function selectorAtCursor(selectors, cursor) {
+export default function selectorAtCursor(selectors, cursor) {
+  if (!selectors) {
+    return null;
+  }
   const matchingSelector = selectors.find(({loc, selector}) => {
     const cursorRow = cursor.row + 1;
     if (cursorRow >= loc.start.line && cursorRow <= loc.end.line) {
diff --git a/test/unit/sagas/projects.js b/test/unit/sagas/projects.js
index 03540a0516..48200ce097 100644
--- a/test/unit/sagas/projects.js
+++ b/test/unit/sagas/projects.js
@@ -11,6 +11,9 @@ import {
   toggleLibrary as toggleLibrarySaga,
   userAuthenticated as userAuthenticatedSaga,
   updateProjectSource as updateProjectSourceSaga,
+  parseCurrentProjectSource as parseCurrentProjectSourceSaga,
+  importGetJsSelectorLocations,
+  importGetCssSelectorLocations,
 } from '../../../src/sagas/projects';
 import {
   gistImportError,
@@ -20,7 +23,10 @@ import {
   updateProjectInstructions,
   updateProjectSource,
   projectSuccessfullySaved,
+
 } from '../../../src/actions/projects';
+import {updateSelectorLocations}
+  from '../../../src/actions/selectorLocations';
 import {
   snapshotImportError,
   snapshotNotFound,
@@ -44,6 +50,10 @@ import {
   getCurrentUserId,
   getCurrentProject,
 } from '../../../src/selectors/index';
+import getCssSelectorLocations
+  from '../../../src/util/getCssSelectorLocations';
+import getJsSelectorLocations
+  from '../../../src/util/getJsSelectorLocations';
 
 test('createProject()', (assert) => {
   let firstProjectKey;
@@ -294,3 +304,50 @@ test('toggleLibrary', (assert) => {
     next().isDone();
   assert.end();
 });
+
+test('parseCurrentProjectSource', (assert) => {
+  const currentProject = project();
+
+  const jsSelectors = [
+    {
+      loc: {
+        start: {line: 1},
+        end: {line: 3},
+      },
+      selector: 'body',
+    },
+  ];
+  const cssSelectors = [
+    {
+      loc: {
+        start: {line: 1},
+        end: {line: 1},
+      },
+      selector: '.gallery',
+    },
+  ];
+
+  testSaga(
+    parseCurrentProjectSourceSaga,
+  ).
+    next().call(importGetJsSelectorLocations).
+    next(getJsSelectorLocations).call(importGetCssSelectorLocations).
+    next(getCssSelectorLocations).select(getCurrentProject).
+    next(currentProject).
+    call(
+      getJsSelectorLocations,
+      currentProject.sources.javascript,
+    ).
+    next(jsSelectors).
+    call(
+      getCssSelectorLocations,
+      currentProject.sources.css,
+    ).
+    next(cssSelectors).put(updateSelectorLocations({
+      javascript: jsSelectors,
+      css: cssSelectors,
+    })).
+    next().isDone();
+
+  assert.end();
+});
diff --git a/test/unit/sagas/ui.js b/test/unit/sagas/ui.js
index 3fe88913e1..e7afa6cc3b 100644
--- a/test/unit/sagas/ui.js
+++ b/test/unit/sagas/ui.js
@@ -7,9 +7,11 @@ import {
   popOutProject as popOutProjectSaga,
   projectSuccessfullySaved as projectSuccessfullySavedSaga,
   updateFocusedSelector as updateFocusedSelectorSaga,
-  importSelectorAtCursor,
 } from '../../../src/sagas/ui';
-import {getCurrentProject} from '../../../src/selectors';
+import {
+  getCurrentProject,
+  getSelectorLocationsForLanguage,
+} from '../../../src/selectors';
 import {
   userDoneTyping,
   popOutProject,
@@ -26,7 +28,7 @@ import {
 import {openWindowWithContent} from '../../../src/util';
 import spinnerPageHtml from '../../../templates/project-export.html';
 import compileProject from '../../../src/util/compileProject';
-import {selectorAtCursor} from '../../../src/util/selectorAtCursor';
+import selectorAtCursor from '../../../src/util/selectorAtCursor';
 
 test('userDoneTyping', (assert) => {
   testSaga(userDoneTypingSaga).
@@ -103,15 +105,23 @@ test('projectSuccessfullySaved', (assert) => {
   assert.end();
 });
 
-test('projectSuccessfullySaved', (assert) => {
-  const source = 'body{}';
+test('updateFocusedSelector', (assert) => {
+  const selectors = [
+    {
+      selector: 'body',
+      loc: {
+        start: {line: 1},
+        end: {line: 3},
+      },
+    },
+  ];
   const cursor = {column: 1, row: 0};
   const language = 'css';
   const selector = 'body';
 
-  testSaga(updateFocusedSelectorSaga, {payload: {source, cursor, language}}).
-    next().call(importSelectorAtCursor).
-    next({selectorAtCursor}).call(selectorAtCursor, source, cursor, language).
+  testSaga(updateFocusedSelectorSaga, {payload: {cursor, language}}).
+    next().select(getSelectorLocationsForLanguage, language).
+    next(selectors).call(selectorAtCursor, selectors, cursor).
     next(selector).put(currentFocusedSelectorChanged(selector)).
     next().isDone();
   assert.end();
diff --git a/test/unit/util/getCssSelectorLocations.js b/test/unit/util/getCssSelectorLocations.js
new file mode 100644
index 0000000000..90504a0405
--- /dev/null
+++ b/test/unit/util/getCssSelectorLocations.js
@@ -0,0 +1,23 @@
+import test from 'tape';
+
+import getCssSelectorLocations from
+  '../../../src/util/getCssSelectorLocations';
+import {css} from '../../data/acceptance.json';
+
+const [source] = css;
+
+test('finds all selectors in css', (assert) => {
+  const selectors = getCssSelectorLocations(source);
+  assert.deepEqual(selectors.map(s => s.selector), [
+    'body',
+    'h1',
+    '#name',
+    '#job',
+    '#fun',
+    '#movie',
+    '#music',
+    '#year',
+    '#image',
+  ]);
+  assert.end();
+});
diff --git a/test/unit/util/getJsSelectorLocations.js b/test/unit/util/getJsSelectorLocations.js
new file mode 100644
index 0000000000..dc29117477
--- /dev/null
+++ b/test/unit/util/getJsSelectorLocations.js
@@ -0,0 +1,25 @@
+import test from 'tape';
+
+import getJsSelectorLocations from
+  '../../../src/util/getJsSelectorLocations';
+import {javascript} from '../../data/acceptance.json';
+
+const [source] = javascript;
+
+test('finds all selectors in js', (assert) => {
+  const selectors = getJsSelectorLocations(source);
+  assert.deepEqual(selectors.map(s => s.selector), [
+    '#submit-name',
+    '#greeting',
+    '#name',
+    '#change-color',
+    'body',
+    '#pic1',
+    '#gallery-main',
+    '#pic2',
+    '#gallery-main',
+    '#pic3',
+    '#gallery-main',
+  ]);
+  assert.end();
+});
diff --git a/test/unit/util/selectorAtCursor.js b/test/unit/util/selectorAtCursor.js
index db8cb53887..e4da93c1b0 100644
--- a/test/unit/util/selectorAtCursor.js
+++ b/test/unit/util/selectorAtCursor.js
@@ -1,42 +1,45 @@
 import test from 'tape';
 
-import {selectorAtCursor} from '../../../src/util/selectorAtCursor';
-import {css, javascript} from '../../data/acceptance.json';
+import selectorAtCursor from '../../../src/util/selectorAtCursor';
 
-test('css rules', (t) => {
-  const [source] = css;
+const selectors = [
+  {
+    selector: 'body',
+    loc: {
+      start: {
+        line: 1,
+        column: 1,
+      },
+      end: {
+        line: 3,
+        column: 1,
+      },
+    },
+  }, {
+    selector: '#gallery',
+    loc: {
+      start: {
+        line: 5,
+        column: 1,
+      },
+      end: {
+        line: 9,
+        column: 1,
+      },
+    },
+  },
+];
 
-  t.test('cursor in css rule returns correct selector', (assert) => {
-    const cursor = {column: 1, row: 0};
-    assert.isEqual(selectorAtCursor(source, cursor, 'css'), 'body');
-    assert.end();
-  });
+test('finds a selector on the start line', (assert) => {
+  const cursor = {column: 1, row: 0};
 
-  t.test('cursor outside css rule returns null', (assert) => {
-    const cursor = {column: 1, row: 2};
-    assert.isEqual(selectorAtCursor(source, cursor, 'css'), null);
-    assert.end();
-  });
+  assert.isEqual(selectorAtCursor(selectors, cursor), 'body');
+  assert.end();
 });
 
-test('jquery selectors that can be analyzed', (t) => {
-  const [source] = javascript;
+test('does not find a selector between lines', (assert) => {
+  const cursor = {column: 1, row: 3};
 
-  t.test('cursor in jquery rule with string return selector', (assert) => {
-    const cursor = {column: 5, row: 1};
-    assert.isEqual(
-      selectorAtCursor(source, cursor, 'javascript'),
-      '#submit-name',
-    );
-    assert.end();
-  });
-
-  t.test('cursor outside jquery rule with string returns null', (assert) => {
-    const cursor = {column: 2, row: 0};
-    assert.isEqual(
-      selectorAtCursor(source, cursor, 'javascript'),
-      null,
-    );
-    assert.end();
-  });
+  assert.isEqual(selectorAtCursor(selectors, cursor), null);
+  assert.end();
 });