Skip to content

Undercat/esutil

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Miniature power tools for ECMAScript.

A collection of powerful utility functions that are generally too small to merit implementation as stand-alone modules.

Installation

npm install @undercat/esutil

Or clone this repository and include util.js directly in an HTML file. Although the module works in the browser, it will not benefit from module isolation if loaded directly, and the code required to use it differs somewhat:

Node.js:

const util = require('@undercat/esutil'); // load the whole wrapper
util.Type(x);
const { Type } = require('@undercat/esutil'); // load a component (or several)
Type(x);

Browser:

<script src='util.js'></script>
<script>
  const { Type } = $UC_esutil;
  Type(x);
</script>

The file itself is written as a CommonJS module, and when it is used in the browser it suffers from unavoidable load-order dependencies. Most of my other modules require esutils, so if you're going to use it directly, you should generally include it first.

Incidentally, it is impossible to write a single file that loads as both a CommonJS module and an ECMAScript module because export is a privileged keyword that cannot be conditionally executed or overloaded and it throws if it is not satisfied or is encountered in a file not loaded as a module and the error cannot be caught because the keyword is restricted to the global scope (and therefore cannot be wrapped in a try/catch block). If export failed with a non-fatal warning, like much of the rest of ECMAScript that deals with resources, there would be no problem writing actual modules that could load as CommonJS and ES6 simultaneously.

API Description

Type(var);

Returns a string describing the type of the supplied variable. This string is similar to the type tag that can be retrieved from an object using Object.prototype.toString.call(), but it is not wrapped in superfluous text and cannot be fooled by defining custom string tags on an object.

object typeof toString.call Type()
null object [object Null] Null
new Date() object [object Date] Date
/re/ object [object RegExp] RegExp
async function(){} function [object AsyncFunction] AsyncFunction
new ArrayBuffer(8) object [object ArrayBuffer] ArrayBuffer
a = []; a[Symbol.toStringTag] = 'Bogus'; object [object Bogus] Array

Type.tag(var);

Similar to Object.prototype.toString.call(), but reads directly from the relevant symbol on the object or its prototype. It does not wrap the value returned in [object ...], preferring to return just the tag itself ('Map', 'Array', etc.). Use this when you want to be 'fooled' by toStringTag overloads.


Type.getPD(obj);

Equivalent to Object.getOwnPropertyDescriptors(obj).


Type.fixPD(source, attribute_default, source_filter, [container]);
Type.create(prototype, source, attribute_default, source_filter, [container]);
Type.add(target, source, attribute_default, source_filter, [container]);

prototype
The prototype to use (when creating a new object).
target
The target object (when adding properties).
source
An object that supplies the properties to assign. The keys of this object use a special syntax to support attribute overrides and property-name aliases (see below).
default_attributes
An octal digit that specifies the attributes to use when assigning new properties: 0 = no permissions, 1 = enumerable, 2 = writable, 4 = configurable, etc. The default is to make all properties enumerable+writable (3) except functions, which are made enumerable only (1). The setters/getters of aliases are always hidden, to keep from cluttering up object iteration.
# C W E
0 - - -
1 - -
2 - -
3 -
4 - -
5 -
6 -
7

The default attributes can be overridden on a per-property basis by appending a colon and digit to the property-name-string (see example below).

source_filter
Only those properties in source having these attributes will be considered when generating new target properties. Uses the same octal schema as default_attributes, above.
container
When using the '*' syntax (see below), the backing object for hidden values can optionally be be reflected into the result, allowing those hidden values to be accessed directly. The container parameter gives the name to use for this reflection property. It defaults to '$', but can be set to null if you don’t want the backing object to be reflected into the result at all.
(return value)
Type.fixPD() returns an object containing the 'fixed' property descriptors from source.
Type.create() returns a new object created from the 'fixed' property descriptors.
Type.add() returns target object with the 'fixed' properties added to it.

The source object may contain special key-like-strings that specify...

  • what aliases, if any, should exist for a given property value,
  • what attributes the values and accessors should have (enumerable, writeable, configurable), and
  • whether values should be attached to the first name in the list, or “hidden” in a special backing object.
