Merge version-2 branch to master
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
*~
|
||||
node_modules
|
||||
data/*
|
||||
|
||||
dist/themes
|
||||
dist/semantic.min.css
|
||||
|
||||
browser-bundle.js
|
||||
browser-bundle.js.map
|
||||
npm-debug.log.*
|
||||
|
||||
stats.json
|
||||
stats.analyzed.txt
|
69
README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# dubdiff
|
||||
|
||||
A diff viewer for markdown-formatted and plaintext documents.
|
||||
|
||||
These diffs are intended for use in copy-editing. The diffs are performed word-by-word, similarly to how the [GNU `wdiff`](http://www.gnu.org/software/wdiff/) tool works. This produces a more meaningful diff for English-language editing.
|
||||
|
||||
The diff may be further processed in a way that is aware of markdown formatting. The resulting output attempts to show differences of copy within the final document format (rather than differences of format).
|
||||
|
||||
The markdown-sensitive processing of the wdiff comparison is at `...`, for the curious.
|
||||
|
||||
|
||||
## Version 2
|
||||
|
||||
This is a complete rewrite of Dubdiff with:
|
||||
|
||||
- simpler project architecture
|
||||
- client-side diffing engine and simplified server
|
||||
- server-side rendering
|
||||
- switch to React from Angular
|
||||
- clean up of diffing engine
|
||||
- goal of implementing a HTML diff viewer
|
||||
|
||||
Basically I'm rewriting it for fun.
|
||||
|
||||
|
||||
## Live Server
|
||||
|
||||
The tool is live at http://dubdiff.com, feel free to use it there.
|
||||
|
||||
## Provisioning
|
||||
|
||||
You'll need node & npm. Then install dependencies with
|
||||
|
||||
npm install
|
||||
|
||||
|
||||
To build and launch a dev server:
|
||||
|
||||
npm start
|
||||
npm run server
|
||||
|
||||
To build and launch the distribution server:
|
||||
|
||||
npm run build:dist
|
||||
npm run server:dist
|
||||
|
||||
|
||||
|
||||
Data is saved to a simple flat file db in the `data` folder. If this folder doesn't exist, create it.
|
||||
|
||||
mkdir data
|
||||
|
||||
|
||||
### Low-memory environments
|
||||
|
||||
On a low-memory machine, eg. a DigitalOcean 512MB instance, you will need to enable virtual memory. Use this guide:
|
||||
|
||||
[How To Configure Virtual Memory (Swap File) on a VPS](https://www.digitalocean.com/community/tutorials/how-to-configure-virtual-memory-swap-file-on-a-vps#2)
|
||||
|
||||
|
||||
### Start on boot
|
||||
|
||||
To make the application start on boot, run the following:
|
||||
|
||||
pm2 start grunt --name dubdiff -- serve:dist
|
||||
pm2 startup systemd
|
||||
pm2 save
|
||||
|
||||
[Digital Ocean: How To Set Up a Node.js Application for Production on Ubuntu 16.04](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-16-04)
|
33
TODO.md
Normal file
@ -0,0 +1,33 @@
|
||||
|
||||
- create production mode build and serve settings: `webpack.js` and `src/server/babel.index.js`
|
||||
|
||||
|
||||
## State changes
|
||||
|
||||
main page edits input documents
|
||||
compare page views compare documents
|
||||
|
||||
compare button:
|
||||
|
||||
- generate id
|
||||
- post input documents to server
|
||||
- input documents copied to compare documents
|
||||
- input documents cleared
|
||||
- go to compare route
|
||||
|
||||
edit button:
|
||||
|
||||
- compare documents copied to input documents
|
||||
- compare documents cleared
|
||||
- go to main route
|
||||
|
||||
client start:
|
||||
|
||||
- load input documents from localStore
|
||||
|
||||
server start:
|
||||
|
||||
- load compare documents from database
|
||||
|
||||
|
||||
* client actually never needs to query server for compare documents... huh!
|
BIN
dist/favicon-16x16.png
vendored
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
dist/favicon-32x32.png
vendored
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
dist/favicon-96x96.png
vendored
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
dist/favicon.ico
vendored
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
dist/img/03-small.jpg
vendored
Normal file
After Width: | Height: | Size: 66 KiB |
BIN
dist/img/03-tiny.jpg
vendored
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
dist/img/03-tinyer.jpg
vendored
Normal file
After Width: | Height: | Size: 33 KiB |
3
dist/main.css
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
#masthead .header {
|
||||
font-size: 4em;
|
||||
}
|
58
package.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "dubdiff-2",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "src/server/babel.index.js",
|
||||
"scripts": {
|
||||
"copy-css": "cpy --parents --cwd=./node_modules/semantic-ui-css semantic.min.css themes/default/assets/fonts/icons.woff2 ../../dist",
|
||||
"build": "npm run copy-css && webpack --progress --colors",
|
||||
"build:prod": "npm run copy-css && cross-env NODE_ENV=production webpack -p --progress --colors",
|
||||
"build:watch": "npm run copy-css && webpack --progress --colors --watch",
|
||||
"serve": "node src/server/babel.index.js",
|
||||
"serve:prod": "cross-env NODE_ENV=production node src/server/babel.index.js",
|
||||
"webpack-stats": "webpack --json > stats.json",
|
||||
"test": "mocha --watch --compilers js:babel-register"
|
||||
},
|
||||
"author": "",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"babel-preset-es2015-mod": "^6.6.0",
|
||||
"babel-preset-es3": "^1.0.1",
|
||||
"babel-preset-stage-2": "^6.18.0",
|
||||
"body-parser": "^1.15.2",
|
||||
"diff": "^3.0.1",
|
||||
"express": "^4.14.0",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"jsonfile": "^2.4.0",
|
||||
"markdown-it": "^5.1.0",
|
||||
"markdown-to-jsx": "^4.0.3",
|
||||
"react": "^0.14.5",
|
||||
"react-dom": "^0.14.5",
|
||||
"react-redux": "^4.4.6",
|
||||
"react-router": "~3.0.0",
|
||||
"redux": "^3.5.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
"request": "^2.79.0",
|
||||
"request-promise-native": "^1.0.3",
|
||||
"reselect": "^2.5.1",
|
||||
"semantic-ui-css": "^2.2.4",
|
||||
"semantic-ui-react": "^0.61.6",
|
||||
"uuid": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.18.2",
|
||||
"babel-loader": "^6.2.0",
|
||||
"babel-preset-es2015": "^6.3.13",
|
||||
"babel-preset-es2015-native-modules": "^6.9.4",
|
||||
"babel-preset-node6": "^11.0.0",
|
||||
"babel-preset-react": "^6.3.13",
|
||||
"babel-register": "^6.18.0",
|
||||
"chai": "^3.5.0",
|
||||
"copyfiles": "^0.2.2",
|
||||
"cross-env": "^3.1.3",
|
||||
"json-loader": "^0.5.4",
|
||||
"mocha": "^3.2.0",
|
||||
"piping": "^1.0.0-rc.4",
|
||||
"webpack": "^2.1.0-beta.27"
|
||||
}
|
||||
}
|
76
src/client/LocalStorage.js
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
|
||||
import * as Actions from '../common/actions'
|
||||
import * as Selectors from '../common/selectors'
|
||||
|
||||
|
||||
/* This component reads the local storage store and adds them to the Redux store.
|
||||
* Local storage is read during the componentDidMount lifecycle method.
|
||||
* Local storage is written during the componentWillReceiveProps lifecycle method.
|
||||
*/
|
||||
|
||||
//an app-specific name for the localStorage state
|
||||
const stateName = "dubdiff_state"
|
||||
|
||||
//return a new object with the given keys, each assigned to the cooresponding value
|
||||
//from the given object
|
||||
const copyKeys = (obj, keys) => keys.reduce((acc, p)=>{acc[p]=obj[p]; return acc}, {})
|
||||
|
||||
//utility method for retrieving json data from the local store
|
||||
function getLocalState (keys) {
|
||||
if (localStorage.getItem(stateName)) {
|
||||
const localState = JSON.parse(localStorage.getItem(stateName))
|
||||
return copyKeys(localState, keys)
|
||||
}
|
||||
else
|
||||
return copyKeys({}, keys)
|
||||
}
|
||||
|
||||
//utility method for writing json data to the local store
|
||||
function setLocalState (state, keys) {
|
||||
let toSave = copyKeys(state, keys)
|
||||
localStorage.setItem(stateName, JSON.stringify(toSave))
|
||||
}
|
||||
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
input: state.input,
|
||||
//the loading/empty/clean state
|
||||
})
|
||||
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChangeOriginal: (text) => dispatch(Actions.updateOriginalInput(text)),
|
||||
onChangeFinal: (text) => dispatch(Actions.updateFinalInput(text)),
|
||||
})
|
||||
|
||||
|
||||
class LocalStorage extends React.Component {
|
||||
|
||||
//load the state from the local storage
|
||||
componentDidMount() {
|
||||
//only if the status is EMPTY
|
||||
/*
|
||||
if (this.props.input.original=='' && this.props.input.final == '') {
|
||||
const localState = getLocalState(['input'])
|
||||
if (localState.input && localState.input.original)
|
||||
this.props.onChangeOriginal(localState.input.original)
|
||||
if (localState.input && localState.input.final)
|
||||
this.props.onChangeFinal(localState.input.final)
|
||||
}
|
||||
*/
|
||||
}
|
||||
//save the state to local storage
|
||||
componentWillReceiveProps(nextProps) {
|
||||
setLocalState(nextProps, ['input'])
|
||||
}
|
||||
|
||||
render () {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(LocalStorage)
|
||||
|
50
src/client/index.js
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
import * as Redux from 'redux'
|
||||
|
||||
import {Provider} from 'react-redux'
|
||||
|
||||
//import createBrowserHistory from 'history/lib/createBrowserHistory'
|
||||
import {Router, Route, IndexRoute, Redirect, browserHistory } from 'react-router'
|
||||
|
||||
import thunk from 'redux-thunk'
|
||||
|
||||
import * as reducers from '../common/reducers'
|
||||
import routes from '../common/routes'
|
||||
import * as Actions from '../common/actions'
|
||||
|
||||
import LocalStorage from './LocalStorage'
|
||||
|
||||
|
||||
|
||||
//initial state is rehydrated from the server
|
||||
const initialState = window.__INITIAL_STATE__
|
||||
|
||||
//create the redux store
|
||||
//initial state is retrieved from localStore
|
||||
const store = Redux.createStore(
|
||||
Redux.combineReducers(reducers),
|
||||
initialState,
|
||||
Redux.compose(
|
||||
Redux.applyMiddleware(thunk),
|
||||
window.devToolsExtension ? window.devToolsExtension() : f => f
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
function render() {
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<LocalStorage >
|
||||
<Router history={browserHistory}>
|
||||
{routes}
|
||||
</Router>
|
||||
</LocalStorage>
|
||||
</Provider>
|
||||
, document.getElementById('root'))
|
||||
}
|
||||
|
||||
render()
|
||||
|
155
src/common/actions.js
Normal file
@ -0,0 +1,155 @@
|
||||
import fetch from 'isomorphic-fetch'
|
||||
import uuid from 'uuid/v4'
|
||||
import {browserHistory} from 'react-router'
|
||||
import {Status, StatusError} from './constants'
|
||||
|
||||
//All state transitions in the app happen in these methods
|
||||
//this includes redux state changes, asyncronous data requests, and browser location changes
|
||||
|
||||
export const updateOriginalInput = (text) =>
|
||||
(dispatch, getState) => {
|
||||
dispatch({type: 'UPDATE_ORIGINAL_INPUT', data:text})
|
||||
if (getState().input.original.length>0)
|
||||
dispatch({type: 'STATUS_SET', data:Status.DIRTY})
|
||||
else
|
||||
dispatch({type: 'STATUS_SET', data:Status.EMPTY})
|
||||
}
|
||||
|
||||
export const updateFinalInput = (text) =>
|
||||
(dispatch, getState) => {
|
||||
dispatch({ type: 'UPDATE_FINAL_INPUT', data:text})
|
||||
if (getState().input.final.length>0)
|
||||
dispatch({type: 'STATUS_SET', data:Status.DIRTY})
|
||||
else
|
||||
dispatch({type: 'STATUS_SET', data:Status.EMPTY})
|
||||
}
|
||||
|
||||
export const clearInput = () =>
|
||||
(dispatch) => {
|
||||
dispatch({type: 'CLEAR_INPUT'})
|
||||
dispatch({type: 'STATUS_SET', data:Status.EMPTY})
|
||||
}
|
||||
|
||||
export const setPlaintextFormat = () => ({ type: 'SET_PLAINTEXT_FORMAT'})
|
||||
export const setMarkdownFormat = () => ({ type: 'SET_MARKDOWN_FORMAT'})
|
||||
export const showOriginal = () => ({ type: 'SHOW_ORIGINAL'})
|
||||
export const showFinal = () => ({ type: 'SHOW_FINAL'})
|
||||
export const showDifference = () => ({ type: 'SHOW_DIFFERENCE'})
|
||||
|
||||
|
||||
//if the input is dirty, saves it to the server
|
||||
//creates a new uuid for the same,
|
||||
//then changes the browser location to a comparison view with that id
|
||||
export const compare = () =>
|
||||
(dispatch, getState) => {
|
||||
//!!! could test that the input is dirty before triggering a save
|
||||
//if the input is empty, the compare should do nothing
|
||||
//if the input is clean, the compare should not save and keep using the same id
|
||||
|
||||
//start saving the input to the server
|
||||
const id = dispatch(save())
|
||||
|
||||
//we can use the id created by the save method to build a path
|
||||
const comparePath = `/${id}`
|
||||
browserHistory.replace(comparePath)
|
||||
}
|
||||
|
||||
|
||||
//clear the input and return to the edit page
|
||||
export const reset = () =>
|
||||
(dispatch, getState) => {
|
||||
dispatch(clearInput())
|
||||
browserHistory.push('/')
|
||||
}
|
||||
|
||||
|
||||
//switch to the edit view
|
||||
export const edit = () =>
|
||||
(dispatch, getState) => {
|
||||
browserHistory.push('/')
|
||||
}
|
||||
|
||||
|
||||
//saves the current input fields to the server
|
||||
//creates and returns a new id for the comparison
|
||||
//should this method ensure that the initial state is valid? ('DIRTY')
|
||||
export const save = () =>
|
||||
(dispatch, getState) => {
|
||||
|
||||
//generate an id
|
||||
const id = uuid()
|
||||
|
||||
//set waiting state
|
||||
dispatch( {type: 'STATUS_SET', data:Status.SAVING})
|
||||
|
||||
const endpointUri = `/api/compare/${id}`
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
a: getState().input.original,
|
||||
b: getState().input.final
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
//dispatch post request
|
||||
fetch(endpointUri, fetchOptions)
|
||||
.then(response => {
|
||||
if (response.ok)
|
||||
dispatch({type: 'STATUS_SET', data: Status.CLEAN})
|
||||
else {
|
||||
response.text().then( (responseText) => {
|
||||
const error = {message:`${response.status}: ${responseText}`}
|
||||
dispatch({type: 'STATUS_SET', data: Status.DIRTY})
|
||||
dispatch({type: 'STATUS_SET_ERROR', data: StatusError.SAVE_ERROR, error})
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
//!!! could use a better error message here
|
||||
dispatch({type: 'STATUS_SET', data: Status.DIRTY})
|
||||
dispatch({type: 'STATUS_SET_ERROR', data: StatusError.SAVE_ERROR, error})
|
||||
})
|
||||
|
||||
//return the id after the request has been sent
|
||||
return id;
|
||||
}
|
||||
|
||||
/*
|
||||
const load = (id) =>
|
||||
(dispatch, getState) => {
|
||||
|
||||
//set waiting state
|
||||
dispatch( {type: 'SAVE_STATUS_WAITING'})
|
||||
|
||||
const endpointUri = `/api/compare/${id}`
|
||||
const fetchOptions = {
|
||||
method: 'GET'
|
||||
}
|
||||
|
||||
|
||||
//dispatch post request
|
||||
fetch(endpointUri, fetchOptions)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
dispatch( {type: 'UPDATE_ORIGINAL_INPUT', data:json.a})
|
||||
dispatch( {type: 'UPDATE_FINAL_INPUT', data:json.b})
|
||||
dispatch( {type: 'LOAD_STATUS_LOADED'})
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch( {type: 'LOAD_STATUS_FAILED', error})
|
||||
})
|
||||
|
||||
//return the id after the request has been sent
|
||||
return id;
|
||||
|
||||
}
|
||||
|
||||
export const loadIfNeeded = (id) =>
|
||||
(dispatch, getState) => {
|
||||
if
|
||||
}
|
||||
*/
|
104
src/common/components/Compare.js
Normal file
@ -0,0 +1,104 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
import {Segment, Grid, Form} from 'semantic-ui-react'
|
||||
|
||||
import * as Actions from '../actions'
|
||||
import * as Selectors from '../selectors'
|
||||
|
||||
import Header from './Header'
|
||||
import Footer from './Footer'
|
||||
import CompareControls from './CompareControls'
|
||||
|
||||
import ShowPlaintext from './ShowPlaintext'
|
||||
import ShowMarkdown from './ShowMarkdown'
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
isMarkdownFormat: Selectors.isMarkdownFormat(state),
|
||||
isShowOriginal: Selectors.isShowOriginal(state),
|
||||
isShowFinal: Selectors.isShowFinal(state),
|
||||
isShowDifference: Selectors.isShowDifference(state),
|
||||
safeInput: Selectors.safeInput(state),
|
||||
diff: Selectors.diff(state)
|
||||
})
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
//loadIfNeeded: (id) => dispatch(Actions.loadIfNeeded())
|
||||
})
|
||||
|
||||
|
||||
|
||||
class Compare extends React.Component {
|
||||
/*
|
||||
componentDidMount() {
|
||||
this.props.loadIfNeeded(this.props.routeParams.compareId)
|
||||
}
|
||||
*/
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Header/>
|
||||
|
||||
<Segment basic padded>
|
||||
<Grid stackable columns={2}>
|
||||
<Grid.Column width="3">
|
||||
<CompareControls/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width="13">
|
||||
<Segment>
|
||||
{
|
||||
(!this.props.isMarkdownFormat && this.props.isShowDifference) ?
|
||||
<ShowPlaintext diff={this.props.diff}>{this.props.diff}</ShowPlaintext>:
|
||||
(this.props.isMarkdownFormat && this.props.isShowDifference) ?
|
||||
<ShowMarkdown diff={this.props.diff}>{this.props.diff}</ShowMarkdown>:
|
||||
(!this.props.isMarkdownFormat && !this.props.isShowDifference) ?
|
||||
<ShowPlaintext
|
||||
text={this.props.isShowOriginal? this.props.safeInput.original: this.props.safeInput.final}
|
||||
/> :
|
||||
(this.props.isMarkdownFormat && !this.props.isShowDifference) ?
|
||||
<ShowMarkdown
|
||||
text={this.props.isShowOriginal? this.props.safeInput.original: this.props.safeInput.final}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</Segment>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Segment>
|
||||
|
||||
<Footer/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Compare)
|
||||
|
||||
|
||||
/* <div ng-if="isMarkdownFormat">
|
||||
<div ng-show="isShowBefore" class="col-md-10 col-sm-12 content-well">
|
||||
<div btf-markdown="before" class="before">
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="isShowWdiff" class="col-md-10 col-sm-12 content-well">
|
||||
<div btf-markdown="wdiff" class="wdiff">
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="isShowAfter" class="col-md-10 col-sm-12 content-well">
|
||||
<div btf-markdown="after" class="after">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="!isMarkdownFormat">
|
||||
<div ng-show="isShowBefore" class="col-md-10 col-sm-12 content-well">
|
||||
<div ng-bind-html="before" class="content-pre before"></div>
|
||||
</div>
|
||||
<div ng-show="isShowWdiff" class="col-md-10 col-sm-12 content-well">
|
||||
<div ng-bind-html="wdiff" class="content-pre wdiff"></div>
|
||||
</div>
|
||||
<div ng-show="isShowAfter" class="col-md-10 col-sm-12 content-well">
|
||||
<div ng-bind-html="after" class="content-pre after"></div>
|
||||
</div>
|
||||
</div>
|
||||
*/
|
62
src/common/components/CompareControls.js
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {Link} from 'react-router'
|
||||
|
||||
import {Button, Icon, Segment} from 'semantic-ui-react'
|
||||
|
||||
import * as Actions from '../actions'
|
||||
import * as Selectors from '../selectors'
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
isMarkdownFormat: Selectors.isMarkdownFormat(state),
|
||||
isShowOriginal: Selectors.isShowOriginal(state),
|
||||
isShowFinal: Selectors.isShowFinal(state),
|
||||
isShowDifference: Selectors.isShowDifference(state),
|
||||
})
|
||||
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSetPlaintextFormat: () => dispatch(Actions.setPlaintextFormat()),
|
||||
onSetMarkdownFormat: () => dispatch(Actions.setMarkdownFormat()),
|
||||
onShowOriginal: () => dispatch(Actions.showOriginal()),
|
||||
onShowFinal: () => dispatch(Actions.showFinal()),
|
||||
onShowDifference: () => dispatch(Actions.showDifference()),
|
||||
onEdit: () => dispatch(Actions.edit())
|
||||
})
|
||||
|
||||
class CompareControls extends React.Component {
|
||||
|
||||
onClickMarkdownFormat() {
|
||||
if (this.props.isMarkdownFormat)
|
||||
this.props.onSetPlaintextFormat()
|
||||
else
|
||||
this.props.onSetMarkdownFormat()
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Segment.Group>
|
||||
<Segment>
|
||||
<Button fluid onClick={this.props.onEdit}>Edit</Button>
|
||||
</Segment>
|
||||
|
||||
<Segment >
|
||||
<Button fluid onClick={this.props.onShowOriginal} active={this.props.isShowOriginal}>Original</Button>
|
||||
<Button fluid onClick={this.props.onShowFinal} active={this.props.isShowFinal}>Final</Button>
|
||||
<Button fluid onClick={this.props.onShowDifference} active={this.props.isShowDifference}>Difference</Button>
|
||||
</Segment>
|
||||
|
||||
<Segment >
|
||||
<Button fluid active={this.props.isMarkdownFormat} type="submit" onClick={this.onClickMarkdownFormat.bind(this)}>
|
||||
{this.props.isMarkdownFormat ? <Icon name="checkmark"/> : <span/>}
|
||||
As Markdown
|
||||
</Button>
|
||||
</Segment>
|
||||
</Segment.Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CompareControls)
|
||||
|
11
src/common/components/Footer.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
import {Segment} from 'semantic-ui-react'
|
||||
|
||||
const Footer = (props) => (
|
||||
<Segment basic padded textAlign="center" as="footer">
|
||||
<p><a href="http://adamarthurryan.com">Adam Brown</a> | This website is <a href="https://github.com/adamarthurryan/dubdiff">open source</a>.</p>
|
||||
</Segment>
|
||||
)
|
||||
|
||||
export default Footer
|
36
src/common/components/Header.js
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
import {Segment, Header, Rail, Container} from 'semantic-ui-react'
|
||||
import {Link} from 'react-router'
|
||||
|
||||
import * as Actions from '../actions'
|
||||
import SaveStatus from './SaveStatus'
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
})
|
||||
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onReset: () => { console.log(Actions.reset()); dispatch(Actions.reset())},
|
||||
})
|
||||
|
||||
const SiteHeader = (props) => (
|
||||
|
||||
|
||||
<Segment basic >
|
||||
|
||||
<Segment basic padded textAlign="center" as="header" id='masthead'>
|
||||
<Header><Link onClick={props.onReset}>dubdiff</Link></Header>
|
||||
</Segment>
|
||||
|
||||
<Rail internal position="right">
|
||||
<Segment basic padded>
|
||||
<SaveStatus/>
|
||||
</Segment>
|
||||
</Rail>
|
||||
|
||||
</Segment>
|
||||
)
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SiteHeader)
|
72
src/common/components/Main.js
Normal file
@ -0,0 +1,72 @@
|
||||
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
import {Segment, Grid, Form} from 'semantic-ui-react'
|
||||
|
||||
import * as Actions from '../actions'
|
||||
import * as Selectors from '../selectors'
|
||||
|
||||
import Header from './Header'
|
||||
import Footer from './Footer'
|
||||
import MainControls from './MainControls'
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
input: state.input,
|
||||
safeInput: Selectors.safeInput(state),
|
||||
})
|
||||
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChangeOriginal: (text) => dispatch(Actions.updateOriginalInput(text)),
|
||||
onChangeFinal: (text) => dispatch(Actions.updateFinalInput(text)),
|
||||
})
|
||||
|
||||
|
||||
class Main extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
<Header/>
|
||||
|
||||
<Segment basic padded>
|
||||
<Grid stackable columns={3}>
|
||||
<Grid.Column width="3">
|
||||
<MainControls/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width="6">
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Original</label>
|
||||
<textarea value={this.props.input.original} onChange={event => this.props.onChangeOriginal(event.target.value)}></textarea>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Grid.Column>
|
||||
<Grid.Column width="6">
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Final</label>
|
||||
<textarea value={this.props.input.final} onChange={event => this.props.onChangeFinal(event.target.value)}></textarea>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Segment>
|
||||
|
||||
|
||||
<Footer/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Main)
|
||||
|
55
src/common/components/MainControls.js
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
import {Button, Icon, Segment} from 'semantic-ui-react'
|
||||
|
||||
import * as Actions from '../actions'
|
||||
import * as Selectors from '../selectors'
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
format: state.format,
|
||||
isMarkdownFormat: Selectors.isMarkdownFormat(state),
|
||||
saveStatus: state.saveStatus
|
||||
})
|
||||
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSetPlaintextFormat: (format) => dispatch(Actions.setPlaintextFormat()),
|
||||
onSetMarkdownFormat: (format) => dispatch(Actions.setMarkdownFormat()),
|
||||
|
||||
//returns an id for the record to be saved
|
||||
onCompare: () => dispatch(Actions.compare())
|
||||
})
|
||||
|
||||
class MainControls extends React.Component {
|
||||
|
||||
onClickMarkdownFormat() {
|
||||
if (this.props.isMarkdownFormat)
|
||||
this.props.onSetPlaintextFormat()
|
||||
else
|
||||
this.props.onSetMarkdownFormat()
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Segment.Group>
|
||||
<Segment >
|
||||
<Button fluid onClick={this.props.onCompare}>Compare</Button>
|
||||
</Segment>
|
||||
|
||||
<Segment >
|
||||
<Button fluid active={this.props.isMarkdownFormat} type="submit" onClick={this.onClickMarkdownFormat.bind(this)}>
|
||||
{this.props.isMarkdownFormat ? <Icon name="checkmark"/> : <span/>}
|
||||
As Markdown
|
||||
</Button>
|
||||
</Segment>
|
||||
</Segment.Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(MainControls)
|
||||
|
||||
/*
|
||||
<a type="button" onClick={this.onClickCompare.bind(this)} className="btn btn-block btn-primary">compare</a>*/
|
64
src/common/components/SaveStatus.js
Normal file
@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
import { Message, Icon, Button} from 'semantic-ui-react'
|
||||
import { browserHistory} from 'react-router'
|
||||
|
||||
import * as Actions from '../actions'
|
||||
import {Status, StatusError} from '../constants'
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
status: state.status
|
||||
})
|
||||
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSave: () => dispatch(Actions.save()),
|
||||
onReset: () => dispatch(Actions.reset())
|
||||
})
|
||||
|
||||
const SaveStatus = (props) => {
|
||||
if (props.status.type == Status.SAVING) return (
|
||||
<Message size='tiny' floating compact icon>
|
||||
<Icon name='circle notched' loading />
|
||||
<Message.Content>
|
||||
<Message.Header>Saving diff</Message.Header>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
if (props.status.type == Status.LOADING) return (
|
||||
<Message size='tiny' floating compact icon>
|
||||
<Icon name='circle notched' loading />
|
||||
<Message.Content>
|
||||
<Message.Header>Loading diff</Message.Header>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
else if (props.status.hasError && props.status.errorType == StatusError.SAVE_ERROR) return (
|
||||
<Message size='tiny' floating compact icon>
|
||||
<Icon name='exclamation' />
|
||||
<Message.Content>
|
||||
<Message.Header>Error saving diff</Message.Header>
|
||||
{props.status.error.message}
|
||||
<br/>
|
||||
<Button onClick={props.onSave}>Retry</Button>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
else if (props.status.hasError && props.status.errorType == StatusError.LOAD_ERROR) return (
|
||||
<Message size='tiny' floating compact icon>
|
||||
<Icon name='exclamation' />
|
||||
<Message.Content>
|
||||
<Message.Header>Error loading diff</Message.Header>
|
||||
{props.status.error.message}
|
||||
<br/>
|
||||
<Button onClick={props.onReset}>New Diff</Button>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
|
||||
else return ( <div></div> )
|
||||
}
|
||||
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SaveStatus)
|
21
src/common/components/ShowMarkdown.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import markdownCompiler from 'markdown-to-jsx'
|
||||
|
||||
import {diffToString, diffToHtml} from '../util/dubdiff'
|
||||
|
||||
const ShowMarkdown = (props) => {
|
||||
|
||||
return <div>
|
||||
{
|
||||
props.text ?
|
||||
markdownCompiler(props.text) :
|
||||
props.diff ?
|
||||
markdownCompiler(diffToHtml(props.diff)) :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ShowMarkdown
|
||||
|
25
src/common/components/ShowPlaintext.js
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
|
||||
const ShowPlaintext = (props) => {
|
||||
return <div>
|
||||
<pre style={{whiteSpace:'pre-wrap'}}>
|
||||
{props.text ?
|
||||
props.text:
|
||||
props.diff ?
|
||||
diffToPre(props.diff) :
|
||||
null
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ShowPlaintext
|
||||
|
||||
function diffToPre(diff) {
|
||||
return diff.map((part, index) => (
|
||||
part.added ? <ins key={index}>{part.value}</ins> :
|
||||
part.removed ? <del key={index}>{part.value}</del> :
|
||||
<span key={index}>{part.value}</span>
|
||||
))
|
||||
}
|
24
src/common/constants.js
Normal file
@ -0,0 +1,24 @@
|
||||
export const Format = {
|
||||
PLAINTEXT: 'PLAINTEXT',
|
||||
MARKDOWN: 'MARKDOWN'
|
||||
}
|
||||
|
||||
export const Show = {
|
||||
ORIGINAL:'ORIGINAL',
|
||||
FINAL:'FINAL',
|
||||
DIFFERENCE:'DIFFERENCE'
|
||||
}
|
||||
|
||||
export const Status = {
|
||||
INIT: 'INIT',
|
||||
LOADING: 'LOADING',
|
||||
EMPTY: 'EMPTY',
|
||||
CLEAN: 'CLEAN',
|
||||
DIRTY: 'DIRTY',
|
||||
SAVING: 'SAVING'
|
||||
}
|
||||
|
||||
export const StatusError = {
|
||||
LOAD_ERROR: 'LOAD_ERROR',
|
||||
SAVE_ERROR: 'SAVE_ERROR'
|
||||
}
|
75
src/common/reducers.js
Normal file
@ -0,0 +1,75 @@
|
||||
import {Format, Show, Status, StatusError} from './constants'
|
||||
|
||||
|
||||
export function input (state, action ) {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_ORIGINAL_INPUT':
|
||||
return Object.assign({}, state, {original:action.data})
|
||||
case 'UPDATE_FINAL_INPUT':
|
||||
return Object.assign({}, state, {final:action.data})
|
||||
case 'CLEAR_INPUT':
|
||||
return {original:'', final:''}
|
||||
default:
|
||||
return state || {original:'', final:''}
|
||||
}
|
||||
}
|
||||
|
||||
export function format (state, action) {
|
||||
switch (action.type) {
|
||||
case 'SET_PLAINTEXT_FORMAT':
|
||||
return Format.PLAINTEXT
|
||||
case 'SET_MARKDOWN_FORMAT':
|
||||
return Format.MARKDOWN
|
||||
default:
|
||||
return state || Format.PLAINTEXT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function show (state, action) {
|
||||
switch (action.type) {
|
||||
case 'SHOW_ORIGINAL':
|
||||
return Show.ORIGINAL
|
||||
case 'SHOW_FINAL':
|
||||
return Show.FINAL
|
||||
case 'SHOW_DIFFERENCE':
|
||||
return Show.DIFFERENCE
|
||||
default:
|
||||
return state || Show.DIFFERENCE
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
export function saveStatus (state, action) {
|
||||
switch (action.type) {
|
||||
case 'SAVE_STATUS_DIRTY':
|
||||
return {dirty: true}
|
||||
case 'SAVE_STATUS_EMPTY':
|
||||
return {dirty: false, empty: true}
|
||||
case 'SAVE_STATUS_SAVED':
|
||||
return {dirty: false, saved: true}
|
||||
case 'SAVE_STATUS_FAILED' :
|
||||
return Object.assign({}, state, {waiting: false, failed: true, error: action.error})
|
||||
case 'SAVE_STATUS_WAITING' :
|
||||
return Object.assign({}, state, {waiting: true, failed: false, error: null})
|
||||
default:
|
||||
return state || {empty: true, dirty:false}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
//tracks status of the app, especially with respect to loaded and saved user data
|
||||
export function status (state, action) {
|
||||
//the status or error type is valid if it is in the list of Status or StatusError types
|
||||
const isValidStatus = (type) => Status[type] == type
|
||||
const isValidError = (type) => StatusError[type] == type
|
||||
|
||||
//the error is cleared when status changes
|
||||
if (action.type == 'STATUS_SET' && isValidStatus(action.data))
|
||||
return {type:action.data, error: null, hasError: false, errorType: null}
|
||||
//the error is set in addition to the status
|
||||
else if (action.type == 'STATUS_SET_ERROR' && isValidError(action.data))
|
||||
return Object.assign({}, state, {error: action.error, hasError: true, errorType:action.data})
|
||||
else
|
||||
return state || {type:Status.EMPTY, hasError: false, error:null}
|
||||
}
|
13
src/common/routes.js
Normal file
@ -0,0 +1,13 @@
|
||||
import {Route, IndexRout, Redirect } from 'react-router'
|
||||
import React from 'react';
|
||||
|
||||
import Main from './components/Main'
|
||||
import Compare from './components/Compare'
|
||||
|
||||
var routes = [
|
||||
<Route key="root" path="/" component={Main}/>,
|
||||
<Route key="compare" path="/:compareId" component={Compare}/>
|
||||
|
||||
]
|
||||
|
||||
export default routes
|
68
src/common/selectors.js
Normal file
@ -0,0 +1,68 @@
|
||||
//per http://redux.js.org/docs/recipes/ComputingDerivedData.html
|
||||
|
||||
import { createSelector } from 'reselect'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
|
||||
import {Format, Show} from './constants'
|
||||
|
||||
import * as Dubdiff from './util/dubdiff'
|
||||
|
||||
|
||||
const input = (state) => state.input
|
||||
const format = (state) => state.format
|
||||
const show = (state) => state.show
|
||||
|
||||
export const safeInput = createSelector(
|
||||
[input],
|
||||
(input) => {
|
||||
//!!! sanitize the input here and return
|
||||
return input
|
||||
}
|
||||
)
|
||||
|
||||
export const isMarkdownFormat = createSelector(
|
||||
[format],
|
||||
(format) => {
|
||||
return format == Format.MARKDOWN
|
||||
}
|
||||
)
|
||||
|
||||
const isShow = (type) => createSelector(
|
||||
[show],
|
||||
(show) => {
|
||||
return show == type
|
||||
}
|
||||
)
|
||||
|
||||
export const isShowOriginal = isShow(Show.ORIGINAL)
|
||||
export const isShowFinal = isShow(Show.FINAL)
|
||||
export const isShowDifference= isShow(Show.DIFFERENCE)
|
||||
|
||||
|
||||
export const diff = createSelector(
|
||||
[format, safeInput],
|
||||
(format, safeInput) => {
|
||||
return Dubdiff.plaintextDiff(safeInput.original, safeInput.final)
|
||||
/*
|
||||
let diff = JsDiff.diffWords (input.original.replace(/ /g, ' '), input.final.replace(/ /g, ' '))
|
||||
return diff.map(({added, removed, value})=>({added, removed, value:value.replace(/ /g, ' ')})).map(part => (
|
||||
part.added ? <ins>{part.value}</ins> :
|
||||
part.removed ? <del>{part.value}</del> :
|
||||
<span>{part.value}</span>
|
||||
))
|
||||
*/
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
/*
|
||||
html diff
|
||||
---
|
||||
|
||||
diffHtml(parentOriginal, parentFinal) {
|
||||
create stringOriginal, stringFinal consisting of
|
||||
}
|
||||
*/
|
63
src/common/util/EditorsDiff.js
Normal file
@ -0,0 +1,63 @@
|
||||
|
||||
import {Diff} from 'diff'
|
||||
|
||||
// EditorsDiff is a custom Diff implementation from the jsdiff library
|
||||
// It allows diffing by phrases. Whitespace is ignored for the purpose of comparison,
|
||||
// but is preserved and included in the output.
|
||||
|
||||
const TOKEN_BOUNDARYS = /([\s,.:])/
|
||||
|
||||
class EditorsDiff extends Diff {
|
||||
constructor (tokenBoundaries=TOKEN_BOUNDARYS) {
|
||||
super()
|
||||
this.tokenBoundaries = tokenBoundaries
|
||||
}
|
||||
|
||||
equals (left, right) {
|
||||
return (
|
||||
left.string == right.string
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
//splits the input string into a series of word and punctuation tokens
|
||||
//each token is associated with an optional trailing array of spaces
|
||||
tokenize (value) {
|
||||
let tokens = value.split(this.tokenBoundaries)
|
||||
let annotatedTokens = []
|
||||
tokens.forEach( token => {
|
||||
if (isSpace(token)) {
|
||||
if (annotatedTokens.length == 0)
|
||||
annotatedTokens.push({string:'', whitespace:[]})
|
||||
|
||||
let last = annotatedTokens[annotatedTokens.length-1]
|
||||
last.whitespace.push(token)
|
||||
}
|
||||
else {
|
||||
annotatedTokens.push({string:token, whitespace:[]})
|
||||
}
|
||||
})
|
||||
|
||||
//this final empty token is necessary for the jsdiff diffing engine to work properly
|
||||
annotatedTokens.push({string:'', whitespace:[]})
|
||||
return annotatedTokens
|
||||
}
|
||||
join(annotatedTokens) {
|
||||
let tokens = []
|
||||
annotatedTokens.forEach(annotatedToken => {
|
||||
tokens.push(annotatedToken.string)
|
||||
annotatedToken.whitespace.forEach(item => {
|
||||
tokens.push(item)
|
||||
})
|
||||
})
|
||||
return tokens.join('')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export default EditorsDiff
|
||||
|
||||
const isSpace = str => /[ ]+/.test(str)
|
227
src/common/util/dubdiff.js
Normal file
@ -0,0 +1,227 @@
|
||||
import * as JsDiff from 'diff'
|
||||
import EditorsDiff from './EditorsDiff'
|
||||
|
||||
let plaintextDiffer = new EditorsDiff()
|
||||
let markdownDiffer = new EditorsDiff(/([\s,.:]|[*\[\]\(\)])/)
|
||||
|
||||
//returns a comparison of the texts as plaintext
|
||||
export function plaintextDiff(original, final) {
|
||||
let diff = plaintextDiffer.diff(original, final)
|
||||
return diff
|
||||
}
|
||||
|
||||
//returns a comparison of the texts as markdown
|
||||
export function markdownDiff(original, final) {
|
||||
let diff = markdownDiffer.diff(original, final)
|
||||
diff = rewriteMarkdownDiff(diff)
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
// returns a string version of the diff, with "{+ ... +}" and "[- ... -]"
|
||||
// representing ins and del blocks
|
||||
export function diffToString(diff, tags={added:{start:'{+', end:'+}'}, removed:{start:'[-', end:'-]'}, same:{start:'', end:''}}) {
|
||||
|
||||
return diff.map(({added, removed, value}) => {
|
||||
|
||||
let {start,end} = added ? tags.added : (removed ? tags.removed : tags.same)
|
||||
|
||||
let string = value
|
||||
if (Array.isArray(value))
|
||||
string = value.join('')
|
||||
|
||||
return start+string+end
|
||||
}).join('')
|
||||
}
|
||||
|
||||
export function diffToHtml(diff) {
|
||||
return diffToString(diff, {added:{start:'<ins>', end:'</ins>'}, removed:{start:'<del>', end:'</del>'}, same:{start:'', end:''}})
|
||||
}
|
||||
|
||||
|
||||
// Rewrites the given diff to correctly render as markdown, assuming the source
|
||||
// documents were also valid markdown.
|
||||
// In essence, moves the markdown formatting elements in or out of the inserted and deleted blocks, as appropriate
|
||||
|
||||
//rules:
|
||||
// 1. if a multiline del block is followed by an ins block,
|
||||
// the first line of the ins block should be inserted at the end of the first line of the del block
|
||||
// so the markdown will apply to the ins text as it should
|
||||
// 2. after a newline, if an ins or del block begins with a markdown line formatting prefix (eg. for a title or list)
|
||||
// then that prefix should be moved out of the block
|
||||
|
||||
//not yet implemented rules:
|
||||
// 3. if an ins or del block spans one half of a bold, italic or link string
|
||||
// eg. **Hello <del>World** I</del><ins>Darling** she</ins> said
|
||||
// the block should be broken up to move the formatting code outside
|
||||
// OR the whole formatting string could be brought into the block
|
||||
// eg. **Hello <del>World</del><ins>Darling</ins>** <ins>I</ins><del>she</del> said
|
||||
function rewriteMarkdownDiff(diff) {
|
||||
//apply transformation rules
|
||||
let transformedDiff = diff
|
||||
transformedDiff= applyTransformationRule1(transformedDiff)
|
||||
transformedDiff= applyTransformationRule2(transformedDiff)
|
||||
return transformedDiff
|
||||
}
|
||||
|
||||
//Transformation rule 1
|
||||
// 1. if a multiline del block is followed by an ins block,
|
||||
// the first line of the ins block should be inserted at the end of the first line of the del block
|
||||
// so the markdown will apply to the ins text as it should
|
||||
function applyTransformationRule1(diff) {
|
||||
let transformedDiff = []
|
||||
|
||||
const B_ADDED='added', B_REMOVED='removed', B_SAME='same'
|
||||
let previousBlockType = null
|
||||
let currentBlockType = null
|
||||
let previousBlockWasMultiline = false
|
||||
let currentBlockIsMultiline = false
|
||||
|
||||
//iterate the input tokens to create the intermediate representation
|
||||
diff.forEach((currentBlock) => {
|
||||
|
||||
previousBlockType = currentBlockType
|
||||
previousBlockWasMultiline = currentBlockIsMultiline
|
||||
currentBlockType = (currentBlock.added ? B_ADDED : (currentBlock.removed ? B_REMOVED : B_SAME))
|
||||
currentBlockIsMultiline = isMultilineDiffBlock(currentBlock)
|
||||
|
||||
//transform rule 1 applys when:
|
||||
// the previous block was a del and had multiple lines
|
||||
// the current block is an ins
|
||||
if (previousBlockType == B_REMOVED && currentBlockType == B_ADDED && previousBlockWasMultiline) {
|
||||
|
||||
//split the first line from the current block
|
||||
let currentBlockSplit = splitMultilineDiffBlock(currentBlock)
|
||||
|
||||
//pop the previous diff entry
|
||||
let previousBlock = transformedDiff.pop()
|
||||
|
||||
//split the first line from the previous block
|
||||
let previousBlockSplit = splitMultilineDiffBlock(previousBlock)
|
||||
|
||||
|
||||
//now add the blocks back, interleaving del and ins blocks
|
||||
for (let i=0; i<Math.max(previousBlockSplit.length, currentBlockSplit.length); i++) {
|
||||
if (i<previousBlockSplit.length)
|
||||
transformedDiff.push(previousBlockSplit[i])
|
||||
if (i<currentBlockSplit.length)
|
||||
transformedDiff.push(currentBlockSplit[i])
|
||||
}
|
||||
}
|
||||
else {
|
||||
//otherwise, we just add the current block to the transformed list
|
||||
transformedDiff.push(currentBlock)
|
||||
}
|
||||
})
|
||||
|
||||
return transformedDiff
|
||||
}
|
||||
|
||||
|
||||
// matches markdown prefixes that affect the formatting of the whole subsequent line
|
||||
// ^ - start of line
|
||||
// ([ \t]*\>)* - blockquotes (possibly nested)
|
||||
// (
|
||||
// ([ \t]*#*) - headers
|
||||
// |([ \t]+[\*\+-]) - unordered lists
|
||||
// |([ \t]+[0-9]+\.) - numeric lists
|
||||
// )?
|
||||
// [ \t]* - trailing whitespace
|
||||
const MARKDOWN_PREFIX = /^([ \t]*\>)*(([ \t]*#*)|([ \t]*[\*\+-])|([ \t]*[\d]+\.))?[ \t]*/
|
||||
|
||||
//matches strings that end with a newline followed by some whitespace
|
||||
const NEWLINE_SUFFIX = /\n\s*$/
|
||||
|
||||
// transformation rule 2:
|
||||
// after a newline, if an ins or del block begins with a markdown line formatting prefix (eg. for a title or list)
|
||||
// then that prefix should be moved out of the block
|
||||
// also, if an ins block begins with a formatting prefix and follows immediately after a del block that follows a newline,
|
||||
// the prefix should be moved out of the block _and_ an extra newline character should be added to the beginning of it
|
||||
function applyTransformationRule2(diff) {
|
||||
let transformedDiff = []
|
||||
|
||||
let isNewline = true
|
||||
let newlineString = '\n'
|
||||
|
||||
//iterate the input tokens to create the intermediate representation
|
||||
diff.forEach((currentBlock) => {
|
||||
|
||||
if (isNewline && (currentBlock.added || currentBlock.removed) ) {
|
||||
let match = currentBlock.value.match(MARKDOWN_PREFIX)
|
||||
if (match) {
|
||||
let preBlock = {value:match[0]}
|
||||
let postBlock = {added:currentBlock.added, removed:currentBlock.removed, value:currentBlock.value.substring(match[0].length)}
|
||||
|
||||
if (currentBlock.added) {
|
||||
let newlineBlock = {value: newlineString}
|
||||
transformedDiff.push(newlineBlock)
|
||||
}
|
||||
transformedDiff.push(preBlock)
|
||||
transformedDiff.push(postBlock)
|
||||
}
|
||||
else {
|
||||
transformedDiff.push(currentBlock)
|
||||
}
|
||||
}
|
||||
else {
|
||||
transformedDiff.push(currentBlock)
|
||||
isNewline = NEWLINE_SUFFIX.test(currentBlock.value)
|
||||
if (isNewline)
|
||||
newlineString = currentBlock.value.match(NEWLINE_SUFFIX)[0]
|
||||
}
|
||||
})
|
||||
|
||||
return transformedDiff
|
||||
}
|
||||
|
||||
|
||||
|
||||
//returns true if the given diff block contains a newline element
|
||||
function isMultilineDiffBlock({value}) {
|
||||
return value.indexOf('\n') != -1
|
||||
}
|
||||
|
||||
|
||||
//returns an array of diff blocks that have the same added, removed fields as the given one
|
||||
//but with the string split by newlines
|
||||
//if the diff block has no newlines, an array containing only that diff will be returned
|
||||
//if the diff block has newlines, the resulting array will have a series of blocks,
|
||||
// each of which subsequent to the first block will begin with a newline
|
||||
//if the diff block begins with a newline, the returned array will begin with an empty diff
|
||||
function splitMultilineDiffBlock({added, removed, value}) {
|
||||
//find the indices of the diff block that coorespond to newlines
|
||||
const splits = indicesOf(value, c => (c=='\n') )
|
||||
|
||||
splits.push(value.length)
|
||||
|
||||
//create a range from each index
|
||||
const ranges = splits.reduce(
|
||||
//the accumulator is a structure with the last index and the list of ranges
|
||||
//the ranges are a {start, end} structure
|
||||
({last, ranges}, i) => {
|
||||
ranges = ranges.concat([{start:last, end:i}])
|
||||
return {last:i, ranges}
|
||||
},
|
||||
//start with the zero index and an empty array
|
||||
{last: 0, ranges:[]}
|
||||
).ranges
|
||||
|
||||
|
||||
//map the ranges into blocks
|
||||
const blocks = ranges.map(
|
||||
//each block is the same as the given original block, but with the values split at newlines
|
||||
({start, end}) => ({added, removed, value:value.substring(start, end)})
|
||||
)
|
||||
|
||||
//console.log({value, splits, ranges, blocks})
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
//collect all the indices of the given string that satisfy the test function
|
||||
const indicesOf = (string, test) => string.split('').reduce(
|
||||
//add indexes that satisfy the test function to the array
|
||||
(acc, x, i) => (test(x) ? acc.concat([i]) : acc ),
|
||||
//start with the empty array
|
||||
[]
|
||||
)
|
30
src/server/babel.index.js
Normal file
@ -0,0 +1,30 @@
|
||||
var Path = require('path');
|
||||
|
||||
var srcRoot = Path.join(__dirname, '..')
|
||||
|
||||
//there should be some option for distribution / optimization?
|
||||
var config = {
|
||||
presets: ["node6", "react"],
|
||||
//enable source maps for non-production instances
|
||||
sourceMaps: (process.env.NODE_ENV !== "production" ? "both" : false),
|
||||
//highlightCode: false,
|
||||
sourceRoot: srcRoot,
|
||||
only: /src/
|
||||
|
||||
};
|
||||
|
||||
require('babel-core/register')(config);
|
||||
|
||||
// Enable piping for non-production environments
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (!require("piping")({hook: true, includeModules: false})) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
require('./index.js');
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error.stack);
|
||||
}
|
92
src/server/comparison.js
Normal file
@ -0,0 +1,92 @@
|
||||
import express from 'express'
|
||||
import jf from 'jsonfile'
|
||||
import fs from 'fs'
|
||||
import uuid from 'uuid'
|
||||
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/:id', showComparison)
|
||||
router.post('/:id', createComparisonWithId)
|
||||
router.post('/', createComparison)
|
||||
|
||||
//return the comparison given an id, if it exsits
|
||||
function showComparison(req, res) {
|
||||
const id = req.params.id
|
||||
return readRecord(res, id)
|
||||
}
|
||||
|
||||
// Creates a new comparison
|
||||
function createComparison(req, res) {
|
||||
//generate a new id
|
||||
const id = uuid()
|
||||
const {a,b} = req.body
|
||||
|
||||
return writeRecord(res, id, {a,b,id})
|
||||
}
|
||||
|
||||
// Creates a new comparison
|
||||
function createComparisonWithId(req, res) {
|
||||
//use the id provided in the req
|
||||
const id = req.params.id
|
||||
const {a, b} = req.body
|
||||
|
||||
return writeRecord(res, id, {a, b, id})
|
||||
}
|
||||
|
||||
//reads the record from the database
|
||||
function readRecord(res, id, data) {
|
||||
//generate a filename
|
||||
const filename = fnData(id)
|
||||
|
||||
//check if that file exists
|
||||
fs.exists(filename, function (exists) {
|
||||
//if the file does not exist, return a 404
|
||||
if (!exists) return res.status(404).send(`Data id ${id} not found.`)
|
||||
|
||||
//otherwise, read the file as JSON
|
||||
jf.readFile(filename, function(err, data) {
|
||||
if(err) { return handleError(res, err) }
|
||||
|
||||
//and return
|
||||
return res.json(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
//writes the record to the database, if it doesn't exist
|
||||
function writeRecord(res, id, data) {
|
||||
|
||||
//look up its filename
|
||||
var filename = fnData(id)
|
||||
|
||||
//need to test that the file does not exist
|
||||
|
||||
//check if that file exists
|
||||
fs.exists(filename, (exists) => {
|
||||
//if the file already exists, return a 405
|
||||
if (exists) return res.status(405).send(`Data id ${id} is already in use.`)
|
||||
|
||||
|
||||
//and write it to the filesystem
|
||||
jf.writeFile(filename, data, (err) => (
|
||||
err ?
|
||||
handleError(res, err) :
|
||||
//if successful, return the comparison object
|
||||
res.status(201).json(data)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = router
|
||||
|
||||
function handleError(res, err) {
|
||||
console.log(err)
|
||||
return res.send(500, err)
|
||||
}
|
||||
|
||||
|
||||
// returns a filename for the given comparison
|
||||
function fnData (id) {
|
||||
return `./data/${id}.json`
|
||||
}
|
90
src/server/index.js
Normal file
@ -0,0 +1,90 @@
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import bodyParser from 'body-parser'
|
||||
import * as Redux from 'redux'
|
||||
|
||||
import fetch from 'isomorphic-fetch'
|
||||
|
||||
import comparisonRouter from './comparison'
|
||||
|
||||
|
||||
import * as reducers from '../common/reducers'
|
||||
|
||||
import {Status, StatusError} from '../common/constants'
|
||||
|
||||
import render from './render'
|
||||
|
||||
const PORT = 8080
|
||||
|
||||
const app = express()
|
||||
|
||||
//serve the dist static files at /dist
|
||||
app.use('/dist', express.static(path.join(__dirname, '..', '..', 'dist')))
|
||||
|
||||
//serve the comparison api at /api/compare
|
||||
app.use(bodyParser.json())
|
||||
app.use('/api/compare', comparisonRouter);
|
||||
|
||||
|
||||
|
||||
//the following routes are for server-side rendering of the app
|
||||
//we should render the comparison directly from the server
|
||||
//this loading logic could be moved into ../common/actions because it is isomorphic
|
||||
app.route('/:comparisonId')
|
||||
.get((req, res) => {
|
||||
|
||||
|
||||
const store = createSessionStore()
|
||||
const endpointUri = `http://localhost:${PORT}/api/compare/${req.params.comparisonId}`
|
||||
|
||||
//fetch the comparison
|
||||
fetch(endpointUri)
|
||||
.then(response => {
|
||||
if (response.ok)
|
||||
return response.json()
|
||||
else {
|
||||
response.text().then( () => {
|
||||
const error = {message:`${response.status}: ${response.statusText}`}
|
||||
initAndRenderError(error, store, req, res)
|
||||
})
|
||||
}
|
||||
})
|
||||
.then( ({a,b}) => {
|
||||
initAndRenderComparison({a,b}, store, req, res)
|
||||
})
|
||||
.catch( error => {
|
||||
initAndRenderError(error, store, req, res)
|
||||
})
|
||||
|
||||
})
|
||||
app.route('/')
|
||||
.get((req, res) => {
|
||||
render(createSessionStore(), req, res)
|
||||
})
|
||||
|
||||
|
||||
app.listen(PORT, function () {
|
||||
console.log('Server listening on port 8080.')
|
||||
})
|
||||
|
||||
|
||||
//creates the session store
|
||||
function createSessionStore() {
|
||||
//create the redux store
|
||||
return Redux.createStore(
|
||||
Redux.combineReducers(reducers)
|
||||
)
|
||||
}
|
||||
|
||||
function initAndRenderComparison({a,b}, store, req, res) {
|
||||
store.dispatch({type: 'UPDATE_ORIGINAL_INPUT', data: a})
|
||||
store.dispatch({type: 'UPDATE_FINAL_INPUT', data: b})
|
||||
store.dispatch({type: 'STATUS_SET', data: Status.CLEAN})
|
||||
render(store, req, res)
|
||||
}
|
||||
|
||||
function initAndRenderError(error, store, req, res) {
|
||||
store.dispatch({type: 'STATUS_SET', data: Status.EMPTY})
|
||||
store.dispatch({type: 'STATUS_SET_ERROR', data: StatusError.LOAD_ERROR, error})
|
||||
render(store, req, res)
|
||||
}
|
89
src/server/render.js
Normal file
@ -0,0 +1,89 @@
|
||||
import React from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { Provider } from 'react-redux'
|
||||
import { match, RouterContext } from 'react-router'
|
||||
|
||||
import routes from '../common/routes.js'
|
||||
|
||||
|
||||
export default function render(store, req, res) {
|
||||
// Send the rendered page back to the client
|
||||
match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
|
||||
if (error) {
|
||||
res.status(500).send(renderError('Routing Error:', error.message))
|
||||
} else if (redirectLocation) {
|
||||
res.redirect(302, redirectLocation.pathname + redirectLocation.search)
|
||||
} else if (renderProps) {
|
||||
// Render the component to a string
|
||||
try {
|
||||
const html = renderToString(
|
||||
<Provider store={store}>
|
||||
<RouterContext {...renderProps} />
|
||||
</Provider>
|
||||
)
|
||||
|
||||
// Grab the initial state from our Redux store
|
||||
const initialState = store.getState()
|
||||
// and send
|
||||
res.status(200).send(appTemplate(html, initialState))
|
||||
}
|
||||
catch(ex) {
|
||||
console.log("Render Exception:",ex)
|
||||
res.status(500).send(errorTemplate('Render Exception', ex.message, ex))
|
||||
}
|
||||
|
||||
} else {
|
||||
res.status(404).send(errorTemplate('Not found', `${req.url} not found.`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pageTemplate = (body) => {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Dubdiff</title>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="dist/semantic.min.css"/>
|
||||
<link rel="stylesheet" href="dist/main.css"/>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="dist/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="dist/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="dist/favicon-16x16.png">
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
function errorTemplate(title, message, exception) {
|
||||
return pageTemplate(`
|
||||
<h1>${title}</h1>
|
||||
<p>${message}</p>
|
||||
${exception ?
|
||||
`<pre>${exception.toString()}</pre>`:
|
||||
``
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function appTemplate(html, initialState) {
|
||||
return pageTemplate(`
|
||||
<div id="root">${html}</div>
|
||||
|
||||
<script>
|
||||
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
|
||||
</script>
|
||||
<!-- <script>__REACT_DEVTOOLS_GLOBAL_HOOK__ = parent.__REACT_DEVTOOLS_GLOBAL_HOOK__</script> -->
|
||||
<script type="text/javascript" src="dist/browser-bundle.js"></script>
|
||||
`)
|
||||
}
|
74
test/dubdiffMarkdown.js
Normal file
@ -0,0 +1,74 @@
|
||||
/*eslint-env node, mocha */
|
||||
/*global expect */
|
||||
/*eslint no-console: 0*/
|
||||
'use strict';
|
||||
|
||||
import chai from 'chai'
|
||||
|
||||
import {markdownDiff, diffToString} from '../src/common/util/dubdiff'
|
||||
|
||||
let diff = (a,b) => diffToString(markdownDiff(a,b))
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
describe('dubdiff', () => {
|
||||
let db;
|
||||
|
||||
beforeEach(() => {
|
||||
});
|
||||
|
||||
it('plaintext diffs consecutive words', ()=>{
|
||||
expect(diff(
|
||||
'This is a smlb sentnce with no errors.',
|
||||
'This is a simple sentence with no errors.'
|
||||
)).to.equal('This is a [-smlb sentnce -]{+simple sentence +}with no errors.')
|
||||
})
|
||||
|
||||
it('plaintext diffs with word deletion', ()=>{
|
||||
expect(diff(
|
||||
'Gonna delete a word.',
|
||||
'Gonna delete word.'
|
||||
)).to.equal('Gonna delete [-a -]word.')
|
||||
})
|
||||
|
||||
it('plaintext diffs with word insertion', ()=>{
|
||||
expect(diff(
|
||||
'Gonna delete word.',
|
||||
'Gonna delete a word.'
|
||||
)).to.equal('Gonna delete {+a +}word.')
|
||||
})
|
||||
|
||||
it('reorganizes insertions after multiline deletions', ()=>{
|
||||
expect(diff(
|
||||
`# Title
|
||||
other`,
|
||||
`# Subtitle`
|
||||
)).to.equal('# [-Title-]{+Subtitle+}[-\nother-]')
|
||||
})
|
||||
|
||||
it('pulls prefixes out of ins or del blocks after newline', () => {
|
||||
expect(diff(
|
||||
'# Title\n > hello',
|
||||
'# Title\n - goodbye'
|
||||
)).to.equal('# Title\n > [-hello-]\n - {+goodbye+}')
|
||||
})
|
||||
|
||||
it('respects bold and italic boundaries', () => {
|
||||
expect(diff(
|
||||
'This *word* **isn\'t** changed.',
|
||||
'This *other one* **is** changed.'
|
||||
)).to.equal('This *[-word-]{+other one+}* **[-isn\'t-]{+is+}** changed.')
|
||||
})
|
||||
it('respects link boundaries in link text', () => {
|
||||
expect(diff(
|
||||
'This [link](https://somewhere.com) is the same.',
|
||||
'This [target](https://somewhere.com) changed.'
|
||||
)).to.equal('This [[-link-]{+target+}](https://somewhere.com) [-is the same-]{+changed+}.')
|
||||
})
|
||||
it('respects link boundaries in link target', () => {
|
||||
expect(diff(
|
||||
'This [link](https://somewhere.com) is the same.',
|
||||
'This [link](https://somewhere.org) changed.'
|
||||
)).to.equal('This [link](https://somewhere.[-com-]{+org+}) [-is the same-]{+changed+}.')
|
||||
})
|
||||
})
|
69
test/dubdiffPlaintext.js
Normal file
@ -0,0 +1,69 @@
|
||||
/*eslint-env node, mocha */
|
||||
/*global expect */
|
||||
/*eslint no-console: 0*/
|
||||
'use strict';
|
||||
|
||||
import chai from 'chai'
|
||||
|
||||
import {plaintextDiff, diffToString} from '../src/common/util/dubdiff'
|
||||
|
||||
let diff = (a,b) => diffToString(plaintextDiff(a,b))
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
describe('dubdiff', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
});
|
||||
|
||||
it('diffs single words', ()=>{
|
||||
expect(diff(
|
||||
'This is a smlb sentence.',
|
||||
'This is a simple sentence.'
|
||||
)).to.equal('This is a [-smlb -]{+simple +}sentence.')
|
||||
})
|
||||
|
||||
it('diffs consecutive words', ()=>{
|
||||
expect(diff(
|
||||
'This is a smlb sentnce with no errors.',
|
||||
'This is a simple sentence with no errors.'
|
||||
)).to.equal('This is a [-smlb sentnce -]{+simple sentence +}with no errors.')
|
||||
})
|
||||
|
||||
it('diffs with word deletion', ()=>{
|
||||
expect(diff(
|
||||
'Gonna delete a word.',
|
||||
'Gonna delete word.'
|
||||
)).to.equal('Gonna delete [-a -]word.')
|
||||
})
|
||||
it('diffs with word insertion', ()=>{
|
||||
expect(diff(
|
||||
'Gonna add word.',
|
||||
'Gonna add a word.'
|
||||
)).to.equal('Gonna add {+a +}word.')
|
||||
})
|
||||
it('diffs accross newline without weird spaces', () => {
|
||||
expect(diff(
|
||||
'This is a flawed\ncomment',
|
||||
'This is a corrected\nitem'
|
||||
)).to.equal('This is a [-flawed-]{+corrected+}\n[-comment-]{+item+}')
|
||||
})
|
||||
it('doesn\'t add spaces after newline', () => {
|
||||
expect(diff(
|
||||
'\nhere',
|
||||
'\nhere'
|
||||
)).to.equal('\nhere')
|
||||
})
|
||||
it('doesn\'t add spaces before newline', () => {
|
||||
expect(diff(
|
||||
'there\n',
|
||||
'there\n'
|
||||
)).to.equal('there\n')
|
||||
})
|
||||
it('treats punctuation separately', () => {
|
||||
expect(diff(
|
||||
'Hello world.',
|
||||
'Hello, world.'
|
||||
)).to.equal('Hello{+, +}world.')
|
||||
})
|
||||
})
|
36
webpack.config.js
Normal file
@ -0,0 +1,36 @@
|
||||
let config = {
|
||||
cache: true,
|
||||
entry: './src/client/index.js',
|
||||
output: {
|
||||
filename: './dist/browser-bundle.js'
|
||||
},
|
||||
target: 'web',
|
||||
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
presets: ['es2015-native-modules', 'react'],
|
||||
compact: "true"
|
||||
}
|
||||
},
|
||||
{ test: /\.json$/, loader: "json-loader" },
|
||||
]
|
||||
},
|
||||
node: {
|
||||
fs: 'empty',
|
||||
net: 'empty',
|
||||
tls: 'empty'
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV == "production") {
|
||||
config.devtool = "cheap-module-source-map"
|
||||
}
|
||||
else {
|
||||
config.devtool = "eval"
|
||||
}
|
||||
|
||||
module.exports = config;
|