@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.
- package/README.md +571 -0
- package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
- package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
- package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
- package/dist/chunk-3EFDH7PK.js +5235 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-3X5IR6WE.js +884 -0
- package/dist/chunk-4FV5ZDCE.js +5236 -0
- package/dist/chunk-4OVMSF2J.js +243 -0
- package/dist/chunk-63FEETIS.js +4048 -0
- package/dist/chunk-B43KP7XJ.js +884 -0
- package/dist/chunk-BMTJXWUV.js +655 -0
- package/dist/chunk-C3SVH4N7.js +882 -0
- package/dist/chunk-EVWR7Y47.js +874 -0
- package/dist/chunk-F6A5VWUC.js +1285 -0
- package/dist/chunk-FD7KW7GE.js +882 -0
- package/dist/chunk-GBQ6UD6I.js +655 -0
- package/dist/chunk-GMDD3M6U.js +5227 -0
- package/dist/chunk-JBHRXOXM.js +1058 -0
- package/dist/chunk-JFOO3EYO.js +1182 -0
- package/dist/chunk-JQ5H3WXL.js +1291 -0
- package/dist/chunk-JQD5NASE.js +234 -0
- package/dist/chunk-KRHJP5R7.js +592 -0
- package/dist/chunk-KWF6WVJE.js +962 -0
- package/dist/chunk-LHYQVN3H.js +1038 -0
- package/dist/chunk-M3TLQLGC.js +1032 -0
- package/dist/chunk-MVW4Q5OP.js +240 -0
- package/dist/chunk-NXCZSWLU.js +1294 -0
- package/dist/chunk-O25TNRO6.js +607 -0
- package/dist/chunk-PNECDA2I.js +884 -0
- package/dist/chunk-QIHWRLJR.js +962 -0
- package/dist/chunk-QW5YMQ7K.js +882 -0
- package/dist/chunk-R5U7XKVJ.js +16 -0
- package/dist/chunk-RP2MVQLR.js +962 -0
- package/dist/chunk-TP6RXGXA.js +1087 -0
- package/dist/chunk-TQQSTITZ.js +655 -0
- package/dist/chunk-X24GWXQV.js +1281 -0
- package/dist/components/index.d.ts +802 -0
- package/dist/components/index.js +149 -0
- package/dist/data/index.d.ts +2554 -0
- package/dist/data/index.js +51 -0
- package/dist/forms/index.d.ts +1596 -0
- package/dist/forms/index.js +464 -0
- package/dist/index-CQRFZntR.d.ts +867 -0
- package/dist/index.d.ts +579 -0
- package/dist/index.js +786 -0
- package/dist/interactive-D0JkWosD.d.ts +217 -0
- package/dist/keyboard/index.d.ts +2 -0
- package/dist/keyboard/index.js +43 -0
- package/dist/renderers/index.d.ts +546 -0
- package/dist/renderers/index.js +2157 -0
- package/dist/storybook/index.d.ts +396 -0
- package/dist/storybook/index.js +641 -0
- package/dist/theme/index.d.ts +1339 -0
- package/dist/theme/index.js +123 -0
- package/dist/types-Bxu5PAgA.d.ts +710 -0
- package/dist/types-CIlop5Ji.d.ts +701 -0
- package/dist/types-Ca8p_p5X.d.ts +710 -0
- package/package.json +90 -0
- package/src/__tests__/components/data/card.test.ts +458 -0
- package/src/__tests__/components/data/list.test.ts +473 -0
- package/src/__tests__/components/data/metrics.test.ts +541 -0
- package/src/__tests__/components/data/table.test.ts +448 -0
- package/src/__tests__/components/input/field.test.ts +555 -0
- package/src/__tests__/components/input/form.test.ts +870 -0
- package/src/__tests__/components/input/search.test.ts +1238 -0
- package/src/__tests__/components/input/select.test.ts +658 -0
- package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
- package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
- package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
- package/src/__tests__/components/navigation/tabs.test.ts +995 -0
- package/src/__tests__/components.test.tsx +1197 -0
- package/src/__tests__/core/compiler.test.ts +986 -0
- package/src/__tests__/core/parser.test.ts +785 -0
- package/src/__tests__/core/tier-switcher.test.ts +1103 -0
- package/src/__tests__/core/types.test.ts +1398 -0
- package/src/__tests__/data/collections.test.ts +1337 -0
- package/src/__tests__/data/db.test.ts +1265 -0
- package/src/__tests__/data/reactive.test.ts +1010 -0
- package/src/__tests__/data/sync.test.ts +1614 -0
- package/src/__tests__/errors.test.ts +660 -0
- package/src/__tests__/forms/integration.test.ts +444 -0
- package/src/__tests__/integration.test.ts +905 -0
- package/src/__tests__/keyboard.test.ts +1791 -0
- package/src/__tests__/renderer.test.ts +489 -0
- package/src/__tests__/renderers/ansi-css.test.ts +948 -0
- package/src/__tests__/renderers/ansi.test.ts +1366 -0
- package/src/__tests__/renderers/ascii.test.ts +1360 -0
- package/src/__tests__/renderers/interactive.test.ts +2353 -0
- package/src/__tests__/renderers/markdown.test.ts +1483 -0
- package/src/__tests__/renderers/text.test.ts +1369 -0
- package/src/__tests__/renderers/unicode.test.ts +1307 -0
- package/src/__tests__/theme.test.ts +639 -0
- package/src/__tests__/utils/assertions.ts +685 -0
- package/src/__tests__/utils/index.ts +115 -0
- package/src/__tests__/utils/test-renderer.ts +381 -0
- package/src/__tests__/utils/utils.test.ts +560 -0
- package/src/components/containers/card.ts +56 -0
- package/src/components/containers/dialog.ts +53 -0
- package/src/components/containers/index.ts +9 -0
- package/src/components/containers/panel.ts +59 -0
- package/src/components/feedback/badge.ts +40 -0
- package/src/components/feedback/index.ts +8 -0
- package/src/components/feedback/spinner.ts +23 -0
- package/src/components/helpers.ts +81 -0
- package/src/components/index.ts +153 -0
- package/src/components/layout/breadcrumb.ts +31 -0
- package/src/components/layout/index.ts +10 -0
- package/src/components/layout/list.ts +29 -0
- package/src/components/layout/sidebar.ts +79 -0
- package/src/components/layout/table.ts +62 -0
- package/src/components/primitives/box.ts +95 -0
- package/src/components/primitives/button.ts +54 -0
- package/src/components/primitives/index.ts +11 -0
- package/src/components/primitives/input.ts +88 -0
- package/src/components/primitives/select.ts +97 -0
- package/src/components/primitives/text.ts +60 -0
- package/src/components/render.ts +155 -0
- package/src/components/templates/app.ts +43 -0
- package/src/components/templates/index.ts +8 -0
- package/src/components/templates/site.ts +54 -0
- package/src/components/types.ts +777 -0
- package/src/core/compiler.ts +718 -0
- package/src/core/parser.ts +127 -0
- package/src/core/tier-switcher.ts +607 -0
- package/src/core/types.ts +672 -0
- package/src/data/collection.ts +316 -0
- package/src/data/collections.ts +50 -0
- package/src/data/context.tsx +174 -0
- package/src/data/db.ts +127 -0
- package/src/data/hooks.ts +532 -0
- package/src/data/index.ts +138 -0
- package/src/data/reactive.ts +1225 -0
- package/src/data/saas-collections.ts +375 -0
- package/src/data/sync.ts +1213 -0
- package/src/data/types.ts +660 -0
- package/src/forms/converters.ts +512 -0
- package/src/forms/index.ts +133 -0
- package/src/forms/schemas.ts +403 -0
- package/src/forms/types.ts +476 -0
- package/src/index.ts +542 -0
- package/src/keyboard/focus.ts +748 -0
- package/src/keyboard/index.ts +96 -0
- package/src/keyboard/integration.ts +371 -0
- package/src/keyboard/manager.ts +377 -0
- package/src/keyboard/presets.ts +90 -0
- package/src/renderers/ansi-css.ts +576 -0
- package/src/renderers/ansi.ts +802 -0
- package/src/renderers/ascii.ts +680 -0
- package/src/renderers/breadcrumb.ts +480 -0
- package/src/renderers/command-palette.ts +802 -0
- package/src/renderers/components/field.ts +210 -0
- package/src/renderers/components/form.ts +327 -0
- package/src/renderers/components/index.ts +21 -0
- package/src/renderers/components/search.ts +449 -0
- package/src/renderers/components/select.ts +222 -0
- package/src/renderers/index.ts +101 -0
- package/src/renderers/interactive/component-handlers.ts +622 -0
- package/src/renderers/interactive/cursor-manager.ts +147 -0
- package/src/renderers/interactive/focus-manager.ts +279 -0
- package/src/renderers/interactive/index.ts +661 -0
- package/src/renderers/interactive/input-handler.ts +164 -0
- package/src/renderers/interactive/keyboard-handler.ts +212 -0
- package/src/renderers/interactive/mouse-handler.ts +167 -0
- package/src/renderers/interactive/state-manager.ts +109 -0
- package/src/renderers/interactive/types.ts +338 -0
- package/src/renderers/interactive-string.ts +299 -0
- package/src/renderers/interactive.ts +59 -0
- package/src/renderers/markdown.ts +950 -0
- package/src/renderers/sidebar.ts +549 -0
- package/src/renderers/tabs.ts +682 -0
- package/src/renderers/text.ts +791 -0
- package/src/renderers/unicode.ts +917 -0
- package/src/renderers/utils.ts +942 -0
- package/src/router/adapters.ts +383 -0
- package/src/router/types.ts +140 -0
- package/src/router/utils.ts +452 -0
- package/src/schemas.ts +205 -0
- package/src/storybook/index.ts +91 -0
- package/src/storybook/interactive-decorator.tsx +659 -0
- package/src/storybook/keyboard-simulator.ts +501 -0
- package/src/theme/ansi-codes.ts +80 -0
- package/src/theme/box-drawing.ts +132 -0
- package/src/theme/color-convert.ts +254 -0
- package/src/theme/color-support.ts +321 -0
- package/src/theme/index.ts +134 -0
- package/src/theme/strip-ansi.ts +50 -0
- package/src/theme/tailwind-map.ts +469 -0
- package/src/theme/text-styles.ts +206 -0
- package/src/theme/theme-system.ts +568 -0
- 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
|
+
}
|