this repo has no description
1/**
2 * The strict tree input format. Must start with a string.
3 * This type is exported for testing purposes and advanced usage.
4 */
5export type TreeInput = Array<string | TreeInput>
6
7/**
8 * Represents a node in the tree structure.
9 * Can be either a string (a leaf node) or an array of TreeNodes (a branch with children).
10 */
11type TreeNode = string | TreeNode[]
12
13/**
14 * Flexible input type that accepts any array.
15 * Runtime validation ensures the first element is a string.
16 */
17type FlexibleTreeInput = readonly (string | unknown[])[]
18
19/**
20 * ASCII characters used to render the tree.
21 */
22export type TreeChars = {
23 branch: string
24 lastBranch: string
25 pipe: string
26 space: string
27}
28
29const DEFAULT_CHARS: TreeChars = {
30 branch: '├─ ',
31 lastBranch: '└─ ',
32 pipe: '│ ',
33 space: ' ',
34} as const
35const SPACE = ' ' as const
36const EMPTY_CHARS: TreeChars = {
37 branch: SPACE.repeat(DEFAULT_CHARS.branch.length),
38 lastBranch: SPACE.repeat(DEFAULT_CHARS.lastBranch.length),
39 pipe: SPACE.repeat(DEFAULT_CHARS.pipe.length),
40 space: SPACE.repeat(DEFAULT_CHARS.space.length),
41} as const
42
43/**
44 * @description Creates a text tree representation from a nested array structure using Unicode box-drawing characters.
45 *
46 * The expected input format is a hierarchical structure where:
47 * - The first element must be a string (the root node)
48 * - String elements represent nodes at the current level
49 * - Array elements following a string represent the children of the previous node
50 * - Nested arrays create deeper levels in the tree
51 *
52 * Examples of supported formats:
53 * - `['root', ['child1', 'child2', 'child3']]` creates a root with three children
54 * - `['root', 'second', ['child1', 'child2']]` creates multiple root nodes with children
55 * - `['root', ['child1', ['grandchild1', 'grandchild2']]]` creates a root with nested children
56 * - `['root', ['childA', ['grandchildA'], 'childB']]` creates multiple branches
57 *
58 * The output uses Unicode box-drawing characters to visualize the tree structure.
59 *
60 * @param list {FlexibleTreeInput} - An array representing the tree structure. First element must be a string.
61 * @param options {Object} - An object containing optional configuration:
62 * - `chars` {TreeChars} - Custom characters for the tree. Defaults to Unicode box-drawing characters.
63 * - `plain` {boolean} - Whether to use plain whitespace characters instead of Unicode box-drawing characters.
64 *
65 * @returns {string} A string containing the tree representation
66 *
67 * @example
68 * treeify(['root', ['child1', 'child2', ['grandchild']]])
69 * // root
70 * // ├─ child1
71 * // └─ child2
72 * // └─ grandchild
73 */
74export function treeify(
75 list: FlexibleTreeInput,
76 options?: {
77 chars?: TreeChars
78 plain?: boolean
79 },
80): string {
81 if (!Array.isArray(list) || list.length === 0) return ''
82 if (list[0] === undefined) return ''
83 if (typeof list[0] !== 'string')
84 throw new Error('First element must be a string')
85
86 let chars = DEFAULT_CHARS
87 if (options?.plain) chars = EMPTY_CHARS
88 if (options?.chars) chars = options.chars
89
90 const result: string[] = []
91
92 result.push(list[0]) // first string is the root
93
94 let i = 1
95 while (i < list.length) {
96 const node = list[i]
97
98 if (typeof node === 'string') {
99 // add strings here
100 result.push(node)
101 i++
102 } else if (Array.isArray(node)) {
103 // array is the children of the previous item
104 renderTreeNodes(node, '', result, chars)
105 i++
106 } else {
107 // idk. skip it.
108 i++
109 }
110 }
111
112 return result.join('\n')
113}
114
115/**
116 * @description Renders tree nodes with appropriate ASCII indentation and branching
117 */
118function renderTreeNodes(
119 nodes: TreeNode[],
120 indent: string,
121 result: string[],
122 chars: TreeChars,
123): void {
124 if (!Array.isArray(nodes) || nodes.length === 0) return
125
126 const parentNodeIndices = findParentNodeIndices(nodes)
127
128 let i = 0
129 while (i < nodes.length) {
130 const node = nodes[i]
131 const isParentNode = parentNodeIndices.has(i)
132
133 if (isParentNode) {
134 const parentIndex = i
135 const childrenIndex = i + 1
136 const stringNode = nodes[parentIndex] as string
137 const arrayNode = nodes[childrenIndex] as TreeNode[]
138
139 const isLast = !hasNextStringNode(nodes, childrenIndex + 1)
140
141 const prefix = isLast ? chars.lastBranch : chars.branch
142 result.push(indent + prefix + stringNode)
143
144 // children with increased indent
145 const childIndent = indent + (isLast ? chars.space : chars.pipe)
146 renderTreeNodes(arrayNode, childIndent, result, chars)
147
148 // skip both the parent node and its children array
149 i += 2
150 } else if (typeof node === 'string') {
151 // string is simple. add it.
152 const isLast = !hasNextStringNode(nodes, i + 1)
153 const prefix = isLast ? chars.lastBranch : chars.branch
154 result.push(indent + prefix + node)
155 i++
156 } else if (Array.isArray(node)) {
157 // (>_>)
158 const isLast = i === nodes.length - 1
159 const childIndent = indent + (isLast ? chars.space : chars.pipe)
160 renderTreeNodes(node, childIndent, result, chars)
161 i++
162 } else {
163 // (0_o)
164 i++
165 }
166 }
167}
168
169/**
170 * @description Locate parent nodes in the array to handle nesting.
171 */
172function findParentNodeIndices(nodes: TreeNode[]): Set<number> {
173 const parentNodeIndices = new Set<number>()
174
175 for (let i = 0; i < nodes.length; i++) {
176 if (
177 typeof nodes[i] === 'string' &&
178 i + 1 < nodes.length &&
179 Array.isArray(nodes[i + 1])
180 ) {
181 parentNodeIndices.add(i)
182 }
183 }
184
185 return parentNodeIndices
186}
187
188/**
189 * @description
190 * Determines if there's another string node after the given index.
191 * Used to decide if the current node is the last at its level.
192 */
193function hasNextStringNode(nodes: TreeNode[], startIndex: number): boolean {
194 for (let i = startIndex; i < nodes.length; i++) {
195 if (typeof nodes[i] === 'string') {
196 return true
197 }
198 }
199 return false
200}