From 3298ff6198c9b455095c2e17b66d78bf9badde10 Mon Sep 17 00:00:00 2001 From: Chris Harvey Date: Fri, 25 Aug 2017 13:32:19 -0400 Subject: [PATCH 1/3] Create README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcf5b4a --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# helpers-js +Javascript helpers for lazy people. From ff87bca395ae431e0ffb6c2bd1c8fa1446c04aba Mon Sep 17 00:00:00 2001 From: Chris Harvey Date: Fri, 25 Aug 2017 13:58:31 -0400 Subject: [PATCH 2/3] add initial class files --- _models/Element.class.js | 413 +++++++++++++++++++++++++++++++++++++++ _models/Mapp.class.js | 207 ++++++++++++++++++++ _models/Tree.class.js | 378 +++++++++++++++++++++++++++++++++++ _models/Util.class.js | 322 ++++++++++++++++++++++++++++++ index.js | 6 + 5 files changed, 1326 insertions(+) create mode 100644 _models/Element.class.js create mode 100644 _models/Mapp.class.js create mode 100644 _models/Tree.class.js create mode 100644 _models/Util.class.js create mode 100644 index.js diff --git a/_models/Element.class.js b/_models/Element.class.js new file mode 100644 index 0000000..15303a6 --- /dev/null +++ b/_models/Element.class.js @@ -0,0 +1,413 @@ +var Util = require('./Util.class.js') + +/** + * Represents an HTML element. + * @module + */ +module.exports = class Element { + /** + * Construct a new Element object. + * @param {string} name the immutable name of the tag + * @param {boolean=} is_void `true` if this element is void (has no closing tag) + */ + constructor(name, is_void = false) { + /** @private @final */ this._NAME = name + /** @private @final */ this._VOID = is_void + + /** + * All the HTML attributes of this element. + * @private + * @type {Object} + */ + this._attributes = {} + + /** + * The contents of this element. + * If this is a void element, it must have no contents, and its tag must be self-closing. + * @private + * @type {?string} + */ + this._contents = (this._VOID) ? null : '' + } + + + + /** + * Render this element’s attributes as a string. + * The string is returned in the following format: + * ` attr1="val1" attr2="val2" attr3="val3"` + * @private + * @return {string} string containing key-value pairs + */ + _attributeString() { + let out = '' + for (let i in this._attributes) { + if (this._attributes[i]!==undefined) out += ` ${i}="${this._attributes[i]}"` + } + return out + } + + + + /** + * Return this element’s name. + * @return {string} the name of this Element + */ + get name() { return this._NAME } + + /** + * Return whether this element is a void element. + * Void elements have no end tag, and have the + * **nothing content model** (they must not have any contents). + * @return {boolean} `true` if this element is void; `false` otherwise + */ + get isVoid() { return this._VOID } + + /** + * Return the contents of this element. + * @return {?string} this element’s contents, or `null` if this is a void element + */ + get contents() { return this._contents } + + + + /** + * Set or get an attribute of this element. + * + * If a key *and* value are provided, then the attribute name (the key) + * will be created (or modified if it already exists), and it will be assigned the value given. + * + * There is one exception: If a key *and* value are provided, and the value is the primitive `null`, + * then the attribute is removed from this element. + * (Thus it is impossible to assign the primitive value `null` to an attribute. + * To approximate this, provide the string `'null'`.) + * + * If the attribute is a **boolean attribute** and is present, provide the empty string `''` as the value. + * + * If only a key is provided and the value is not provided, then this method will return + * the value of this element’s attribute named by the given key. + * If no such attribute exists, `undefined` is returned. + * + * Examples: + * To set the boolean `itemscope` attribute, call `my_elem.attr('itemscope', '')`. + * To get the value of the `itemtype` attriute, call `my_elem.attr('itemtype')`. + * To remove the `itemprop` attribute, call `my_elem.attr('itemprop', null)`. + * + * @param {string} key the name of the attribute to set or get + * @param {?*=} value the name of the value to set, or `null` to remove the attribute + * @return {(Element|string)=} `this` if setting an attribute, else the value of the attribute specified + */ + attr(key, value) { + if (value===undefined) return this._attributes[key] + this._attributes[key] = (value===null) ? undefined : `${value}` // convert to string without Object#toString() + return this + } + + /** + * Set/remove multiple attributes at once, providing an attributes object. + * + * The argument must be an object who has string or null values. No values may be `undefined`. + * The values of the argument act just like the `value` parameter in {@link Element#attr()}. + * For example: + * + * `my_element.attrObj({ itemprop:'name' })` sets the attribute `[itemprop="name"]` on this element. + * If the `[itemprop]` attribute already exists, it will be overriden to the value `"name"`. + * + * `my_element.attrObj({ itemprop:null })` removes the `[itemprop]` attribute altogether. + * + * Example: + * ``` + * my_elem.attr('itemprop','name').attr('itemscope','').attr('itemtype':'Person') // old + * my_elem.attrObj({ itemprop:'name', itemscope:'', itemtype:'Person' }) // new + * ``` + * + * @param {Object} attr_obj the attributes object given + * @return {Element} `this` + */ + attrObj(attr_obj) { + for (let i in attr_obj) { this.attr(i, attr_obj[i]) } + return this + } + + /** + * Add (or modify) one or more attributes, given strings. + * Strings must take the form `'attribute="attr value"'`. + * Multiple arguments may be provided. + * This method does not remove attributes. + * + * Example: + * ``` + * my_elem.attr('itemprop','name').attr('itemscope','').attr('itemtype':'Person') // old + * my_elem.attrStr('itemprop="name"', 'itemscope=""', 'itemtype="Person"') // new + * ``` + * @param {string} attr_str a string of the format `'attribute="attr value"'` + * @return {Element} `this` + */ + attrStr(...attr_str) { + attr_str.forEach((str) => this.attr(str.split('=')[0], str.split('=')[1].slice(1,-1))) + return this + } + + /** + * Shortcut method for setting/getting the `id` attribute of this element. + * @param {?string=} id_str the value to set for the `id` attribute, or `null` to remove it + * @return {(Element|string)} `this` if setting the id, else the value of the id + */ + id(id_str) { + return this.attr('id', id_str) + } + + /** + * Shortcut method for setting/getting the `class` attribute of this element. + * @param {?string=} class_str the value to set for the `class` attriubte, or `null` to remove it + * @return {(Element|string)} `this` if setting the class, else the value of the class + */ + class(class_str) { + return this.attr('class', class_str) + } + + /** + * Append to this element’s `class` attribute. + * When adding classes, use this method instead of {@link Element#class()|Element#class(...)} + * @param {string} class_str the classname(s) to add, space-separated + * @return {Element} `this` + */ + addClass(class_str) { + return this.class(`${this.class() || ''} ${class_str}`) + } + + /** + * Remove a single token from this element’s `class` attribute. + * @param {string} classname classname to remove; must not contain spaces + * @return {Element} `this` + */ + removeClass(classname) { + let classes = (this.class() || '').split(' ') + let index = classes.indexOf(classname) + if (index >= 0) classes.splice(index, 1) + return this.class(classes.join(' ')) + } + + /** + * Shortcut method for setting/getting the `style` attribute of this element. + * @param {?string=} style_str the value to set for the `style` attriubte (as valid CSS), or `null` to remove it + * @return {(Element|string)} `this` if setting the style, else the value of the style + */ + style(style_str) { + return this.attr('style', style_str) + } + + /** + * Shortcut method for setting/getting the `style` attribute of this element, + * where the I/O of this method is an *Object* instead of a string. + * @param {?Object=} style_obj the properties to set for the `style` attribute, or `null` to remove it + * @return {(Element|Object)} `this` if setting the style, else the value of the style as an object + */ + styleObj(style_obj) { + if (style_obj !== undefined) { + if (style_obj !== null) { + let css_string = '' + for (let i in style_obj) { + css_string += `${i}:${style_obj[i]};` + } + return this.attr('style', css_string) + } else return this.attr('style', null) + } else { + let css_object = {} + ;(this.attr('style') || '').split(';').map((rule) => rule.split(':')).forEach(function (rule_arr) { + // rule_arr[0] == css property + // rule_arr[1] == css value + if (rule_arr[0] && rule_arr[1]) css_object[rule_arr[0]] = rule_arr[1] + }) + return css_object + } + } + + /** + * Append to this element’s `style` attribute. + * @param {string} style_str the style(s) to add, as valid CSS + * @return {Element} `this` + */ + addStyle(style_str) { + return this.style(`${this.style() || ''}; ${style_str}`) + } + + /** + * Append to this element’s `style` attribute, using an object as an argument. + * @param {Object} style_obj the style(s) to add, as an object + * @return {Element} `this` + */ + addStyleObj(style_obj) { + return this.addStyle(new Element('html').styleObj(style_obj).style()) + // alternate implementation: + Object.assign(this.styleObj(), style_obj) + return this + // even another implementation, if you prefer a "pure" (nondestructive) function: + return this.styleObj(Object.assign({}, this.styleObj(), style_obj)) + } + + /** + * Remove a single CSS rule from this element’s `style` attribute. + * @param {string} cssprop single CSS property name + * @return {Element} `this` + */ + removeStyleProp(cssprop) { + delete this.styleObj()[cssprop] + return this + } + + /** + * Add content to this element. + * **May not be called on elements that are void!** + * @param {string} contents the contents to add + * @return {Element} `this` + */ + addContent(contents) { + if (this.isVoid) throw new Error('Cannot add contents to a void element.') + this._contents += contents + return this + } + + /** + * Add elements as children of this element. + * @param {Array} elems array of Element objects to add + */ + addElements(elems) { + return this.addContent(elems.map((el) => el.html()).join('')) + } + + /** + * Render this element as an HTML string. + * @return {string} an HTML string representing this element + */ + html() { + if (this.isVoid) return `<${this.name}${this._attributeString()}/>` + return `<${this.name}${this._attributeString()}>${this.contents}` + } + + + + /** + * Mark up data using an HTML element. + * NOTE: recursive function. + * + * If the argument is an array, then a `
    ` element is returned, with `
  • ` items. + * If the argument is a (non-array, non-function) object, then a `
    ` element is returned, with + * `
    ` keys and `
    ` values. + * Then, each `
  • `, `
    `, and `
    ` contains the result of this function called on that respective datum. + * If the argument is not an object (or is a function), then it is converted to a string and returned. + * + * Optionally, an `options` argument may be supplied to enhance the data. + * The following template serves as an example: + * ```js + * let options = { + * ordered: true, + * classes: { + * list: 'o-List', + * value: 'o-List__Item o-List__Value', + * key: `o-List__Key ${(true) ? 'truthy' : 'falsy' }`, + * }, + * attributes: { + * list: { itemscope: '', itemtype: 'Event'}, + * value: { itemprop: ((true) ? 'startTime' : 'endTime') }, + * key: { itemprop: `${(true) ? 'name' : 'headline'}` }, + * }, + * options: { + * ordered: false, + * }, + * } + * ``` + * + * This is the formal schema for the `options` parameter: + * ```json + * { + * "$schema": "http://json-schema.org/schema#", + * "title": "@param options", + * "type": "object", + * "description": "configurations for the output", + * "definitions": { + * "{Object}": { + * "type": "object", + * "additionalProperties": false, + * "patternproperties": { + * "*": { "type": "string" } + * } + * } + * }, + * "additionalProperties": false, + * "properties": { + * "ordered": { + * "type": "boolean", + * "description": "if the argument is an array, specify `true` to output an
      instead of a
        " + * }, + * "classes": { + * "type": "object", + * "description": "an object with string values, describing how to render the output elements’ classes", + * "additionalProperties": false, + * "properties": { + * "list" : { "type": "string", "description": "string of space-separated tokens for the `[class]` attribute of the list (
          ,
            , or
            )" }, + * "value": { "type": "string", "description": "string of space-separated tokens for the `[class]` attribute of the item or value (
          1. or
            )" }, + * "key" : { "type": "string", "description": "string of space-separated tokens for the `[class]` attribute of the key (
            )" } + * } + * }, + * "attributes": { + * "type": "object", + * "description": "an object, with object or string values, describing how to render the output elements’ attributes", + * "additionalProperties": false, + * "properties": { + * "list" : { "allOf": [{ "$ref": "#/definitions/{Object}" }], "description": "attributes of the list (
              ,
                , or
                )" }, + * "value": { "allOf": [{ "$ref": "#/definitions/{Object}" }], "description": "attributes of the item or value (
              1. or
                )" }, + * "key" : { "allOf": [{ "$ref": "#/definitions/{Object}" }], "description": "attributes of the key (
                )" } + * } + * }, + * "options": { + * "allOf": [{ "$ref": "#" }], + * "description": "configurations for nested items/keys/values; identical specs as param `options`" + * } + * } + * } + * ``` + * + * @param {*} thing the data to mark up + * @param {Object} options configurations for the output + * @param {boolean} options.ordered if the argument is an array, specify `true` to output an
                  instead of a
                    + * @param {Object} options.classes an object with string values, describing how to render the output elements’ classes + * @param {string} options.classes.list string of space-separated tokens for the `[class]` attribute of the list (
                      ,
                        , or
                        ) + * @param {string} options.classes.value string of space-separated tokens for the `[class]` attribute of the item or value (
                      1. or
                        ) + * @param {string} options.classes.key string of space-separated tokens for the `[class]` attribute of the key (
                        ) + * @param {Object>} options.attributes an object, with object or string values, describing how to render the output elements’ attributes + * @param {(Object)} options.attributes.list attributes of the list (
                          ,
                            , or
                            ) + * @param {(Object)} options.attributes.value attributes of the item or value (
                          1. or
                            ) + * @param {(Object)} options.attributes.key attributes of the key (
                            ) + * @param {Object} options.options configurations for nested items/keys/values; identical specs as param `options` + * @return {string} the argument rendered as an HTML element + */ + static data(thing, options = {}) { + let class_list = (options.classes && options.classes.list) || '' + let class_val = (options.classes && options.classes.value) || '' + let class_key = (options.classes && options.classes.key) || '' + let attr_list = (options.attributes && options.attributes.list) || {} + let attr_val = (options.attributes && options.attributes.value) || {} + let attr_key = (options.attributes && options.attributes.key) || {} + let options_val = options.options || {} + + if (Util.Object.typeOf(thing) === 'object') { + let returned = new Element('dl').class(class_list).attrObj(attr_list) + for (let i in thing) { + returned.addElements([ + new Element('dt').class(class_key).attrs(attr_key).addContent(i), + new Element('dd').class(class_val).attrs(attr_val).addContent(Element.data(thing[i], options_val)), + ]) + } + return returned + } else if (Util.Object.typeOf(thing) === 'array') { + let returned = new Element((options.ordered) ? 'ol' : 'ul').class(class_list).attrObj(attr_list) + thing.forEach(function (el) { + returned.addElements([ + new Element('li').class(class_val).attrObj(attr_val).addContent(Element.data(el, options_val)) + ]) + }) + return returned + } else return thing.toString() + } +} diff --git a/_models/Mapp.class.js b/_models/Mapp.class.js new file mode 100644 index 0000000..35296ef --- /dev/null +++ b/_models/Mapp.class.js @@ -0,0 +1,207 @@ +/** + * `Mapp` is a slightly different implementation of the ES6 `Map` class. + * It encapsulates an array of variable length, whose elements are each + * arrays of length two. + * Each two-entry array represents a "key-value" pair, where + * the first entry is the "key", and the second entry is the "value". + * + * The "key" may be any primitive type EXCEPT `null`, `undefined`, or `NaN`, + * or it may be any object. (This is different from ES6 `Map` but is much easier to code.) + * On the other hand, the "value" may be any type whatsoever, even `null`, `undefined`, or `NaN`. + * NOTE: it is advised not to use `undefined` as values, due to implementations of methods. + * + * Mapps contain elements with unique keys. + * No two elements may have the same "key", in the sense of strict equality (`===`). + * Alternatively, if strict equality is not desired, keys may be compared via some + * provided comparator function (which may be specified as `Object.is`). + * (If a comparator function is not provided, strict equality is used.) + * The comparator function is used only to compare keys, not values. + * Examples follow. + * + * 1. If a mapp contains `[0, 'hello']`, then it may not also contain `[0, 'world']`, + * because `0 === 0`. However, it may contain both `['hello', 0]` and `['world', 0]`, + * because 'hello' !== 'world'. + * 2. If `Object.is` is provided as a comparator, then a mapp may contain + * both `[0, 'hello']` and `[-0, 'world']`, because `Object.is(0,-0)` is false. + * 3. If the comparator `function compare(a,b) { return a.length===b.length && a[0]===b[0] }` + * is provided, then a Mapp object may not contain both `[['hello'], 'world']` and `[['hello'], 'today']`, + * because `compare(['hello'],['hello'])` is true. + * (However, this is a bad comparator function, since for example + * `compare('hello', 'haiku')` and `compare(12, 42)` are true.) + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array + * @module + */ +module.exports = class Mapp { + /** + * Construct a new Mapp object. + * @param {function(*,*):boolean=} equalityComparator an optional comparator function, comparing keys in this Mapp object + */ + constructor(equalityComparator = ((a,b) => a===b)) { + /** @private @final */ this._IS = equalityComparator + /** @private */ this._entries = [] + } + + /** + * Return whether the provided key is valid for entry or deletion. + * @private + * @param {*} key the key to test + * @return {boolean} `true` if the key can be successfully added or deleted from this map + */ + static _isValidKey(key) { + return ['null', 'NaN', 'undefined'].includes(Util.Object.typeof(key)) + } + + /** + * Return a specified element of this Mapp. + * This method returns `undefined` if the given key can’t be found; + * otherwise, it returns an array of length 2. + * @private + * @param {*} key the key of the element to return; must not be `null`, `NaN`, nor `undefined` + * @return {*} element with the with the specified key, or `undefined` if the key cannot be found + */ + _getEntry(key) { + if (Mapp._isValidKey(key)) throw new TypeError('Argument must not be `null`, `NaN`, nor `undefined`.') + return this._entries.find((item) => this._IS(item[0],key)) || null + } + + /** + * The size of (number of elements in) this mapp. + * @return {number} the number of elements in this mapp + */ + get size() { + return this._entries.length + } + + /** + * Add a new element to this Mapp. + * If an element with the specified key already exists, then it will be overridden. + * If not, the new element will be added to this Mapp. + * @param {*} key the name of the element; must not be `null`, `NaN`, nor `undefined` + * @param {*} value the description of the element; may be of any type (`undefined` not recommended) + * @return {Mapp} this Mapp object + */ + set(key, value) { + if (this.has(key)) { + this._getEntry(key)[1] = value + } else { + this._entries.push([key, value]) + } + return this + } + + /** + * Return a specified value of this mapp. + * NOTE: WARNING: This method will return `undefined` in two cases: if the given key can’t be found, + * or if `undefined` is the actual value of the key given. Thus it is advised never to use `undefined` + * as values in a Mapp object. However, the result of {@link Mapp#has()|`this.has(key)`} will make the case clear. + * @param {*} key the key whose value return; must not be `null`, `NaN`, nor `undefined` + * @return {*} value associated with the specified key, or `undefined` if the key cannot be found + */ + get(key) { + if (this.has(key)) { + return this._getEntry(key)[1] + } else return; + } + + /** + * Return whether a value has been associated to the specified key. + * @param {*} key the key of the element to test for presence + * @return {boolean} true if an element with the specified key exists; else false + */ + has(key) { + return !!this._getEntry(key) + } + + /** + * Find the first key that satisfies the condition provided, and return it. + * Here, "first" is defined in order of {@link Mapp#set()} calls in your program. + * Once the key is "found", you may pass it to {@link Mapp#has()}, {@link Mapp#get()}, etc. + * Example: + * ```js + * my_map.find((key) => typeof key === 'object') // returns the first key that is an object + * ``` + * @param {function(?):boolean} fn condition to test for each key in this map + * @return {?*} the first key that passes the test, else `null` + */ + find(fn) { + let result = this._entries.find((item) => fn.call(null, item[0])) + return (result) ? result[0] : null + } + + /** + * Remove the specified element. + * @param {object} key the key of the element to remove + * @return {Mapp} this Mapp object + */ + delete(key) { + if (Mapp._isValidKey(key)) throw new TypeError('Argument must not be `null`, `NaN`, nor `undefined`') + this._entries.forEach(function (item, index) { + if (this._IS(item[0],key)) this._entries.splice(index, 1) + }, this) + return this + } + + /** + * Remove all elements. + * @return {Mapp} this Mapp object + */ + clear() { + this._entries.splice(0, this._entries.length) + return this + } + + /** + * Return an array containing all keys in this mapp. + * @return {Array} an array that contains all keys in this Mapp + */ + keys() { + return this._entries.map((item) => item[0]) + } + + /** + * Return an array containing all values in this mapp. + * @return {Array} an array that contains all values in this Mapp + */ + values() { + return this._entries.map((item) => item[1]) + } + + /** + * Sort the elements in this mapp and put them into an array. + * + * The returned array contains this mapp’s elements, each being a two-length array + * representing the key-value pair. + * The returned result will not affect this mapp’s original data. + * (That is, mutating the returned result will not change this mapp.) + * + * The elements are sorted by the provided comparator function. + * The function signature is identical to that required by `Array#sort()`; + * see the example below: + * + * ```js + * function compare(a, b) { + * if (a is less than b by some ordering criterion) return -42 + * if (a is greater than b by the ordering criterion) return 6.28 + * return 0 // a must be "equal" to b per the criterion + * } + * ``` + * + * @param {function(*,*):number} comparator defines the sort order + * @return {Array} an array of the elements of this mapp + */ + sort(comparator) { + throw new Error('Feature not yet supported.') + } + + // NOTE this method does not protect the elements of this mapp. + // /** + // * Returns a copy of the entries in this Mapp. + // * The ES6 `Map.prototype.entries()` method is a bit more complicated. + // * @return {Mapp} this + // */ + // entries() { + // return this._entries.slice() + // } +} diff --git a/_models/Tree.class.js b/_models/Tree.class.js new file mode 100644 index 0000000..454d964 --- /dev/null +++ b/_models/Tree.class.js @@ -0,0 +1,378 @@ +var Util = require('./Util.class.js') +var Mapp = require('./Mapp.class.js') + +/** + * A Tree is a data structure containing a set of nodes arranged in a hierarchy. + * + * The following properties of the tree hierarchy are observed. + * I. The tree hierarchy embeds an "ancestor" relation, which is a strict partial order: + * 1. (antireflexive) no node is "an ancestor of" itself + * 2. (asymmetric) if A is "an ancestor of" B, then B is not "an ancestor of" A. + * 3. (transitive) if A is "an ancestor of" B, and B is "an ancestor of" C, then A is "an ancestor of" C. + * II. The tree contains a "root node" (a unique minimal element): + * There exists a node that is "an ancestor of" every other node. + * III. Every non-root node has a "parent" (a unique least upper bound of all its ancestors): + * For each node A there exists a node B that: (1) is not equal to A, and (2) shares all the ancestors (excluding B) of A. + * There are no nodes "between" a node and its parent in this ordering. + * IV. Each set of nodes that share the same "parent" embeds two relations: + * 1. an equivalence relation, "sibling": + * a. (reflexive) every node is "a sibling of" itself + * b. (symmetric) if A is "a sibling of" B, then B is also "a sibling of" A. + * c. (transitive) if A is "a sibling of" B, and B is "a sibling of" C, then A is "a sibling of" C. + * 2. a strict total order, "older than": + * a. (antireflexive) no node is "older than" itself + * b. (asymmetric) if A is "older than" B, then B is not "older than" A. + * c. (transitive) if A is "older than" B, and B is "older than" C, then A is "older than" C. + * d. (total) for any A and B, either A is "older than" B or B is "older than A" + * + * Meta: The outlined list above is an example of a tree, with the root node being the sentence, + * “The following properties of the tree hierarchy ...”. + * For example, the list item ‘I’ is a sibling of ‘IV’ and is a parent of ‘2’; the item ‘IV’ is an ancestor of the first item ‘a’. + * + * This constructor requires 3 arguments: + * 1. the string id of this tree; used in calculations involving finding nodes + * 2. the root data value; it may be of any type + * 3. an equality function comparing two of this tree’s nodes, with the signature {function(Tree.Node,Tree.Node):boolean} + * For example: + * ``` + * function equal(a, b) { + * if (a "equals" b by some criterion) return true + * // else + * return false + * } + * ``` + * + * @module + */ +module.exports = class Tree { + /** + * Construct a new Tree object. + * You must provide a string identifier. + * + * You must provide a string ID, which serves as an identifier for this tree. + * + * You may also provide an equality comparator, which is a function that determines how + * "equality" of node values is computed. + * + * @param {string} id this tree’s URI identifier + * @param {Tree.Node} $root the root of this tree + * @param {function(Tree.Node,Tree.Node):boolean=} equalityComparator a function determining how equality of node values is computed + */ + constructor(id, $root, equalityComparator = ((a,b) => a===b)) { + /** @private @final */ this._ID = id + /** @private @final */ this._ROOT = $root + /** @private @final */ this._IS = equalityComparator + + /** + * NOTE: Type Definition + * Data describing a node’s relationship to other nodes as it is added to this tree. + * It represents an "edge" in graph theory terms. + * It has the following signature: + * {{path:string, children:Array}} + * Example: + * ``` + * { + * path : 'bobs-tree/root-node', + * children: ['first-child, child2, hijo-tercero'], + * } + * ``` + * @typedef {Object} Edge - an object of type `{path:string, children:Array}` + * @property {string} path - a unique string that describes a node’s “location” in this tree + * @property {Array} children - an array of child node IDs + */ + + /** + * Corresponds each node in this tree to an {@link Edge} type. + * @private + * @type {Mapp} + */ + this._map = new Mapp(this._IS).set(this._ROOT, { + path : `${this._ID}/${this._ROOT.id}`, + children: [], + }) + } + + /** + * Return this tree’s id. + * @return {string} this tree’s identifier + */ + get id() { + return this._ID.slice() + } + + /** + * Return the root of this tree. + * @return {Tree.Node} this tree’s root node + */ + get root() { + return this._ROOT + } + + /** + * Return whether this tree "equals" the given tree. + * @param {Tree} $tree another tree to compare to + * @return {boolean} `true` if this "equals" `$tree` + */ + equals($tree) { + throw new Error('feature not yet supported') + } + + /** + * Return whether two nodes in this tree are "equal", + * as determined by this tree’s equality comparator. + * @param {Tree.Node} $nodeA the first node + * @param {Tree.Node} $nodeB the second node + * @return {boolean} `true` if `$nodeA` equals `$nodeB` + */ + equalNodes($nodeA, $nodeB) { + return this._IS($nodeA, $nodeB) + } + + + + /** + * Return whether the specified node has been added to this tree. + * @param {Tree.Node} $node the node to test + * @return {boolean} `true` if `$node` is in this tree + */ + has($node) { + return this._map.has($node) + } + + /** + * Get the tree-path of the specified node in this tree. + * @param {Tree.Node} $node a node in this tree + * @return {string} the path to $node through this tree + */ + pathOf($node) { + if (!this.has($node)) throw new ReferenceError('The specified node is not in this tree.') + return this._map.get($node).path + } + + /** + * Get the tree-path of the parent of the specified node in this tree. + * @param {Tree.Node} $node a node in this tree + * @return {string} the path of the parent of $node in this tree + */ + parentOf($node) { + if (!this.has($node)) throw new ReferenceError('The specified node is not in this tree.') + // remove the substring from the last slash onward + return this._map.get($node).path.split('/').slice(0,-1).join('/') + } + + /** + * Get the children of the specified node in this tree. + * This method returns a shallow copy of the children array. + * @param {Tree.Node} $node a node in this tree + * @return {Array} IDs of the children of $node in this tree + */ + childrenOf($node) { + if (!this.has($node)) throw new ReferenceError('The specified node is not in this tree.') + return this._map.get($node).children.slice() + } + + /** + * Find the first node that satisfies the condition provided, and return it. + * Here, "first" is defined in order of {@link Tree#add()} calls in your program. + * Once the node is “found”, you may pass it to {@link Tree#remove()}, etc. + * Example: + * ```js + * my_tree.find((node) => node.id === 'about.html') // returns the first node whose ID is 'about.html'. + * ``` + * @param {function(Tree.Node):boolean} fn condition to test for each node in this tree + * @return {?Tree.Node} the first node that passes the test, else `null` + */ + find(fn) { + this._map.find((key) => fn.call(null, key)) // this._map.find(fn) + } + + /** + * Add a new node to this tree, then return this tree. + * Only new nodes can be added; you cannot add the same node twice. + * You may, however, “move” it by {@link Tree#remove()|removing} it first, and then adding it again. + * + * When adding a node to this tree, a parent node must be specified. + * Otherwise, there would be no place to put it! + * If no parent is specified, this tree’s root node is used by default. + * + * You may optionally specify an index at which to insert the node among its siblings. + * If no index is given, it is pushed to the end of its siblings. + * @param {Tree.Node} $node the node to add + * @param {Tree.Node=} $parentNode the unique parent of the newly-added node + * @param {number=} index index at which to insert the node, pushing siblings forward by 1 + * @return {Tree} this + */ + add($node, $parentNode = this._ROOT, index = this._map.get($parentNode).children.length) { + if (!this.has($parentNode)) throw new ReferenceError('The specified parent node is not in this tree.') + if (this.has($node)) throw new Error('The specified node is already in this tree. Cannot be added twice.') + // if (this.find((node) => node.id===$node.id)) throw new Error('The specified node must have an ID unique to this tree.') + if (this._map.get($parentNode).children.contains($node.id)) throw new Error('The specified node must have a unique ID among its siblings.') + this._map.get($parentNode).children.splice(index, 0, $node.id) + this._map.set($node, { + path : this.pathOf($parentNode) + `/${$node.id}`, + children: [], + }) + return this + } + + /** + * Remove a node from this tree, then return this tree. + * The root node cannot be removed. + * If the node being removed has any children, those children will also be removed from this tree. + * This method is recursive. + * @param {Tree.Node} $node the node to remove + * @return {Tree} this + */ + remove($node) { + if (!this.has($node)) throw new ReferenceError('The specified node is not in this tree.') + if (this.equalNodes($node, this.root)) throw new Error('Cannot remove root node.') + + // for each child of $node, remove that child from this tree, and then remove the ID + this.childrenOf($node).forEach(function (id, index) { + this.remove(this.find((child) => child.id === id)) + this._map.get($node).children.splice(index, 1) + }, this) + + // finally remove the $node + this._map.delete($node) + + return this + } + + /** + * Remove all nodes from this tree except the root node. + * @return {Tree} this + */ + removeAll() { + this.childrenOf(this.root).forEach(function (id, index) { + this.remove(this.find((child) => this.pathOf(child) === this.pathOf(this.root) + `/${id}`)) + }, this) + let top_nodes = this._map.get(this.root).children + top_nodes.splice(0, top_nodes.length) + return this + } + + /** + * Traverse this tree, running a callback function on each node. + * Example: + * ```js + * my_tree.traverse(function (node) { + * console.log(node.value) + * }) + * ``` + * @param {function(Tree.Node)} callback a function to call on each node + * @param {Tree.Traversal=} method a value of Tree.Traversal + */ + traverse(callback, method = Tree.Traversal.DEPTH) { + var self = this + if (method === Tree.Traversal.BREADTH) { + throw new Error('Not yet supported.') + } else { + ;(function recurse(current_node) { + callback.call(null, current_node) + self.childrenOf(current_node).forEach(function (id, index) { + recurse(this.find((child) => child.id === id)) + }, self) + })(self.root) + } + } + + + + /** + * A Tree is a data structure with directed, linked nodes. + * The links are directed in that they only link parent nodes to child nodes (not vice versa). + * The links are also unique in that there may be no more than one link from A to B. + * A Tree must have exactly one root, which is a unique node that has no parent. + * Trees must have at least one node, but no more than a finite number of nodes. + * An enum for tree traversal methods. + * @enum {number} + */ + static get Traversal() { + return { + BREADTH: 0, + DEPTH : 1, + } + } + + /** + * A node in a Tree. + * A node is a unit of data in a tree and has a value. + * It is linked to other nodes in the tree. + * A node may have at most one parent, but may have unlimited children. + * The children of a node are Trees, not other Nodes. + * @type {class} + */ + static get Node() { return Node } +} + + + + + +/** + * Class representing a node in a tree. + */ +class Node { + /** + * Construct a new Node object. + * + * You must provide a string ID, which serves as an identifier for the node in the tree it is in. + * The ID must be tree-unique, meaning no two nodes in a tree may have the same identifier. + * (You may construct two nodes with the same ID, but they cannot then be added to the same Tree.) + * + * The data, if provided, may be a primititve, object, array, or function, except + * it may not be `null`, `undefined`, or `NaN`. + * If it is an object, array, or function, it may be mutable. + * Data may be assigned and re-assigned to this node after construction, but the ID is immutable. + * + * @param {string} id this node’s URI identifier + * @param {*=} data this node’s data value + */ + constructor(id, data = false) { + if (data===null || Number.isNaN(data) || data===undefined) throw new TypeError('Value must not be `null`, `NaN`, nor `undefined`.') + /** @private @final */ this._ID = id + /** @private */ this._value = data + } + + /** + * Return this node’s URI identifier. + * @return {string} this node’s ID + */ + get id() { + return this._ID.slice() + } + + /** + * Change this node’s data value. + * Equivalent to assignment during construction. + * May not be `null`, `undefined`, or `NaN`. + * @param {*} data the value to assign + */ + set value(data) { + if (data===null || Number.isNaN(data) || data===undefined) throw new TypeError('Value must not be `null`, `NaN`, nor `undefined`.') + this._value = data + } + + /** + * Return this node’s value exactly. + * WARNING: this method returns this node’s actual value. + * If the value is mutable and is changed, this node will be affected. + * @return {*} the value of this node + */ + get value() { + return this._value + } + + /** + * Return a deep clone of this node’s value. + * If the value is an array or object, a deep copy is returned. + * If the value is a primitive type or a function, the value itself is returned. + * CAUTION: If the value returned is mutable and is changed, this node will remain unaffected. + * **NOTE WARNING: infinite loop possible!** + * @return {*} a clone of this node’s value + */ + valueDeep() { + return Util.Object.cloneDeep(this._value) + } +} diff --git a/_models/Util.class.js b/_models/Util.class.js new file mode 100644 index 0000000..47a5baa --- /dev/null +++ b/_models/Util.class.js @@ -0,0 +1,322 @@ +const OBJ = class { + /** + * Return the type of a thing. + * Similar to the `typeof` primitive operator, but more refined. + * + * NOTE! passing undeclared variables will throw a `ReferenceError`! + * ``` + * var x; // declare `x` + * typeof x; // 'undefined' + * typeof y; // 'undefined' + * Util.typeOf(x); // 'undefined' + * Util.typeOf(y); // Uncaught ReferenceError: y is not defined + * ``` + * Credit to @zaggino. + * + * @see https://github.com/zaggino/z-schema/blob/bddb0b25daa0c96119e84b121d7306b1a7871594/src/Utils.js#L12 + * @param {*} thing anything + * @return {string} the type of the thing + */ + static typeOf(thing) { + let type = typeof thing + if (type === 'object') { + if (thing === null) return 'null' + if (Array.isArray(thing)) return 'array' + return type // 'object' + } + if (type === 'number') { + if (Number.isNaN(thing)) return 'NaN' + if (!Number.isFinite(thing)) return 'infinite' + return type // 'number' + } + return type // 'undefined', 'boolean', 'string', 'function' + } + + /** + * Specify the type of number given. + * If the number is finite, return one of the following strings: + * - `'integer'` : the number is an integer, that is, `num % 1 === 0` + * - `'float'` : the number is not an integer + * Else, throw a RangeError (the argument is of the correct type but does not qualify). + * @param {number} num the given number + * @return {string} one of the strings described above + */ + static typeOfNumber(num) { + if (OBJ.typeOf(num) === 'number') { + return (Number.isInteger(num)) ? 'integer' : 'float' + } else throw new RangeError('The number is not finite.') + } + + /** + * Deep freeze an object, and return the result. + * If an array or object is passed, + * Recursively call `Object.freeze()` on every property and sub-property of the given parameter. + * Else, return the given argument. + * @param {*} thing any value to freeze + * @return {*} the returned value, with everything frozen + */ + static freezeDeep(thing) { + Object.freeze(thing) + if (OBJ.typeOf(thing) === 'array') { + for (let val of thing) { + if (!Object.isFrozen(val)) OBJ.freezeDeep(val) + } + } else if (OBJ.typeOf(thing) === 'object') { + for (let key in thing) { + if (!Object.isFrozen(thing[key])) OBJ.freezeDeep(thing[key]) + } + } + return thing + } + + /** + * Deep clone an object, and return the result. + * If an array or object is passed, + * This method is recursively called, cloning properties and sub-properties of the given parameter. + * The returned result is an object seemingly identical to the given parameter, except that + * corresponding properties are not "equal" in the sense of `==` or `===`, unless they are primitive values. + * Else, the original argument is returned. + * **NOTE WARNING: infinite loop possible!** + * + * This method provides a deeper clone than `Object.assign()`: whereas `Object.assign()` only + * copies the top-level properties, this method recursively clones into all sub-levels. + * + * // ==== Example ==== + * var x = { first: 1, second: { value: 2 }, third: [1, '2', { v:3 }] } + * + * // Object.assign x into y: + * var y = Object.assign({}, x) // returns { first: x.first, second: x.second, third: x.third } + * + * // you can reassign properties of `y` without affecting `x`: + * y.first = 'one' + * y.second = 2 + * console.log(y) // returns { first: 'one', second: 2, third: x.third } + * console.log(x) // returns { first: 1, second: { value: 2 }, third: [1, '2', { v:3 }] } + * + * // however you cannot mutate properties of `y` without affecting `x`: + * y.third[0] = 'one' + * y.third[1] = 2 + * y.third[2].v = [3] + * console.log(y) // returns { first: 'one', second: 2, third: ['one', 2, { v:[3] }] } + * console.log(x) // returns { first: 1, second: { value: 2 }, third: ['one', 2, { v:[3] }] } + * + * // Util.cloneDeep x into y: + * var z = Util.cloneDeep(x) // returns { first: 1, second: { value: 2 }, third: [1, '2', {v:3}] } + * + * // as with Object.assign, you can reassign properties of `z` without affecting `x`: + * z.first = 'one' + * z.second = 2 + * console.log(z) // returns { first: 'one', second: 2, third: [1, '2', {v:3}] } + * console.log(x) // returns { first: 1, second: { value: 2 }, third: [1, '2', { v:3 }] } + * + * // but unlike Object.assign, you can mutate properties of `z` without affecting `x`: + * z.third[0] = 'one' + * z.third[1] = 2 + * z.third[2].v = [3] + * console.log(z) // returns { first: 'one', second: 2, third: ['one', 2, { v:[3] }] } + * console.log(x) // returns { first: 1, second: { value: 2 }, third: [1, '2', { v:3 }] } + * + * @param {*} obj any value to clone + * @return {*} an exact copy of the given value, but with nothing equal via `==` (unless the value given is primitive) + */ + static cloneDeep(thing) { + let result; + if (OBJ.typeOf(thing) === 'array') { + result = [] + for (let val of thing) { + result.push(OBJ.cloneDeep(val)) + } + } else if (OBJ.typeOf(thing) === 'object') { + result = {} + for (let key in thing) { + result[key] = OBJ.cloneDeep(thing[key]) + } + } else { + result = thing + } + return result + } +} + + + +const ARR = class { + /** + * Test whether two arrays are the same, + * using `Object.is()` equality on corresponding entries. + * @param {Array} arr1 the first array + * @param {Array} arr2 the second array + * @return {boolean} `true` if corresponding elements are the same (via `Object.is()`) + */ + static is(arr1, arr2) { + if (Util.Object.typeOf(arr1) !== 'array' || Util.Object.typeOf(arr2) !== 'array') return false + if (arr1.length !== arr2.length) return false + let result = true + for (let i = 0; i < arr1.length; i++) { + // result &&= Object.is(arr1[i], arr2[i]) // IDEA + result = result && Object.is(arr1[i], arr2[i]) + } + return result + } + + /** + * “Convert” an array, number, or string into an array. (Doesn’t really convert.) + * - If the argument is an array, it is returned unchanged. + * - If the argument is a number `n`, an array of length `n`, filled with increasing integers, + * starting with 1, is returned. (E.g. if `n===5` then `[1,2,3,4,5]` is returned.) + * - If the argument is a string, that string is checked as an **own property** of the given database. + * If the value of that property *is* a string, then *that* string is checked, and so on, + * until an array or number is found. If no entry is found, an empty array is returned. + * The default database is an empty object `{}`. + * @param {(number|Array|string)} arg the argument to convert + * @param {!Object=} database a database to check against + * @return {Array} an array + */ + static toArray(arg, database={}) { + if (Util.Object.typeOf(arg) === 'array') { + return arg + } else if (Util.Object.typeOf(arg) === 'number') { + let array = [] + for (let n = 1; n <= arg; n++) { array.push(n) } + return array + } else if (Util.Object.typeOf(arg) === 'string') { + return Util.Array.toArray(database[arg], database) + } else { + return [] + } + } + + /** + * Make a copy of an array, and then remove duplicate entries. + * "Duplicate entries" are entries that considered "the same" by + * the provided comparator function, or if none is given, `Object.is()`. + * Only duplicate entries are removed; the order of non-duplicates is preserved. + * @param {Array} arr an array to use + * @param {function(*,*):boolean=} comparator a function comparing elements in the array + * @return {Array} a new array, with duplicates removed + */ + static removeDuplicates(arr, comparator = Object.is) { + let returned = arr.slice() + for (let i = 0; i < returned.length; i++) { + for (let j = i+1; j < returned.length; j++) { + if (comparator.call(null, returned[i], returned[j])) returned.splice(j, 1) + } + } + return returned + } +} + + + +const DTE = class { + /** + * List of full month names in English. + * @type {Array} + */ + static get MONTH_NAMES() { + return [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ] + } + + /** + * List of full day names in English. + * @type {Array} + */ + static get DAY_NAMES() { + return [ + 'Sundary', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ] + } + + /** + * Date formatting functions. + * + * Readable examples: + * ``` + * FORMATS['Y-m-d' ](new Date()) // returns '2017-08-05' + * FORMATS['j M Y' ](new Date()) // returns '5 Aug 2017' + * FORMATS['d F Y' ](new Date()) // returns '05 August 2017' + * FORMATS['l, j F, Y'](new Date()) // returns 'Friday, 5 August, 2017' + * FORMATS['j M' ](new Date()) // returns '5 Aug' + * FORMATS['M Y' ](new Date()) // returns 'Aug 2017' + * FORMATS['M j' ](new Date()) // returns 'Aug 5' + * FORMATS['M' ](new Date()) // returns 'Aug' + * FORMATS['H:i' ](new Date()) // returns '21:33' + * FORMATS['g:ia' ](new Date()) // returns '9:33pm' + * ``` + * @type {Object} + */ + static get FORMATS() { + /** + * Convert a positive number to a string, adding a leading zero if and only if it is less than 10. + * @param {number} n any positive number + * @return {string} that number as a string, possibly prepended with '0' + */ + function leadingZero(n) { return `${(n < 10) ? '0' : ''}${n}` } + return { + 'Y-m-d' : (date) => `${date.getFullYear()}-${date.getUTCMonth()+1}-${leadingZero(date.getUTCDate())}`, + 'j M Y' : (date) => `${date.getUTCDate()} ${Util.Date.MONTH_NAMES[date.getUTCMonth()].slice(0,3)} ${date.getFullYear()}`, + 'd F Y' : (date) => `${leadingZero(date.getUTCDate())} ${Util.Date.MONTH_NAMES[date.getUTCMonth()]} ${date.getFullYear()}`, + 'l, j F, Y': (date) => `${Util.Date.DAY_NAMES[date.getUTCDay()]}, ${date.getUTCDate()} ${Util.Date.MONTH_NAMES[date.getUTCMonth()]}, ${date.getFullYear()}`, + 'j M' : (date) => `${date.getUTCDate()} ${Util.Date.MONTH_NAMES[date.getUTCMonth()].slice(0,3)}`, + 'M Y' : (date) => `${Util.Date.MONTH_NAMES[date.getUTCMonth()].slice(0,3)} ${date.getFullYear()}`, + 'M j' : (date) => `${Util.Date.MONTH_NAMES[date.getUTCMonth()].slice(0,3)} ${date.getUTCDate()}`, + 'M' : (date) => `${Util.Date.MONTH_NAMES[date.getUTCMonth()].slice(0,3)}`, + 'H:i' : (date) => `${(date.getHours() < 10) ? '0' : ''}${date.getHours()}:${(date.getMinutes() < 10) ? '0' : ''}${date.getMinutes()}`, + 'g:ia' : (date) => `${(date.getHours() - 1)%12 + 1}:${(date.getMinutes() < 10) ? '0' : ''}${date.getMinutes()}${(date.getHours() < 12) ? 'am' : 'pm'}`, + } + } +} + + + +/** + * Utilities that extend native Javascript. + * @module + */ +class Util { + /** @private */ constructor() {} + + /** + * Additional static members for the Object class. + * Does not extend the native Object class. + * @namespace + */ + static get Object() { return OBJ } + + /** + * Additional static members for the Array class. + * Does not extend the native Array class. + * @namespace + */ + static get Array() { return ARR } + + /** + * Additional static members for the Date class. + * Does not extend the native Date class. + * @namespace + */ + static get Date() { return DTE } +} + + + +module.exports = Util diff --git a/index.js b/index.js new file mode 100644 index 0000000..ef6dc48 --- /dev/null +++ b/index.js @@ -0,0 +1,6 @@ +module.exports = { + Util: require('./_models/Util.class.js'), + Element: require('./_models/Element.class.js'), + Mapp: require('./_models/Mapp.class.js'), + // Tree: require('./_models/Tree.class.js'), +} From d31e60d0040fddaa6559e5a09339592f313b5f4a Mon Sep 17 00:00:00 2001 From: Chris Harvey Date: Fri, 25 Aug 2017 14:04:14 -0400 Subject: [PATCH 3/3] 0.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4df3524..3e027b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "helpers-js", - "version": "0.0.0", + "version": "0.1.0", "description": "Javascript helpers for lazy people.", "main": "index.js", "scripts": {