# Introduction

**js-text-tools** is just a little collection of tools for modifying text. It has both programmatic and command line APIs.

# Installation

To use in the command line:

```bash
git clone https://git.sr.ht/~jrc03c/monorepo
cd monorepo/packages/js-text-tools
npm link
```

Or to use in Node or the browser:

```bash
npm install --save @jrc03c/js-text-tools
```

# Usage

In Node / bundlers:

```js
const {
  camelify,
  collapseWhitespace,
  convertObjectToTypedArray,
  convertTypedArrayToObject,
  damerauLevenshteinDistance,
  EmailAddressStandardizer,
  extractEmailAddresses,
  fuzzyFind,
  fuzzyFindScore,
  getCharCounts,
  getChars,
  getCharSet,
  getIDFScore,
  getNGramCounts,
  getNGrams,
  getNGramSet,
  getStats,
  getTFIDFScore,
  getTFScore,
  indent,
  isEmailAddress,
  isNumberString,
  kebabify,
  levenshteinDistance,
  parse,
  pascalify,
  punctuation,
  removeDiacriticalMarks,
  screamify,
  screamingSnakeify,
  snakeify,
  spongeify,
  standardizeEmailAddress,
  StringCounter,
  stringify,
  strip,
  TextObject,
  TextStats,
  unindent,
  urlPathJoin,
  wrap,
} = require("@jrc03c/js-text-tools")
```

In the browser (standalone):

```html
<script src="path/to/dist/js-text-tools.js"></script>
<script>
  // import functions individually
  const {
    camelify,
    collapseWhitespace,
    indent,
    // etc.
  } = JSTextTools

  // or dump everything into the global scope
  JSTextTools.dump()
</script>
```

In the command line (where all results are written out to `stdout`):

```bash
camelify "Hello, world!"
# helloWorld

kebabify "Hello, world!"
# hello-world

snakeify "Hello, world!"
# hello_world

# indent the lines of somefile.txt by two spaces
indent somefile.txt "  "

# unindent the lines of somefile.txt
unindent somefile.txt

# wrap the lines in somefile.txt at 80 characters and show the output in stdout
wrap somefile.txt

# wrap the lines in somefile.txt at 40 characters and save the wrapped text
# back into somefile.txt
wrap -m 40 -s somefile.txt

# wrap the lines in somefile.txt to 80 characters and save the wrapped text
# into a new file called somewrappedfile.txt
wrap -o somewrappedfile.txt somefile.txt
```

# API

## `camelify(text)`

Returns the text in camel-case.

```js
camelify("Hello, world!")
// helloWorld
```

## `collapseWhitespace(text)`

Returns the text with all whitespace reduced to single spaces and all leading and trailing whitespace removed.

```js
collapseWhitespace("\n\t   \n\r \t Hello,    \t world!\n\n \t   ")
// Hello, world!
```

## `damerauLevenshteinDistance(text1, text2)`

Returns the [Damerau-Levenshtein distance](https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance) between two strings. This metric differs from the traditional Levenshtein distance in that it considers transpositions between adjacent characters to be a single operation (as opposed to two substitution operations).

```js
const a = "brat"
const b = "bart"

damerauLevenshteinDistance(a, b)
// 1

levenshteinDistance(a, b)
// 2
```

## `EmailAddressStandardizer`

### `EmailAddressStandardizer(options)` (constructor)

The constructor can accept an options object with any of these properties (corresponding to the instance properties described below):

- `shouldConvertDomainToPunycode`
- `shouldRemoveDiacriticalMarksInUsername`
- `shouldRemovePeriodsInUsername`
- `shouldRemoveTagsInUsername`

### Properties

#### `shouldConvertDomainToPunycode`

