@mojule/tree-factory
Version:
Takes an adapter/plugins and generates a consistent API over arbitrary tree-like data
791 lines (542 loc) • 18.9 kB
Markdown
# tree-factory
Generates a tree API over whatever tree-like backing data you may have.
If you don't have a specific tree structure that you'd like to use and just
want to stick data into a tree and get the benefits of tree-factory's common
API without having to write adapters, plugins etc. consider using the
[tree](https://github.com/mojule/tree) package instead
The API returned by the factory supports the following methods, documented more
extensively later in this document:
```javascript
const api = {
// adapter
isNode, isValue, createRawNode, getChildren, getValue, setValue, remove, add,
// miscellaneous
get, getRoot, map, clone,
// traversal
ancestors, childAt, closest, descendants, firstChild, getParent, lastChild,
nextSibling, previousSibling, siblings, walk, walkUp,
// query
accepts, atPath, contains, find, findAll, getMeta, getPath, hasChild,
hasChildren, index, isEmpty, meta, nodeType, setMeta, slug,
// manipulation
append, empty, insertAfter, insertAt, insertBefore, prepend, prune,
removeAt, replaceChild, unwrap, value, wrap
}
```
## Usage
`npm install @mojule/tree-factory`
```javascript
const TreeFactory = require( '@mojule/tree-factory' )
// see below for information on how adapters work and how to create them
const adapter = require( './path/to/your/adapter' )
const plugins = require( './path/to/your/plugins' )
const MyTree = TreeFactory( adapter, plugins )
const root = MyTree( 'Root' )
const child = MyTree( 'Child' )
root.add( child )
```
## About
It takes an `adapter`, which tells the factory how to use your backing data as a
tree, then adds a bunch of commonly used functions over the top, for doing
things like querying the data, traversing the data and manipulating the data,
then any custom functions you may want to provide. Your custom functions can
also extend or override the default behaviours. The ability to map from one type
of tree to another is also provided.
The common functions are modelled on the DOM/jQuery, but they are suitable for
working with any tree-like structure, not just DOM elements - for example, all
of the following can be treated as trees:
- Various markup languages, HTML, XML etc.
- Concrete implementations of HTML, eg DOM and virtual DOM nodes
- Objects without circular references, eg JSON-compatible objects
- Natural language texts
- The directory structure of a file system
- Code (as parse trees, ASTs etc)
- Decision trees, like some flowcharts
## Basic terminology
Nodes in our trees have two important properties - their `value`, and their
`children`.
The `value` is any information that you need to know about a node.
The `children` is a list of nodes that descend from the current node.
We call the underlying representation of a node in your backing data a
`raw node`
We use `node` to refer to a object that wraps your raw data in useful functions,
such as getting or setting the node's value, adding new children etc.
### value
For very simple tree data, a string may suffice as the value, as in this
[biology tree example](http://interactivepython.org/runestone/static/pythonds/Trees/ExamplesofTrees.html)
A more complex value, like for HTML nodes, might be an object something like
this:
```javascript
const value = {
nodeName: 'div',
nodeType: 'element',
attributes: {
id: 'myDiv'
}
}
```
### children
Regardless of how the children are actually stored in your raw data, the API
expects to always work with an array. This is covered further in the Adapter
section below.
### raw node
A `raw node` will vary according to your backing structure - let's use the case
described above of a biology tree, with the value as a string
A raw node described as an object might look like this:
```javascript
{
value: 'Animalia',
children: [
{
value: 'Chordate',
children: [] // omitted for brevity
},
{
value: 'Arthropoda',
children: [] // omitted for brevity
}
]
}
```
A raw node described as an array might look like this:
```javascript
[
'Animalia',
[
'Chordate'
],
[
'Arthropoda'
]
]
```
A raw node in text format might look like this:
```
Animalia
Chordate
Arthropoda
```
### node
Finally, a `node` as created by the API would be used like this:
```javascript
const animalia = Tree( 'Animalia' )
const chordate = Tree( 'Chordate' )
const arthropoda = Tree( 'Arthropoda' )
animalia.add( chordate )
animalia.add( arthropoda )
// Animalia
console.log( animalia.getValue() )
/*
Chordate
Arthropoda
*/
animalia.getChildren().forEach( child => console.log( child.getValue() ) )
```
## Adapters
An adapter is a function that takes the node API and the current node's state
(the raw node), and returns a bag of functions, which is an object where each
key is the name of the function and the property is the function itself:
```javascript
const adapter = ( api, state ) => {
// implement functions here
return {
$isNode, $isValue, $createRawNode, getChildren, getValue, setValue, remove, add
}
}
```
For examples of adapters, please see the [test fixtures](/test/fixtures),
which include raw tree data for the biology example represented as both arrays
and objects, and the adapters for working with either format
The $prefix means that the function is static and will be available on your
tree API, not just node instances.
If you want a better understanding of why this pattern is used (it is used by
tree-factory for both adapters and plugins), please read the documentation for
[api-factory](https://github.com/mojule/api-factory), on which tree-factory is
built.
### Adapter functions
Adapters must implement the following functions:
`$isNode, $isValue, $createRawNode, getChildren, getValue, setValue, remove, add`
The signatures for these are as follows, using similar syntax to typescript or
rtype.
`RawNode` refers to the raw underlying node data
```
$isNode( rawNode:Any ) => isNode:Boolean
$isValue( value:Any ) => isValue:Boolean
$createRawNode( value:Any ) => rawNode:RawNode
getChildren() => childNodes:[RawNode]
getValue() => value:Any
setValue( value:Any ) => value:Any
remove( rawChild:RawNode ) => removedChild:RawNode
add( rawChild:RawNode, reference?:RawNode ) => addedChild:RawNode
```
### Notes
`$isNode`, `$isValue` and `$createRawNode` are treated as static functions, that
is, they shouldn't depend on the state passed to your adapter
`add` called with a single argument should add the new child to the end of the
child node list, but if called with a second `reference` argument, should
insert the new child before the `reference` node.
### Adapter wrapper
Once you provide your adapter, it will be wrapped in such a way that other
functions in the API that work with it can pass it wrapped API nodes and get
the same back - this abstracts away the underlying raw implementation to make
implementing the various functions much safer and easier.
It also wraps `remove` to allow calling remove on a node with no child argument,
which instead removes the node from it's parent's children - it will throw if
you try and do this with the root node, as it doesn't have a parent.
## Common functions
These categories are a bit arbitrary - while some functions clearly fit into one
or the other, others were placed where they are by gut feel alone.
```javascript
const common = {
// miscellaneous
get, getRoot, map, clone,
// traversal
ancestors, childAt, closest, descendants, firstChild, getParent, hasAncestor,
lastChild, nextSibling, previousSibling, siblings, walk, walkUp,
// query
accepts, atPath, contains, find, findAll, getMeta, getPath, hasChild,
hasChildren, index, isEmpty, meta, nodeType, setMeta, slug,
// manipulation
append, empty, insertAfter, insertAt, insertBefore, prepend, prune,
removeAt, replaceChild, unwrap, value, wrap
}
```
### Miscellaneous
#### get
Gets the underlying raw node
```javascript
const rawNode = node.get()
```
#### getRoot
Gets the node at the root of the tree
```javascript
const root = node.getRoot()
```
#### map
Maps the tree from one type of tree to another - takes an optional value mapper
if the value expected by the target tree differs from that of the source tree
```javascript
const Tree1 = require( './path/to/implementation' )
const Tree2 = require( './path/to/implementation' )
const tree1RawData = require( './path/to/data.json' )
// our first imaginary tree's value type is just a string, our second imaginary
// tree expects an object with the string in a 'text' property
const mapper = value => ({ text: value })
const tree1Root = Tree1( tree1RawData )
const tree2Root = tree1Root.map( Tree2, mapper )
```
#### clone
Creates a true clone of the node and its children - true in the sense that the
underlying value of the cloned node will not === the original node, changes to
either will not affect the other etc.
Only use if your value is JSON serializable! Anything else will throw, as it
works behind the scenes by roundtripping `JSON.stringify`/`JSON.parse`
```javascript
const cloned = node.clone()
```
### Traversal
#### ancestors
Returns an array of the node's parent, the parent's parent etc. all the way to
the root. If called on the root node, returns an empty array
```javascript
const ancestors = node.ancestors()
```
#### childAt
Returns the child at the specified index
```javascript
const second = node.childAt( 2 )
```
#### closest
Finds the closest ancestor matching the predicate, or `undefined` if no node is
found
```javascript
const divAncestor = node.closest( n => n.nodeName() === 'div' )
```
#### descendants
Returns an array of all descendant nodes, or an empty array if the node has no
children
```javascript
const descendants = node.descendants()
```
#### firstChild
Gets the first child of the node, or `undefined` if the node has no children
```javascript
const first = node.firstChild()
```
#### getParent
Gets the parent of the current node, or `undefined` if the node is the root
```javascript
const parent = node.getParent()
```
#### hasAncestor
Returns `true` if the given node is an ancestor of the current node, or `false`
if not
```javascript
const has = node.hasAncestor( otherNode )
```
#### lastChild
Gets the last child of the node, or `undefined` if the node has no children
```javascript
const last = node.lastChild()
```
#### nextSibling
Gets the node in the parent's children after the current node, or `undefined`
if the current node is the last node
```javascript
const next = node.nextSibling()
```
#### previousSibling
Gets the node in the parent's children before the current node, or `undefined`
if the current node is the last node
```javascript
const previous = node.previousSibling()
```
#### siblings
Gets all of the parent's children excluding the current node
```javascript
const siblings = node.siblings()
```
#### walk
Does a depth first traversal from the current node, calling the supplied
callback for each node including the start node. If your callback returns a
truthy value at any point the walk will be terminated.
The callback is passed the current node, its parent, and the current depth in
the tree (where the root is 0)
```javascript
node.walk( ( current, parent, depth ) => {
console.log( current.getValue() )
return depth > 5
})
```
#### walkUp
Walks up the tree from the current node, then to its parent, then the parent's
parent etc until it reaches the root, or until your callback returns a truthy
value
```javascript
node.walkUp( current => {
const value = current.getValue()
console.log( value )
return value.nodeName === 'body'
})
```
### Query
#### accepts
Returns a boolean indicating whether the current node will accept the given
node as a child
Also used by the wrapper around `add` to throw an error if an unacceptable child
is added
The default implementation just returns the result of `isEmpty`, which in the
default implementation always returns false
This is provided as a stub that you can override with a plugin, using knowledge
about your specific tree, eg text nodes in the DOM can't accept children,
UL nodes can only accept LI nodes etc.
```javascript
if( !node.accepts( child ) ){
console.log( 'Drat' )
}
```
#### atPath
Returns the node in the tree at the specified path. The path is parsed by
splitting on the provided separator if provided, or the default separator of '/'
if not provided, then walking down from the root and finding the child with a
matching `slug` (see slug below)
```javascript
const target1 = root.atPath( '/0/4/1/1/0' )
const target2 = root.atPath( '.0.4.1.1.0', '.' )
```
#### contains
Returns a boolean indicating whether the node or any of its descendants matches
the predicate
```javascript
const hasText = node.contains( n => n.nodeType() === 'text' )
```
#### find
Starting from and including the current node and walking down its descendants,
find the first node that matches the predicate
```javascript
const text = node.find( n => n.nodeType() === 'text' )
```
#### findAll
Starting from and including the current node and walking down its descendants,
find all nodes that matches the predicate and return them in an array
```javascript
const textNodes = node.findAll( n => n.nodeType() === 'text' )
```
#### getMeta
Get previously stored metadata about a node - see `setMeta`. Metadata is
persisted for the lifetime of the node API, but not stored anywhere in the raw
data. Useful for things like displaying a tree in the browser where the user can
expand or collapse nodes visually.
```javascript
if( node.getMeta( 'collapsed' ) ){
console.log( '' )
}
```
#### getPath
Get a string representing the path to the current node - the path is generated
by walking down to the node from the root and joining each node's `slug` with
the passed in seperator, or '/' if no seperator is defined
```javascript
const path1 = node.getPath()
const path2 = node.getPath( '.' )
```
#### hasChild
Returns a boolean indicating if the current node has a child matching the
predicate
```javascript
if( node.hasChild( n => n.getValue() === 'Dang' ) ){
console.log( 'Drat' )
}
```
#### hasChildren
Returns a boolean indicating whether the current node has any children, eg the
length of its children is > 0
Not the same as whether or not a node *can* have children - see `isEmpty`
```javascript
if( !node.hasChildren() ){
console.log( 'So sad' )
}
```
#### index
Returns the index of the node within its parent's child list
```javascript
const index = node.index()
```
#### isEmpty
Returns a boolean indicating whether a node is not allowed to have children.
The default implementation always returns false, all nodes can have children.
Intended to be overridden by implementations that have leaf-only nodes, like a
text node in HTML for example.
Named to align with the definition of empty nodes in HTML
```javascript
if( node.isEmpty() ){
console.log( 'Too bad' )
}
```
#### meta
Convenience function over getMeta and setMeta that does several things depending
on the shape of its arguments.
Get all metadata associated with this node - note that this is the same object
used behind the scenes, so manipulating the returned value will affect the
underlying metadata
```javascript
const meta = node.meta()
```
Set all metadata associated with this node - node that this uses Object.assign
under the hood, so any properties omitted in the object passed to meta will
retain their current values. Returns all metadata associated with the node.
```javascript
const newMeta = node.meta( { collapsed: true } )
```
Get the metadata for a named property, same as `getMeta`:
```javascript
const isCollapsed = node.meta( 'collapsed' )
```
Set the metadata for a named property, same as `setMeta`:
```javascript
node.meta( 'collapsed', true )
```
#### nodeType
Returns a string representing the type of the node. The default implementation
always returns 'node', intended as a stub that can be overridden by
implementations
```javascript
console.log( node.nodeType() )
```
#### setMeta
Sets some metadata for a node - see `getMeta` for more information on metadata.
```javascript
node.setMeta( 'collapsed', true )
```
#### slug
Returns a string that identifies a node uniquely amongst its siblings. By
default it returns the node's index within its parent, or if called on the root,
and empty string.
```javascript
const slug = node.slug()
```
### Manipulation
#### append
An alias for `add`, but won't accept a reference argument. Adds the child to the
end of the node's child list.
```javascript
node.append( child )
```
#### empty
Removes all of a node's children
```javascript
node.empty()
```
#### insertAfter
Insert the child after the reference node in the node's children
```javascript
node.insertAfter( child, reference )
```
#### insertAt
Inserts the child at the specified index within the node's children
```javascript
node.insertAt( child, 1 )
```
#### insertBefore
An alias for `add`. Inserts the child into the node's children before the
reference node.
```javascript
node.insertBefore( child, reference )
```
#### prepend
Adds the child to the beginning of the node's child list
```javascript
node.prepend( child )
```
#### prune
Searches downwards from and including the current node for all nodes that match
the predicate, and removes them from the tree
```javascript
node.prune( n => n.getValue() === 'delet this' )
```
#### removeAt
Removes the child at the specified index
```javascript
node.removeAt( 4 )
```
#### replaceChild
Replaces the old child with a new one, at the same position in the child list
```javascript
node.replaceChild( child, old )
```
#### unwrap
Removes the current node, while moving all of its children to the position in
the parent's child list previously occupied by the current node
```javascript
node.unwrap()
```
#### value
Convenience method over `getValue` and `setValue`. If called with no arguments,
the same as `getValue`. If called with an argument, the same as `setValue`
```javascript
const value = node.value()
value.text = 'Hi'
node.value( value )
```
#### wrap
Adds the current node to the wrapper node, then replaces the current node in
its parent's child list with the wrapper node
```javascript
node.wrap( wrapper )
```
## Tree
The function returned by the tree-factory.
Takes either a `raw node` or a `value`, and returns a node API, with the wrapped
node set as the root of the tree.
If you add one of these wrapped nodes to another tree, they'll be demoted from
root nodes to ordinary nodes.
```javascript
const root = Tree( 'Root' )
const child = Tree( 'Child' )
root.add( child )
```
Also has the static methods defined in the adapter attached:
```javascript
console.log( Tree.isValue( 'Root' ) )
console.log( Tree.isNode( [ 'Root' ] ) )
```