From fc428489c2c881c02c407d2cd228eefb1c150d5b Mon Sep 17 00:00:00 2001 From: Taylor Beseda Date: Thu, 13 Aug 2026 19:32:29 -0600 Subject: [PATCH] Make input validation consistent, add CHANGELOG treeify had three different reactions to the same class of mistake. A non-string where a label belongs threw at index 0, was silently dropped anywhere else, and input that wasn't an array at all returned '' -- the quietest outcome for the input most likely to be a bug. One policy now: throw for what can't be rendered, render everything else. - Input that is not an array throws a TypeError instead of returning ''. - A non-string first element throws a TypeError, including [undefined], which used to return ''. Messages name what was received, e.g. "array-treeify: expected the first element to be a string, received number (1)". - Non-string labels are stringified instead of dropped, at any depth, and can be parent nodes. Numbers, bigints and booleans are accepted without a cast. - treeify([]) still returns '', which is a useful "nothing to render". No error class is exported; the TypeError message is the API. Valid trees render exactly as before: 15,000 renders of randomly generated string trees across all three option shapes are byte-identical to the previous implementation. The README claimed TypeScript "cannot enforce at the type level that the first element is a string", which isn't true -- a tuple type can. It would rule out building trees with push, which is the actual reason for the permissive type, so the docs now say that instead. Also documents labels and errors, with the new example covered by the README test. Adds CHANGELOG.md, backfilled from the tags, with the breaking changes above under Unreleased. --- CHANGELOG.md | 66 +++++++++++++++++++++ README.md | 34 +++++++++-- src/index.test.ts | 46 +++++++++++++-- src/index.ts | 112 ++++++++++++++++++++---------------- src/readme-examples.test.ts | 15 +++++ src/types.test.ts | 15 ++++- 6 files changed, 229 insertions(+), 59 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4bc4cd4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Non-string labels render instead of disappearing. Any value that isn't an array is stringified, at any depth, and can be a parent node: `treeify(['deploys', [2025, ['spring']]])` renders `2025` with a child. +- `LICENSE` file (MIT). The README had linked to it since the beginning, but it was never committed. It now ships in the npm tarball. +- `exports`, `sideEffects` and `engines` fields in `package.json`. +- Continuous integration on GitHub Actions: tests and build across Node.js 22, 24 and 26. +- This changelog. + +### Changed + +- **Breaking:** input validation now throws a `TypeError` rather than an `Error`, with a message naming what it received: `array-treeify: expected the first element to be a string, received number (1)`. It used to be `Error: First element must be a string`. +- **Breaking:** input that is not an array now throws instead of returning an empty string. `treeify(null)` used to render nothing at all, which quietly hid the mistake. +- **Breaking:** `treeify([undefined])` now throws. It is the same failure as any other non-string first element, and used to return an empty string. +- **Breaking:** requires Node.js 22 or newer. Node.js 20 reached end of life on 2026-04-30. +- `treeify` accepts numbers, bigints and booleans as labels without a cast. +- Development toolchain: Biome 2 and TypeScript 7. `tsx` is gone — tests are `.ts` files run directly by `node --test` using Node's built-in type stripping, and `npm run typecheck` now covers the test files, which it never did before. + +`treeify([])` still returns an empty string. + +## [0.1.5] - 2025-05-08 + +### Changed + +- More permissive input type. + +## [0.1.4] - 2025-05-07 + +### Changed + +- `TreeInput` accepts an empty array. +- Upgraded dependencies. + +## [0.1.3] - 2025-03-30 + +### Added + +- `chars` option for custom tree characters. +- `plain` option for whitespace-only output. + +## [0.1.2] - 2025-03-29 + +### Changed + +- Tests are no longer published to npm. + +## [0.1.1] - 2025-03-28 + +### Added + +- Initial release. + +[Unreleased]: https://github.com/tbeseda/array-treeify/compare/0.1.5...HEAD +[0.1.5]: https://github.com/tbeseda/array-treeify/compare/0.1.4...0.1.5 +[0.1.4]: https://github.com/tbeseda/array-treeify/compare/0.1.3...0.1.4 +[0.1.3]: https://github.com/tbeseda/array-treeify/compare/0.1.2...0.1.3 +[0.1.2]: https://github.com/tbeseda/array-treeify/compare/0.1.1...0.1.2 +[0.1.1]: https://github.com/tbeseda/array-treeify/releases/tag/0.1.1 diff --git a/README.md b/README.md index dde4168..702bf0b 100644 --- a/README.md +++ b/README.md @@ -152,13 +152,10 @@ Lumon Industries ## Input Format -> **Disclaimer:** -> The exported `TreeInput` type (`Array`) is intentionally flexible to support dynamic and programmatic tree construction. However, TypeScript cannot enforce at the type level that the first element is a string. This requirement is checked at runtime by the `treeify` function, which will throw an error if the first element is not a string. Please ensure your input arrays follow this convention. - The `treeify` function accepts arrays with the following structure: 1. First element must be a string (the root node) -2. Subsequent elements can be strings (nodes at same level) or arrays (children of previous node) +2. Subsequent elements can be labels (nodes at same level) or arrays (children of previous node) 3. Arrays can be nested to any depth ```typescript @@ -167,6 +164,35 @@ The `treeify` function accepts arrays with the following structure: ['root', ['child', ['grandchild']]] // Grandchildren ``` +### Labels + +Any value that isn't an array is a label. Non-strings are stringified, so numbers and booleans work anywhere a string does — including as parent nodes: + +```typescript +console.log(treeify(['deploys', [2025, ['spring', 'summer'], 2026, ['q1']]])) +/* +deploys +├─ 2025 +│ ├─ spring +│ └─ summer +└─ 2026 + └─ q1 +*/ +``` + +### Errors + +`treeify` throws a `TypeError` when it can't render what it was given: + +- the input is not an array — `array-treeify: expected an array, received null` +- the first element is not a string — `array-treeify: expected the first element to be a string, received number (1)` + +An empty array returns an empty string, so `treeify([])` is a safe way to say "nothing to render". + +### Types + +The exported `TreeInput` type (`Array`) is intentionally permissive so trees can be assembled programmatically — build an array up with `push` and hand it over. A tuple type such as `[string, ...(string | TreeInput)[]]` *could* require a string first element at compile time, but it would rule out that dynamic construction, so the rule is enforced at runtime instead. + ## Options - `chars`: Custom characters for the tree. Defaults to Unicode box-drawing characters. diff --git a/src/index.test.ts b/src/index.test.ts index 03e75f2..73552e2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -183,15 +183,53 @@ second test('first element must be a string', () => { assert.throws(() => treeify([null, ['child']] as unknown as TreeInput), { - message: 'First element must be a string', + name: 'TypeError', + message: + 'array-treeify: expected the first element to be a string, received null', }) assert.throws(() => treeify([1, ['child']] as unknown as TreeInput), { - message: 'First element must be a string', + name: 'TypeError', + message: + 'array-treeify: expected the first element to be a string, received number (1)', }) + assert.throws(() => treeify([undefined] as unknown as TreeInput), { + name: 'TypeError', + message: + 'array-treeify: expected the first element to be a string, received undefined', + }) + }) + + test('input must be an array', () => { + for (const input of [null, undefined, 'root', 42, {}]) { + assert.throws(() => treeify(input as unknown as TreeInput), { + name: 'TypeError', + message: /^array-treeify: expected an array, received /, + }) + } }) - test('empty or invalid input returns empty string', () => { + test('empty input returns empty string', () => { assert.strictEqual(treeify([] as unknown as TreeInput), '') - assert.strictEqual(treeify([undefined] as unknown as TreeInput), '') + }) + + test('non-string labels are stringified rather than dropped', () => { + assert.strictEqual( + treeify(['root', [1, true, null]] as unknown as TreeInput), + `root +├─ 1 +├─ true +└─ null`, + ) + }) + + test('a non-string label can be a parent node', () => { + assert.strictEqual( + treeify(['root', [2025, ['q1', 'q2'], 'note']] as unknown as TreeInput), + `root +├─ 2025 +│ ├─ q1 +│ └─ q2 +└─ note`, + ) }) }) diff --git a/src/index.ts b/src/index.ts index 1457d1a..58ae303 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,16 +5,36 @@ export type TreeInput = Array /** - * Represents a node in the tree structure. - * Can be either a string (a leaf node) or an array of TreeNodes (a branch with children). + * A value rendered as a node label. Anything that isn't an array is a label; + * non-strings are stringified. */ -type TreeNode = string | TreeNode[] +type TreeLeaf = string | number | bigint | boolean /** - * Flexible input type that accepts any array. + * Flexible input type that accepts labels and nested arrays. * Runtime validation ensures the first element is a string. */ -type FlexibleTreeInput = readonly (string | unknown[])[] +type FlexibleTreeInput = readonly (TreeLeaf | readonly unknown[])[] + +/** + * @description An array is a branch (the children of the node before it); + * anything else is a leaf. + */ +function isBranch(node: unknown): node is readonly unknown[] { + return Array.isArray(node) +} + +/** + * @description Describes a value for error messages without dumping its contents. + */ +function describe(value: unknown): string { + if (value === null) return 'null' + if (Array.isArray(value)) return 'an array' + const type = typeof value + if (type === 'number' || type === 'bigint' || type === 'boolean') + return `${type} (${String(value)})` + return type +} /** * ASCII characters used to render the tree. @@ -45,8 +65,9 @@ const EMPTY_CHARS: TreeChars = { * * The expected input format is a hierarchical structure where: * - The first element must be a string (the root node) - * - String elements represent nodes at the current level - * - Array elements following a string represent the children of the previous node + * - Label elements represent nodes at the current level. Anything that isn't an + * array is a label, and non-strings are stringified + * - Array elements following a label represent the children of that node * - Nested arrays create deeper levels in the tree * * Examples of supported formats: @@ -62,7 +83,9 @@ const EMPTY_CHARS: TreeChars = { * - `chars` {TreeChars} - Custom characters for the tree. Defaults to Unicode box-drawing characters. * - `plain` {boolean} - Whether to use plain whitespace characters instead of Unicode box-drawing characters. * - * @returns {string} A string containing the tree representation + * @returns {string} A string containing the tree representation. An empty array returns an empty string. + * + * @throws {TypeError} If `list` is not an array, or its first element is not a string. * * @example * treeify(['root', ['child1', 'child2', ['grandchild']]]) @@ -78,10 +101,15 @@ export function treeify( plain?: boolean }, ): string { - if (!Array.isArray(list) || list.length === 0) return '' - if (list[0] === undefined) return '' + if (!Array.isArray(list)) + throw new TypeError( + `array-treeify: expected an array, received ${describe(list)}`, + ) + if (list.length === 0) return '' if (typeof list[0] !== 'string') - throw new Error('First element must be a string') + throw new TypeError( + `array-treeify: expected the first element to be a string, received ${describe(list[0])}`, + ) let chars = DEFAULT_CHARS if (options?.plain) chars = EMPTY_CHARS @@ -95,18 +123,15 @@ export function treeify( while (i < list.length) { const node = list[i] - if (typeof node === 'string') { - // add strings here - result.push(node) - i++ - } else if (Array.isArray(node)) { + if (isBranch(node)) { // array is the children of the previous item renderTreeNodes(node, '', result, chars) - i++ } else { - // idk. skip it. - i++ + // everything else is a label + result.push(String(node)) } + + i++ } return result.join('\n') @@ -116,51 +141,44 @@ export function treeify( * @description Renders tree nodes with appropriate ASCII indentation and branching */ function renderTreeNodes( - nodes: TreeNode[], + nodes: readonly unknown[], indent: string, result: string[], chars: TreeChars, ): void { - if (!Array.isArray(nodes) || nodes.length === 0) return + if (nodes.length === 0) return const parentNodeIndices = findParentNodeIndices(nodes) let i = 0 while (i < nodes.length) { const node = nodes[i] - const isParentNode = parentNodeIndices.has(i) - if (isParentNode) { - const parentIndex = i - const childrenIndex = i + 1 - const stringNode = nodes[parentIndex] as string - const arrayNode = nodes[childrenIndex] as TreeNode[] - - const isLast = !hasNextStringNode(nodes, childrenIndex + 1) + if (parentNodeIndices.has(i)) { + // a leaf followed by an array: that array holds its children + const children = nodes[i + 1] as readonly unknown[] + const isLast = !hasNextLeaf(nodes, i + 2) const prefix = isLast ? chars.lastBranch : chars.branch - result.push(indent + prefix + stringNode) + result.push(indent + prefix + String(node)) // children with increased indent const childIndent = indent + (isLast ? chars.space : chars.pipe) - renderTreeNodes(arrayNode, childIndent, result, chars) + renderTreeNodes(children, childIndent, result, chars) // skip both the parent node and its children array i += 2 - } else if (typeof node === 'string') { - // string is simple. add it. - const isLast = !hasNextStringNode(nodes, i + 1) - const prefix = isLast ? chars.lastBranch : chars.branch - result.push(indent + prefix + node) - i++ - } else if (Array.isArray(node)) { - // (>_>) + } else if (isBranch(node)) { + // an array with no leaf before it. (>_>) render it a level deeper. const isLast = i === nodes.length - 1 const childIndent = indent + (isLast ? chars.space : chars.pipe) renderTreeNodes(node, childIndent, result, chars) i++ } else { - // (0_o) + // a leaf is simple. add it. + const isLast = !hasNextLeaf(nodes, i + 1) + const prefix = isLast ? chars.lastBranch : chars.branch + result.push(indent + prefix + String(node)) i++ } } @@ -169,15 +187,11 @@ function renderTreeNodes( /** * @description Locate parent nodes in the array to handle nesting. */ -function findParentNodeIndices(nodes: TreeNode[]): Set { +function findParentNodeIndices(nodes: readonly unknown[]): Set { const parentNodeIndices = new Set() for (let i = 0; i < nodes.length; i++) { - if ( - typeof nodes[i] === 'string' && - i + 1 < nodes.length && - Array.isArray(nodes[i + 1]) - ) { + if (!isBranch(nodes[i]) && isBranch(nodes[i + 1])) { parentNodeIndices.add(i) } } @@ -187,12 +201,12 @@ function findParentNodeIndices(nodes: TreeNode[]): Set { /** * @description - * Determines if there's another string node after the given index. + * Determines if there's another leaf after the given index. * Used to decide if the current node is the last at its level. */ -function hasNextStringNode(nodes: TreeNode[], startIndex: number): boolean { +function hasNextLeaf(nodes: readonly unknown[], startIndex: number): boolean { for (let i = startIndex; i < nodes.length; i++) { - if (typeof nodes[i] === 'string') { + if (!isBranch(nodes[i])) { return true } } diff --git a/src/readme-examples.test.ts b/src/readme-examples.test.ts index 9655b9b..493f840 100644 --- a/src/readme-examples.test.ts +++ b/src/readme-examples.test.ts @@ -115,4 +115,19 @@ describe('readme examples', () => { console.log(result) assert.strictEqual(result, expected) }) + + test('labels example', () => { + const deploys = ['deploys', [2025, ['spring', 'summer'], 2026, ['q1']]] + const expected = `deploys +├─ 2025 +│ ├─ spring +│ └─ summer +└─ 2026 + └─ q1` + + const result = treeify(deploys) + console.log('\nLabels example:') + console.log(result) + assert.strictEqual(result, expected) + }) }) diff --git a/src/types.test.ts b/src/types.test.ts index 9592570..92a4358 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -23,7 +23,9 @@ describe('treeify types', () => { // @ts-expect-error const inputWithoutRootString: TreeInput = [{ bad: 'root' }, 'root2'] assert.throws(() => treeify(inputWithoutRootString), { - message: 'First element must be a string', + name: 'TypeError', + message: + 'array-treeify: expected the first element to be a string, received object', }) const inputWithRootStringAndInvalidValues: TreeInput = ['root'] @@ -32,7 +34,16 @@ describe('treeify types', () => { // @ts-expect-error inputWithRootStringAndInvalidValues.push({}, [], Number.POSITIVE_INFINITY) result = treeify(inputWithRootStringAndInvalidValues) - assert.ok(result) // non-strings are ignored + // non-strings are stringified, empty arrays render nothing + assert.equal(result, 'root\n1\n[object Object]\nInfinity') + }) + + test('non-string labels need no cast', () => { + const result = treeify(['deploys', [2025, ['spring', 'summer'], true]]) + assert.equal( + result, + 'deploys\n├─ 2025\n│ ├─ spring\n│ └─ summer\n└─ true', + ) }) test('generated inputs', () => {