const o = Type.create(Object.prototype, {
  'foo,alias:0': 123,
  'bar:5': function() { return this.foo; },
  'cat,dog,mammal,animal': true,
  'paws,mits*': { value: 4, set: "if (typeof v == 'number') $v = v;" },
  raw: 'no aliases'
});

console.log(Object.getOwnPropertyDescriptors(o));
{
  raw: {
    value: 'no aliases',
    writable: true,
    enumerable: true,
    configurable: false
  },
  foo: {
    value: 123,
    writable: false,
    enumerable: false,
    configurable: false
  },
  alias: {
    get: [Function: get],
    set: [Function: set],
    enumerable: false,
    configurable: false
  },
  bar: {
    value: [Function: bar:5],
    writable: false,
    enumerable: true,
    configurable: true
  },
  cat: {
    value: true,
    writable: true,
    enumerable: true,
    configurable: false
  },
  dog: {
    get: [Function: get],
    set: [Function: set],
    enumerable: true,
    configurable: false
  },
  mammal: {
    get: [Function: get],
    set: [Function: set],
    enumerable: true,
    configurable: false
  },
  animal: {
    get: [Function: get],
    set: [Function: set],
    enumerable: true,
    configurable: false
  },
  '$': {
    value: { paws: 4 },
    writable: false,
    enumerable: false,
    configurable: false
  },
  paws: {
    get: [Function (anonymous)],
    set: [Function: setter],
    enumerable: true,
    configurable: false
  },
  mits: {
    get: [Function (anonymous)],
    set: [Function: setter],
    enumerable: true,
    configurable: false
  }
}

...as you can see, the resulting object 'o' has four properties and four aliases:

  • o.raw ⇒ an ordinary property that inherits the default attributes (enumerable+writable)
  • o.foo ⇒ a 'hidden' number that is not enumerable, writable or configurable
  • o.alias ⇒ an alias to o.foo
  • o.bar ⇒ an enumerable+configurable function reference
  • o.cat ⇒ a Boolean value
  • o.dog | o.mammal | o.animal ⇒ aliases to o.cat
  • o.paws | o.mits ⇒ aliases to o.$.paws

The first property name in a key-list is used to label the actual value; all the other names in a list are accessors (getters/setters) of that first property name. If the optional '*' suffix is added to a name list, the value for all the names in the list is instead stored in a “hidden” object—the first name in the list winds up being just another accessor to that value. These '*'-lists take objects as arguments, and the sub-properties of those objects are very similar to those used by Object.defineProperty(), except that they are restricted to...

value
The value assigned to the hidden property value.
get
A getter for the hidden value. Although “normal” aliases are also implemented as accessors, those default accessors provide no way for the user to override their logic. The '*' syntax allows the user to define fully-custom accessors, which then are used by all the names in the list. The raw value can still be accessed directly, if there is a reference to the backing object in the result, which there is, by default, in the non-enumerable property '$'. If a custom getter is not defined by the user, a default getter that returns the hidden value will be used (in other words, this sub-property is optional).
set
A setter for the hidden value. No default is provided, so if the user does not supply one, the resulting value will be read-only.

Getters and setters defined using the '*' syntax need not actually be functions, although that is still an option. Simple strings giving the relevant logic of the getter or setter will suffice. In those strings, the value supplied to a setter will be identified by the variable v. The actual backing variable itself can be accessed through $v. If you write an actual function, no such translation will be performed, and you will have to use the “real” object path to the backing variable, so you might have to write something like, 'foo,bar*': { value: 123, set(x) { this.$.foo = x; } } instead of 'foo,bar*': { value: 123, set: '$v = v;' }. Note that the path can change if you supply a container parameter, which sets the identifier name for the backing object.

Performance-critical code may elect to bypass accessors and handle values directly, even for objects defined with the '*' syntax. Ordinarily, the first name in the property name list is used to label the actual value, but if the '*' syntax is used, all the names will be aliases, and if you want direct access to the value, you will need to do it through the backing object reference, something like myObject.$.myValue. If you invoke Type.fixPD(), Type.create() or Type.add() with a null value for the container parameter, no reference to the backing object will appear in the resulting object, so there will be no “fast path” access to any such values.

