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', () => {