A boolean indicating whether or not the domain name part of the email address (e.g., the "example.com" part of "someone@example.com") should be converted to its [Punycode](https://en.wikipedia.org/wiki/Punycode) equivalent. The default is `false`.

For example:

```js
const standardizer = new EmailAddressStandardizer()
console.log(standardizer.standardize("sömeoné@exåmple.com"))
// someone@exåmple.com

standardizer.shouldConvertDomainToPunycode = true
console.log(standardizer.standardize("sömeoné@exåmple.com"))
// someone@xn--exmple-jua.com
```

#### `shouldRemoveDiacriticalMarksInUsername`

A boolean indicating whether or not diacritical marks on characters in the username part of the email address (e.g., the "someone" in "someone@example.com") should be removed. The default is `true`.

For example:

```js
const standardizer = new EmailAddressStandardizer()
console.log(standardizer.standardize("sömeoné@exåmple.com"))
// someone@exåmple.com

standardizer.shouldRemoveDiacriticalMarksInUsername = false
console.log(standardizer.standardize("sömeoné@exåmple.com"))
// sömeoné@exåmple.com
```

#### `shouldRemovePeriodsInUsername`

A boolean indicating whether or not periods in the username part of the email address (e.g., the "someone" in "someone@example.com") should be removed. The default is `false`.

For example:

```js
const standardizer = new EmailAddressStandardizer()
console.log(standardizer.standardize("s.o.m.e.o.n.e@example.com"))
// s.o.m.e.o.n.e@example.com

standardizer.shouldRemovePeriodsInUsername = true
console.log(standardizer.standardize("s.o.m.e.o.n.e@example.com"))
// someone@example.com
```

#### `shouldRemoveTagsInUsername`

A boolean indicating whether or not tags in the username part of the email address (e.g., the "+whatevs" in "someone+whatevs@example.com") should be removed. The default is `false`.

For example:

```js
const standardizer = new EmailAddressStandardizer()
console.log(standardizer.standardize("someone+whatevs@example.com"))
// someone+whatevs@example.com

standardizer.shouldRemoveTagsInUsername = true
console.log(standardizer.standardize("someone+whatevs@example.com"))
// someone@example.com
```

### Methods

#### `standardize(emailAddress)`

Returns the standardized form of `emailAddress`.

#### `transform(emailAddress)`

Is an alias for the `standardize` method.

## `extractEmailAddresses(x)`

Returns an array of email addresses present in `x`. Note that `x` can have any data type.

```js
const x = "Hi! I'm Josh, and you can email me at foo@bar.com any time!"
console.log(extractEmailAddresses(x))
// ["foo@bar.com"]

const y = { name: "Josh", email: "foo@bar.com" }
console.log(extractEmailAddresses(y))
// ["foo@bar.com"]
```

## `fuzzyFind(query, docs, resultsCount=1, maxNGramLength=1)`

Returns the best-scoring result(s) from `fuzzyFindScore`. (See that function's documentation below for more information about the returned results.) If `resultsCount` is greater than 1, then an array of results will be returned; otherwise only a single result (not in an array) will be returned.

```js
const query = "cat"
const docs = ["I like bars", "I like cars", "I like cats"]

// return a single result:
console.log(fuzzyFind(query, docs))
// { matches: [ 'cats' ], score: 1, doc: 'I like cats' }

// return multiple results:
console.log(fuzzyFind(query, docs, 3))
// [
//   { matches: [ 'cats' ], score: 1, doc: 'I like cats' },
//   { matches: [ 'cars' ], score: 0.75, doc: 'I like cars' },
//   { matches: [ 'bars' ], score: 0.4375, doc: 'I like bars' }
// ]
```

## `fuzzyFindScore(query, docs, maxNGramLength=1, shouldOmitDocsFromResults=false)`

Returns an array of objects (one per item in `docs`) with these properties:

- `doc` = the string that was searched (included if `shouldOmitDocsFromResults` is not `true`)
- `matches` = the part of the string (`doc`) that best matched `query`
- `score` = how well the string matched the query (between [0, 1], with higher scores corresponding to better matches)

```js
const query = "cat"
const docs = ["I like bars", "I like cars", "I like cats"]
console.log(fuzzyFindScore(query, docs))
// [
//   { matches: [ 'bars' ], score: 0.4375, doc: 'I like bars' },
//   { matches: [ 'cars' ], score: 0.75, doc: 'I like cars' },
//   { matches: [ 'cats' ], score: 1, doc: 'I like cats' }
// ]
```

Note that, by default, the function will not search over all of a document's (_n_ > 1)-grams; it will only search over individual words. This means, for example, that if you search for "foo bar" and one document contains "foo bar" and another document contains "bar foo", then both of those documents will receive the same score.

```js
const query = "foo bar"
const docs = ["foo bar", "bar foo"]
console.log(fuzzyFindScore(query, docs))
// [
//   { matches: [ 'foo', 'bar' ], score: 1 },
//   { matches: [ 'foo', 'bar' ], score: 1 }
// ]
```

That's because the function's default behavior is to take the mean over all of the comparison scores between all of the search terms and all of the terms in each document; and in the example above, both documents contain both search terms, and thus receive the same scores.

But the documents and their scores can be differentiated by passing in a higher `maxNGramLength` value. In the example above, if we pass in a `maxNGramLength` of 2 (meaning that not only will our search terms "foo" and "bar" be compared to the documents but also that the 2-gram "foo bar" will be compared with all of the 1- and 2-grams in all of the documents), the document containing the 2-gram "foo bar" will receive a better score than the document that merely contains the same words but in the wrong order.

```js
const query = "foo bar"
const docs = ["foo bar", "bar foo"]
const maxNGramLength = 2
console.log(fuzzyFindScore(query, docs, maxNGramLength))
// [
//   { matches: [ 'foo', 'foo bar' ], score: 1 },
//   { matches: [ 'bar foo', 'bar' ], score: 0.891156462585034 }
// ]
```

The reason to _avoid_ increasing the `maxNGramLength` value, though, is that it adds significantly more execution time.

## `getCharCounts(raw)`

Returns a dictionary containing the number of times each character appears in `raw`.

```js
const raw = "a ab abc abcd abcde"
console.log(getCharCounts(raw))
// { a: 5, ' ': 4, b: 4, c: 3, d: 2, e: 1 }
```

## `getChars(raw)`

Returns the list of all characters in `raw`. Can include duplicates. (To get the list of unique characters in `raw`, use `getCharSet`.)

```js
const raw = "a ab abc abcd abcde"
console.log(getChars(raw))
// [
//   'a', ' ', 'a', 'b', ' ',
//   'a', 'b', 'c', ' ', 'a',
//   'b', 'c', 'd', ' ', 'a',
//   'b', 'c', 'd', 'e'
// ]
```

## `getCharSet(raw)`

Returns the list of unique characters in `raw`. (To get the full list of characters that potentially includes duplicates, use `getChars`.)

```js
const raw = "a ab abc abcd abcde"
console.log(getCharSet(raw))
// [ 'a', ' ', 'b', 'c', 'd', 'e' ]
```

## `getIDFScore(term, allDocStats)`

Returns the inverse document frequency (IDF) score of a string called `term` given an array of `TextStats` instances (i.e., the things returned from the `getStats` function).

```js
import { getIDFScore, getStats } from "@jrc03c/js-text-tools"
import fs from "node:fs"
import path from "node:path"

const dir = "path/to/files"
const files = fs.readdirSync(dir)
const docs = files.map(f => fs.readFileSync(path.join(dir, f), "utf8"))
const maxNGramLength = 1
const allDocStats = docs.map(d => getStats(d, maxNGramLength))
console.log(getIDFScore("hello", allDocStats))
```

Note that the formula used to compute the inverse document frequency score is my own variant and is kind of a hodge-podge of [the formulae for computing inverse document frequency on Wikipedia](https://en.wikipedia.org/wiki/Tf%E2%80%93idf#Inverse_document_frequency). Using Wikipedia's notation, my formula is something like:

$\text{idf}(t, D) = 1 - (\dfrac{n_t}{N})^{0.25}$

Where:

- $t$ = the term
- $D$ = the corpus of documents
- $N$ = the number of documents in $D$
- $n_t$ = the number of documents in $D$ in which $t$ appears

The traditional inverse document frequency formula is $-log(\dfrac{n_t}{N})$, which looks like this:

![](data:image/png;base64,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)

I suppose I've always found it a bit odd that the traditional IDF score was allowed to be arbitrarily large. Maybe there's good reason for it. But my formula returns a maximum possible value of 1:

![](data:image/png;base64,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)

## `getNGramCounts(raw, maxNGramLength=Infinity)`

Returns a dictionary containing the number of times each _n_-gram appears in `raw`.

```js
const raw = "a a b a b c a b c d a b c d e"
console.log(getNGramCounts(raw, 3))
// {
//   a: 5,
//   'a a': 1,
//   'a a b': 1,
//   'a b': 4,
//   'a b a': 1,
//   b: 4,
//   'b a': 1,
//   'b a b': 1,
//   'a b c': 3,
//   'b c': 3,
//   'b c a': 1,
//   c: 3,
//   'c a': 1,
//   'c a b': 1,
//   'b c d': 2,
//   'c d': 2,
//   'c d a': 1,
//   d: 2,
//   'd a': 1,
//   'd a b': 1,
//   'c d e': 1,
//   'd e': 1,
//   e: 1
// }
```

## `getNGrams(raw, maxNGramLength=Infinity)`

Gets the list of _n_-grams in `raw`. Is allowed to include duplicates. (To get the set of unique _n_-grams, use `getNGramSet`.)

> **NOTE:** Be aware that this function doesn't do any cleaning of the text, which means that (e.g.) "Hello" is treated as a different _n_-gram than "Hello!".

```js
const raw = "It was the best of times..."
console.log(getNGrams(raw))
// [
//   'It',
//   'It was',
//   'It was the',
//   'It was the best',
//   'It was the best of',
//   'It was the best of times...',
//   'was',
//   'was the',
//   'was the best',
//   'was the best of',
//   'was the best of times...',
//   'the',
//   'the best',
//   'the best of',
//   'the best of times...',
//   'best',
//   'best of',
//   'best of times...',
//   'of',
//   'of times...',
//   'times...'
// ]

console.log(getNGrams(raw, 3))
// [
//   'It',          'It was',
//   'It was the',  'was',
//   'was the',     'was the best',
//   'the',         'the best',
//   'the best of', 'best',
//   'best of',     'best of times...',
//   'of',          'of times...',
//   'times...'
// ]
```

## `getNGramSet(raw, maxNGramLength=Infinity)`

Returns the set of unique _n_-grams in `raw`. (To get the full list of _n_-grams that potentially includes duplicates, use `getNGrams`.)

> **NOTE:** Be aware that this function doesn't do any cleaning of the text, which means that (e.g.) "Hello" is treated as a different _n_-gram than "Hello!".

```js
const raw = "i came i saw i conquered"
console.log(getNGramSet(raw))
// [
//   'i',
//   'i came',
//   'i came i',
//   'i came i saw',
//   'i came i saw i',
//   'i came i saw i conquered',
//   'came',
//   'came i',
//   'came i saw',
//   'came i saw i',
//   'came i saw i conquered',
//   'i saw',
//   'i saw i',
//   'i saw i conquered',
//   'saw',
//   'saw i',
//   'saw i conquered',
//   'i conquered',
//   'conquered'
// ]

console.log(getNGramSet(raw, 1))
// [ 'i', 'came', 'saw', 'conquered' ]
```

## `getStats(raw, maxNGramLength=Infinity)`

Returns a `TextStats` instance.

```js
const raw = "It was the best of times..."
console.log(getStats(raw))
// {
//   charCounts: { ... },
//   chars: [ ... ],
//   charSet: [ ... ],
//   leastFrequentChars: [ ... ],
//   leastFrequentNGrams: [ ... ],
//   mostFrequentChars: [ ... ],
//   mostFrequentNGrams: [ ... ],
//   nGramCounts: { ... },
//   nGrams: [ ... ],
//   nGramSet: [ ... ],
// }
```

## `getTFScore(term, docStats)`

Returns the term frequency (TF) score of a string called `term` given a `TextStats` instance (i.e., a thing returned from the `getStats` function).

```js
const doc = fs.readFileSync("path/to/some-file.txt", "utf8")
const maxNGramLength = 1
const docStats = getStats(doc, maxNGramLength)
console.log(getTFScore("hello", docStats))
```

Note that the formula used to compute the text frequency score is my own variant and is kind of a hodge-podge of [the formulae for computing text frequency on Wikipedia](https://en.wikipedia.org/wiki/Tf%E2%80%93idf#Term_frequency). Using Wikipedia's notation, my formula is something like:

$\text{tf}(t, d) = (\dfrac{f_{t,d}}{\text{max}_{\{t' \in d\}} f_{t',d}})^{0.25}$

Where:

- $t$ = the term
- $d$ = the document
- $f_{t,d}$ = the number of times the term appears in the document
- $\text{max}_{\{t' \in d\}} f_{t',d}$ = the maximum number of times any term appears in the document

So, it's sort of like the traditional term frequency score fraction but modified to use the augmented term frequency (as in the double normalization $K$ variant) and then taken to the power of 0.25.

Traditionally, term frequency and augmented term frequency scores are linear, meaning that the scores approach 1 as quickly as the frequency of the term in the document approaches 100%. But my variant takes the augmented frequency to the 0.25 power so that each additional appearance of a term yields less of a boost to the score:

![](data:image/png;base64,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)

## `getTFIDFScore(term, docStats, allDocStats)`

Returns the TF-IDF score of a string called `term` relative to a particular `TextStats` instance given an array of `TextStats` instances. (`TextStats` instances are the things returned from the `getStats` function.) It's just computed as the product of the results from the `getTFScore` and `getIDFScore` functions.

```js
import { getTFIDFScore, getStats } from "@jrc03c/js-text-tools"
import fs from "node:fs"
import path from "node:path"

const dir = "path/to/files"
const files = fs.readdirSync(dir)
const docs = files.map(f => fs.readFileSync(path.join(dir, f), "utf8"))
const maxNGramLength = 1
const allDocStats = docs.map(d => getStats(d, maxNGramLength))
console.log(getTFIDFScore("hello", allDocStats[0], allDocStats))
```

Please see the documentation for the `getTFScore` and `getIDFScore` functions to learn more about their inner workings. Here, though, I'll just say that I wanted a couple extra criteria not found in the TF and IDF formulae on Wikipedia:

- I wanted the TF and IDF formulae to be as analogous to one another as possible. Unless you use the logarithmic variants, no TF formula seems conceptually similar to any IDF formula except for the fact that most of them are built on frequency percentages.
- I wanted them to return values in the range [0, 1].

So, I ended up with these two that are vertically symmetrical:

![](data:image/png;base64,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)

## `indent(text, chars="")`

Returns the text with all lines indented by `chars`. By default, `chars` is an empty string.

```js
indent("Hello, world!", "\t\t")
// \t\tHello, world!
```

## `isEmailAddress(text)`

Returns a boolean indicating whether or not the given value is an email address. Note that the function neither trims whitespace nor performs any other kind of processing of the given value before evaluating whether or not it is an email address.

```js
console.log(isEmailAddress("foo@bar.com"))
// true

console.log(isEmailAddress("   foo@bar.com   "))
// false

console.log(isEmailAddress("Hello, world!"))
// false

console.log(isEmailAddress(true))
// false
```

## `isNumberString(text)`

Returns a boolean indicating whether or not the given value is a number in string form (e.g., `"23.45"`). Forms recognized as number strings include:

- positive and negative integers (e.g., `"234"`, `"+234"`, and `"-234"`)
- positive and negative `BigInt` values (e.g., `"234n"`, `"+234n"`, and `"-234n"`)
- positive and negative floats (e.g., `"23.45"`, `"+23.45"`, and `"-23.45"`)
- scientific notations (e.g., `"23.45e67"`, `"+23.45e+67"`, and `"-23.45e-67"`)
- positive and negative infinity (e.g., `"Infinity"`, `"+Infinity"`, `"-Infinity"`, `"∞"`, `"+∞"`, `"-∞"`)
- not-a-number values (e.g., `"NaN"`)

## `kebabify(text)`

Returns the text in kebab-case.

```js
kebabify("Hello, world!")
// hello-world
```

## `levenshteinDistance(text1, text2)`

Returns the [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) between two strings.

```js
levenshteinDistance("cat", "hat")
// 1
```

## `parse(text)`

Returns the value represented by the string `text`. For security reasons, function strings are not parsed.

## `pascalify(text)`

Returns the text in Pascal-case.

```js
pascalify("Hello, world!")
// HelloWorld
```

## `removeDiacriticalMarks(text)`

Returns a version of the text in which characters containing diacritical marks are replaced with characters that do not contain diacritical marks.

```js
removeDiacriticalMarks("Å")
// A

removeDiacriticalMarks("ü")
// u

removeDiacriticalMarks("ß")
// ß
// (not changed)
```

## `screamify(text)`

Returns the text in screaming snake case.

```js
screamify("Hello, world!")
// HELLO_WORLD
```

## `screamingSnakeify(text)`

Identical to `screamify`.

## `snakeify(text)`

Returns the text in snake-case.

```js
snakeify("Hello, world!")
// hello_world
```

## `spongeify(text)`

Returns the text in "spongecase" (AKA "SpongeBob case" or "alternating caps").

```js
spongeify("It was the best of times...")
// It WaS tHe BeSt Of TiMeS...
```

## `standardizeEmailAddress(emailAddress, options)`

A standalone function that is equivalent to doing this:

```js
new EmailAddressStandardizer(options).standardize(emailAddress)
```

## `StringCounter` (class)

A little utility class to help with string counting.

```js
const counter = new StringCounter()
const raw = "aaaaabbbbcccdde"

for (const char of raw.split("")) {
  counter.increment(char)
}

console.log(counter.counts)
// { a: 5, b: 4, c: 3, d: 2, e: 1 }

console.log(counter.countsSorted)
// [
//   { count: 1, value: 'e' },
//   { count: 2, value: 'd' },
//   { count: 3, value: 'c' },
//   { count: 4, value: 'b' },
//   { count: 5, value: 'a' }
// ]

console.log(counter.leastFrequentValues)
// [ 'e' ]

console.log(counter.mostFrequentValues)
// [ 'a' ]

console.log(counter.getCount("a"))
// 5
```

### `StringCounter(data)` (constructor)

The `data` object can include these properties (all of which are optional):

- `counts` = corresponds to the `counts` property (described below)

### `Properties`

#### `counts`

A dictionary that maps a string to the number of times that string has been counted.

#### `countsSorted` (getter)

An array of objects (each with `count` and `value` properties) sorted by `count`.

#### `leastFrequentValues` (getter)

An array of values with the lowest count.

#### `mostFrequentValues` (getter)

An array of values with the highest count.

#### `values` (getter)

An array of all the values that have been counted.

### `Methods`

#### `getCount(value)`

Returns the number of times `value` has been counted.

#### `increment(value)`

Increases the number of times `value` has been counted by 1 and then returns the new count.

## `stringify(value, [indentation])`

Returns `value` converted to a string. If a string is passed as `indentation`, then that string is used to indent each line. For example, passing `"  "` will use two spaces for each indentation of each line; and passing `"\t"` will use a tab for each indentation of each line. If no value or an empty string is passed as `indentation`, then items in lists and key-value pairs in objects won't be placed on new lines and indented. In that way, its functionality is somewhat similar to `JSON.stringify`.

This function automatically handles cyclic references by replacing each cyclic reference with the string `<reference to "/some/path">` where `"/some/path"` represents the path down through the root object to the original referent. Consider this object:

```js
const myObj = {
  this: {
    is: {
      deeply: {
        nested: "yep!",
      },
    },
  },
}
```

We could add a circular reference to it:

```js
myObj.this.is.deeply.circular = myObj.this.is
```

Now, when we inspect the object, we see:

```js
const util = require("util")
console.log(util.inspect(myObj, { depth: null, colors: true }))
// {
//   this: {
//     is: <ref *1> {
//       deeply: { nested: 'yep!', circular: [Circular *1] }
//     }
//   }
// }
```

Since the circular reference points to `myObj.this.is`, the `stringify` function will replace the circular reference with `"<reference to \"/this/is\">"`:

```js
const { stringify } = require("@jrc03c/js-text-tools")
console.log(stringify(myObj, null, 2))
// {
//   "this": {
//     "is": {
//       "deeply": {
//         "nested": "yep!",
//         "circular": "<reference to \"/this/is\">"
//       }
//     }
//   }
// }
```

The gist is that the value to be stringified is first copied in such a way that cyclic references are replaced with string descriptions, and then the safe copy is actually what gets stringified.

Finally, note that the built-in typed arrays (e.g., `Float64Array`) are stringified in a special way: they're converted to objects and _then_ stringified. The objects to which they're converted have these properties:

- `constructor` = A string representing the name of the class to which the array belongs (e.g., a `Float64Array` would have a `constructor` value of `"Float64Array"`).
- `flag` = The string `"FLAG_TYPED_ARRAY"`.
- `values` = A new, non-typed array containing the values from the original typed array.

The reason for this additional stringification step is that typed arrays can't be stringified by `JSON.stringify` and then reinstantiated automatically in their original type by `JSON.parse`. So, the `stringify` and `parse` functions in this library are designed to handle those and a few other edge cases — though they otherwise function mostly like `JSON.stringify` and `JSON.parse`.

## `strip(text, options={})`

Returns a lower-cased version of the text with all punctuation removed and all whitespace collapsed. The options object is optional and can include these properties:

- `exclude` = a string of characters, a regular expression, a function, or an array of those things that indicate which characters should not be present in the return value; the default is a string containing all punctuation marks
- `include` = a string of characters, a regular expression, a function, or an array of those things that indicate which characters should be present in the return value; the default is undefined
- `shouldPreserveCase` = a boolean indicating whether or not character case (upper or lower) should be preserved; the default is `false`
- `shouldPreserveDiacriticalMarks` = a boolean indicating whether or not diacritical marks should be preserved; the default is `true`
- `shouldPreserveWhitespace` = a boolean indicating whether or not non-single-space whitespace characters (e.g., newlines, carriage returns, tabs, etc.) should be preserved; the default is `false`

> **NOTE:** The `exclude` and `include` properties are mutually exclusive and cannot be used at the same time!

```js
strip("Hello, world!")
// hello world

strip("Hello, world!", { shouldPreserveCase: true })
// Hello world
```

## `TextObject` (class)

This is a helper class that can be used to give structure to otherwise unstructured text. It's intentionally minimalistic with the goal of providing maximum flexibility. The basic idea is that virtually every chunk of text:

- can be thought of as having _children_ (e.g., a paragraph's children are its sentences, a word's children are its characters, etc.)
- has an _identifier_ (e.g., a chapter has a title, a book has an ISBN number, etc.)
- can have _references_ to other chunks of text (e.g., footnotes, endnotes, asides, etc.)
- can combine its children by use of a _separator_ to form a total _value_ (e.g., a chapter whose children are paragraphs can join those paragraphs with newlines to form its total value, a word whose children are characters can join those characters with empty strings to form its total value, etc.)

An example use case might be taking a book in plaintext from [gutenberg.org](https://gutenberg.org/), breaking it into parts, chapters, sections, paragraphs, sentences, and words; and then using this class to encode all of that structure and store it on disk.

### `TextObject(data)` (constructor)

The `data` options object can have any or all of the properties described below. The only exception is that `children` and `value` properties are mutually exclusive, and a `children` property will be preferred over a `value` property if both are present.

### Properties

#### `children`

An array of strings and/or `TextObject` instances.

#### `id`

A string.

#### `references`

An array of strings and/or `TextObject` instances.

#### `separator`

A string.

#### `value` (getter / setter)

A string made of the instance's `children` joined by its `separator`.

### Methods

#### `toObject()`

Returns a plain JS object with the same basic structure as the instance but ready to be stringified and written to disk.

## `TextStats` (class)

### `TextStats(data)` (constructor)

The `data` options object can have any or all of the properties described below.

### Properties

#### `charCounts`

A dictionary containing the number of times each character appears in a given string. Equivalent to the value returned from `getCharCounts`.

#### `chars`

A list of all of the characters in a given string. Equivalent to the value returned from `getChars`.

#### `charSet`

A list of all unique characters in a given string. Equivalent to the value returned from `getCharSet`.

#### `leastFrequentChars`

A list of characters that appear least often in a given string.

#### `leastFrequentNGrams`

A list of _n_-grams that appear least often in a given string.

#### `mostFrequentChars`

A list of characters that appear most often in a given string.

#### `mostFrequentNGrams`

A list of _n_-grams that appear most often in a given string.

#### `nGramCounts`

A dictionary containing the number of times each _n_-gram appears in a given string. Equivalent to the value returned from `getNGramCounts`.

#### `nGrams`

A list of all of the _n_-grams in a given string. Equivalent to the value returned from `getNGrams`.

#### `nGramSet`

A list of all unique _n_-grams in a given string. Equivalent to the value returned from `getNGramSet`.

### Methods

#### `compute(raw, maxNGramLength)` (static)

Returns a `TextStats` instance.

## `unindent(text)`

Returns the text with all lines unindented by the same number of characters. For example, if the _smallest_ amount of indentation is 4 spaces, then each line will be unindented by 4 spaces.

For example, suppose I have a file called `message.txt` with this content:

```
    Hello, world!
      My name is Josh.
        What's your name?
```

The smallest amount of indentation in the file is 4 spaces. So, unindenting it will move all lines to the left by 4 spaces.

```js
const { unindent } = require("@jrc03c/js-text-tools")
const fs = require("fs")
const message = fs.readFileSync("message.txt", "utf8")
const unindentedMessage = unindent(message)
fs.writeFileSync("unindented-message.txt", unindentedMessage, "utf8")
```

The contents of `unindented-message.txt` would be:

```
Hello, world!
  My name is Josh.
    What's your name?
```

**NOTE:** The `unindent` function does _not_ pay attention to whether indentation consists of spaces or tabs. It only cares whether or not a character is a whitespace character. It also makes no attempt to make the whitespace characters consistent (i.e., it doesn't try to begin each line with _all_ spaces or _all_ tabs); it merely removes the minimum number of whitespace characters from each line and returns the result.

## `urlPathJoin(a, b, c, ...)`

Returns parts of a URL joined by forward-slashes. Collapses multiple forward-slashes but preserves the colon-double-forward-slash (`://`) that indicates a protocol.

```js
urlPathJoin("foo", "bar", "baz")
// "foo/bar/baz"

urlPathJoin("https://example.com", "path", "to", "image.png")
// "https://example.com/path/to/image.png"

urlPathJoin("///foo///", "//bar///", "/////baz//////")
// "/foo/bar/baz/"

urlPathJoin("path", "to", "image.png", "https://example.com")
// "path/to/image.png/https://example.com"
```

As illustrated by that last example, this function isn't very intelligent and isn't concerned with whether or not the value it returns is a valid URL. Its purpose is simply to concatenate strings with forward-slashes while removing duplicate forward-slashes where possible.

## `wrap(text, maxLineLength=80, wrappedLinePrefix="")`

Returns the text with all lines wrapped to a maximum length of `maxLineLength`. By default, the `maxLineLength` is 80 in the browser or the minimum of 80 and the number of `stdout` columns in the command line. Note that this function only wraps at spaces; it does not wrap mid-word, and it does not attempt to hyphenate words. The wrapping _does_ preserve indentation, though. Wrapped lines can optionally be prefixed with a specific string value.

```js
const text =
  "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam mollis tellus eu mi condimentum, a congue ipsum luctus. Donec vel suscipit dolor, vitae faucibus massa. Curabitur rhoncus semper tortor et mattis. Nullam laoreet lobortis nibh eget viverra. Nam molestie risus vitae ante placerat convallis. Pellentesque quis tristique dui. Vivamus efficitur mi erat, nec gravida felis posuere at. Donec sapien ipsum, viverra et aliquam quis, posuere ac ligula. Aenean egestas tincidunt mauris, in hendrerit tortor malesuada id. Proin viverra sodales ex eu fermentum. Aenean nisl ipsum, tristique venenatis massa eget, tempor facilisis felis. Praesent aliquam sem vitae arcu porta commodo. Aliquam tempor sollicitudin dapibus. Nulla ullamcorper orci eu ultricies cursus."

wrap(text, 20, ">> ")

/*
Lorem ipsum dolor
>> sit amet,
>> consectetur
>> adipiscing elit.
>> Nam mollis
>> tellus eu mi
>> condimentum, a
>> congue ipsum
>> luctus. Donec
>> vel suscipit
>> dolor, vitae
>> faucibus massa.
>> Curabitur
>> rhoncus semper
>> tortor et
>> mattis. Nullam
>> laoreet lobortis
>> nibh eget
>> viverra. Nam
>> molestie risus
>> vitae ante
>> placerat
>> convallis.
>> Pellentesque
>> quis tristique
>> dui. Vivamus
>> efficitur mi
>> erat, nec
>> gravida felis
>> posuere at.
>> Donec sapien
>> ipsum, viverra
>> et aliquam quis,
>> posuere ac
>> ligula. Aenean
>> egestas
>> tincidunt
>> mauris, in
>> hendrerit tortor
>> malesuada id.
>> Proin viverra
>> sodales ex eu
>> fermentum.
>> Aenean nisl
>> ipsum, tristique
>> venenatis massa
>> eget, tempor
>> facilisis felis.
>> Praesent aliquam
>> sem vitae arcu
>> porta commodo.
>> Aliquam tempor
>> sollicitudin
>> dapibus. Nulla
>> ullamcorper orci
>> eu ultricies
>> cursus.
*/
```