The drawback to this property label processing scheme is that it precludes the possibility of defining “ordinary” property names containing commas, colons or asterisks. Since most people probably do not consider such property names to be “ordinary” anyway, this was considered to be an acceptable trade-off.

Another example will help clarify all this.

const o = Type.create(Array.prototype, {
  // The first property uses a functional style setter, so the data must be explicitly referenced.
  'foo,a1*7': { value: 'text', set(x) { this.$$.foo = x + ' text'; }},
  'bar,a2*1': { value: 123 },
  // Using a string as a setter results in automatic source/target translation.
  'a3*': { value: new Date(), set: "$v = (typeof v == 'string') ? new Date(v) : v;" }
}, null, null, '$$');

console.log(Object.getOwnPropertyDescriptors(o));

-------
// The data object gets reflected into the result with the name '$$' instead of the default '$'
// It is never made enumerable, writeable or configurable. Moreover, it is sealed. (Not the values.)
// If null had also been used for the 'container' parameter, the data would not have been reflected
// and direct access would be impossible.
  '$$': {
    value: { foo: 'text', bar: 123, a3: '2020-04-30T14:17:25.866Z' },
    writable: false,
    enumerable: false,
    configurable: false
  },
// 'foo' and 'a1' are both accessors for the value in '#.$$.foo'
// The first name in the list is always used to label the value in the reflected data structure.
  foo: {
    get: [Function (anonymous)],
    set: [Function: set],
    enumerable: true,
    configurable: true  // defined with '7' attribute flag := all options, including configurable
  },
  a1: {
    get: [Function (anonymous)],
    set: [Function: set],
    enumerable: true,
    configurable: true
  },
// 'bar' and 'a2' are accessors for '#.$$.bar'
// Since no setter was declared for these, the value is read-only (unless read directly)
  bar: {
    get: [Function (anonymous)],
    set: undefined,
    enumerable: true,
    configurable: false  // attribute flag was '1', so only enumerable
  },
  a2: {
    get: [Function (anonymous)],
    set: undefined,
    enumerable: true,
    configurable: false
  },
// 'a3' accesses '#.$$.a3'
  a3: {
    get: [Function (anonymous)],
    set: [Function: setter],
    enumerable: true,
    configurable: false  // default attribute flag is '3' := enumerable+writeable
  }
// Note that accessors never have a 'writeable' attribute, so that flag value is irrelevant.
-------

console.log(o.foo);
// => 'text'

o.foo = 'hello'; console.log(o.a1);
// => 'hello'

o.a3 = '1980-10-01'; console.log(o.a3);
// => 1980-10-01T00:00:00.000Z

If you do not reflect the data object into the result (i.e., if you use null as the container parameter), then you must declare all getters and setters using string style, as there will not be any way to access the data with 'this'. Since string-style getters and setters are translated at construction time, there is no need to use 'this' to reference any data.


Type.fill(target, source, [filter]);

Assigns selected properties of source, which may be an Object or Map, to target, which must be an Object. Properties are selected either be being in the target object already (presumably with a different value), or by being present in a filter object, which may itself be an Array, Object, Map or Set. Only the keys of Maps and Objects are referenced in a filter.

If the target object has aliases defined on it (see above), multiple keys from source may map to the same actual value in target, in which case the last property encountered in source as it is being iterated will prevail. This ambiguity is actually quite useful for allowing options to be satisfied by multiple keywords, as the following example shows:

const prog_opt = Type.create(Object.prototype, {
  'color,fgColor,foregroundColor': 'black',
  'bgColor,backgroundColor': 'white'
});
let user_opt1 = { color: 'red', bgColor: 'yellow', foo: 'purple' };
let user_opt2 = { fgColor: 'red', bgColor: 'yellow', bar: 'purple' };
let user_opt3 = { foregroundColor: 'red', backgroundColor: 'yellow' };

