@nrkn/treescript
v0.3.0
Published
Hyperscript-like creation of generic trees
Downloads
3
Readme
treescript
Hyperscript-like creation of generic trees in typescript
npm install @nrkn/treescript
import { t, serialize } from '@nrkn/treescript'
const carnivora = t(
'Carnivora',
t('Caniformia',
t('Canids',
t('Dogs'), t('Wolves'), t('Foxes')
),
t('Ursids',
t('Brown Bears'), t('Polar Bears'), t('Black Bears'), t('Pandas')
),
t('Mustelids',
t('Weasels'), t('Otters'), t('Badgers')
)
),
t('Feliformia',
t('Felids',
t('Domestic Cats'), t('Lions'), t('Tigers'), t('Leopards')
),
t('Hyenas',
t('Spotted Hyenas'), t('Striped Hyenas'), t('Brown Hyenas')
),
t('Mongooses',
t('Meerkats'), t('Banded Mongooses')
)
)
)
/*
don't do this, it will throw, string trees expect nothing but nodes after the
initial value
t( 'a', 'b' )
*/
const ursids = carnivora.find( n => n.value === 'Ursids' )
const pandaNote = { value: 'herbivore', note: 'We know this one' }
for( const bear of ursids.children ){
console.log( 'adding todo metadata to', bear.value )
// don't have to be strings - they were just clear and concise above
const additionalBearData = t(
{ type: 'metadata' },
t( { key: 'weight', value: 0, note: 'Todo - update weight' } ),
t( { key: 'height', value: 0, note: 'Todo - update height' } ),
t(
{ key: 'diet', value: '', note: '' },
// if it's an object tree, we can pass partial objects as well as nodes
bear.value === 'Pandas' ? pandaNote : { note: 'Todo - update diet' }
// in typescript, the type is inferred from the initial value, so all
// potential properties must be present for partials to work. javascript
// doesn't care, but you'll get a runtime error if you try mix eg
// strings and objects within the same node
)
)
bear.append( additionalBearData )
}
console.log( serialize( ursids ) )
Contents
Usage
We have three levels of abstraction:
- High - treescript - hyperscript like creation of trees
- Mid - wnode - creation of nodes, similar to the DOM, backing treescript
- Low - stree - a symbol-tree clone, backing for wnode
The default exports from the module are:
import {
t, ne, tOf, T, wnode, wdoc, wnodeExtra, stree, serialize, deserialize, isWnode
} from '@nrkn/treescript'
t
- primary hyperscript-like factory for creating typedwnode
instances (in typescript - see notes below about javascript) - the default exportedt
(andne
andtOf
) createswnode
instances that are pre-decorated with extra functions, see belowne
- factory for creatingwnode
instances with anany
type, for compositiontOf
- factory for creatingt
factories bound to an initial value returned by acreate
function - useful for concisely creating nodes of different typesT
- a factory for creating at
instance with a customcreateNode
function, eg one that has or does not have decoratorswnode
- a factory function for creatingwnode
instances - the default export is decorated with extra functionswdoc
- a factory function for creating awnode
create node factory, with no extra decoration, or custom decorationwnodeExtra
- a decorator forwnode
instances, contains the extra functions which decorate many of the default exportsstree
-symbol-tree
clone, used bywnode
instancesserialize
,deserialize
- functions for serializing and deserializingwnode
instancesisWnode
- a type guard predicate forwnode
instances
A brief description of how the parts are created and how they relate to each other, if you want to customize or override behaviour, at any level:
- tOf is backed by t
- ne is backed by t
- t is backed by T
- T is backed by wnode
- wnode is backed by wdoc
- wdoc is backed by stree
treescript
Create wnode
instances with a hyperscript-like syntax. More on wnode
later.
There are three factories provided for creating nodes, the nodes created by them
can be used interchangeably, eg you can append a node created by t
to a node
created by ne
etc - they are all just wnode
instances
t
// due to the decorator, the real types are more complex, but this is the gist
type TArg<V> = Wnode | Partial<V>
const t: <V extends {}>(initial: V, ...args: TArg<V>[]) => Wnode<V>
The initial value sets the type of the node.
The args
can be a mix of Partial<T>
and Wnode
, the partials are
applied to the initial value, and the nodes are added as children.
import { t } from '@nrkn/treescript'
const clubs = t(
{ type: 'clubs' },
t(
{ type: 'club', name: 'star gazers' },
t( 'alex sun' ),
t( 'nick moon' )
),
t(
{ type: 'club', name: 'wildlife watchers' },
t( 'quinn bird' ),
t( 'blake bear' ),
// I'm guessing someone in the club added this - this partial will override
// the name of the club - this pattern is useful for composition
{ name: 'the best wildlife watchers club' }
)
)
const people = clubs.all( n => typeof n.value === 'string' )
for( const person of people ){
console.log( person.value )
}
In typescript, the initial value must provide the full value for the type if using inference, eg, this works:
t({ type: 'foo', name: '' }, { name: 'bar' } )
And this fails:
t({ type: 'foo' }, { name: 'bar' } )
Argument of type '{ name: string; }' is not assignable to parameter of type
'TArg<{ type: string; }>'.
Object literal may only specify known properties, and 'name' does not exist in
type 'TArg<{ type: string; }>'.ts(2345)
You can compose using partial types if you use ne
, see below, or, you can
explicitly set the type as Partial, eg:
type MyType = { type: string, name: string }
const p = t<Partial<MyType>>({ type: 'foo' }, { name: 'bar' } )
In javascript, provided your developement environment doesn't enforce typescript
types, you can use t
in the same way as ne
below, the run time behaviour is
the same.
ne
Like t
, but with no initial value required, therefore the node type is 'any',
and the value is the composite of all non-node arguments. Useful for
composition, eg
import { ne } from '@nrkn/treescript'
const feature1 = { fast: true }
const feature2 = { shinyButtons: true }
const machine = ne(
{ name: 'embiggening inversocatorer' }, feature1, feature2,
ne(
{ name: 'inversocatorer dial' }, feature2
)
)
tOf
Takes a create
function and returns a function similar to t
, but which
has its initial value populated by the create
function, so doesn't need to be
passed in.
import { tOf } from '@nrkn/treescript'
const createOutfit = () => ({ type: 'outfit', hasPockets: true, name: '' })
const createHat = () => ({ type: 'hat', hatBrimSize: 'medium', name: '' })
const createCoat = () => ({ type: 'coat', isWaterproof: false, name: '' })
const createPants = () => ({ type: 'pants', numberOfBeltLoops: 6, name: '' })
const createDress = () => ({ type: 'dress', twirlFactor: 'high', name: '' })
const createTop = () => ({ type: 'top', hasSequins: true, name: '' })
const outfit = tOf( createOutfit )
const hat = tOf( createHat )
const coat = tOf( createCoat )
const pants = tOf( createPants )
const dress = tOf( createDress )
const top = tOf( createTop )
const myOutfit = outfit(
{ name: 'dancing outfit' },
hat({ name: 'witch hat', hatBrimSize: 'enormous' }),
dress({ name: 'polka dot dress' }),
top({ name: 'sparkly blouse' })
)
T
T
is a factory function that creates a t
function, where nodes are created
using the passed in create
function.
You can use this when:
- you want to use the decorate capability of
wnode
(see below), so you have a custom wnode you want to use - you want to wrap an instance of wnode in a function that modifies it in some way, or adds logging etc
const T: (createNode: <N>(value: N) => Wnode<N>) =>
<T extends {}>(initial: T, ...args: TArg<T>[]) => Wnode<T>
import { T, wdoc, wnode } from '@nrkn/treescript'
const myCustomDecorator = { /* see below */ }
const create = wdoc( myCustomDecorator )
const t = T( create )
const myDoc = t(
{ type: 'doc' },
t( { type: 'section' }, t( 'section 1' ) ),
t( { type: 'section' }, t( 'section 2' ) )
)
// or:
const myCreate = value => {
console.log( 'creating a node with value:', value )
return wnode( value )
}
const t2 = T( myCreate )
const myDoc2 = t2(
{ type: 'doc' },
t2( { type: 'section' }, t2( 'section 1' ) ),
t2( { type: 'section' }, t2( 'section 2' ) )
)
wnode
The backing node for treescript
is wnode
, which is similar to a DOM node in
operation, but has a different API. It is basically just a light weight wrapper
around symbol-tree
The custom value is stored in wnode.value
import { wnode } from '@nrkn/treescript'
const chest = wnode({ type: 'chest', capacity: 10, used: 0, size: 12 })
const sword = wnode({ type: 'sword', quality: 'rusty af', size: 3 })
const addToContainer = ( container, item } ) => {
const { value: c } = container
const { value: i } = item
if( ( c.used + i.size ) > c.capacity ){
return false
}
container.appendChild( item )
c.used += i.size
return true
}
if( !addToContainer( chest, sword ) ){
console.log( 'no room for sword' )
}
properties
Note that the real types are more complex due to the use of decorators, but this serves for understanding the basics.
type WnodeProps<T = any> = {
// the value passed in eg wnode( value ) or t( value ) etc
value: T
// iterator of child nodes
children: IterableIterator<Wnode>
// iterator of ancestor nodes - includes self
ancestors: IterableIterator<Wnode>
// iterator of descendant nodes - includes self
descendants: IterableIterator<Wnode>
// iterator of previous sibling nodes
previousSiblings: IterableIterator<Wnode>
// iterator of next sibling nodes
nextSiblings: IterableIterator<Wnode>
// the parent node, if any
parent: MaybeNode // MaybeNode = Wnode | null
// the previous sibling node, if any
prev: MaybeNode
// the next sibling node, if any
next: MaybeNode
// the first child node, if any
firstChild: MaybeNode
// the last child node, if any
lastChild: MaybeNode
// true if the node has children
hasChildren: boolean
// the index within parent, or -1 if no parent
index: number
// the number of children
childrenCount: number
}
methods
type WnodeMethods = {
// removes the node from its parent, if any
remove: () => Wnode
// insert the newNode before the referenceNode - the referenceNode must be a
// child of this node, or null or undefined, in which case the newNode is
// appended to the end of the children
insertBefore: (newNode: Wnode, referenceNode?: MaybeNode) => Wnode
// same as insertBefore, but inserts after the referenceNode
insertAfter: (newNode: Wnode, referenceNode?: MaybeNode) => Wnode
// prepends the newNode to the start of children
prependChild: (newNode: Wnode) => Wnode
// appends the newNode to the end of children
appendChild: (newNode: Wnode) => Wnode
// get the last node traversing the tree downwards - could be self
lastInclusiveDescendant: () => Wnode
// gets the previous node in a depth-first tree traversal, if any
preceding: (options?: SincdescOpts<Wnode>) => MaybeNode
// gets the next node in a depth-first tree traversal, if any
following: (options?: SincdescOpts<Wnode>) => MaybeNode
}
type SincdescOpts<T> = Partial<{
// must be inclusive ancestor of the returned node, or null is returned
root: T
// following only - ignores the children of the current node when traversing
skipChildren: boolean
}>
decorators
Decorate wnode
instances with custom methods.
import { wdoc } from '@nrkn/treescript'
import { myCustom } from './custom'
const wnode = wdoc( myCustom )
const myNode = wnode({ name: 'hello' })
There is an example provided that adds the following useful methods:
type WnodeSelector = (node: Wnode) => boolean
type WnodeExtra = {
after: (...nodes: Wnode[]) => void
append: (...nodes: Wnode[]) => void
before: (...nodes: Wnode[]) => void
prepend: (...nodes: Wnode[]) => void
replaceChildren: (...nodes: Wnode[]) => void
replaceWith: (...nodes: Wnode[]) => void
ancestor: (selector: WnodeSelector) => MaybeNode
find: (selector: WnodeSelector) => MaybeNode
all: (selector: WnodeSelector) => IterableIterator<Wnode>
matches: (selector: WnodeSelector) => boolean
}
import { wdoc, wnodeExtra } from '@nrkn/treescript'
const wnode = wdoc( wnodeExtra )
If you use a custom decorator, the instances created by wnode
will be typed with
your additional methods.
Custom decorators are structured like so:
export const wnodeExtra = node => {
const wnodeExtra = {
after(...nodes) {
for (const n of nodes) {
node.insertAfter(n, node)
}
},
// etc
}
return wnodeExtra
}
You can only pass through a single decorator object - if you want to combine several, to eg extend wnodeExtra, you can do with a pattern like this:
import { wnodeExtra, wdoc } from '@nrkn/treescript'
import { myCustomDecorator } from './myCustomDecorator'
const myDecorator = node => {
return {
...wnodeExtra( node ),
myCustomMethod() {
console.log(
`this node's value has ${ Object.keys( node.value ).length } keys`
)
}
...myCustomDecorator( node )
}
}
const wnode = wdoc( myDecorator )
stree
stree
is a clone of symbol-tree
, but ported to functional style typescript
and using conventions that I find comfortable. It's used to create the backing
tree for wnode
. It has the same interface as the official symbol-tree
package, except instances are generated via stree()
rather than
new SymbolTree()
import { stree } from '@nrkn/treescript'
const tree = stree()
let a = {foo: 'bar'} // or `new Whatever()`
let b = {foo: 'baz'}
let c = {foo: 'qux'}
tree.insertBefore(b, a) // insert a before b
tree.insertAfter(b, c) // insert c after b
console.log(tree.nextSibling(a) === b)
console.log(tree.nextSibling(b) === c)
console.log(tree.previousSibling(c) === b)
tree.remove(b)
console.log(tree.nextSibling(a) === c)
utils
Serialize and deserialize wnodes to a plain array of value followed by children. You can pass a value transformer if you plan to export to JSON and your value is not JSON serializable.
import { t, serialize, deserialize } from '@nrkn/treescript'
const tree = t(
'a',
t( 'b' ),
t( 'c' ),
t( 'd', t( 'e' ) )
)
const serialized = JSON.stringify( serialize( tree ), null, 2 )
/*
[
"a",
[ "b" ],
[ "c" ],
[
"d",
[ "e" ]
]
]
*/
console.log( serialized )
const deserialized = deserialize( JSON.parse( serialized ) )
console.log( deserialized.firstChild.value ) // 'b'
With transformers:
import { t, serialize, deserialize } from '@nrkn/treescript'
const tree = t(
{ id: 'foo', created: new Date() },
t( { id: 'bar' } ),
t( { id: 'baz' } )
)
const serializeDate = value => {
if( !('created' in value) ) return value
return { ...value, created: value.date.toJSON() }
}
const deserializeDate = value => {
if( !('created' in value) ) return value
return { ...value, created: new Date( value.created ) }
}
const serialized = JSON.stringify( serialize( tree, serializeDate ), null, 2 )
console.log( serialized )
/*
[
{
"id": "foo",
"created": "2021-08-01T00:00:00.000Z"
},
[
{
"id": "bar"
}
],
[
{
"id": "baz"
}
]
]
*/
const deserialized = deserialize( JSON.parse( serialized ), deserializeDate )
console.log( deserialized.value.created instanceof Date ) // true
Acknowledgements
This package came about because I was really interested in how symbol-tree
worked, so I cloned it as a learning exercise. Once it was passing the test
suite for real symbol-tree
, I ended up building the other parts on top of that
as I wanted something as concise, readable and composable as hyperscript, but
for generic tree nodes of varying kinds.
This project is based on symbol-tree
by Joris van der Wel. The original code is licensed under the MIT License, and
the modified code in this project is also distributed under the same license.
See the LICENSE file in the /src/symbol-tree
folder for the full text of the license.
License
MIT License
Copyright (c) 2023 Nik Coughlin
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.