Beside the Xtext framework which has made language engineering easy as a known tool for developing Domain Specific Languages (DSL) in the Java/Eclipse ecosystem, there is the web platform and the desire to practice DSL use cases within the JavaScript client frameworks. In this article, I am going to demonstrate how to use ReactJS to write a dedicated web editor for designing and editing a DSL. This part 1 will have follow-up parts.
ReactJS is a JavaScript library written by Facebook for rendering UI components in the browser in a way that makes big amount of data re-rendered consistently with respect to user interactions or in general state mutations. In Xtext web to name one, the client is used as tool to view and interact with the back-end that does all the jobs and send responses back via Rest/Soap web-services or web-sockets.
Differently, I will implement the DSL features here as a fully client-side Single Page Application (SPA) with React.
In SPAs, many JS libraries unlike ReactJS have been using the pattern Model View Controller (MVC) for defining the three layers. For a use case like a web editor for models with high density of domain vocabularies mostly rendered as text, the MVC is an over-engineering path to go for me. ReactJS made that much more simple. It defines three aspects with which I will build the DSL editor:
UI is defined as a tree of components usually with a stateful root component. The global state is the data to be rendered as well as the UI data. It is to be managed by the root component and passed down to the child ones. The state can be transformed from one snapshot to another by calling a reducer function. Every performed action (e.g. user-click) on the browser calls a suitable reducer function and once the global-state is mutated, ReactJS re-renders affected UI elements automatically and consistently.
You define what a component does look like:
ReactDOM.render( <h1>Hello, world!</h1>, document.body )
const data = [['Hello', ',', 'world', '!'],['A', 'new', 'line']] function Line(props) { return <div>{props.words.map(w=><span>{w}{' '}</span>)}</div> } function App(props) { const lines=props.data.map(l=>Line words={l}/>) return <div>{lines}</div>
}
ReactDOM.render(<App data={data}/>, document.getElementById('root'))
The result:
Let's make the input domain specific. For example a Bookshop DSL.
const data = [ {rule: 'Book', name: 'Clean Code', author: 'Robert Cecil Martin'}, {rule: 'Book', name: 'Learning React', author: 'Alex Banks, Eve Porcello'}, ]
And a nice dedicated web viewer with some CSS would result this:
But if we use our React App function above so far to achieve that, we would have to re-write the input into:
const data = [ [{classes:'keyword', word:'book'}, {classes:'word', word:'Clean Code'}, {classes:'keyword', word:'by'}, {classes:'word', word:'Robert Cecil Martin'}], [{classes:'keyword', word:'book'}, {classes:'word', word:'Learning React'}, {classes:'keyword', word:'by'}, {classes:'word', word:'Alex Banks, Eve Porcello'}]]
function Line(props) { return <div> {props.words.map(w=><span className={w.classes}>{w}{' '}</span>)}
</div> } //CSS .keyword { color: #D2691E; font-weight: bold; }
Yes of course. The duplicated contents can be extracted in a separate data structure. So, the final renderable input to the App function would be constructed from both the pure data and the schema or grammar data.
const grammar = [ {name: 'Book', identifiable: true, contents: [ {keyword: 'book', id: 'name'}, {keyword: 'by', input: 'author'}]} // transform into renderable data structure const lines = prepareContents(grammar, initialData, this.onRefClick)
onRefClick
) which means, a user click on a cross-reference to highlight the referenced model element.identifiable: true
and {keyword: 'book', id: 'name'}
. With that I am telling: Likewise I will extend the Book grammar rule to enable adding cross-references among books. {keyword: 'recommends', ref: {id: 'recommends', type: 'Book'}}
.
So, I extended the Book rule so that the user can declare book recommendations. Syntactically, by writing the keyword 'recommends' then a name of another declared book. For simplicity, I will assume that a book can recommend 0 or 1 book and it may recommend itself.
You can give a simple guess what a sample data would look like now.
{rule: 'Book', name: 'AngularJS in Action', author: 'Brian Ford & Lukas Ruebbelke', recommends: 'Learning React'} }
So, in the data, the book „AngularJS in Action“ recommends „Learning React“. The editor would render our above data like this:
Plenty of UI features can be gained by just updating the state of the app. The root component will pass the updated state down to its child components and React will re-render accordingly. Simply enough, to make the referenced book highlighted, all I do is to implement the above onRefClick function in the root component such that, the referenced state element is searched and once found, it gets a CSS class which in turn causes the line to be highlighted in the browser.
onRefClick(e, text, rule) { e.stopPropagation() const newLines = this.state.lines.map(line=> { if (line['rule'] !== rule) { return line } const idWord = line.words.find(w=> { return w['type'] == 'id' }) if (idWord['text'].toLowerCase() == text.toLowerCase()) { line['classes'] = this.addClasses(line, 'selected') return line } return line }) this.setState({lines: newLines}) }
Once this.setState({lines: newLines})
is called meaning a new state is given to the root component, everything else will happen by React based on how we designed our components. The programming effort went into the function prepareContents(grammar, data, onRefClick)
.