Type.fill(prog_opt, user_opt1);
Type.fill(prog_opt, user_opt2);
Type.fill(prog_opt, user_opt3);

In this example, all of the user option structures produce the same program option object. Essentially, the user can choose between label aliases in specifying a given option instead of being forced to use a canonical form. Any 'invalid' properties will be ignored.


Type.merge(target, source, keys, props);

target
The container to merge elements into, either an Object, Array, Map or Set.
source
Supplies elements to transfer. It does not need to be the same type as the target.
keys
If the target is an Array or Set, then Object and Maps will not be able to transfer both keys and values to them simultaneously. If this flag is set, the keys from the Object or Map are transferred; if it is clear, the values are transferred.
props
If the source object is not an actual Object, any properties defined on the container (as opposed to elements of the container) will only be copied if this flag is set. Properties are always copied if the source is an actual Object, because it has no other elements to provide.
console.log(Type.merge(new Map([['foo',64]]), { bar: 23 })); // Map(2) { 'foo' => 64, 'bar' => 23 }
console.log(Type.merge([1,2,3], { foo: 4, bar: 6 })); // [ 1, 2, 3, 4, 6 ]
console.log(Type.merge([1,2,3], { foo: 4, bar: 6 }, true)); // [ 1, 2, 3, 'foo', 'bar' ]
console.log(Type.merge([1,2,3], Object.defineProperty([4,5,6], 'foo', { enumerable: true, value: 23 }), null, true));
// [ 1, 2, 3, 4, 5, 6, foo: 23 ]

Type.asMap(source, [recursive]);

