@mdxui/terminal 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,942 @@
1
+ /**
2
+ * Renderer Common Utilities
3
+ *
4
+ * Shared abstractions used across all 6-tier renderers (TEXT, MARKDOWN, ASCII, UNICODE, ANSI, INTERACTIVE).
5
+ * Extracted to ensure DRY principles and consistent behavior across renderers.
6
+ *
7
+ * Key abstractions:
8
+ * - Box drawing (ASCII/Unicode share logic)
9
+ * - Width/height calculations
10
+ * - Text alignment and wrapping
11
+ * - Indentation helpers
12
+ * - Character sanitization
13
+ * - Renderer registry pattern
14
+ */
15
+
16
+ import type { UINode } from '../core/types'
17
+
18
+ /**
19
+ * Box drawing character sets for different border styles
20
+ */
21
+ export interface BoxChars {
22
+ topLeft: string
23
+ topRight: string
24
+ bottomLeft: string
25
+ bottomRight: string
26
+ horizontal: string
27
+ vertical: string
28
+ }
29
+
30
+ /**
31
+ * Text alignment options
32
+ */
33
+ export type TextAlign = 'left' | 'center' | 'right'
34
+
35
+ /**
36
+ * Indent calculation: returns a string of spaces for the given level
37
+ * Each level = 2 spaces
38
+ *
39
+ * @param level - Indentation level (0-based)
40
+ * @returns Indentation string (2 spaces per level)
41
+ */
42
+ export function getIndentStr(level: number): string {
43
+ return ' '.repeat(Math.max(0, level))
44
+ }
45
+
46
+ /**
47
+ * Calculates visible text width by counting characters
48
+ * This is a basic implementation; use in TEXT/MARKDOWN tiers
49
+ *
50
+ * @param text - Text to measure
51
+ * @returns Character count (visible width)
52
+ */
53
+ export function getTextWidth(text: string): number {
54
+ return text.length
55
+ }
56
+
57
+ /**
58
+ * Pads text to a given width for alignment
59
+ * Supports left/center/right alignment
60
+ *
61
+ * @param text - Text to pad
62
+ * @param width - Target width
63
+ * @param align - Alignment direction (default: 'left')
64
+ * @param padChar - Character to use for padding (default: space)
65
+ * @returns Padded text string
66
+ */
67
+ export function padText(
68
+ text: string,
69
+ width: number,
70
+ align: TextAlign = 'left',
71
+ padChar: string = ' '
72
+ ): string {
73
+ const textLen = getTextWidth(text)
74
+ if (textLen >= width) {
75
+ return text.slice(0, width)
76
+ }
77
+
78
+ const totalPad = width - textLen
79
+ const padStr = padChar.repeat(totalPad)
80
+
81
+ switch (align) {
82
+ case 'center':
83
+ const leftPad = Math.floor(totalPad / 2)
84
+ const rightPad = totalPad - leftPad
85
+ return padChar.repeat(leftPad) + text + padChar.repeat(rightPad)
86
+
87
+ case 'right':
88
+ return padStr + text
89
+
90
+ case 'left':
91
+ default:
92
+ return text + padStr
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Wraps text to fit within a given width, preserving words when possible.
98
+ * Handles explicit newlines and long words.
99
+ *
100
+ * @param text - Text to wrap
101
+ * @param maxWidth - Maximum line width
102
+ * @returns Array of wrapped lines
103
+ */
104
+ export function wrapText(text: string, maxWidth: number): string[] {
105
+ if (maxWidth <= 0) {
106
+ return [text]
107
+ }
108
+
109
+ // Normalize whitespace
110
+ const normalized = text.replace(/[ \t]+/g, ' ')
111
+ const lines: string[] = []
112
+
113
+ // Handle explicit newlines first
114
+ const paragraphs = normalized.split('\n')
115
+
116
+ for (const paragraph of paragraphs) {
117
+ if (paragraph.length === 0) {
118
+ lines.push('')
119
+ continue
120
+ }
121
+
122
+ const words = paragraph.split(' ').filter((w) => w.length > 0)
123
+ let currentLine = ''
124
+
125
+ for (const word of words) {
126
+ if (word.length > maxWidth) {
127
+ // Word is too long, must break it
128
+ if (currentLine.length > 0) {
129
+ lines.push(currentLine)
130
+ currentLine = ''
131
+ }
132
+ // Break the long word
133
+ let remaining = word
134
+ while (remaining.length > maxWidth) {
135
+ lines.push(remaining.slice(0, maxWidth))
136
+ remaining = remaining.slice(maxWidth)
137
+ }
138
+ if (remaining.length > 0) {
139
+ currentLine = remaining
140
+ }
141
+ } else if (currentLine.length === 0) {
142
+ currentLine = word
143
+ } else if (currentLine.length + 1 + word.length <= maxWidth) {
144
+ currentLine += ' ' + word
145
+ } else {
146
+ lines.push(currentLine)
147
+ currentLine = word
148
+ }
149
+ }
150
+
151
+ if (currentLine.length > 0 || paragraph.length === 0) {
152
+ lines.push(currentLine)
153
+ }
154
+ }
155
+
156
+ return lines
157
+ }
158
+
159
+ /**
160
+ * Sanitizes text to ensure only ASCII characters (0x00-0x7F) are present.
161
+ * Replaces unicode characters with ASCII equivalents or removes them.
162
+ *
163
+ * @param text - Text to sanitize
164
+ * @returns ASCII-safe text string
165
+ */
166
+ export function sanitizeToASCII(text: string): string {
167
+ let result = ''
168
+ for (const char of text) {
169
+ const code = char.codePointAt(0) ?? 0
170
+ if (code <= 0x7f) {
171
+ result += char
172
+ } else if (char === '\u2022' || char === '\u25CF' || char === '\u25CB' || char === '\u25A0') {
173
+ // Unicode bullets -> ASCII
174
+ result += '*'
175
+ } else if (code >= 0x2500 && code <= 0x257f) {
176
+ // Unicode box drawing -> ASCII equivalents
177
+ result += boxDrawingToASCII(char)
178
+ } else if ((code >= 0x1f600 && code <= 0x1f64f) || code > 0x10000) {
179
+ // Emoji range and high unicode - remove
180
+ result += ''
181
+ } else {
182
+ // Other unicode - skip
183
+ result += ''
184
+ }
185
+ }
186
+ return result
187
+ }
188
+
189
+ /**
190
+ * Converts unicode box drawing character to ASCII equivalent
191
+ * Used by sanitizeToASCII and ASCII renderer
192
+ *
193
+ * @param char - Unicode box drawing character
194
+ * @returns ASCII equivalent or '+'
195
+ */
196
+ export function boxDrawingToASCII(char: string): string {
197
+ const code = char.codePointAt(0) ?? 0
198
+ // Horizontal lines
199
+ if ([0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d].includes(code)) {
200
+ return '-'
201
+ }
202
+ // Vertical lines
203
+ if ([0x2502, 0x2503, 0x2506, 0x2507, 0x250a, 0x250b, 0x254e, 0x254f].includes(code)) {
204
+ return '|'
205
+ }
206
+ // Corners and intersections -> +
207
+ return '+'
208
+ }
209
+
210
+ /**
211
+ * Builds a box (border + content) using provided box characters
212
+ * Shared logic for ASCII and Unicode renderers
213
+ *
214
+ * @param chars - Box characters (corners, edges)
215
+ * @param contentLines - Content lines to place inside box
216
+ * @param width - Total box width (including borders)
217
+ * @returns Array of box lines (ready to join with '\n')
218
+ */
219
+ export function buildBox(chars: BoxChars, contentLines: string[], width: number): string[] {
220
+ // Handle zero or minimal dimensions
221
+ if (width <= 0) return []
222
+ if (width <= 2) return [chars.topLeft + chars.topRight]
223
+
224
+ const innerWidth = width - 2
225
+ const lines: string[] = []
226
+
227
+ // Top border
228
+ lines.push(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight)
229
+
230
+ // Content lines
231
+ for (const lineContent of contentLines) {
232
+ const paddedContent = padText(lineContent, innerWidth, 'left')
233
+ lines.push(chars.vertical + paddedContent.slice(0, innerWidth) + chars.vertical)
234
+ }
235
+
236
+ // Bottom border
237
+ lines.push(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight)
238
+
239
+ return lines
240
+ }
241
+
242
+ /**
243
+ * Extracts string values from mixed-type arrays
244
+ * Common pattern: converting items array to string array for rendering
245
+ *
246
+ * @param items - Array of unknown items
247
+ * @returns Array of string values
248
+ */
249
+ export function extractStringArray(items: unknown[]): string[] {
250
+ if (!Array.isArray(items)) return []
251
+
252
+ return items.map((item) => {
253
+ if (typeof item === 'string') {
254
+ return item
255
+ }
256
+ if (typeof item === 'object' && item !== null && 'text' in item) {
257
+ return String((item as { text: string }).text)
258
+ }
259
+ return String(item)
260
+ })
261
+ }
262
+
263
+ /**
264
+ * Extracts headers from column definitions
265
+ * Common pattern: table renderers need to map columns to headers
266
+ *
267
+ * @param columns - Array of column definitions with 'header' property
268
+ * @returns Array of header strings
269
+ */
270
+ export function extractHeaders(
271
+ columns: Array<{ header?: string; key?: string }> | undefined
272
+ ): string[] {
273
+ if (!Array.isArray(columns)) return []
274
+ return columns.map((col) => col.header || col.key || '')
275
+ }
276
+
277
+ /**
278
+ * Extracts values from a row using column keys
279
+ * Common pattern: table row rendering
280
+ *
281
+ * @param row - Object with row data
282
+ * @param columns - Array of column definitions with 'key' property
283
+ * @returns Array of row values as strings
284
+ */
285
+ export function extractRowValues(
286
+ row: Record<string, unknown>,
287
+ columns: Array<{ key: string }> | undefined
288
+ ): string[] {
289
+ if (!Array.isArray(columns)) return []
290
+ return columns.map((col) => {
291
+ const val = row[col.key]
292
+ return val != null ? String(val) : ''
293
+ })
294
+ }
295
+
296
+ /**
297
+ * Combines multiple strings into a single output with separator
298
+ * Used for joining title, content, and footer sections
299
+ *
300
+ * @param parts - Array of string parts (filtered to remove empty)
301
+ * @param separator - String to join parts (default: '\n\n')
302
+ * @returns Joined string or empty string
303
+ */
304
+ export function joinParts(parts: string[], separator: string = '\n\n'): string {
305
+ return parts.filter((p) => p.length > 0).join(separator)
306
+ }
307
+
308
+ /**
309
+ * Type-safe property access with default fallback
310
+ * Used throughout renderers to safely extract typed props
311
+ *
312
+ * @param obj - Object to access
313
+ * @param key - Property key
314
+ * @param defaultValue - Fallback value if undefined
315
+ * @returns Property value or default
316
+ */
317
+ export function getProp<T>(
318
+ obj: Record<string, unknown>,
319
+ key: string,
320
+ defaultValue: T
321
+ ): T {
322
+ const val = obj[key]
323
+ return val === undefined ? defaultValue : (val as T)
324
+ }
325
+
326
+ /**
327
+ * Renderer registry pattern: allows dynamic renderer lookup
328
+ * Used by multi-tier rendering to delegate to appropriate renderer
329
+ */
330
+ export type RendererRegistry = {
331
+ [tier in 'text' | 'markdown' | 'ascii' | 'unicode' | 'ansi' | 'interactive']?: (
332
+ node: any,
333
+ context?: any
334
+ ) => string
335
+ }
336
+
337
+ /**
338
+ * Theme token color constants
339
+ * Shared across renderers for theme support
340
+ */
341
+ export const DEFAULT_THEME_TOKENS = {
342
+ primary: '',
343
+ secondary: '',
344
+ muted: '',
345
+ foreground: '',
346
+ background: '',
347
+ border: '',
348
+ success: '',
349
+ warning: '',
350
+ error: '',
351
+ info: '',
352
+ } as const
353
+
354
+ /**
355
+ * Default render context values
356
+ * Standard dimensions for terminal rendering
357
+ */
358
+ export const DEFAULT_RENDER_CONTEXT = {
359
+ width: 80,
360
+ height: 24,
361
+ depth: 0,
362
+ } as const
363
+
364
+ // ============================================================================
365
+ // Box Character Sets
366
+ // ============================================================================
367
+
368
+ /**
369
+ * ASCII box drawing characters
370
+ * Uses +, -, | for maximum compatibility
371
+ */
372
+ export const ASCII_BOX_CHARS: BoxChars = {
373
+ topLeft: '+',
374
+ topRight: '+',
375
+ bottomLeft: '+',
376
+ bottomRight: '+',
377
+ horizontal: '-',
378
+ vertical: '|',
379
+ }
380
+
381
+ /**
382
+ * ASCII double-line box drawing characters
383
+ * Uses = for horizontal lines
384
+ */
385
+ export const ASCII_DOUBLE_BOX_CHARS: BoxChars = {
386
+ topLeft: '+',
387
+ topRight: '+',
388
+ bottomLeft: '+',
389
+ bottomRight: '+',
390
+ horizontal: '=',
391
+ vertical: '|',
392
+ }
393
+
394
+ /**
395
+ * Unicode single-line box drawing characters
396
+ */
397
+ export const UNICODE_SINGLE_BOX_CHARS: BoxChars = {
398
+ topLeft: '┌',
399
+ topRight: '┐',
400
+ bottomLeft: '└',
401
+ bottomRight: '┘',
402
+ horizontal: '─',
403
+ vertical: '│',
404
+ }
405
+
406
+ /**
407
+ * Unicode double-line box drawing characters
408
+ */
409
+ export const UNICODE_DOUBLE_BOX_CHARS: BoxChars = {
410
+ topLeft: '╔',
411
+ topRight: '╗',
412
+ bottomLeft: '╚',
413
+ bottomRight: '╝',
414
+ horizontal: '═',
415
+ vertical: '║',
416
+ }
417
+
418
+ /**
419
+ * Unicode rounded box drawing characters
420
+ */
421
+ export const UNICODE_ROUNDED_BOX_CHARS: BoxChars = {
422
+ topLeft: '╭',
423
+ topRight: '╮',
424
+ bottomLeft: '╰',
425
+ bottomRight: '╯',
426
+ horizontal: '─',
427
+ vertical: '│',
428
+ }
429
+
430
+ /**
431
+ * Gets ASCII box characters for a border style
432
+ *
433
+ * @param style - Border style: 'single', 'double', or 'none'
434
+ * @returns BoxChars for the specified style
435
+ */
436
+ export function getASCIIBoxChars(style: string): BoxChars {
437
+ if (style === 'double') {
438
+ return ASCII_DOUBLE_BOX_CHARS
439
+ }
440
+ return ASCII_BOX_CHARS
441
+ }
442
+
443
+ /**
444
+ * Gets Unicode box characters for a border style
445
+ *
446
+ * @param style - Border style: 'single', 'double', 'rounded', or 'none'
447
+ * @returns BoxChars for the specified style
448
+ */
449
+ export function getUnicodeBoxChars(style: string): BoxChars {
450
+ switch (style) {
451
+ case 'double':
452
+ return UNICODE_DOUBLE_BOX_CHARS
453
+ case 'rounded':
454
+ return UNICODE_ROUNDED_BOX_CHARS
455
+ case 'single':
456
+ default:
457
+ return UNICODE_SINGLE_BOX_CHARS
458
+ }
459
+ }
460
+
461
+ // ============================================================================
462
+ // Unicode Symbol Constants
463
+ // ============================================================================
464
+
465
+ /**
466
+ * Unicode symbols for lists, progress, spinners, etc.
467
+ * Shared across Unicode and ANSI renderers
468
+ */
469
+ export const UNICODE_SYMBOLS = {
470
+ // Bullets and list markers
471
+ bullet: '•',
472
+ hollowBullet: '◦',
473
+ squareBullet: '▪',
474
+ triangleRight: '▸',
475
+ triangleDown: '▾',
476
+ checkmark: '✓',
477
+ crossMark: '✗',
478
+ unchecked: '☐',
479
+ arrowRight: '→',
480
+ arrowDown: '↓',
481
+
482
+ // Progress bar characters
483
+ progressFull: '▓',
484
+ progressEmpty: '░',
485
+ progressHalf: '▒',
486
+
487
+ // Dividers
488
+ ellipsis: '…',
489
+ middleDot: '·',
490
+
491
+ // Table junction characters
492
+ teeLeft: '├',
493
+ teeRight: '┤',
494
+ teeTop: '┬',
495
+ teeBottom: '┴',
496
+ crossJunction: '┼',
497
+ } as const
498
+
499
+ /**
500
+ * Braille spinner animation frames
501
+ */
502
+ export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
503
+
504
+ // ============================================================================
505
+ // Table Utilities
506
+ // ============================================================================
507
+
508
+ /**
509
+ * Calculates column widths for table rendering
510
+ *
511
+ * @param headers - Array of header strings
512
+ * @param rows - Array of row data (string arrays)
513
+ * @param maxTableWidth - Maximum allowed table width (optional)
514
+ * @returns Array of column widths
515
+ */
516
+ export function calculateColumnWidths(
517
+ headers: string[],
518
+ rows: string[][],
519
+ maxTableWidth?: number
520
+ ): number[] {
521
+ const columnCount = Math.max(headers.length, ...rows.map((r) => r.length))
522
+ const colWidths: number[] = []
523
+
524
+ for (let i = 0; i < columnCount; i++) {
525
+ let maxWidth = headers[i]?.length ?? 0
526
+ for (const row of rows) {
527
+ const cellLen = (row[i] ?? '').length
528
+ maxWidth = Math.max(maxWidth, cellLen)
529
+ }
530
+ colWidths.push(maxWidth)
531
+ }
532
+
533
+ // Respect max table width if specified, but preserve header widths
534
+ // Headers should not be truncated - content may overflow instead
535
+ if (maxTableWidth && maxTableWidth > 0 && columnCount > 0) {
536
+ // Calculate minimum widths needed for headers
537
+ const headerWidths = headers.map((h) => h?.length ?? 0)
538
+
539
+ // +1 for each | and borders
540
+ const totalWidth = colWidths.reduce((a, b) => a + b, 0) + columnCount + 1
541
+ if (totalWidth > maxTableWidth) {
542
+ const availableForContent = maxTableWidth - columnCount - 1
543
+ const maxPerColumn = Math.max(1, Math.floor(availableForContent / columnCount))
544
+
545
+ for (let i = 0; i < colWidths.length; i++) {
546
+ // Ensure we keep at least the header width to prevent header truncation
547
+ const minWidth = headerWidths[i] ?? 1
548
+ colWidths[i] = Math.max(minWidth, Math.min(colWidths[i], maxPerColumn))
549
+ }
550
+ }
551
+ }
552
+
553
+ return colWidths
554
+ }
555
+
556
+ /**
557
+ * Builds a table separator line
558
+ *
559
+ * @param colWidths - Array of column widths
560
+ * @param chars - Characters for corners and horizontal line
561
+ * @returns Separator line string
562
+ */
563
+ export function buildTableSeparator(
564
+ colWidths: number[],
565
+ chars: { left: string; middle: string; right: string; horizontal: string }
566
+ ): string {
567
+ return chars.left + colWidths.map((w) => chars.horizontal.repeat(w)).join(chars.middle) + chars.right
568
+ }
569
+
570
+ /**
571
+ * Builds a table row with cell content
572
+ *
573
+ * @param cells - Array of cell content strings
574
+ * @param colWidths - Array of column widths
575
+ * @param vertical - Vertical separator character
576
+ * @returns Row line string
577
+ */
578
+ export function buildTableRow(cells: string[], colWidths: number[], vertical: string): string {
579
+ const parts = colWidths.map((w, i) => {
580
+ const cell = cells[i] ?? ''
581
+ const truncated = cell.length > w ? cell.slice(0, w) : cell
582
+ return truncated.padEnd(w)
583
+ })
584
+ return vertical + parts.join(vertical) + vertical
585
+ }
586
+
587
+ // ============================================================================
588
+ // Router Utilities (Router-Agnostic Navigation Patterns)
589
+ // ============================================================================
590
+
591
+ /**
592
+ * Route matching mode for navigation components
593
+ */
594
+ export type RouteMatchMode = 'exact' | 'prefix' | 'pattern'
595
+
596
+ /**
597
+ * Router adapter interface - implement this to integrate with any router library
598
+ * This abstraction allows components to work with React Router, Next.js, Vue Router, etc.
599
+ */
600
+ export interface RouterAdapter {
601
+ /** Get the current path/location */
602
+ getCurrentPath(): string
603
+ /** Navigate to a path */
604
+ navigate(path: string): void
605
+ /** Check if a path matches the current route */
606
+ isActive(path: string, mode?: RouteMatchMode): boolean
607
+ /** Subscribe to route changes (returns unsubscribe function) */
608
+ subscribe?(callback: (path: string) => void): () => void
609
+ }
610
+
611
+ /**
612
+ * Default router adapter that works without any router library
613
+ * Uses callbacks provided by the component user
614
+ */
615
+ export function createCallbackRouterAdapter(config: {
616
+ currentPath?: string
617
+ onNavigate?: (path: string) => void
618
+ }): RouterAdapter {
619
+ return {
620
+ getCurrentPath: () => config.currentPath || '/',
621
+ navigate: (path: string) => config.onNavigate?.(path),
622
+ isActive: (path: string, mode: RouteMatchMode = 'exact') => {
623
+ const current = config.currentPath || '/'
624
+ return matchPath(current, path, mode)
625
+ },
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Matches a current path against a target path using the specified mode
631
+ *
632
+ * @param currentPath - The current route path
633
+ * @param targetPath - The path to match against
634
+ * @param mode - Match mode: 'exact', 'prefix', or 'pattern'
635
+ * @returns true if the paths match according to the mode
636
+ */
637
+ export function matchPath(currentPath: string, targetPath: string, mode: RouteMatchMode = 'exact'): boolean {
638
+ // Normalize paths (remove trailing slashes, except for root)
639
+ const normCurrent = normalizePath(currentPath)
640
+ const normTarget = normalizePath(targetPath)
641
+
642
+ switch (mode) {
643
+ case 'exact':
644
+ return normCurrent === normTarget
645
+
646
+ case 'prefix':
647
+ // Root path only matches itself in prefix mode
648
+ if (normTarget === '/') {
649
+ return normCurrent === '/'
650
+ }
651
+ return normCurrent === normTarget || normCurrent.startsWith(normTarget + '/')
652
+
653
+ case 'pattern':
654
+ // Simple pattern matching with :param and * wildcards
655
+ return matchPathPattern(normCurrent, normTarget)
656
+
657
+ default:
658
+ return normCurrent === normTarget
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Normalizes a path by removing trailing slashes (except for root)
664
+ *
665
+ * @param path - Path to normalize
666
+ * @returns Normalized path
667
+ */
668
+ export function normalizePath(path: string): string {
669
+ if (!path || path === '/') return '/'
670
+ // Remove trailing slash
671
+ const normalized = path.endsWith('/') ? path.slice(0, -1) : path
672
+ // Ensure leading slash
673
+ return normalized.startsWith('/') ? normalized : '/' + normalized
674
+ }
675
+
676
+ /**
677
+ * Matches a path against a pattern with :param and * wildcards
678
+ *
679
+ * @param path - Actual path to test
680
+ * @param pattern - Pattern with :param segments or * wildcard
681
+ * @returns true if the path matches the pattern
682
+ */
683
+ export function matchPathPattern(path: string, pattern: string): boolean {
684
+ // Handle wildcard at end
685
+ if (pattern.endsWith('*')) {
686
+ const prefix = pattern.slice(0, -1)
687
+ return path.startsWith(prefix)
688
+ }
689
+
690
+ const pathSegments = path.split('/').filter(Boolean)
691
+ const patternSegments = pattern.split('/').filter(Boolean)
692
+
693
+ if (pathSegments.length !== patternSegments.length) {
694
+ return false
695
+ }
696
+
697
+ for (let i = 0; i < patternSegments.length; i++) {
698
+ const patternSeg = patternSegments[i]
699
+ const pathSeg = pathSegments[i]
700
+
701
+ // :param matches any single segment
702
+ if (patternSeg.startsWith(':')) {
703
+ continue
704
+ }
705
+
706
+ if (patternSeg !== pathSeg) {
707
+ return false
708
+ }
709
+ }
710
+
711
+ return true
712
+ }
713
+
714
+ /**
715
+ * Generates breadcrumb segments from a path
716
+ *
717
+ * @param path - URL path (e.g., '/products/electronics/phones')
718
+ * @param options - Configuration for segment generation
719
+ * @returns Array of breadcrumb segments with labels and paths
720
+ */
721
+ export function generateBreadcrumbSegments(
722
+ path: string,
723
+ options?: {
724
+ /** Custom labels for specific paths (e.g., { '/products': 'Shop' }) */
725
+ labels?: Record<string, string>
726
+ /** Function to generate label from segment (default: capitalize) */
727
+ labelGenerator?: (segment: string, fullPath: string) => string
728
+ /** Whether to include home segment */
729
+ includeHome?: boolean
730
+ /** Home segment label */
731
+ homeLabel?: string
732
+ }
733
+ ): Array<{ label: string; path: string }> {
734
+ const {
735
+ labels = {},
736
+ labelGenerator = (s: string) => formatSegmentLabel(s),
737
+ includeHome = true,
738
+ homeLabel = 'Home',
739
+ } = options || {}
740
+
741
+ const segments: Array<{ label: string; path: string }> = []
742
+
743
+ // Add home segment if requested
744
+ if (includeHome) {
745
+ segments.push({ label: labels['/'] || homeLabel, path: '/' })
746
+ }
747
+
748
+ // Split path and build cumulative paths
749
+ const parts = path.split('/').filter(Boolean)
750
+ let cumulativePath = ''
751
+
752
+ for (const part of parts) {
753
+ cumulativePath += '/' + part
754
+ const label = labels[cumulativePath] || labelGenerator(part, cumulativePath)
755
+ segments.push({ label, path: cumulativePath })
756
+ }
757
+
758
+ return segments
759
+ }
760
+
761
+ /**
762
+ * Formats a URL segment into a human-readable label
763
+ * Converts kebab-case or snake_case to Title Case
764
+ *
765
+ * @param segment - URL path segment (e.g., 'my-products')
766
+ * @returns Formatted label (e.g., 'My Products')
767
+ */
768
+ export function formatSegmentLabel(segment: string): string {
769
+ return segment
770
+ .replace(/[-_]/g, ' ')
771
+ .replace(/\b\w/g, (c) => c.toUpperCase())
772
+ }
773
+
774
+ /**
775
+ * Finds the active item in a navigation structure based on current path
776
+ *
777
+ * @param items - Array of items with path property
778
+ * @param currentPath - Current route path
779
+ * @param mode - Match mode for path comparison
780
+ * @returns The matching item's ID or undefined
781
+ */
782
+ export function findActiveItemByPath<T extends { id: string; path?: string }>(
783
+ items: T[],
784
+ currentPath: string,
785
+ mode: RouteMatchMode = 'prefix'
786
+ ): string | undefined {
787
+ // First try exact match
788
+ const exactMatch = items.find((item) => item.path && matchPath(currentPath, item.path, 'exact'))
789
+ if (exactMatch) return exactMatch.id
790
+
791
+ // Then try prefix match (for nested routes)
792
+ if (mode === 'prefix') {
793
+ // Sort by path length descending to get most specific match
794
+ const sortedItems = [...items]
795
+ .filter((item) => item.path)
796
+ .sort((a, b) => (b.path?.length ?? 0) - (a.path?.length ?? 0))
797
+
798
+ for (const item of sortedItems) {
799
+ if (item.path && matchPath(currentPath, item.path, 'prefix')) {
800
+ return item.id
801
+ }
802
+ }
803
+ }
804
+
805
+ return undefined
806
+ }
807
+
808
+ /**
809
+ * Finds the active item in nested navigation sections based on current path
810
+ *
811
+ * @param sections - Array of sections containing items with path property
812
+ * @param currentPath - Current route path
813
+ * @param mode - Match mode for path comparison
814
+ * @returns The matching item's ID or undefined
815
+ */
816
+ export function findActiveItemInSections<T extends { id: string; path?: string }>(
817
+ sections: Array<{ items: T[] }>,
818
+ currentPath: string,
819
+ mode: RouteMatchMode = 'prefix'
820
+ ): string | undefined {
821
+ const allItems = sections.flatMap((s) => s.items)
822
+ return findActiveItemByPath(allItems, currentPath, mode)
823
+ }
824
+
825
+ /**
826
+ * Builds a path by joining segments
827
+ *
828
+ * @param segments - Path segments to join
829
+ * @returns Joined and normalized path
830
+ */
831
+ export function joinPath(...segments: string[]): string {
832
+ const joined = segments
833
+ .map((s) => s.replace(/^\/+|\/+$/g, ''))
834
+ .filter(Boolean)
835
+ .join('/')
836
+ return '/' + joined
837
+ }
838
+
839
+ /**
840
+ * Extracts path parameters from a path using a pattern
841
+ *
842
+ * @param path - Actual path (e.g., '/users/123/edit')
843
+ * @param pattern - Pattern with :param segments (e.g., '/users/:id/edit')
844
+ * @returns Object with extracted parameters or null if no match
845
+ */
846
+ export function extractPathParams(
847
+ path: string,
848
+ pattern: string
849
+ ): Record<string, string> | null {
850
+ const pathSegments = path.split('/').filter(Boolean)
851
+ const patternSegments = pattern.split('/').filter(Boolean)
852
+
853
+ if (pathSegments.length !== patternSegments.length) {
854
+ return null
855
+ }
856
+
857
+ const params: Record<string, string> = {}
858
+
859
+ for (let i = 0; i < patternSegments.length; i++) {
860
+ const patternSeg = patternSegments[i]
861
+ const pathSeg = pathSegments[i]
862
+
863
+ if (patternSeg.startsWith(':')) {
864
+ const paramName = patternSeg.slice(1)
865
+ params[paramName] = pathSeg
866
+ } else if (patternSeg !== pathSeg) {
867
+ return null
868
+ }
869
+ }
870
+
871
+ return params
872
+ }
873
+
874
+ // ============================================================================
875
+ // UINode Helper Functions
876
+ // ============================================================================
877
+
878
+ /**
879
+ * Normalizes UINode children to an array of UINodes.
880
+ * Handles both array and string children forms.
881
+ *
882
+ * @param children - Children from UINode (can be UINode[], string, or undefined)
883
+ * @returns Array of UINodes (empty array if no children)
884
+ *
885
+ * @example
886
+ * ```tsx
887
+ * const children = normalizeChildren(node.children)
888
+ * children.forEach(child => renderNode(child))
889
+ * ```
890
+ */
891
+ export function normalizeChildren(children: UINode['children']): UINode[] {
892
+ if (!children) return []
893
+ if (typeof children === 'string') {
894
+ // Convert string to a text node
895
+ return [{ type: 'text', props: { content: children } }]
896
+ }
897
+ return children
898
+ }
899
+
900
+ /**
901
+ * Gets text content from a UINode, checking both text property and props.content.
902
+ *
903
+ * @param node - The UINode to extract text from
904
+ * @returns Text content as string, or empty string if none found
905
+ *
906
+ * @example
907
+ * ```tsx
908
+ * const text = getNodeText(node)
909
+ * ```
910
+ */
911
+ export function getNodeText(node: UINode): string {
912
+ // Check direct text property first
913
+ if (node.text !== undefined) {
914
+ return node.text
915
+ }
916
+ // Then check props.content
917
+ const content = node.props?.content
918
+ if (content !== undefined && content !== null) {
919
+ return String(content)
920
+ }
921
+ // Finally check if children is a string
922
+ if (typeof node.children === 'string') {
923
+ return node.children
924
+ }
925
+ return ''
926
+ }
927
+
928
+ /**
929
+ * Safely gets props from a UINode, defaulting to empty object.
930
+ *
931
+ * @param node - The UINode to get props from
932
+ * @returns Props object (never undefined)
933
+ *
934
+ * @example
935
+ * ```tsx
936
+ * const props = getNodeProps(node)
937
+ * const color = props.color as string | undefined
938
+ * ```
939
+ */
940
+ export function getNodeProps(node: UINode): Record<string, unknown> {
941
+ return node.props || {}
942
+ }