Copies all the elements from a source Array, Map or Set, or copies all the enumerable, intrinsic ("own") properties from a source Object, into a new stand-alone Map object. Useful for allowing code to be written for a single interface (namely, Map's) while accepting data from any container type. Arrays elements supply keys for the resulting Map entries, while their indices (i.e., order) supplies the values. Set elements are used as both the keys and values.

If the recursive flag is set to a Number or true, Type.asMap() will recursively convert containers in the appropriate number of object levels (or all of them) to Maps. Because Maps can use object instances, like Arrays and Sets, as actual keys, it would be difficult to distinguish between objects intended to be keys and objects intended to be collections on a level-by-level basis, so they are never recursively converted.

console.log(Type.asMap({ foo: 12, bar:64 }));    // Map(2) { 'foo' => 12, 'bar' => 64 }
console.log(Type.asMap(['foo', 'bar']));         // Map(2) { 'foo' => 0, 'bar' => 1 }
console.log(Type.asMap(new Set(['foo','bar']))); // Map(2) { 'foo' => 'foo', 'bar' => 'bar' }
console.log(Type.asMap({ foo: 12, bar: { yes: 23, no: 34 }}, true));
// Map(2) { 'foo' => 12, Map(2) { 'yes' => 23, 'no' = 34 } }

Type.asIndex(object, elements, default_index);

object
Any object, which may or may not be covertible to a Number, and which may or may not be in-bounds.
elements
If object can be converted to a Number (or is a Number), it must lie between zero and this value. Default is 230, to provide a safe margin on V8's index limit of ±231.
default_index
If object cannot be converted to a Number, or is out-of-bounds, this index will be returned.
let a = [10,20,30,40,50], i = 45;
console.log(a[Type.asIndex(i, 5, 0)]); // 10
console.log(a[Type.asIndex(i - 41, 5, 0)]); // 50

Index();
Index(initial_value, [mask]);
Index(block_list);
Index(block_function, [...arguments]);

If no argument is supplied, a function is returned that increments a counter from zero each time it is called (the default is to increment by one, unless an argument is supplied giving the increment value). It returns the pre-increment value. This allows post-increment expressions to be created in-line, like pointers in C++.

If a Number is supplied as the initial argument, that value is used to set the counter's initial value. If an optional integer mask is supplied, it will be ANDed with the counter after each operation...cheap modulus wrapping for rings.

If an Array is supplied, it may take two forms: [block_size, base1, base2, base3, ...] or [[base1, length1], [base2, length2], ...]. The first form uses a uniform block size built on any number of block bases; the second form supplies both the base and length for each block explicitly. Index() will thread blocks transparently, updating the counter when space in one block is exhausted with the next block base. If an increment is too large to fit in the remaining 'space' within a block, the next block of suitable size is returned.

If a Function is supplied as the initial argument, it is invoked both to supply the base and length of the initial block and each following block. The callback function is invoked with the following parameters: block_threader(null, null, ...threader_arguments) ...to supply the first block, and... block_threader(block_base, block_length, ...threader_arguments) to supply all subsequent blocks. It should return an array [block_base, block_length] that describes the base and length of the next block, which will be supplied to it again, in turn, when another block is need (i.e., they are 'stable' and can be used as keys).

The object returned by Index() is itself a function. That function takes one optional parameter: the number by which to increment (or decrement, for negative numbers) the value of the counter. It returns the unincremented value, however, turning the function into a sort of 'pseudo-post-incremented-pointer' for pointerless languages, like ECMAScript.

There are also a few properties defined on the returned function-object:

  • value — returns the current value of the internal index
  • octets — returns the total number of increments (minus decrements) applied to the internal index; this is not that same as the difference between starting and ending index values, owing to block spanning, and that fact that some blocks may be left partially (or completely) empty if they are not big enough to satisfy a request
  • reset() — sets the internal index value to zero

A few examples will be much more instructive than further verbiage expended in explanation:

let t = Index();
console.log(t.value); // 0
console.log(t());     // 0
console.log(t(8));    // 1
console.log(t(-1));   // 9
console.log(t.value); // 8
console.log(t.octets);// 8

t = Index(128, 0xff);
console.log(t.value); // 128
console.log(t(64));   // 128
console.log(t(64));   // 192
console.log(t(64));   // 0
console.log(t(64));   // 64
console.log(t.value); // 128
console.log(t.octets);// 256

t = Index([100, 0, 200]);
console.log(t.value); // 0
console.log(t(50));   // 0
console.log(t(50));   // 50
console.log(t(50));   // 200
console.log(t.value); // 250
console.log(t.octets);// 150

t = Index([[100, 100],[300, 100]]);
console.log(t.value); // 100
console.log(t(50));   // 100
console.log(t(50));   // 150
console.log(t(50));   // 300
console.log(t.value); // 350
console.log(t.octets);// 150

let base = [1000, 2000], i = 0;
t = Index(() => [base[i++], 256]);
console.log(t.value); // 1000
console.log(t(128));  // 1000
console.log(t(128));  // 1128
console.log(t(128));  // 2000
console.log(t.value); // 2128
console.log(t.octets);// 384

Pool(max, allocater, [...allocator_instance_args]);

A very simple object pooler that stores references to freed objects, then provides them again in preference to invoking the allocator the next time one is needed. Obviously, this only works for objects that can be reused (not Promises, for instance) and presumably the object is expensive to reconstruct. It basically functions as a push-down-stack that keeps discarded objects "out of your way" until needed again, with the benefit that it automatically allocates new objects if the cache of freed objects is empty.

function alloc(...s) { this.version = s[0]; }
const pool1 = Pool(4, alloc, 'first');  // holds sixteen freed objects, parameterized with 'first'
const pool2 = Pool(4, alloc, 'second'); // another sixteen objects, parameterized with 'second'

let x = pool1(), y = pool2();
console.log(x.version, y.version); // 'first' 'second'
pool1.free(x); pool2.free(y);

Run the validate.js or validate.html files to obtain some time comparisons.


RingBuffer(n);

Creates a ring buffer with 2n elements (default 210 = 1024), of which (2n – 2) elements are available for data (the remaining two elements are used as the 'head' and 'tail' elements, which cannot overlap). The resulting object supports the following methods:

queue(v)
Adds a value to the tail of the ring.
unqueue()
Pops a value from the tail of the ring.
tail()
Returns the tail value without popping it.
push(v)
Adds a value to the head of the ring.
pop()
Pops a value from the head of the ring.
head()
Returns the head value without popping it.
skip()
Pops the head value and returns the value under it (without popping it).
const r = RingBuffer();
r.queue(42); r.queue(18); console.log(r.pop(), r.pop()); // 42 18
r.queue(10); r.queue(20); console.log(r.skip()); // 20
r.queue(35); r.queue(45); console.log(r.unqueue(), r.unqueue()); // 45 35

Because the RingBuffer never reallocates its storage or copies its elements, it benefits from constant insertion/removal time at both ends of the ring. For push()/pop() operations, it is as fast as an Array at ring sizes of about 512 elements, and faster for larger stacks; for queue()/unqueue() operations, it is faster than an Array at any size.

Of course, since it never reallocates, you can exhaust the ring's buffer space if you do not size it sufficiently. An Array, on the other hand, will be dynamically and automatically resized as the number of elements increase, though it will get slower as its reallocation window gets larger.


callHistory(function, n);

Remembers 2n (default: 25 = 32) invocations of a function. Both the call parameters and result generated are recorded and can be recalled at any subsequent time through the history virtual property added to the resulting function-object.

This feature is implemented by defining a Proxy on the wrapped function, and proxies are slow. Do not use this on functions that support critical loops in production code!

const f = callHistory(s => 'result of ' + s);
f('first call'); f('second call'); f('third call'); f('fourth call');
console.log(f.history);
[
  [ 4, 'result of fourth call', 'fourth call' ],
  [ 3, 'result of third call', 'third call' ],
  [ 2, 'result of second call', 'second call' ],
  [ 1, 'result of first call', 'first call' ]
]

capture(string, regular_expression, ...default_values);

Applies regular_expression to string and returns an array containing only the captures found, if any. Default values provided in the 'rest' parameter will substitue for any missing captures.

If the default value for a particular capture is a Number or a Date, the string from that capture position will be converted to a Number or Date value before it is returned. The default will also be returned if the capture cannot be converted to the default's type, even if the capture matches something.

console.log(capture('12345 number 2000-01-01 date', /(\d+) (number) ([^ ]*) (date)/,
    999, 999, new Date(), new Date()));
[ 12345, 999, 2000-01-01T00:00:00.000Z, 2020-02-14T21:02:15.021Z ]

Notice that the second capture returns the default value, because 'number' cannot be converted to an actual Number. Likewise, since 'date' is not convertible to a real Date, the fourth capture returns the default (read the date values).


forEach(iterable, callback, [initializer], [finalizer], [code_points], [this], [max_elements], [max_properties], [initial_sequence_value]);

iterable
An iterable container, property-containing element, or string. If a string is provided, the characters will be iterated over. If a non-iterable or scalar element is supplied, it will be fed to the callback directly.
callback
The function that will be called for each element of the supplied iterable. It is called with
callback(value, key, accumulator, source_type, sequence);
value
The value of the instant element.
key
The key for the instant element. If the source object is an Array, the key will be the index for element. This is not necessarily the same as the sequence value (i) because the array might have holes (but the sequence does not).
accumulator
An arbitrary value that is initially set to the return value of the initializer (q.v.), and can subsequently be updated by the iteration callback every time it explicitly returns a value. If no value is returned by the callback, the accumulator is unchanged. (More detailed description provided below, and in the examples.)
source_type
An integer that indicates the source providing this element:
  1. Property, either from an actual Object, or defined on some other type of container, like an Array or Map (uncommon, but it happens).
  2. Map element.
  3. Array element.
  4. Set member.
  5. UCS-2 character code (16-bit).
  6. UTF code point (21-bit).
Certain containers, like Maps and Array, can have both intrinsic elements and object properties. If neither is filtered out (see max-elements sections, below), both will be fed to the callback and distinguished by source_type.
sequence
This is a monotonic counter. It starts at initial_sequence_value, which default to zero, and increases by one each time the callback is invoked. It continues to increase linearly as forEach iterates through elements and properties, unlike an array index value.
initializer
If this value is a function, it is invoked to create the initial value of the accumulator; if it is not a function, initializer is used verbatim as the accumulator. An initialization function is called with iterable as its only argument. Its return value becomes the accumulator's initial value. Defaults to <undefined>.
finalizer
A function that is used to finalize the accumulator before it is returned by forEach(). Its call signature is:
finalizer(accumulator, count, all_elms?, all_props?, iterable);
accumulator
The value of the accumulator after all the permitted elements have been iterated.
count
The actual number of elements iterated.
all_elms?
True, if all the elements in iterable were processed; false, if some were left out due to limits placed on iteration by max_elements.
all_props?
True, if all properties of iterable were processed; false, if some were left out.
iterable
The object supplied to forEach to iterate over.
code_points
The default is to iterate strings by UCS-2 character codes. If you need to iterate by UTF code points instead, set this flag.
this
Object to use as the 'this' argument when invoking callback, initializer and finalizer. Defaults to iterable
max_elements
The maximum number of intrinsic elements to process (for Arrays, Maps and Sets). Defaults to 252.
max_properties
The maximum number of properties to process. If iterable has both intrinsic elements and properties, this will control only the properties. Defaults to 252.
initial_sequence_value
Sets the sequence origin for the callback parameter. Defaults to zero. Has no effect on the count value that is given to the finalizer.
log(forEach(['foo','bar'], (v,k,a) => a += `<li>${v}</li>`, '', a => `<ul>${a}</ul>`));
<ul><li>foo</li><li>bar</li></ul>

partition(number, partition_array, [overflow_value]);

number
The number to be sorted into its appropriate range.
partition_array
An sorted array of two-element sub-arrays defining a partition on a numeric range. A partition can be thought of as an arbitrary set of 'dividers' that are placed on the number line, implicitly defining a set that consists of the span between every adjacent divider. Academic grades, for example, are usually treated as a partition of scores ranging from zero to one-hundred. The partition_array defines the cut-off scores for each grade, if you will. Each sub-array consists of two elements: the first gives the cut-off value that a number must be less-than-or-equal-to in order 'belong' in that bin, while the second is some arbitrary value that is assigned to members of the bin.
overflow_value
Because of its relational definition, all values less than the first 'divider' will be placed into that bin and assigned its return value, but what of elements that are greater than the last divider? This 'ultra-bin' can be defined in one of two ways: implicitly, where only the return value of the last element in partition_array is used (i.e., it does not define another 'divider' and any cut-off value it gives is ignored); or explicitly, in which case all elements of partition_array define 'dividers' and overflow_value is returned for numbers greater than the highest 'divider'.
const { partition } = require('./util.js');
const grade_partition = [[59, 'F'], [69, 'D'], [79, 'C'], [89, 'B']];
const score = [-1, 59, 60, 69, 70, 79, 80, 89, 90, 99, 100, 110];
const grade = new Map();
for (const e of score) grade.set(e, partition(e, grade_partition, 'A'));
console.log(grade);
Map(12) {
  -1 => 'F',
  59 => 'F',
  60 => 'D',
  69 => 'D',
  70 => 'C',
  79 => 'C',
  80 => 'B',
  89 => 'B',
  90 => 'A',
  99 => 'A',
  100 => 'A',
  110 => 'A'
}

This function is implemented as a binary search on partition_array, so it can scale to very large partitions with little run-time penalty. The downside, if any, is that partition_array must be sorted in strictly-increasing order by cut-off values.

Minifier Utility

Included in this distribution is a modest (read that as: "not very robust") minification utility. Unlike most minifiers, line numbers are preserved between the source and target files. The newline character is itself a statement terminator in ECMAScript and can often substitute for a semicolon, so there isn't much to be gained from eliminating newlines.

The benefit of preserving line structure in the minified file is that you can use it without a code map. You can simply open up the un-minified file and navigate directly to the reported line. There's very little difference in compressability between files that use newlines as terminators and files that do not...provided that you do not leave in huge, vertical blocks of comments that minify to vast, empty expanses of whitespace. Comments that are placed at the end of a line will minify out completely, however.

To run the minifier, use node minify my_source_file.js my_target_file.js. If you leave out the target, the result will be dumped to standard output.

Help Undercat buy kibble to fuel his long nights of coding! Meow! 😺